diff options
Diffstat (limited to 'apps/files/lib')
91 files changed, 9173 insertions, 1174 deletions
diff --git a/apps/files/lib/Activity/FavoriteProvider.php b/apps/files/lib/Activity/FavoriteProvider.php new file mode 100644 index 00000000000..e56b13b902a --- /dev/null +++ b/apps/files/lib/Activity/FavoriteProvider.php @@ -0,0 +1,150 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IEventMerger; +use OCP\Activity\IManager; +use OCP\Activity\IProvider; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\L10N\IFactory; + +class FavoriteProvider implements IProvider { + public const SUBJECT_ADDED = 'added_favorite'; + public const SUBJECT_REMOVED = 'removed_favorite'; + + /** @var IL10N */ + protected $l; + + /** + * @param IFactory $languageFactory + * @param IURLGenerator $url + * @param IManager $activityManager + * @param IEventMerger $eventMerger + */ + public function __construct( + protected IFactory $languageFactory, + protected IURLGenerator $url, + protected IManager $activityManager, + protected IEventMerger $eventMerger, + ) { + } + + /** + * @param string $language + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { + if ($event->getApp() !== 'files' || $event->getType() !== 'favorite') { + throw new UnknownActivityException(); + } + + $this->l = $this->languageFactory->get('files', $language); + + if ($this->activityManager->isFormattingFilteredObject()) { + try { + return $this->parseShortVersion($event); + } catch (UnknownActivityException) { + // Ignore and simply use the long version... + } + } + + return $this->parseLongVersion($event, $previousEvent); + } + + /** + * @param IEvent $event + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parseShortVersion(IEvent $event): IEvent { + if ($event->getSubject() === self::SUBJECT_ADDED) { + $event->setParsedSubject($this->l->t('Added to favorites')); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.svg'))); + } + } elseif ($event->getSubject() === self::SUBJECT_REMOVED) { + $event->setType('unfavorite'); + $event->setParsedSubject($this->l->t('Removed from favorites')); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.svg'))); + } + } else { + throw new UnknownActivityException(); + } + + return $event; + } + + /** + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parseLongVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent { + if ($event->getSubject() === self::SUBJECT_ADDED) { + $subject = $this->l->t('You added {file} to your favorites'); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.svg'))); + } + } elseif ($event->getSubject() === self::SUBJECT_REMOVED) { + $event->setType('unfavorite'); + $subject = $this->l->t('You removed {file} from your favorites'); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.svg'))); + } + } else { + throw new UnknownActivityException(); + } + + $this->setSubjects($event, $subject); + $event = $this->eventMerger->mergeEvents('file', $event, $previousEvent); + return $event; + } + + /** + * @param IEvent $event + * @param string $subject + */ + protected function setSubjects(IEvent $event, $subject) { + $subjectParams = $event->getSubjectParameters(); + if (empty($subjectParams)) { + // Try to fall back to the old way, but this does not work for emails. + // But at least old activities still work. + $subjectParams = [ + 'id' => $event->getObjectId(), + 'path' => $event->getObjectName(), + ]; + } + $parameter = [ + 'type' => 'file', + 'id' => (string)$subjectParams['id'], + 'name' => basename($subjectParams['path']), + 'path' => trim($subjectParams['path'], '/'), + 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $subjectParams['id']]), + ]; + + $event->setRichSubject($subject, ['file' => $parameter]); + } +} diff --git a/apps/files/lib/Activity/Filter/Favorites.php b/apps/files/lib/Activity/Filter/Favorites.php new file mode 100644 index 00000000000..0159dd20b82 --- /dev/null +++ b/apps/files/lib/Activity/Filter/Favorites.php @@ -0,0 +1,130 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity\Filter; + +use OCA\Files\Activity\Helper; +use OCP\Activity\IFilter; +use OCP\Activity\IManager; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IURLGenerator; + +class Favorites implements IFilter { + + /** + * @param IL10N $l + * @param IURLGenerator $url + * @param IManager $activityManager + * @param Helper $helper + * @param IDBConnection $db + */ + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + protected IManager $activityManager, + protected Helper $helper, + protected IDBConnection $db, + ) { + } + + /** + * @return string Lowercase a-z only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'files_favorites'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return $this->l->t('Favorites'); + } + + /** + * @return int + * @since 11.0.0 + */ + public function getPriority() { + return 10; + } + + /** + * @return string Full URL to an icon, empty string when none is given + * @since 11.0.0 + */ + public function getIcon() { + return $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star-dark.svg')); + } + + /** + * @param string[] $types + * @return string[] An array of allowed apps from which activities should be displayed + * @since 11.0.0 + */ + public function filterTypes(array $types) { + return array_intersect([ + 'file_created', + 'file_changed', + 'file_deleted', + 'file_restored', + ], $types); + } + + /** + * @return string[] An array of allowed apps from which activities should be displayed + * @since 11.0.0 + */ + public function allowedApps() { + return ['files']; + } + + /** + * @param IQueryBuilder $query + */ + public function filterFavorites(IQueryBuilder $query) { + try { + $user = $this->activityManager->getCurrentUserId(); + } catch (\UnexpectedValueException $e) { + return; + } + + try { + $favorites = $this->helper->getFavoriteFilePaths($user); + } catch (\RuntimeException $e) { + return; + } + + $limitations = []; + if (!empty($favorites['items'])) { + $limitations[] = $query->expr()->in('file', $query->createNamedParameter($favorites['items'], IQueryBuilder::PARAM_STR_ARRAY)); + } + foreach ($favorites['folders'] as $favorite) { + $limitations[] = $query->expr()->like('file', $query->createNamedParameter( + $this->db->escapeLikeParameter($favorite . '/') . '%' + )); + } + + if (empty($limitations)) { + return; + } + + $function = $query->createFunction(' + CASE + WHEN ' . $query->getColumnName('app') . ' <> ' . $query->createNamedParameter('files') . ' THEN 1 + WHEN ' . $query->getColumnName('app') . ' = ' . $query->createNamedParameter('files') . ' + AND (' . implode(' OR ', $limitations) . ') + THEN 1 + END = 1' + ); + + $query->andWhere($function); + } +} diff --git a/apps/files/lib/Activity/Filter/FileChanges.php b/apps/files/lib/Activity/Filter/FileChanges.php new file mode 100644 index 00000000000..0ca8f6792e0 --- /dev/null +++ b/apps/files/lib/Activity/Filter/FileChanges.php @@ -0,0 +1,78 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity\Filter; + +use OCP\Activity\IFilter; +use OCP\IL10N; +use OCP\IURLGenerator; + +class FileChanges implements IFilter { + + /** + * @param IL10N $l + * @param IURLGenerator $url + */ + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { + } + + /** + * @return string Lowercase a-z only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'files'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return $this->l->t('File changes'); + } + + /** + * @return int + * @since 11.0.0 + */ + public function getPriority() { + return 30; + } + + /** + * @return string Full URL to an icon, empty string when none is given + * @since 11.0.0 + */ + public function getIcon() { + return $this->url->getAbsoluteURL($this->url->imagePath('core', 'places/files.svg')); + } + + /** + * @param string[] $types + * @return string[] An array of allowed apps from which activities should be displayed + * @since 11.0.0 + */ + public function filterTypes(array $types) { + return array_intersect([ + 'file_created', + 'file_changed', + 'file_deleted', + 'file_restored', + ], $types); + } + + /** + * @return string[] An array of allowed apps from which activities should be displayed + * @since 11.0.0 + */ + public function allowedApps() { + return ['files']; + } +} diff --git a/apps/files/lib/Activity/Helper.php b/apps/files/lib/Activity/Helper.php new file mode 100644 index 00000000000..9b8ad9cd442 --- /dev/null +++ b/apps/files/lib/Activity/Helper.php @@ -0,0 +1,88 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files\Activity; + +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\ITagManager; + +class Helper { + /** If a user has a lot of favorites the query might get too slow and long */ + public const FAVORITE_LIMIT = 50; + + public function __construct( + protected ITagManager $tagManager, + protected IRootFolder $rootFolder, + ) { + } + + /** + * Return an array with nodes marked as favorites + * + * @param string $user User ID + * @param bool $foldersOnly Only return folders (default false) + * @return Node[] + * @psalm-return ($foldersOnly is true ? Folder[] : Node[]) + * @throws \RuntimeException when too many or no favorites where found + */ + public function getFavoriteNodes(string $user, bool $foldersOnly = false): array { + $tags = $this->tagManager->load('files', [], false, $user); + $favorites = $tags->getFavorites(); + + if (empty($favorites)) { + throw new \RuntimeException('No favorites', 1); + } elseif (isset($favorites[self::FAVORITE_LIMIT])) { + throw new \RuntimeException('Too many favorites', 2); + } + + // Can not DI because the user is not known on instantiation + $userFolder = $this->rootFolder->getUserFolder($user); + $favoriteNodes = []; + foreach ($favorites as $favorite) { + $node = $userFolder->getFirstNodeById($favorite); + if ($node) { + if (!$foldersOnly || $node instanceof Folder) { + $favoriteNodes[] = $node; + } + } + } + + if (empty($favoriteNodes)) { + throw new \RuntimeException('No favorites', 1); + } + + return $favoriteNodes; + } + + /** + * Returns an array with the favorites + * + * @param string $user + * @return array + * @throws \RuntimeException when too many or no favorites where found + */ + public function getFavoriteFilePaths(string $user): array { + $userFolder = $this->rootFolder->getUserFolder($user); + $nodes = $this->getFavoriteNodes($user); + $folders = $items = []; + foreach ($nodes as $node) { + $path = $userFolder->getRelativePath($node->getPath()); + + $items[] = $path; + if ($node instanceof Folder) { + $folders[] = $path; + } + } + + return [ + 'items' => $items, + 'folders' => $folders, + ]; + } +} diff --git a/apps/files/lib/Activity/Provider.php b/apps/files/lib/Activity/Provider.php new file mode 100644 index 00000000000..3ef79ac107f --- /dev/null +++ b/apps/files/lib/Activity/Provider.php @@ -0,0 +1,526 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IEventMerger; +use OCP\Activity\IManager; +use OCP\Activity\IProvider; +use OCP\Contacts\IManager as IContactsManager; +use OCP\Federation\ICloudIdManager; +use OCP\Files\Folder; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; + +class Provider implements IProvider { + /** @var IL10N */ + protected $l; + + /** @var string[] cached displayNames - key is the cloud id and value the displayname */ + protected $displayNames = []; + + protected $fileIsEncrypted = false; + + public function __construct( + protected IFactory $languageFactory, + protected IURLGenerator $url, + protected IManager $activityManager, + protected IUserManager $userManager, + protected IRootFolder $rootFolder, + protected ICloudIdManager $cloudIdManager, + protected IContactsManager $contactsManager, + protected IEventMerger $eventMerger, + ) { + } + + /** + * @param string $language + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { + if ($event->getApp() !== 'files') { + throw new UnknownActivityException(); + } + + $this->l = $this->languageFactory->get('files', $language); + + if ($this->activityManager->isFormattingFilteredObject()) { + try { + return $this->parseShortVersion($event, $previousEvent); + } catch (UnknownActivityException) { + // Ignore and simply use the long version... + } + } + + return $this->parseLongVersion($event, $previousEvent); + } + + protected function setIcon(IEvent $event, string $icon, string $app = 'files') { + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath($app, $icon . '.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath($app, $icon . '.svg'))); + } + } + + /** + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parseShortVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent { + $parsedParameters = $this->getParameters($event); + + if ($event->getSubject() === 'created_by') { + $subject = $this->l->t('Created by {user}'); + $this->setIcon($event, 'add-color'); + } elseif ($event->getSubject() === 'changed_by') { + $subject = $this->l->t('Changed by {user}'); + $this->setIcon($event, 'change'); + } elseif ($event->getSubject() === 'deleted_by') { + $subject = $this->l->t('Deleted by {user}'); + $this->setIcon($event, 'delete-color'); + } elseif ($event->getSubject() === 'restored_by') { + $subject = $this->l->t('Restored by {user}'); + $this->setIcon($event, 'actions/history', 'core'); + } elseif ($event->getSubject() === 'renamed_by') { + $subject = $this->l->t('Renamed by {user}'); + $this->setIcon($event, 'change'); + } elseif ($event->getSubject() === 'moved_by') { + $subject = $this->l->t('Moved by {user}'); + $this->setIcon($event, 'change'); + } else { + throw new UnknownActivityException(); + } + + if (!isset($parsedParameters['user'])) { + // External user via public link share + $subject = str_replace('{user}', $this->l->t('"remote account"'), $subject); + } + + $this->setSubjects($event, $subject, $parsedParameters); + + return $this->eventMerger->mergeEvents('user', $event, $previousEvent); + } + + /** + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parseLongVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent { + $this->fileIsEncrypted = false; + $parsedParameters = $this->getParameters($event); + + if ($event->getSubject() === 'created_self') { + $subject = $this->l->t('You created {file}'); + if ($this->fileIsEncrypted) { + $subject = $this->l->t('You created an encrypted file in {file}'); + } + $this->setIcon($event, 'add-color'); + } elseif ($event->getSubject() === 'created_by') { + $subject = $this->l->t('{user} created {file}'); + if ($this->fileIsEncrypted) { + $subject = $this->l->t('{user} created an encrypted file in {file}'); + } + $this->setIcon($event, 'add-color'); + } elseif ($event->getSubject() === 'created_public') { + $subject = $this->l->t('{file} was created in a public folder'); + $this->setIcon($event, 'add-color'); + } elseif ($event->getSubject() === 'changed_self') { + $subject = $this->l->t('You changed {file}'); + if ($this->fileIsEncrypted) { + $subject = $this->l->t('You changed an encrypted file in {file}'); + } + $this->setIcon($event, 'change'); + } elseif ($event->getSubject() === 'changed_by') { + $subject = $this->l->t('{user} changed {file}'); + if ($this->fileIsEncrypted) { + $subject = $this->l->t('{user} changed an encrypted file in {file}'); + } + $this->setIcon($event, 'change'); + } elseif ($event->getSubject() === 'deleted_self') { + $subject = $this->l->t('You deleted {file}'); + if ($this->fileIsEncrypted) { + $subject = $this->l->t('You deleted an encrypted file in {file}'); + } + $this->setIcon($event, 'delete-color'); + } elseif ($event->getSubject() === 'deleted_by') { + $subject = $this->l->t('{user} deleted {file}'); + if ($this->fileIsEncrypted) { + $subject = $this->l->t('{user} deleted an encrypted file in {file}'); + } + $this->setIcon($event, 'delete-color'); + } elseif ($event->getSubject() === 'restored_self') { + $subject = $this->l->t('You restored {file}'); + $this->setIcon($event, 'actions/history', 'core'); + } elseif ($event->getSubject() === 'restored_by') { + $subject = $this->l->t('{user} restored {file}'); + $this->setIcon($event, 'actions/history', 'core'); + } elseif ($event->getSubject() === 'renamed_self') { + $oldFileName = $parsedParameters['oldfile']['name']; + $newFileName = $parsedParameters['newfile']['name']; + + if ($this->isHiddenFile($oldFileName)) { + if ($this->isHiddenFile($newFileName)) { + $subject = $this->l->t('You renamed {oldfile} (hidden) to {newfile} (hidden)'); + } else { + $subject = $this->l->t('You renamed {oldfile} (hidden) to {newfile}'); + } + } else { + if ($this->isHiddenFile($newFileName)) { + $subject = $this->l->t('You renamed {oldfile} to {newfile} (hidden)'); + } else { + $subject = $this->l->t('You renamed {oldfile} to {newfile}'); + } + } + + $this->setIcon($event, 'change'); + } elseif ($event->getSubject() === 'renamed_by') { + $oldFileName = $parsedParameters['oldfile']['name']; + $newFileName = $parsedParameters['newfile']['name']; + + if ($this->isHiddenFile($oldFileName)) { + if ($this->isHiddenFile($newFileName)) { + $subject = $this->l->t('{user} renamed {oldfile} (hidden) to {newfile} (hidden)'); + } else { + $subject = $this->l->t('{user} renamed {oldfile} (hidden) to {newfile}'); + } + } else { + if ($this->isHiddenFile($newFileName)) { + $subject = $this->l->t('{user} renamed {oldfile} to {newfile} (hidden)'); + } else { + $subject = $this->l->t('{user} renamed {oldfile} to {newfile}'); + } + } + + $this->setIcon($event, 'change'); + } elseif ($event->getSubject() === 'moved_self') { + $subject = $this->l->t('You moved {oldfile} to {newfile}'); + $this->setIcon($event, 'change'); + } elseif ($event->getSubject() === 'moved_by') { + $subject = $this->l->t('{user} moved {oldfile} to {newfile}'); + $this->setIcon($event, 'change'); + } else { + throw new UnknownActivityException(); + } + + if ($this->fileIsEncrypted) { + $event->setSubject($event->getSubject() . '_enc', $event->getSubjectParameters()); + } + + if (!isset($parsedParameters['user'])) { + // External user via public link share + $subject = str_replace('{user}', $this->l->t('"remote account"'), $subject); + } + + $this->setSubjects($event, $subject, $parsedParameters); + + if ($event->getSubject() === 'moved_self' || $event->getSubject() === 'moved_by') { + $event = $this->eventMerger->mergeEvents('oldfile', $event, $previousEvent); + } else { + $event = $this->eventMerger->mergeEvents('file', $event, $previousEvent); + } + + if ($event->getChildEvent() === null) { + // Couldn't group by file, maybe we can group by user + $event = $this->eventMerger->mergeEvents('user', $event, $previousEvent); + } + + return $event; + } + + private function isHiddenFile(string $filename): bool { + return strlen($filename) > 0 && $filename[0] === '.'; + } + + protected function setSubjects(IEvent $event, string $subject, array $parameters): void { + $event->setRichSubject($subject, $parameters); + } + + /** + * @param IEvent $event + * @return array + * @throws UnknownActivityException + */ + protected function getParameters(IEvent $event): array { + $parameters = $event->getSubjectParameters(); + switch ($event->getSubject()) { + case 'created_self': + case 'created_public': + case 'changed_self': + case 'deleted_self': + case 'restored_self': + return [ + 'file' => $this->getFile($parameters[0], $event), + ]; + case 'created_by': + case 'changed_by': + case 'deleted_by': + case 'restored_by': + if ($parameters[1] === '') { + // External user via public link share + return [ + 'file' => $this->getFile($parameters[0], $event), + ]; + } + return [ + 'file' => $this->getFile($parameters[0], $event), + 'user' => $this->getUser($parameters[1]), + ]; + case 'renamed_self': + case 'moved_self': + return [ + 'newfile' => $this->getFile($parameters[0]), + 'oldfile' => $this->getFile($parameters[1]), + ]; + case 'renamed_by': + case 'moved_by': + if ($parameters[1] === '') { + // External user via public link share + return [ + 'newfile' => $this->getFile($parameters[0]), + 'oldfile' => $this->getFile($parameters[2]), + ]; + } + return [ + 'newfile' => $this->getFile($parameters[0]), + 'user' => $this->getUser($parameters[1]), + 'oldfile' => $this->getFile($parameters[2]), + ]; + } + return []; + } + + /** + * @param array|string $parameter + * @param IEvent|null $event + * @return array + * @throws UnknownActivityException + */ + protected function getFile($parameter, ?IEvent $event = null): array { + if (is_array($parameter)) { + $path = reset($parameter); + $id = (int)key($parameter); + } elseif ($event !== null) { + // Legacy from before ownCloud 8.2 + $path = $parameter; + $id = $event->getObjectId(); + } else { + throw new UnknownActivityException('Could not generate file parameter'); + } + + $encryptionContainer = $this->getEndToEndEncryptionContainer($id, $path); + if ($encryptionContainer instanceof Folder) { + $this->fileIsEncrypted = true; + try { + $fullPath = rtrim($encryptionContainer->getPath(), '/'); + // Remove /user/files/... + [,,, $path] = explode('/', $fullPath, 4); + if (!$path) { + throw new InvalidPathException('Path could not be split correctly'); + } + + return [ + 'type' => 'file', + 'id' => (string)$encryptionContainer->getId(), + 'name' => $encryptionContainer->getName(), + 'path' => $path, + 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $encryptionContainer->getId()]), + ]; + } catch (\Exception $e) { + // fall back to the normal one + $this->fileIsEncrypted = false; + } + } + + return [ + 'type' => 'file', + 'id' => (string)$id, + 'name' => basename($path), + 'path' => trim($path, '/'), + 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $id]), + ]; + } + + protected $fileEncrypted = []; + + /** + * Check if a file is end2end encrypted + * @param int $fileId + * @param string $path + * @return Folder|null + */ + protected function getEndToEndEncryptionContainer($fileId, $path) { + if (isset($this->fileEncrypted[$fileId])) { + return $this->fileEncrypted[$fileId]; + } + + $fileName = basename($path); + if (!preg_match('/^[0-9a-fA-F]{32}$/', $fileName)) { + $this->fileEncrypted[$fileId] = false; + return $this->fileEncrypted[$fileId]; + } + + $userFolder = $this->rootFolder->getUserFolder($this->activityManager->getCurrentUserId()); + $file = $userFolder->getFirstNodeById($fileId); + if (!$file) { + try { + // Deleted, try with parent + $file = $this->findExistingParent($userFolder, dirname($path)); + } catch (NotFoundException $e) { + return null; + } + + if (!$file instanceof Folder || !$file->isEncrypted()) { + return null; + } + + $this->fileEncrypted[$fileId] = $file; + return $file; + } + + if ($file instanceof Folder && $file->isEncrypted()) { + // If the folder is encrypted, it is the Container, + // but can be the name is just fine. + $this->fileEncrypted[$fileId] = true; + return null; + } + + $this->fileEncrypted[$fileId] = $this->getParentEndToEndEncryptionContainer($userFolder, $file); + return $this->fileEncrypted[$fileId]; + } + + /** + * @param Folder $userFolder + * @param string $path + * @return Folder + * @throws NotFoundException + */ + protected function findExistingParent(Folder $userFolder, $path) { + if ($path === '/') { + throw new NotFoundException('Reached the root'); + } + + try { + $folder = $userFolder->get(dirname($path)); + } catch (NotFoundException $e) { + return $this->findExistingParent($userFolder, dirname($path)); + } + + return $folder; + } + + /** + * Check all parents until the user's root folder if one is encrypted + * + * @param Folder $userFolder + * @param Node $file + * @return Node|null + */ + protected function getParentEndToEndEncryptionContainer(Folder $userFolder, Node $file) { + try { + $parent = $file->getParent(); + + if ($userFolder->getId() === $parent->getId()) { + return null; + } + } catch (\Exception $e) { + return null; + } + + if ($parent->isEncrypted()) { + return $parent; + } + + return $this->getParentEndToEndEncryptionContainer($userFolder, $parent); + } + + /** + * @param string $uid + * @return array + */ + protected function getUser($uid) { + // First try local user + $displayName = $this->userManager->getDisplayName($uid); + if ($displayName !== null) { + return [ + 'type' => 'user', + 'id' => $uid, + 'name' => $displayName, + ]; + } + + // Then a contact from the addressbook + if ($this->cloudIdManager->isValidCloudId($uid)) { + $cloudId = $this->cloudIdManager->resolveCloudId($uid); + return [ + 'type' => 'user', + 'id' => $cloudId->getUser(), + 'name' => $this->getDisplayNameFromAddressBook($cloudId->getDisplayId()), + 'server' => $cloudId->getRemote(), + ]; + } + + // Fallback to empty dummy data + return [ + 'type' => 'user', + 'id' => $uid, + 'name' => $uid, + ]; + } + + protected function getDisplayNameFromAddressBook(string $search): string { + if (isset($this->displayNames[$search])) { + return $this->displayNames[$search]; + } + + $addressBookContacts = $this->contactsManager->search($search, ['CLOUD'], [ + 'limit' => 1, + 'enumeration' => false, + 'fullmatch' => false, + 'strict_search' => true, + ]); + foreach ($addressBookContacts as $contact) { + if (isset($contact['isLocalSystemBook'])) { + continue; + } + + if (isset($contact['CLOUD'])) { + $cloudIds = $contact['CLOUD']; + if (is_string($cloudIds)) { + $cloudIds = [$cloudIds]; + } + + $lowerSearch = strtolower($search); + foreach ($cloudIds as $cloudId) { + if (strtolower($cloudId) === $lowerSearch) { + $this->displayNames[$search] = $contact['FN'] . " ($cloudId)"; + return $this->displayNames[$search]; + } + } + } + } + + return $search; + } +} diff --git a/apps/files/lib/Activity/Settings/FavoriteAction.php b/apps/files/lib/Activity/Settings/FavoriteAction.php new file mode 100644 index 00000000000..73b200341ec --- /dev/null +++ b/apps/files/lib/Activity/Settings/FavoriteAction.php @@ -0,0 +1,75 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity\Settings; + +class FavoriteAction extends FileActivitySettings { + /** + * @return string Lowercase a-z and underscore only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'favorite'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return $this->l->t('A file has been added to or removed from your <strong>favorites</strong>'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 11.0.0 + */ + public function getPriority() { + return 5; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function canChangeStream() { + return false; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledStream() { + return true; + } + + /** + * @return bool True when the option can be changed for the mail + * @since 11.0.0 + */ + public function canChangeMail() { + return false; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledMail() { + return false; + } + + /** + * @return bool True when the option can be changed for the notification + * @since 20.0.0 + */ + public function canChangeNotification() { + return false; + } +} diff --git a/apps/files/lib/Activity/Settings/FileActivitySettings.php b/apps/files/lib/Activity/Settings/FileActivitySettings.php new file mode 100644 index 00000000000..0ca7100832f --- /dev/null +++ b/apps/files/lib/Activity/Settings/FileActivitySettings.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity\Settings; + +use OCP\Activity\ActivitySettings; +use OCP\IL10N; + +abstract class FileActivitySettings extends ActivitySettings { + /** + * @param IL10N $l + */ + public function __construct( + protected IL10N $l, + ) { + } + + public function getGroupIdentifier() { + return 'files'; + } + + public function getGroupName() { + return $this->l->t('Files'); + } +} diff --git a/apps/files/lib/Activity/Settings/FileChanged.php b/apps/files/lib/Activity/Settings/FileChanged.php new file mode 100644 index 00000000000..c33ed5e1eba --- /dev/null +++ b/apps/files/lib/Activity/Settings/FileChanged.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity\Settings; + +class FileChanged extends FileActivitySettings { + /** + * @return string Lowercase a-z and underscore only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'file_changed'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return $this->l->t('A file or folder has been <strong>changed</strong>'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 11.0.0 + */ + public function getPriority() { + return 2; + } + + public function canChangeMail() { + return true; + } + + public function isDefaultEnabledMail() { + return false; + } + + public function canChangeNotification() { + return true; + } + + public function isDefaultEnabledNotification() { + return false; + } +} diff --git a/apps/files/lib/Activity/Settings/FileFavoriteChanged.php b/apps/files/lib/Activity/Settings/FileFavoriteChanged.php new file mode 100644 index 00000000000..5000902ed3f --- /dev/null +++ b/apps/files/lib/Activity/Settings/FileFavoriteChanged.php @@ -0,0 +1,75 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Activity\Settings; + +class FileFavoriteChanged extends FileActivitySettings { + /** + * @return string Lowercase a-z and underscore only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'file_favorite_changed'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return $this->l->t('A favorite file or folder has been <strong>changed</strong>'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 11.0.0 + */ + public function getPriority() { + return 1; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function canChangeStream() { + return true; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledStream() { + return true; + } + + /** + * @return bool True when the option can be changed for the mail + * @since 11.0.0 + */ + public function canChangeMail() { + return false; + } + + public function canChangeNotification() { + return false; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledMail() { + return false; + } + + public function isDefaultEnabledNotification() { + return false; + } +} diff --git a/apps/files/lib/AdvancedCapabilities.php b/apps/files/lib/AdvancedCapabilities.php new file mode 100644 index 00000000000..22f990f0cf8 --- /dev/null +++ b/apps/files/lib/AdvancedCapabilities.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files; + +use OCA\Files\Service\SettingsService; +use OCP\Capabilities\ICapability; +use OCP\Capabilities\IInitialStateExcludedCapability; + +/** + * Capabilities not needed for every request. + * This capabilities might be hard to compute or no used by the webui. + */ +class AdvancedCapabilities implements ICapability, IInitialStateExcludedCapability { + + public function __construct( + protected SettingsService $service, + ) { + } + + /** + * Return this classes capabilities + * + * @return array{files: array{'windows_compatible_filenames': bool}} + */ + public function getCapabilities(): array { + return [ + 'files' => [ + 'windows_compatible_filenames' => $this->service->hasFilesWindowsSupport(), + ], + ]; + } +} diff --git a/apps/files/lib/App.php b/apps/files/lib/App.php new file mode 100644 index 00000000000..9e6d35a7538 --- /dev/null +++ b/apps/files/lib/App.php @@ -0,0 +1,56 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files; + +use OC\NavigationManager; +use OCA\Files\Service\ChunkedUploadConfig; +use OCP\App\IAppManager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\INavigationManager; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class App { + private static ?INavigationManager $navigationManager = null; + + /** + * Returns the app's navigation manager + */ + public static function getNavigationManager(): INavigationManager { + // TODO: move this into a service in the Application class + if (self::$navigationManager === null) { + self::$navigationManager = new NavigationManager( + Server::get(IAppManager::class), + Server::get(IUrlGenerator::class), + Server::get(IFactory::class), + Server::get(IUserSession::class), + Server::get(IGroupManager::class), + Server::get(IConfig::class), + Server::get(LoggerInterface::class), + Server::get(IEventDispatcher::class), + ); + self::$navigationManager->clear(false); + } + return self::$navigationManager; + } + + public static function extendJsConfig($settings): void { + $appConfig = json_decode($settings['array']['oc_appconfig'], true); + + $appConfig['files'] = [ + 'max_chunk_size' => ChunkedUploadConfig::getMaxChunkSize(), + ]; + + $settings['array']['oc_appconfig'] = json_encode($appConfig); + } +} diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php new file mode 100644 index 00000000000..2761b44ecf9 --- /dev/null +++ b/apps/files/lib/AppInfo/Application.php @@ -0,0 +1,145 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files\AppInfo; + +use Closure; +use OCA\Files\AdvancedCapabilities; +use OCA\Files\Capabilities; +use OCA\Files\Collaboration\Resources\Listener; +use OCA\Files\Collaboration\Resources\ResourceProvider; +use OCA\Files\Controller\ApiController; +use OCA\Files\Dashboard\FavoriteWidget; +use OCA\Files\DirectEditingCapabilities; +use OCA\Files\Event\LoadSearchPlugins; +use OCA\Files\Event\LoadSidebar; +use OCA\Files\Listener\LoadSearchPluginsListener; +use OCA\Files\Listener\LoadSidebarListener; +use OCA\Files\Listener\NodeAddedToFavoriteListener; +use OCA\Files\Listener\NodeRemovedFromFavoriteListener; +use OCA\Files\Listener\RenderReferenceEventListener; +use OCA\Files\Listener\SyncLivePhotosListener; +use OCA\Files\Notification\Notifier; +use OCA\Files\Search\FilesSearchProvider; +use OCA\Files\Service\TagService; +use OCA\Files\Service\UserConfig; +use OCA\Files\Service\ViewConfig; +use OCA\Files\Settings\DeclarativeAdminSettings; +use OCP\Activity\IManager as IActivityManager; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\Collaboration\Resources\IProviderManager; +use OCP\Files\Cache\CacheEntryRemovedEvent; +use OCP\Files\Events\Node\BeforeNodeCopiedEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\NodeAddedToFavorite; +use OCP\Files\Events\NodeRemovedFromFavorite; +use OCP\Files\IRootFolder; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IPreview; +use OCP\IRequest; +use OCP\IServerContainer; +use OCP\ITagManager; +use OCP\IUserSession; +use OCP\Share\IManager as IShareManager; +use OCP\Util; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +class Application extends App implements IBootstrap { + public const APP_ID = 'files'; + + public function __construct(array $urlParams = []) { + parent::__construct(self::APP_ID, $urlParams); + } + + public function register(IRegistrationContext $context): void { + /** + * Controllers + */ + $context->registerService('APIController', function (ContainerInterface $c) { + /** @var IServerContainer $server */ + $server = $c->get(IServerContainer::class); + + return new ApiController( + $c->get('AppName'), + $c->get(IRequest::class), + $c->get(IUserSession::class), + $c->get(TagService::class), + $c->get(IPreview::class), + $c->get(IShareManager::class), + $c->get(IConfig::class), + $server->getUserFolder(), + $c->get(UserConfig::class), + $c->get(ViewConfig::class), + $c->get(IL10N::class), + $c->get(IRootFolder::class), + $c->get(LoggerInterface::class), + ); + }); + + /** + * Services + */ + $context->registerService(TagService::class, function (ContainerInterface $c) { + /** @var IServerContainer $server */ + $server = $c->get(IServerContainer::class); + + return new TagService( + $c->get(IUserSession::class), + $c->get(IActivityManager::class), + $c->get(ITagManager::class)->load(self::APP_ID), + $server->getUserFolder(), + ); + }); + + /* + * Register capabilities + */ + $context->registerCapability(Capabilities::class); + $context->registerCapability(AdvancedCapabilities::class); + $context->registerCapability(DirectEditingCapabilities::class); + + $context->registerDeclarativeSettings(DeclarativeAdminSettings::class); + + $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); + $context->registerEventListener(RenderReferenceEvent::class, RenderReferenceEventListener::class); + $context->registerEventListener(BeforeNodeRenamedEvent::class, SyncLivePhotosListener::class); + $context->registerEventListener(BeforeNodeDeletedEvent::class, SyncLivePhotosListener::class); + $context->registerEventListener(CacheEntryRemovedEvent::class, SyncLivePhotosListener::class, 1); // Ensure this happen before the metadata are deleted. + $context->registerEventListener(BeforeNodeCopiedEvent::class, SyncLivePhotosListener::class); + $context->registerEventListener(NodeCopiedEvent::class, SyncLivePhotosListener::class); + $context->registerEventListener(LoadSearchPlugins::class, LoadSearchPluginsListener::class); + $context->registerEventListener(NodeAddedToFavorite::class, NodeAddedToFavoriteListener::class); + $context->registerEventListener(NodeRemovedFromFavorite::class, NodeRemovedFromFavoriteListener::class); + $context->registerSearchProvider(FilesSearchProvider::class); + + $context->registerNotifierService(Notifier::class); + $context->registerDashboardWidget(FavoriteWidget::class); + } + + public function boot(IBootContext $context): void { + $context->injectFn(Closure::fromCallable([$this, 'registerCollaboration'])); + $context->injectFn([Listener::class, 'register']); + $this->registerHooks(); + } + + private function registerCollaboration(IProviderManager $providerManager): void { + $providerManager->registerResourceProvider(ResourceProvider::class); + } + + private function registerHooks(): void { + Util::connectHook('\OCP\Config', 'js', '\OCA\Files\App', 'extendJsConfig'); + } +} diff --git a/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php b/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php new file mode 100644 index 00000000000..a1032b2787d --- /dev/null +++ b/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\BackgroundJob; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\DirectEditing\IManager; + +class CleanupDirectEditingTokens extends TimedJob { + public function __construct( + ITimeFactory $time, + private IManager $manager, + ) { + parent::__construct($time); + $this->setInterval(15 * 60); + } + + /** + * Makes the background job do its work + * + * @param array $argument unused argument + * @throws \Exception + */ + public function run($argument) { + $this->manager->cleanup(); + } +} diff --git a/apps/files/lib/BackgroundJob/CleanupFileLocks.php b/apps/files/lib/BackgroundJob/CleanupFileLocks.php new file mode 100644 index 00000000000..91bb145884b --- /dev/null +++ b/apps/files/lib/BackgroundJob/CleanupFileLocks.php @@ -0,0 +1,40 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files\BackgroundJob; + +use OC\Lock\DBLockingProvider; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\Lock\ILockingProvider; +use OCP\Server; + +/** + * Clean up all file locks that are expired for the DB file locking provider + */ +class CleanupFileLocks extends TimedJob { + /** + * sets the correct interval for this timed job + */ + public function __construct(ITimeFactory $time) { + parent::__construct($time); + $this->setInterval(5 * 60); + } + + /** + * Makes the background job do its work + * + * @param array $argument unused argument + * @throws \Exception + */ + public function run($argument) { + $lockingProvider = Server::get(ILockingProvider::class); + if ($lockingProvider instanceof DBLockingProvider) { + $lockingProvider->cleanExpiredLocks(); + } + } +} diff --git a/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php b/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php new file mode 100644 index 00000000000..8a20b6dfb0c --- /dev/null +++ b/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\BackgroundJob; + +use OCA\Files\Db\OpenLocalEditorMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; + +/** + * Delete all expired "Open local editor" token + */ +class DeleteExpiredOpenLocalEditor extends TimedJob { + public function __construct( + ITimeFactory $time, + protected OpenLocalEditorMapper $mapper, + ) { + parent::__construct($time); + + // Run every 12h + $this->interval = 12 * 3600; + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + /** + * Makes the background job do its work + * + * @param array $argument unused argument + */ + public function run($argument): void { + $this->mapper->deleteExpiredTokens($this->time->getTime()); + } +} diff --git a/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php b/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php new file mode 100644 index 00000000000..b925974f24a --- /dev/null +++ b/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php @@ -0,0 +1,177 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files\BackgroundJob; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +/** + * Delete all share entries that have no matching entries in the file cache table. + */ +class DeleteOrphanedItems extends TimedJob { + public const CHUNK_SIZE = 200; + + /** + * sets the correct interval for this timed job + */ + public function __construct( + ITimeFactory $time, + protected IDBConnection $connection, + protected LoggerInterface $logger, + ) { + parent::__construct($time); + $this->setInterval(60 * 60); + } + + /** + * Makes the background job do its work + * + * @param array $argument unused argument + */ + public function run($argument) { + $this->cleanSystemTags(); + $this->cleanUserTags(); + $this->cleanComments(); + $this->cleanCommentMarkers(); + } + + /** + * Deleting orphaned system tag mappings + * + * @param string $table + * @param string $idCol + * @param string $typeCol + * @return int Number of deleted entries + */ + protected function cleanUp(string $table, string $idCol, string $typeCol): int { + $deletedEntries = 0; + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete($table) + ->where($deleteQuery->expr()->eq($idCol, $deleteQuery->createParameter('objectid'))); + + if ($this->connection->getShardDefinition('filecache')) { + $sourceIdChunks = $this->getItemIds($table, $idCol, $typeCol, 1000); + foreach ($sourceIdChunks as $sourceIdChunk) { + $deletedSources = $this->findMissingSources($sourceIdChunk); + $deleteQuery->setParameter('objectid', $deletedSources, IQueryBuilder::PARAM_INT_ARRAY); + $deletedEntries += $deleteQuery->executeStatement(); + } + } else { + $query = $this->connection->getQueryBuilder(); + $query->select('t1.' . $idCol) + ->from($table, 't1') + ->where($query->expr()->eq($typeCol, $query->expr()->literal('files'))) + ->leftJoin('t1', 'filecache', 't2', $query->expr()->eq($query->expr()->castColumn('t1.' . $idCol, IQueryBuilder::PARAM_INT), 't2.fileid')) + ->andWhere($query->expr()->isNull('t2.fileid')) + ->groupBy('t1.' . $idCol) + ->setMaxResults(self::CHUNK_SIZE); + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete($table) + ->where($deleteQuery->expr()->in($idCol, $deleteQuery->createParameter('objectid'))); + + $deletedInLastChunk = self::CHUNK_SIZE; + while ($deletedInLastChunk === self::CHUNK_SIZE) { + $chunk = $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + $deletedInLastChunk = count($chunk); + + $deleteQuery->setParameter('objectid', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + $deletedEntries += $deleteQuery->executeStatement(); + } + } + + return $deletedEntries; + } + + /** + * @param string $table + * @param string $idCol + * @param string $typeCol + * @param int $chunkSize + * @return \Iterator<int[]> + * @throws \OCP\DB\Exception + */ + private function getItemIds(string $table, string $idCol, string $typeCol, int $chunkSize): \Iterator { + $query = $this->connection->getQueryBuilder(); + $query->select($idCol) + ->from($table) + ->where($query->expr()->eq($typeCol, $query->expr()->literal('files'))) + ->groupBy($idCol) + ->andWhere($query->expr()->gt($idCol, $query->createParameter('min_id'))) + ->setMaxResults($chunkSize); + + $minId = 0; + while (true) { + $query->setParameter('min_id', $minId); + $rows = $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + if (count($rows) > 0) { + $minId = $rows[count($rows) - 1]; + yield $rows; + } else { + break; + } + } + } + + private function findMissingSources(array $ids): array { + $qb = $this->connection->getQueryBuilder(); + $qb->select('fileid') + ->from('filecache') + ->where($qb->expr()->in('fileid', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + $found = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + return array_diff($ids, $found); + } + + /** + * Deleting orphaned system tag mappings + * + * @return int Number of deleted entries + */ + protected function cleanSystemTags() { + $deletedEntries = $this->cleanUp('systemtag_object_mapping', 'objectid', 'objecttype'); + $this->logger->debug("$deletedEntries orphaned system tag relations deleted", ['app' => 'DeleteOrphanedItems']); + return $deletedEntries; + } + + /** + * Deleting orphaned user tag mappings + * + * @return int Number of deleted entries + */ + protected function cleanUserTags() { + $deletedEntries = $this->cleanUp('vcategory_to_object', 'objid', 'type'); + $this->logger->debug("$deletedEntries orphaned user tag relations deleted", ['app' => 'DeleteOrphanedItems']); + return $deletedEntries; + } + + /** + * Deleting orphaned comments + * + * @return int Number of deleted entries + */ + protected function cleanComments() { + $deletedEntries = $this->cleanUp('comments', 'object_id', 'object_type'); + $this->logger->debug("$deletedEntries orphaned comments deleted", ['app' => 'DeleteOrphanedItems']); + return $deletedEntries; + } + + /** + * Deleting orphaned comment read markers + * + * @return int Number of deleted entries + */ + protected function cleanCommentMarkers() { + $deletedEntries = $this->cleanUp('comments_read_markers', 'object_id', 'object_type'); + $this->logger->debug("$deletedEntries orphaned comment read marks deleted", ['app' => 'DeleteOrphanedItems']); + return $deletedEntries; + } +} diff --git a/apps/files/lib/BackgroundJob/ScanFiles.php b/apps/files/lib/BackgroundJob/ScanFiles.php new file mode 100644 index 00000000000..f3f9093d648 --- /dev/null +++ b/apps/files/lib/BackgroundJob/ScanFiles.php @@ -0,0 +1,143 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\Files\BackgroundJob; + +use OC\Files\Utils\Scanner; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +/** + * Class ScanFiles is a background job used to run the file scanner over the user + * accounts to ensure integrity of the file cache. + * + * @package OCA\Files\BackgroundJob + */ +class ScanFiles extends TimedJob { + /** Amount of users that should get scanned per execution */ + public const USERS_PER_SESSION = 500; + + public function __construct( + private IConfig $config, + private IEventDispatcher $dispatcher, + private LoggerInterface $logger, + private IDBConnection $connection, + ITimeFactory $time, + ) { + parent::__construct($time); + // Run once per 10 minutes + $this->setInterval(60 * 10); + } + + protected function runScanner(string $user): void { + try { + $scanner = new Scanner( + $user, + null, + $this->dispatcher, + $this->logger + ); + $scanner->backgroundScan(''); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'files']); + } + \OC_Util::tearDownFS(); + } + + /** + * Find a storage which have unindexed files and return a user with access to the storage + * + * @return string|false + */ + private function getUserToScan() { + if ($this->connection->getShardDefinition('filecache')) { + // for sharded filecache, the "LIMIT" from the normal query doesn't work + + // first we try it with a "LEFT JOIN" on mounts, this is fast, but might return a storage that isn't mounted. + // we also ask for up to 10 results from different storages to increase the odds of finding a result that is mounted + $query = $this->connection->getQueryBuilder(); + $query->select('m.user_id') + ->from('filecache', 'f') + ->leftJoin('f', 'mounts', 'm', $query->expr()->eq('m.storage_id', 'f.storage')) + ->where($query->expr()->eq('f.size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->gt('f.parent', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->setMaxResults(10) + ->groupBy('f.storage') + ->runAcrossAllShards(); + + $result = $query->executeQuery(); + while ($res = $result->fetch()) { + if ($res['user_id']) { + return $res['user_id']; + } + } + + // as a fallback, we try a slower approach where we find all mounted storages first + // this is essentially doing the inner join manually + $storages = $this->getAllMountedStorages(); + + $query = $this->connection->getQueryBuilder(); + $query->select('m.user_id') + ->from('filecache', 'f') + ->leftJoin('f', 'mounts', 'm', $query->expr()->eq('m.storage_id', 'f.storage')) + ->where($query->expr()->eq('f.size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->gt('f.parent', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->in('f.storage', $query->createNamedParameter($storages, IQueryBuilder::PARAM_INT_ARRAY))) + ->setMaxResults(1) + ->runAcrossAllShards(); + return $query->executeQuery()->fetchOne(); + } else { + $query = $this->connection->getQueryBuilder(); + $query->select('m.user_id') + ->from('filecache', 'f') + ->innerJoin('f', 'mounts', 'm', $query->expr()->eq('m.storage_id', 'f.storage')) + ->where($query->expr()->eq('f.size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->gt('f.parent', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->setMaxResults(1) + ->runAcrossAllShards(); + + return $query->executeQuery()->fetchOne(); + } + } + + private function getAllMountedStorages(): array { + $query = $this->connection->getQueryBuilder(); + $query->selectDistinct('storage_id') + ->from('mounts'); + return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * @param $argument + * @throws \Exception + */ + protected function run($argument) { + if ($this->config->getSystemValueBool('files_no_background_scan', false)) { + return; + } + + $usersScanned = 0; + $lastUser = ''; + $user = $this->getUserToScan(); + while ($user && $usersScanned < self::USERS_PER_SESSION && $lastUser !== $user) { + $this->runScanner($user); + $lastUser = $user; + $user = $this->getUserToScan(); + $usersScanned += 1; + } + + if ($lastUser === $user) { + $this->logger->warning("User $user still has unscanned files after running background scan, background scan might be stopped prematurely"); + } + } +} diff --git a/apps/files/lib/BackgroundJob/TransferOwnership.php b/apps/files/lib/BackgroundJob/TransferOwnership.php new file mode 100644 index 00000000000..de8d1989733 --- /dev/null +++ b/apps/files/lib/BackgroundJob/TransferOwnership.php @@ -0,0 +1,145 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\BackgroundJob; + +use OCA\Files\AppInfo\Application; +use OCA\Files\Db\TransferOwnership as Transfer; +use OCA\Files\Db\TransferOwnershipMapper; +use OCA\Files\Exception\TransferOwnershipException; +use OCA\Files\Service\OwnershipTransferService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; +use OCP\Files\IRootFolder; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Notification\IManager as NotificationManager; +use Psr\Log\LoggerInterface; +use function ltrim; + +class TransferOwnership extends QueuedJob { + public function __construct( + ITimeFactory $timeFactory, + private IUserManager $userManager, + private OwnershipTransferService $transferService, + private LoggerInterface $logger, + private NotificationManager $notificationManager, + private TransferOwnershipMapper $mapper, + private IRootFolder $rootFolder, + ) { + parent::__construct($timeFactory); + } + + protected function run($argument) { + $id = $argument['id']; + + $transfer = $this->mapper->getById($id); + $sourceUser = $transfer->getSourceUser(); + $destinationUser = $transfer->getTargetUser(); + $fileId = $transfer->getFileId(); + + $userFolder = $this->rootFolder->getUserFolder($sourceUser); + $node = $userFolder->getFirstNodeById($fileId); + + if (!$node) { + $this->logger->alert('Could not transfer ownership: Node not found'); + $this->failedNotication($transfer); + return; + } + $path = $userFolder->getRelativePath($node->getPath()); + + $sourceUserObject = $this->userManager->get($sourceUser); + $destinationUserObject = $this->userManager->get($destinationUser); + + if (!$sourceUserObject instanceof IUser) { + $this->logger->alert('Could not transfer ownership: Unknown source user ' . $sourceUser); + $this->failedNotication($transfer); + return; + } + + if (!$destinationUserObject instanceof IUser) { + $this->logger->alert("Unknown destination user $destinationUser"); + $this->failedNotication($transfer); + return; + } + + try { + $this->transferService->transfer( + $sourceUserObject, + $destinationUserObject, + ltrim($path, '/') + ); + $this->successNotification($transfer); + } catch (TransferOwnershipException $e) { + $this->logger->error( + $e->getMessage(), + [ + 'exception' => $e, + ], + ); + $this->failedNotication($transfer); + } + + $this->mapper->delete($transfer); + } + + private function failedNotication(Transfer $transfer): void { + // Send notification to source user + $notification = $this->notificationManager->createNotification(); + $notification->setUser($transfer->getSourceUser()) + ->setApp(Application::APP_ID) + ->setDateTime($this->time->getDateTime()) + ->setSubject('transferOwnershipFailedSource', [ + 'sourceUser' => $transfer->getSourceUser(), + 'targetUser' => $transfer->getTargetUser(), + 'nodeName' => $transfer->getNodeName(), + ]) + ->setObject('transfer', (string)$transfer->getId()); + $this->notificationManager->notify($notification); + // Send notification to source user + $notification = $this->notificationManager->createNotification(); + $notification->setUser($transfer->getTargetUser()) + ->setApp(Application::APP_ID) + ->setDateTime($this->time->getDateTime()) + ->setSubject('transferOwnershipFailedTarget', [ + 'sourceUser' => $transfer->getSourceUser(), + 'targetUser' => $transfer->getTargetUser(), + 'nodeName' => $transfer->getNodeName(), + ]) + ->setObject('transfer', (string)$transfer->getId()); + $this->notificationManager->notify($notification); + } + + private function successNotification(Transfer $transfer): void { + // Send notification to source user + $notification = $this->notificationManager->createNotification(); + $notification->setUser($transfer->getSourceUser()) + ->setApp(Application::APP_ID) + ->setDateTime($this->time->getDateTime()) + ->setSubject('transferOwnershipDoneSource', [ + 'sourceUser' => $transfer->getSourceUser(), + 'targetUser' => $transfer->getTargetUser(), + 'nodeName' => $transfer->getNodeName(), + ]) + ->setObject('transfer', (string)$transfer->getId()); + $this->notificationManager->notify($notification); + + // Send notification to source user + $notification = $this->notificationManager->createNotification(); + $notification->setUser($transfer->getTargetUser()) + ->setApp(Application::APP_ID) + ->setDateTime($this->time->getDateTime()) + ->setSubject('transferOwnershipDoneTarget', [ + 'sourceUser' => $transfer->getSourceUser(), + 'targetUser' => $transfer->getTargetUser(), + 'nodeName' => $transfer->getNodeName(), + ]) + ->setObject('transfer', (string)$transfer->getId()); + $this->notificationManager->notify($notification); + } +} diff --git a/apps/files/lib/Capabilities.php b/apps/files/lib/Capabilities.php new file mode 100644 index 00000000000..6b50e5807a5 --- /dev/null +++ b/apps/files/lib/Capabilities.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files; + +use OC\Files\FilenameValidator; +use OCA\Files\Service\ChunkedUploadConfig; +use OCP\Capabilities\ICapability; +use OCP\Files\Conversion\ConversionMimeProvider; +use OCP\Files\Conversion\IConversionManager; + +class Capabilities implements ICapability { + + public function __construct( + protected FilenameValidator $filenameValidator, + protected IConversionManager $fileConversionManager, + ) { + } + + /** + * Return this classes capabilities + * + * @return array{files: array{'$comment': ?string, bigfilechunking: bool, blacklisted_files: list<mixed>, forbidden_filenames: list<string>, forbidden_filename_basenames: list<string>, forbidden_filename_characters: list<string>, forbidden_filename_extensions: list<string>, chunked_upload: array{max_size: int, max_parallel_count: int}, file_conversions: list<array{from: string, to: string, extension: string, displayName: string}>}} + */ + public function getCapabilities(): array { + return [ + 'files' => [ + '$comment' => '"blacklisted_files" is deprecated as of Nextcloud 30, use "forbidden_filenames" instead', + 'blacklisted_files' => $this->filenameValidator->getForbiddenFilenames(), + 'forbidden_filenames' => $this->filenameValidator->getForbiddenFilenames(), + 'forbidden_filename_basenames' => $this->filenameValidator->getForbiddenBasenames(), + 'forbidden_filename_characters' => $this->filenameValidator->getForbiddenCharacters(), + 'forbidden_filename_extensions' => $this->filenameValidator->getForbiddenExtensions(), + + 'bigfilechunking' => true, + 'chunked_upload' => [ + 'max_size' => ChunkedUploadConfig::getMaxChunkSize(), + 'max_parallel_count' => ChunkedUploadConfig::getMaxParallelCount(), + ], + + 'file_conversions' => array_map(function (ConversionMimeProvider $mimeProvider) { + return $mimeProvider->jsonSerialize(); + }, $this->fileConversionManager->getProviders()), + ], + ]; + } +} diff --git a/apps/files/lib/Collaboration/Resources/Listener.php b/apps/files/lib/Collaboration/Resources/Listener.php new file mode 100644 index 00000000000..e4ff5d83b7a --- /dev/null +++ b/apps/files/lib/Collaboration/Resources/Listener.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Collaboration\Resources; + +use OCP\Collaboration\Resources\IManager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Server; +use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareDeletedEvent; +use OCP\Share\Events\ShareDeletedFromSelfEvent; + +class Listener { + public static function register(IEventDispatcher $dispatcher): void { + $dispatcher->addListener(ShareCreatedEvent::class, [self::class, 'shareModification']); + $dispatcher->addListener(ShareDeletedEvent::class, [self::class, 'shareModification']); + $dispatcher->addListener(ShareDeletedFromSelfEvent::class, [self::class, 'shareModification']); + } + + public static function shareModification(): void { + /** @var IManager $resourceManager */ + $resourceManager = Server::get(IManager::class); + /** @var ResourceProvider $resourceProvider */ + $resourceProvider = Server::get(ResourceProvider::class); + + $resourceManager->invalidateAccessCacheForProvider($resourceProvider); + } +} diff --git a/apps/files/lib/Collaboration/Resources/ResourceProvider.php b/apps/files/lib/Collaboration/Resources/ResourceProvider.php new file mode 100644 index 00000000000..73883bc4c6a --- /dev/null +++ b/apps/files/lib/Collaboration/Resources/ResourceProvider.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Collaboration\Resources; + +use OCP\Collaboration\Resources\IProvider; +use OCP\Collaboration\Resources\IResource; +use OCP\Collaboration\Resources\ResourceException; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\IPreview; +use OCP\IURLGenerator; +use OCP\IUser; + +class ResourceProvider implements IProvider { + public const RESOURCE_TYPE = 'file'; + + /** @var array */ + protected $nodes = []; + + public function __construct( + protected IRootFolder $rootFolder, + private IPreview $preview, + private IURLGenerator $urlGenerator, + ) { + } + + private function getNode(IResource $resource): ?Node { + if (isset($this->nodes[(int)$resource->getId()])) { + return $this->nodes[(int)$resource->getId()]; + } + $node = $this->rootFolder->getFirstNodeById((int)$resource->getId()); + if ($node) { + $this->nodes[(int)$resource->getId()] = $node; + return $this->nodes[(int)$resource->getId()]; + } + return null; + } + + /** + * @param IResource $resource + * @return array + * @since 16.0.0 + */ + public function getResourceRichObject(IResource $resource): array { + if (isset($this->nodes[(int)$resource->getId()])) { + $node = $this->nodes[(int)$resource->getId()]->getPath(); + } else { + $node = $this->getNode($resource); + } + + if ($node instanceof Node) { + $link = $this->urlGenerator->linkToRouteAbsolute( + 'files.viewcontroller.showFile', + ['fileid' => $resource->getId()] + ); + return [ + 'type' => 'file', + 'id' => $resource->getId(), + 'name' => $node->getName(), + 'path' => $node->getInternalPath(), + 'link' => $link, + 'mimetype' => $node->getMimetype(), + 'preview-available' => $this->preview->isAvailable($node), + ]; + } + + throw new ResourceException('File not found'); + } + + /** + * Can a user/guest access the collection + * + * @param IResource $resource + * @param IUser $user + * @return bool + * @since 16.0.0 + */ + public function canAccessResource(IResource $resource, ?IUser $user = null): bool { + if (!$user instanceof IUser) { + return false; + } + + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $node = $userFolder->getById((int)$resource->getId()); + + if ($node) { + $this->nodes[(int)$resource->getId()] = $node; + return true; + } + + return false; + } + + /** + * Get the resource type of the provider + * + * @return string + * @since 16.0.0 + */ + public function getType(): string { + return self::RESOURCE_TYPE; + } +} 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 new file mode 100644 index 00000000000..37cb3159f4a --- /dev/null +++ b/apps/files/lib/Command/DeleteOrphanedFiles.php @@ -0,0 +1,168 @@ +<?php + +/** + * 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; + +/** + * Delete all file entries that have no matching entries in the storage table. + */ +class DeleteOrphanedFiles extends Command { + public const CHUNK_SIZE = 200; + + public function __construct( + protected IDBConnection $connection, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:cleanup') + ->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 { + $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('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()->in('storage', $deleteQuery->createParameter('storage_ids'))); + + $deletedStorageChunks = array_chunk($deletedStorages, self::CHUNK_SIZE); + foreach ($deletedStorageChunks as $deletedStorageChunk) { + $deleteQuery->setParameter('storage_ids', $deletedStorageChunk, IQueryBuilder::PARAM_INT_ARRAY); + $deletedEntries += $deleteQuery->executeStatement(); + } + + return $deletedEntries; + } + + /** + * @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(): int { + $deletedEntries = 0; + + $query = $this->connection->getQueryBuilder(); + $query->select('m.storage_id') + ->from('mounts', 'm') + ->where($query->expr()->isNull('s.numeric_id')) + ->leftJoin('m', 'storages', 's', $query->expr()->eq('m.storage_id', 's.numeric_id')) + ->groupBy('storage_id') + ->setMaxResults(self::CHUNK_SIZE); + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('mounts') + ->where($deleteQuery->expr()->eq('storage_id', $deleteQuery->createParameter('storageid'))); + + $deletedInLastChunk = self::CHUNK_SIZE; + while ($deletedInLastChunk === self::CHUNK_SIZE) { + $deletedInLastChunk = 0; + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $deletedInLastChunk++; + $deletedEntries += $deleteQuery->setParameter('storageid', (int)$row['storage_id']) + ->executeStatement(); + } + $result->closeCursor(); + } + + return $deletedEntries; + } +} 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 new file mode 100644 index 00000000000..622ccba48a3 --- /dev/null +++ b/apps/files/lib/Command/RepairTree.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Command; + +use OCP\IDBConnection; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class RepairTree extends Command { + public const CHUNK_SIZE = 200; + + public function __construct( + protected IDBConnection $connection, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:repair-tree') + ->setDescription('Try and repair malformed filesystem tree structures') + ->addOption('dry-run'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $rows = $this->findBrokenTreeBits(); + $fix = !$input->getOption('dry-run'); + + $output->writeln('Found ' . count($rows) . ' file entries with an invalid path'); + + if ($fix) { + $this->connection->beginTransaction(); + } + + $query = $this->connection->getQueryBuilder(); + $query->update('filecache') + ->set('path', $query->createParameter('path')) + ->set('path_hash', $query->func()->md5($query->createParameter('path'))) + ->set('storage', $query->createParameter('storage')) + ->where($query->expr()->eq('fileid', $query->createParameter('fileid'))); + + foreach ($rows as $row) { + $output->writeln("Path of file {$row['fileid']} is {$row['path']} but should be {$row['parent_path']}/{$row['name']} based on its parent", OutputInterface::VERBOSITY_VERBOSE); + + if ($fix) { + $fileId = $this->getFileId((int)$row['parent_storage'], $row['parent_path'] . '/' . $row['name']); + if ($fileId > 0) { + $output->writeln("Cache entry has already be recreated with id $fileId, deleting instead"); + $this->deleteById((int)$row['fileid']); + } else { + $query->setParameters([ + 'fileid' => $row['fileid'], + 'path' => $row['parent_path'] . '/' . $row['name'], + 'storage' => $row['parent_storage'], + ]); + $query->execute(); + } + } + } + + if ($fix) { + $this->connection->commit(); + } + + return self::SUCCESS; + } + + private function getFileId(int $storage, string $path) { + $query = $this->connection->getQueryBuilder(); + $query->select('fileid') + ->from('filecache') + ->where($query->expr()->eq('storage', $query->createNamedParameter($storage))) + ->andWhere($query->expr()->eq('path_hash', $query->createNamedParameter(md5($path)))); + return $query->execute()->fetch(\PDO::FETCH_COLUMN); + } + + private function deleteById(int $fileId): void { + $query = $this->connection->getQueryBuilder(); + $query->delete('filecache') + ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId))); + $query->execute(); + } + + private function findBrokenTreeBits(): array { + $query = $this->connection->getQueryBuilder(); + + $query->select('f.fileid', 'f.path', 'f.parent', 'f.name') + ->selectAlias('p.path', 'parent_path') + ->selectAlias('p.storage', 'parent_storage') + ->from('filecache', 'f') + ->innerJoin('f', 'filecache', 'p', $query->expr()->eq('f.parent', 'p.fileid')) + ->where($query->expr()->orX( + $query->expr()->andX( + $query->expr()->neq('p.path_hash', $query->createNamedParameter(md5(''))), + $query->expr()->neq('f.path', $query->func()->concat('p.path', $query->func()->concat($query->createNamedParameter('/'), 'f.name'))) + ), + $query->expr()->andX( + $query->expr()->eq('p.path_hash', $query->createNamedParameter(md5(''))), + $query->expr()->neq('f.path', 'f.name') + ), + $query->expr()->neq('f.storage', 'p.storage') + )); + + return $query->execute()->fetchAll(); + } +} 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 new file mode 100644 index 00000000000..b9057139b0e --- /dev/null +++ b/apps/files/lib/Command/Scan.php @@ -0,0 +1,377 @@ +<?php + +/** + * 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 OC\Core\Command\Base; +use OC\Core\Command\InterruptedException; +use OC\DB\Connection; +use OC\DB\ConnectionAdapter; +use OC\Files\Storage\Wrapper\Jail; +use OC\Files\Utils\Scanner; +use OC\FilesMetadata\FilesMetadataManager; +use OC\ForbiddenException; +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; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Scan extends Base { + protected float $execTime = 0; + protected int $foldersCounter = 0; + protected int $filesCounter = 0; + protected int $errorsCounter = 0; + protected int $newCounter = 0; + protected int $updatedCounter = 0; + protected int $removedCounter = 0; + + public function __construct( + private IUserManager $userManager, + private IRootFolder $rootFolder, + private FilesMetadataManager $filesMetadataManager, + private IEventDispatcher $eventDispatcher, + private LoggerInterface $logger, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + + $this + ->setName('files:scan') + ->setDescription('rescan filesystem') + ->addArgument( + 'user_id', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'will rescan all files of the given user(s)' + ) + ->addOption( + 'path', + 'p', + 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_OPTIONAL, + 'Generate metadata for all scanned files; if specified only generate for named value', + '' + ) + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'will rescan all files of all known users' + )->addOption( + 'unscanned', + null, + InputOption::VALUE_NONE, + 'only scan files which are marked as not fully scanned' + )->addOption( + 'shallow', + null, + InputOption::VALUE_NONE, + 'do not scan folders recursively' + )->addOption( + 'home-only', + null, + InputOption::VALUE_NONE, + 'only scan the home storage, ignoring any mounted external storage or share' + ); + } + + 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 Scanner( + $user, + new ConnectionAdapter($connection), + 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): void { + $output->writeln("\tFile\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE); + ++$this->filesCounter; + $this->abortIfInterrupted(); + 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): 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): 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): 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, $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 isHomeMount(IMountPoint $mountPoint): bool { + // any mountpoint inside '/$user/files/' + return substr_count($mountPoint->getMountPoint(), '/') <= 3; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $inputPath = $input->getOption('path'); + if ($inputPath) { + $inputPath = '/' . trim($inputPath, '/'); + [, $user,] = explode('/', $inputPath, 3); + $users = [$user]; + } elseif ($input->getOption('all')) { + $users = $this->userManager->search(''); + } else { + $users = $input->getArgument('user_id'); + } + + # 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 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') ?? ''; + } + + $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 ?: '/' . $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, + $metadata, + $output, + $mountFilter, + $input->getOption('unscanned'), + !$input->getOption('shallow'), + ); + $output->writeln('', OutputInterface::VERBOSITY_VERBOSE); + } else { + $output->writeln("<error>Unknown user $user_count $user</error>"); + $output->writeln('', OutputInterface::VERBOSITY_VERBOSE); + } + + try { + $this->abortIfInterrupted(); + } catch (InterruptedException $e) { + break; + } + } + + $this->presentStats($output); + return self::SUCCESS; + } + + /** + * Initialises some useful tools for the Command + */ + protected function initTools(OutputInterface $output): void { + // Start the timer + $this->execTime = -microtime(true); + // Convert PHP errors to exceptions + 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 in order to be able to show them in the output + * + * @see https://www.php.net/manual/en/function.set-error-handler.php + * + * @param int $severity the level of the error raised + * @param string $message + * @param string $file the filename that the error was raised in + * @param int $line the line number the error was raised + */ + 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; + } + $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; + } + + 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', + 'New', + 'Updated', + 'Removed', + 'Errors', + 'Elapsed time', + ]; + $niceDate = $this->formatExecTime(); + $rows = [ + $this->foldersCounter, + $this->filesCounter, + $this->newCounter, + $this->updatedCounter, + $this->removedCounter, + $this->errorsCounter, + $niceDate, + ]; + $table = new Table($output); + $table + ->setHeaders($headers) + ->setRows([$rows]); + $table->render(); + } + + + /** + * Formats microtime into a human-readable format + */ + 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); + } + + protected function reconnectToDatabase(OutputInterface $output): Connection { + /** @var Connection $connection */ + $connection = Server::get(Connection::class); + try { + $connection->close(); + } catch (\Exception $ex) { + $output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>"); + } + while (!$connection->isConnected()) { + try { + $connection->connect(); + } catch (\Exception $ex) { + $output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>"); + sleep(60); + } + } + return $connection; + } +} diff --git a/apps/files/lib/Command/ScanAppData.php b/apps/files/lib/Command/ScanAppData.php new file mode 100644 index 00000000000..0e08c6a8cfe --- /dev/null +++ b/apps/files/lib/Command/ScanAppData.php @@ -0,0 +1,247 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Command; + +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; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ScanAppData extends Base { + protected float $execTime = 0; + + protected int $foldersCounter = 0; + + protected int $filesCounter = 0; + + public function __construct( + protected IRootFolder $rootFolder, + protected IConfig $config, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + + $this + ->setName('files:scan-app-data') + ->setDescription('rescan the AppData folder'); + + $this->addArgument('folder', InputArgument::OPTIONAL, 'The appdata subfolder to scan', ''); + } + + 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 self::FAILURE; + } + + if ($folder !== '') { + try { + $appData = $appData->get($folder); + } catch (NotFoundException $e) { + $output->writeln('<error>Could not find folder: ' . $folder . '</error>'); + return self::FAILURE; + } + } + + $connection = $this->reconnectToDatabase($output); + $scanner = new Scanner( + null, + new ConnectionAdapter($connection), + 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): 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): 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): 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): void { + $output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>'); + }); + + try { + $scanner->scan($appData->getPath()); + } 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 self::FAILURE; + } catch (InterruptedException $e) { + # exit the function if ctrl-c has been pressed + $output->writeln('<info>Interrupted by user</info>'); + return self::FAILURE; + } catch (NotFoundException $e) { + $output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>'); + return self::FAILURE; + } catch (\Exception $e) { + $output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>'); + $output->writeln('<error>' . $e->getTraceAsString() . '</error>'); + return self::FAILURE; + } + + return self::SUCCESS; + } + + + protected function execute(InputInterface $input, OutputInterface $output): int { + # restrict the verbosity level to VERBOSITY_VERBOSE + if ($output->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) { + $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); + } + + $output->writeln('Scanning AppData for files'); + $output->writeln(''); + + $folder = $input->getArgument('folder'); + + $this->initTools(); + + $exitCode = $this->scanFiles($output, $folder); + if ($exitCode === 0) { + $this->presentStats($output); + } + return $exitCode; + } + + /** + * Initialises some useful tools for the Command + */ + protected function initTools(): void { + // Start the timer + $this->execTime = -microtime(true); + // Convert PHP errors to exceptions + set_error_handler([$this, 'exceptionErrorHandler'], E_ALL); + } + + /** + * Processes PHP errors as exceptions in order to be able to keep track of problems + * + * @see https://www.php.net/manual/en/function.set-error-handler.php + * + * @param int $severity the level of the error raised + * @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; + } + throw new \ErrorException($message, 0, $severity, $file, $line); + } + + protected function presentStats(OutputInterface $output): void { + // Stop the timer + $this->execTime += microtime(true); + + $headers = [ + 'Folders', 'Files', 'Elapsed time' + ]; + + $this->showSummary($headers, null, $output); + } + + /** + * Shows a summary of operations + * + * @param string[] $headers + * @param string[] $rows + */ + protected function showSummary($headers, $rows, OutputInterface $output): void { + $niceDate = $this->formatExecTime(); + if (!$rows) { + $rows = [ + $this->foldersCounter, + $this->filesCounter, + $niceDate, + ]; + } + $table = new Table($output); + $table + ->setHeaders($headers) + ->setRows([$rows]); + $table->render(); + } + + + /** + * Formats microtime into a human-readable format + */ + 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 = Server::get(Connection::class); + try { + $connection->close(); + } catch (\Exception $ex) { + $output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>"); + } + while (!$connection->isConnected()) { + try { + $connection->connect(); + } catch (\Exception $ex) { + $output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>"); + sleep(60); + } + } + return $connection; + } + + /** + * @throws NotFoundException + */ + private function getAppDataFolder(): Node { + $instanceId = $this->config->getSystemValue('instanceid', null); + + if ($instanceId === null) { + throw new NotFoundException(); + } + + return $this->rootFolder->get('appdata_' . $instanceId); + } +} diff --git a/apps/files/lib/Command/TransferOwnership.php b/apps/files/lib/Command/TransferOwnership.php new file mode 100644 index 00000000000..f7663e26f28 --- /dev/null +++ b/apps/files/lib/Command/TransferOwnership.php @@ -0,0 +1,148 @@ +<?php + +declare(strict_types=1); +/** + * 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 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 { + public function __construct( + private IUserManager $userManager, + private OwnershipTransferService $transferService, + private IConfig $config, + private IMountManager $mountManager, + ) { + parent::__construct(); + } + + 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.') + ->addArgument( + 'source-user', + InputArgument::REQUIRED, + 'owner of files which shall be moved' + ) + ->addArgument( + 'destination-user', + InputArgument::REQUIRED, + 'user who will be the new owner of the files' + ) + ->addOption( + 'path', + null, + InputOption::VALUE_REQUIRED, + 'selectively provide the path to transfer. For example --path="folder_name"', + '' + )->addOption( + 'move', + null, + InputOption::VALUE_NONE, + 'move data from source user to root directory of destination user, which must be empty' + )->addOption( + 'transfer-incoming-shares', + null, + InputOption::VALUE_OPTIONAL, + '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 { + + /** + * Check if source and destination users are same. If they are same then just ignore the transfer. + */ + + 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 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 self::FAILURE; + } + + if (!$destinationUserObject instanceof IUser) { + $output->writeln('<error>Unknown destination user ' . $input->getArgument('destination-user') . '</error>'); + return self::FAILURE; + } + + $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; + } + } + } + } + + try { + $this->transferService->transfer( + $sourceUserObject, + $destinationUserObject, + $path, + $output, + $input->getOption('move') === true, + false, + $includeExternalStorage, + ); + } catch (TransferOwnershipException $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return $e->getCode() !== 0 ? $e->getCode() : self::FAILURE; + } + + 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; + } +} diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php new file mode 100644 index 00000000000..8bb024fb698 --- /dev/null +++ b/apps/files/lib/Controller/ApiController.php @@ -0,0 +1,462 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files\Controller; + +use OC\Files\Node\Node; +use OCA\Files\Helper; +use OCA\Files\ResponseDefinitions; +use OCA\Files\Service\TagService; +use OCA\Files\Service\UserConfig; +use OCA\Files\Service\ViewConfig; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\StrictCookiesRequired; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\StreamResponse; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\Storage\ISharedStorage; +use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IPreview; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use OCP\PreConditionNotMetException; +use OCP\Share\IManager; +use OCP\Share\IShare; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * @psalm-import-type FilesFolderTree from ResponseDefinitions + * + * @package OCA\Files\Controller + */ +class ApiController extends Controller { + public function __construct( + string $appName, + IRequest $request, + private IUserSession $userSession, + private TagService $tagService, + private IPreview $previewManager, + private IManager $shareManager, + private IConfig $config, + private ?Folder $userFolder, + private UserConfig $userConfig, + private ViewConfig $viewConfig, + private IL10N $l10n, + private IRootFolder $rootFolder, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * Gets a thumbnail of the specified file + * + * @since API version 1.0 + * @deprecated 32.0.0 Use the preview endpoint provided by core instead + * + * @param int $x Width of the thumbnail + * @param int $y Height of the thumbnail + * @param string $file URL-encoded filename + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message?: string}, array{}> + * + * 200: Thumbnail returned + * 400: Getting thumbnail is not possible + * 404: File not found + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[StrictCookiesRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getThumbnail($x, $y, $file) { + if ($x < 1 || $y < 1) { + return new DataResponse(['message' => 'Requested size must be numeric and a positive value.'], Http::STATUS_BAD_REQUEST); + } + + try { + $file = $this->userFolder?->get($file); + if ($file === null + || !($file instanceof File) + || ($file->getId() <= 0) + ) { + throw new NotFoundException(); + } + + // Validate the user is allowed to download the file (preview is some kind of download) + /** @var ISharedStorage $storage */ + $storage = $file->getStorage(); + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var IShare $share */ + $share = $storage->getShare(); + if (!$share->canSeeContent()) { + throw new NotFoundException(); + } + } + + $preview = $this->previewManager->getPreview($file, $x, $y, true); + + return new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]); + } catch (NotFoundException|NotPermittedException|InvalidPathException) { + return new DataResponse(['message' => 'File not found.'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + } + + /** + * Updates the info of the specified file path + * The passed tags are absolute, which means they will + * replace the actual tag selection. + * + * @param string $path path + * @param array|string $tags array of tags + * @return DataResponse + */ + #[NoAdminRequired] + public function updateFileTags($path, $tags = null) { + $result = []; + // if tags specified or empty array, update tags + if (!is_null($tags)) { + try { + $this->tagService->updateFileTags($path, $tags); + } catch (NotFoundException $e) { + return new DataResponse([ + 'message' => $e->getMessage() + ], Http::STATUS_NOT_FOUND); + } catch (StorageNotAvailableException $e) { + return new DataResponse([ + 'message' => $e->getMessage() + ], Http::STATUS_SERVICE_UNAVAILABLE); + } catch (\Exception $e) { + return new DataResponse([ + 'message' => $e->getMessage() + ], Http::STATUS_NOT_FOUND); + } + $result['tags'] = $tags; + } + return new DataResponse($result); + } + + /** + * @param \OCP\Files\Node[] $nodes + * @return array + */ + private function formatNodes(array $nodes) { + $shareTypesForNodes = $this->getShareTypesForNodes($nodes); + return array_values(array_map(function (Node $node) use ($shareTypesForNodes) { + $shareTypes = $shareTypesForNodes[$node->getId()] ?? []; + $file = Helper::formatFileInfo($node->getFileInfo()); + $file['hasPreview'] = $this->previewManager->isAvailable($node); + $parts = explode('/', dirname($node->getPath()), 4); + if (isset($parts[3])) { + $file['path'] = '/' . $parts[3]; + } else { + $file['path'] = '/'; + } + if (!empty($shareTypes)) { + $file['shareTypes'] = $shareTypes; + } + return $file; + }, $nodes)); + } + + /** + * Get the share types for each node + * + * @param \OCP\Files\Node[] $nodes + * @return array<int, int[]> list of share types for each fileid + */ + private function getShareTypesForNodes(array $nodes): array { + $userId = $this->userSession->getUser()->getUID(); + $requestedShareTypes = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP, + IShare::TYPE_LINK, + IShare::TYPE_REMOTE, + IShare::TYPE_EMAIL, + IShare::TYPE_ROOM, + IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, + ]; + $shareTypes = []; + + $nodeIds = array_map(function (Node $node) { + return $node->getId(); + }, $nodes); + + foreach ($requestedShareTypes as $shareType) { + $nodesLeft = array_combine($nodeIds, array_fill(0, count($nodeIds), true)); + $offset = 0; + + // fetch shares until we've either found shares for all nodes or there are no more shares left + while (count($nodesLeft) > 0) { + $shares = $this->shareManager->getSharesBy($userId, $shareType, null, false, 100, $offset); + foreach ($shares as $share) { + $fileId = $share->getNodeId(); + if (isset($nodesLeft[$fileId])) { + if (!isset($shareTypes[$fileId])) { + $shareTypes[$fileId] = []; + } + $shareTypes[$fileId][] = $shareType; + unset($nodesLeft[$fileId]); + } + } + + if (count($shares) < 100) { + break; + } else { + $offset += count($shares); + } + } + } + return $shareTypes; + } + + /** + * Returns a list of recently modified files. + * + * @return DataResponse + */ + #[NoAdminRequired] + public function getRecentFiles() { + $nodes = $this->userFolder->getRecent(100); + $files = $this->formatNodes($nodes); + return new DataResponse(['files' => $files]); + } + + /** + * @param \OCP\Files\Node[] $nodes + * @param int $depth The depth to traverse into the contents of each node + */ + private function getChildren(array $nodes, int $depth = 1, int $currentDepth = 0): array { + if ($currentDepth >= $depth) { + return []; + } + + $children = []; + foreach ($nodes as $node) { + if (!($node instanceof Folder)) { + continue; + } + + $basename = basename($node->getPath()); + $entry = [ + 'id' => $node->getId(), + 'basename' => $basename, + 'children' => $this->getChildren($node->getDirectoryListing(), $depth, $currentDepth + 1), + ]; + $displayName = $node->getName(); + if ($basename !== $displayName) { + $entry['displayName'] = $displayName; + } + $children[] = $entry; + } + return $children; + } + + /** + * Returns the folder tree of the user + * + * @param string $path The path relative to the user folder + * @param int $depth The depth of the tree + * + * @return JSONResponse<Http::STATUS_OK, FilesFolderTree, array{}>|JSONResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 200: Folder tree returned successfully + * 400: Invalid folder path + * 401: Unauthorized + * 404: Folder not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/folder-tree')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getFolderTree(string $path = '/', int $depth = 1): JSONResponse { + $user = $this->userSession->getUser(); + if (!($user instanceof IUser)) { + return new JSONResponse([ + 'message' => $this->l10n->t('Failed to authorize'), + ], Http::STATUS_UNAUTHORIZED); + } + try { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $userFolderPath = $userFolder->getPath(); + $fullPath = implode('/', [$userFolderPath, trim($path, '/')]); + $node = $this->rootFolder->get($fullPath); + if (!($node instanceof Folder)) { + return new JSONResponse([ + 'message' => $this->l10n->t('Invalid folder path'), + ], Http::STATUS_BAD_REQUEST); + } + $nodes = $node->getDirectoryListing(); + $tree = $this->getChildren($nodes, $depth); + } catch (NotFoundException $e) { + return new JSONResponse([ + 'message' => $this->l10n->t('Folder not found'), + ], Http::STATUS_NOT_FOUND); + } catch (Throwable $th) { + $this->logger->error($th->getMessage(), ['exception' => $th]); + $tree = []; + } + return new JSONResponse($tree); + } + + /** + * Returns the current logged-in user's storage stats. + * + * @param ?string $dir the directory to get the storage stats from + * @return JSONResponse + */ + #[NoAdminRequired] + public function getStorageStats($dir = '/'): JSONResponse { + $storageInfo = \OC_Helper::getStorageInfo($dir ?: '/'); + $response = new JSONResponse(['message' => 'ok', 'data' => $storageInfo]); + $response->cacheFor(5 * 60); + return $response; + } + + /** + * Set a user view config + * + * @param string $view + * @param string $key + * @param string|bool $value + * @return JSONResponse + */ + #[NoAdminRequired] + public function setViewConfig(string $view, string $key, $value): JSONResponse { + try { + $this->viewConfig->setConfig($view, $key, (string)$value); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfig($view)]); + } + + + /** + * Get the user view config + * + * @return JSONResponse + */ + #[NoAdminRequired] + public function getViewConfigs(): JSONResponse { + return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfigs()]); + } + + /** + * Set a user config + * + * @param string $key + * @param string|bool $value + * @return JSONResponse + */ + #[NoAdminRequired] + public function setConfig(string $key, $value): JSONResponse { + try { + $this->userConfig->setConfig($key, (string)$value); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(['message' => 'ok', 'data' => ['key' => $key, 'value' => $value]]); + } + + + /** + * Get the user config + * + * @return JSONResponse + */ + #[NoAdminRequired] + public function getConfigs(): JSONResponse { + return new JSONResponse(['message' => 'ok', 'data' => $this->userConfig->getConfigs()]); + } + + /** + * Toggle default for showing/hiding hidden files + * + * @param bool $value + * @return Response + * @throws PreConditionNotMetException + */ + #[NoAdminRequired] + public function showHiddenFiles(bool $value): Response { + $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'show_hidden', $value ? '1' : '0'); + return new Response(); + } + + /** + * Toggle default for cropping preview images + * + * @param bool $value + * @return Response + * @throws PreConditionNotMetException + */ + #[NoAdminRequired] + public function cropImagePreviews(bool $value): Response { + $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'crop_image_previews', $value ? '1' : '0'); + return new Response(); + } + + /** + * Toggle default for files grid view + * + * @param bool $show + * @return Response + * @throws PreConditionNotMetException + */ + #[NoAdminRequired] + public function showGridView(bool $show): Response { + $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'show_grid', $show ? '1' : '0'); + return new Response(); + } + + /** + * Get default settings for the grid view + */ + #[NoAdminRequired] + public function getGridView() { + $status = $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'show_grid', '0') === '1'; + return new JSONResponse(['gridview' => $status]); + } + + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + public function serviceWorker(): StreamResponse { + $response = new StreamResponse(__DIR__ . '/../../../../dist/preview-service-worker.js'); + $response->setHeaders([ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/' + ]); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedConnectDomain("'self'"); + $response->setContentSecurityPolicy($policy); + return $response; + } +} diff --git a/apps/files/lib/Controller/ConversionApiController.php b/apps/files/lib/Controller/ConversionApiController.php new file mode 100644 index 00000000000..40a42d6ca4c --- /dev/null +++ b/apps/files/lib/Controller/ConversionApiController.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Controller; + +use OC\Files\Utils\PathHelper; +use OC\ForbiddenException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\Files\Conversion\IConversionManager; +use OCP\Files\File; +use OCP\Files\GenericFileException; +use OCP\Files\IRootFolder; +use OCP\IL10N; +use OCP\IRequest; +use function OCP\Log\logger; + +class ConversionApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private IConversionManager $fileConversionManager, + private IRootFolder $rootFolder, + private IL10N $l10n, + private ?string $userId, + ) { + parent::__construct($appName, $request); + } + + /** + * Converts a file from one MIME type to another + * + * @param int $fileId ID of the file to be converted + * @param string $targetMimeType The MIME type to which you want to convert the file + * @param string|null $destination The target path of the converted file. Written to a temporary file if left empty + * + * @return DataResponse<Http::STATUS_CREATED, array{path: string, fileId: int}, array{}> + * + * 201: File was converted and written to the destination or temporary file + * + * @throws OCSException The file was unable to be converted + * @throws OCSNotFoundException The file to be converted was not found + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 25, period: 120)] + #[ApiRoute(verb: 'POST', url: '/api/v1/convert')] + public function convert(int $fileId, string $targetMimeType, ?string $destination = null): DataResponse { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $file = $userFolder->getFirstNodeById($fileId); + + // Also throw a 404 if the file is not readable to not leak information + if (!($file instanceof File) || $file->isReadable() === false) { + throw new OCSNotFoundException($this->l10n->t('The file cannot be found')); + } + + if ($destination !== null) { + $destination = PathHelper::normalizePath($destination); + $parentDir = dirname($destination); + + if (!$userFolder->nodeExists($parentDir)) { + throw new OCSNotFoundException($this->l10n->t('The destination path does not exist: %1$s', [$parentDir])); + } + + if (!$userFolder->get($parentDir)->isCreatable()) { + throw new OCSForbiddenException($this->l10n->t('You do not have permission to create a file at the specified location')); + } + + $destination = $userFolder->getFullPath($destination); + } + + try { + $convertedFile = $this->fileConversionManager->convert($file, $targetMimeType, $destination); + } catch (ForbiddenException $e) { + throw new OCSForbiddenException($e->getMessage()); + } catch (GenericFileException $e) { + throw new OCSBadRequestException($e->getMessage()); + } catch (\Exception $e) { + logger('files')->error($e->getMessage(), ['exception' => $e]); + throw new OCSException($this->l10n->t('The file could not be converted.')); + } + + $convertedFileRelativePath = $userFolder->getRelativePath($convertedFile); + if ($convertedFileRelativePath === null) { + throw new OCSNotFoundException($this->l10n->t('Could not get relative path to converted file')); + } + + $file = $userFolder->get($convertedFileRelativePath); + $fileId = $file->getId(); + + return new DataResponse([ + 'path' => $convertedFileRelativePath, + 'fileId' => $fileId, + ], Http::STATUS_CREATED); + } +} diff --git a/apps/files/lib/Controller/DirectEditingController.php b/apps/files/lib/Controller/DirectEditingController.php new file mode 100644 index 00000000000..c8addc33e98 --- /dev/null +++ b/apps/files/lib/Controller/DirectEditingController.php @@ -0,0 +1,153 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Controller; + +use Exception; +use OCA\Files\Service\DirectEditingService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\DirectEditing\IManager; +use OCP\DirectEditing\RegisterDirectEditorEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IRequest; +use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; + +class DirectEditingController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + string $corsMethods, + string $corsAllowedHeaders, + int $corsMaxAge, + private IEventDispatcher $eventDispatcher, + private IURLGenerator $urlGenerator, + private IManager $directEditingManager, + private DirectEditingService $directEditingService, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request, $corsMethods, $corsAllowedHeaders, $corsMaxAge); + } + + /** + * Get the direct editing capabilities + * @return DataResponse<Http::STATUS_OK, array{editors: array<string, array{id: string, name: string, mimetypes: list<string>, optionalMimetypes: list<string>, secure: bool}>, creators: array<string, array{id: string, editor: string, name: string, extension: string, templates: bool, mimetypes: list<string>}>}, array{}> + * + * 200: Direct editing capabilities returned + */ + #[NoAdminRequired] + public function info(): DataResponse { + $response = new DataResponse($this->directEditingService->getDirectEditingCapabilitites()); + $response->setETag($this->directEditingService->getDirectEditingETag()); + return $response; + } + + /** + * Create a file for direct editing + * + * @param string $path Path of the file + * @param string $editorId ID of the editor + * @param string $creatorId ID of the creator + * @param ?string $templateId ID of the template + * + * @return DataResponse<Http::STATUS_OK, array{url: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: URL for direct editing returned + * 403: Opening file is not allowed + */ + #[NoAdminRequired] + public function create(string $path, string $editorId, string $creatorId, ?string $templateId = null): DataResponse { + if (!$this->directEditingManager->isEnabled()) { + return new DataResponse(['message' => 'Direct editing is not enabled'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); + + try { + $token = $this->directEditingManager->create($path, $editorId, $creatorId, $templateId); + return new DataResponse([ + 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', ['token' => $token]) + ]); + } catch (Exception $e) { + $this->logger->error( + 'Exception when creating a new file through direct editing', + [ + 'exception' => $e + ], + ); + return new DataResponse(['message' => 'Failed to create file: ' . $e->getMessage()], Http::STATUS_FORBIDDEN); + } + } + + /** + * Open a file for direct editing + * + * @param string $path Path of the file + * @param ?string $editorId ID of the editor + * @param ?int $fileId ID of the file + * + * @return DataResponse<Http::STATUS_OK, array{url: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: URL for direct editing returned + * 403: Opening file is not allowed + */ + #[NoAdminRequired] + public function open(string $path, ?string $editorId = null, ?int $fileId = null): DataResponse { + if (!$this->directEditingManager->isEnabled()) { + return new DataResponse(['message' => 'Direct editing is not enabled'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); + + try { + $token = $this->directEditingManager->open($path, $editorId, $fileId); + return new DataResponse([ + 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', ['token' => $token]) + ]); + } catch (Exception $e) { + $this->logger->error( + 'Exception when opening a file through direct editing', + [ + 'exception' => $e + ], + ); + return new DataResponse(['message' => 'Failed to open file: ' . $e->getMessage()], Http::STATUS_FORBIDDEN); + } + } + + + + /** + * Get the templates for direct editing + * + * @param string $editorId ID of the editor + * @param string $creatorId ID of the creator + * + * @return DataResponse<Http::STATUS_OK, array{templates: array<string, array{id: string, title: string, preview: ?string, extension: string, mimetype: string}>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Templates returned + */ + #[NoAdminRequired] + public function templates(string $editorId, string $creatorId): DataResponse { + if (!$this->directEditingManager->isEnabled()) { + return new DataResponse(['message' => 'Direct editing is not enabled'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); + + try { + return new DataResponse($this->directEditingManager->getTemplates($editorId, $creatorId)); + } catch (Exception $e) { + $this->logger->error( + $e->getMessage(), + [ + 'exception' => $e + ], + ); + return new DataResponse(['message' => 'Failed to obtain template list: ' . $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/apps/files/lib/Controller/DirectEditingViewController.php b/apps/files/lib/Controller/DirectEditingViewController.php new file mode 100644 index 00000000000..b13e68f7766 --- /dev/null +++ b/apps/files/lib/Controller/DirectEditingViewController.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Controller; + +use Exception; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UseSession; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\Response; +use OCP\DirectEditing\IManager; +use OCP\DirectEditing\RegisterDirectEditorEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class DirectEditingViewController extends Controller { + public function __construct( + $appName, + IRequest $request, + private IEventDispatcher $eventDispatcher, + private IManager $directEditingManager, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * @param string $token + * @return Response + */ + #[PublicPage] + #[NoCSRFRequired] + #[UseSession] + public function edit(string $token): Response { + $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); + try { + return $this->directEditingManager->edit($token); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new NotFoundResponse(); + } + } +} diff --git a/apps/files/lib/Controller/OpenLocalEditorController.php b/apps/files/lib/Controller/OpenLocalEditorController.php new file mode 100644 index 00000000000..b000304eef6 --- /dev/null +++ b/apps/files/lib/Controller/OpenLocalEditorController.php @@ -0,0 +1,128 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Controller; + +use OCA\Files\Db\OpenLocalEditor; +use OCA\Files\Db\OpenLocalEditorMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; +use OCP\IRequest; +use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; + +class OpenLocalEditorController extends OCSController { + public const TOKEN_LENGTH = 128; + public const TOKEN_DURATION = 600; // 10 Minutes + public const TOKEN_RETRIES = 50; + + public function __construct( + string $appName, + IRequest $request, + protected ITimeFactory $timeFactory, + protected OpenLocalEditorMapper $mapper, + protected ISecureRandom $secureRandom, + protected LoggerInterface $logger, + protected ?string $userId, + ) { + parent::__construct($appName, $request); + } + + /** + * Create a local editor + * + * @param string $path Path of the file + * + * @return DataResponse<Http::STATUS_OK, array{userId: ?string, pathHash: string, expirationTime: int, token: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}> + * + * 200: Local editor returned + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 10, period: 120)] + public function create(string $path): DataResponse { + $pathHash = sha1($path); + + $entity = new OpenLocalEditor(); + $entity->setUserId($this->userId); + $entity->setPathHash($pathHash); + $entity->setExpirationTime($this->timeFactory->getTime() + self::TOKEN_DURATION); // Expire in 10 minutes + + for ($i = 1; $i <= self::TOKEN_RETRIES; $i++) { + $token = $this->secureRandom->generate(self::TOKEN_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC); + $entity->setToken($token); + + try { + $this->mapper->insert($entity); + + return new DataResponse([ + 'userId' => $this->userId, + 'pathHash' => $pathHash, + 'expirationTime' => $entity->getExpirationTime(), + 'token' => $entity->getToken(), + ]); + } catch (Exception $e) { + if ($e->getCode() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + // Only retry on unique constraint violation + throw $e; + } + } + } + + $this->logger->error('Giving up after ' . self::TOKEN_RETRIES . ' retries to generate a unique local editor token for path hash: ' . $pathHash); + return new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + /** + * Validate a local editor + * + * @param string $path Path of the file + * @param string $token Token of the local editor + * + * @return DataResponse<Http::STATUS_OK, array{userId: string, pathHash: string, expirationTime: int, token: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Local editor validated successfully + * 404: Local editor not found + */ + #[NoAdminRequired] + #[BruteForceProtection(action: 'openLocalEditor')] + public function validate(string $path, string $token): DataResponse { + $pathHash = sha1($path); + + try { + $entity = $this->mapper->verifyToken($this->userId, $pathHash, $token); + } catch (DoesNotExistException $e) { + $response = new DataResponse([], Http::STATUS_NOT_FOUND); + $response->throttle(['userId' => $this->userId, 'pathHash' => $pathHash]); + return $response; + } + + $this->mapper->delete($entity); + + if ($entity->getExpirationTime() <= $this->timeFactory->getTime()) { + $response = new DataResponse([], Http::STATUS_NOT_FOUND); + $response->throttle(['userId' => $this->userId, 'pathHash' => $pathHash]); + return $response; + } + + return new DataResponse([ + 'userId' => $this->userId, + 'pathHash' => $pathHash, + 'expirationTime' => $entity->getExpirationTime(), + 'token' => $entity->getToken(), + ]); + } + +} diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php new file mode 100644 index 00000000000..ee4c86941c7 --- /dev/null +++ b/apps/files/lib/Controller/TemplateController.php @@ -0,0 +1,128 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Controller; + +use OCA\Files\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCSController; +use OCP\Files\GenericFileException; +use OCP\Files\Template\ITemplateManager; +use OCP\Files\Template\Template; +use OCP\Files\Template\TemplateFileCreator; +use OCP\IRequest; + +/** + * @psalm-import-type FilesTemplateFile from ResponseDefinitions + * @psalm-import-type FilesTemplateFileCreator from ResponseDefinitions + * @psalm-import-type FilesTemplateFileCreatorWithTemplates from ResponseDefinitions + * @psalm-import-type FilesTemplateField from ResponseDefinitions + * @psalm-import-type FilesTemplate from ResponseDefinitions + */ +class TemplateController extends OCSController { + public function __construct( + $appName, + IRequest $request, + protected ITemplateManager $templateManager, + ) { + parent::__construct($appName, $request); + } + + /** + * List the available templates + * + * @return DataResponse<Http::STATUS_OK, list<FilesTemplateFileCreatorWithTemplates>, array{}> + * + * 200: Available templates returned + */ + #[NoAdminRequired] + public function list(): DataResponse { + /* Convert embedded Template instances to arrays to match return type */ + return new DataResponse(array_map(static function (array $templateFileCreator) { + $templateFileCreator['templates'] = array_map(static fn (Template $template) => $template->jsonSerialize(), $templateFileCreator['templates']); + return $templateFileCreator; + }, $this->templateManager->listTemplates())); + } + + /** + * List the fields for the template specified by the given file ID + * + * @param int $fileId File ID of the template + * @return DataResponse<Http::STATUS_OK, array<string, FilesTemplateField>, array{}> + * + * 200: Fields returned + */ + #[NoAdminRequired] + public function listTemplateFields(int $fileId): DataResponse { + $fields = $this->templateManager->listTemplateFields($fileId); + + return new DataResponse( + array_merge([], ...$fields), + Http::STATUS_OK + ); + } + + /** + * Create a template + * + * @param string $filePath Path of the file + * @param string $templatePath Name of the template + * @param string $templateType Type of the template + * @param list<FilesTemplateField> $templateFields Fields of the template + * + * @return DataResponse<Http::STATUS_OK, FilesTemplateFile, array{}> + * @throws OCSForbiddenException Creating template is not allowed + * + * 200: Template created successfully + */ + #[NoAdminRequired] + public function create( + string $filePath, + string $templatePath = '', + string $templateType = 'user', + array $templateFields = [], + ): DataResponse { + try { + return new DataResponse($this->templateManager->createFromTemplate( + $filePath, + $templatePath, + $templateType, + $templateFields)); + } catch (GenericFileException $e) { + throw new OCSForbiddenException($e->getMessage()); + } + } + + /** + * Initialize the template directory + * + * @param string $templatePath Path of the template directory + * @param bool $copySystemTemplates Whether to copy the system templates to the template directory + * + * @return DataResponse<Http::STATUS_OK, array{template_path: string, templates: list<FilesTemplateFileCreator>}, array{}> + * @throws OCSForbiddenException Initializing the template directory is not allowed + * + * 200: Template directory initialized successfully + */ + #[NoAdminRequired] + public function path(string $templatePath = '', bool $copySystemTemplates = false) { + try { + /** @var string $templatePath */ + $templatePath = $this->templateManager->initializeTemplateDirectory($templatePath, null, $copySystemTemplates); + return new DataResponse([ + 'template_path' => $templatePath, + 'templates' => array_values(array_map(fn (TemplateFileCreator $creator) => $creator->jsonSerialize(), $this->templateManager->listCreators())), + ]); + } catch (\Exception $e) { + throw new OCSForbiddenException($e->getMessage()); + } + } +} diff --git a/apps/files/lib/Controller/TransferOwnershipController.php b/apps/files/lib/Controller/TransferOwnershipController.php new file mode 100644 index 00000000000..51a25400efb --- /dev/null +++ b/apps/files/lib/Controller/TransferOwnershipController.php @@ -0,0 +1,168 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Controller; + +use OCA\Files\BackgroundJob\TransferOwnership; +use OCA\Files\Db\TransferOwnership as TransferOwnershipEntity; +use OCA\Files\Db\TransferOwnershipMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\Files\IHomeStorage; +use OCP\Files\IRootFolder; +use OCP\IRequest; +use OCP\IUserManager; +use OCP\Notification\IManager as NotificationManager; + +class TransferOwnershipController extends OCSController { + + public function __construct( + string $appName, + IRequest $request, + private string $userId, + private NotificationManager $notificationManager, + private ITimeFactory $timeFactory, + private IJobList $jobList, + private TransferOwnershipMapper $mapper, + private IUserManager $userManager, + private IRootFolder $rootFolder, + ) { + parent::__construct($appName, $request); + } + + + /** + * Transfer the ownership to another user + * + * @param string $recipient Username of the recipient + * @param string $path Path of the file + * + * @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN, list<empty>, array{}> + * + * 200: Ownership transferred successfully + * 400: Transferring ownership is not possible + * 403: Transferring ownership is not allowed + */ + #[NoAdminRequired] + public function transfer(string $recipient, string $path): DataResponse { + $recipientUser = $this->userManager->get($recipient); + + if ($recipientUser === null) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + $userRoot = $this->rootFolder->getUserFolder($this->userId); + + try { + $node = $userRoot->get($path); + } catch (\Exception $e) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + if ($node->getOwner()->getUID() !== $this->userId || !$node->getStorage()->instanceOfStorage(IHomeStorage::class)) { + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + + $transferOwnership = new TransferOwnershipEntity(); + $transferOwnership->setSourceUser($this->userId); + $transferOwnership->setTargetUser($recipient); + $transferOwnership->setFileId($node->getId()); + $transferOwnership->setNodeName($node->getName()); + $transferOwnership = $this->mapper->insert($transferOwnership); + + $notification = $this->notificationManager->createNotification(); + $notification->setUser($recipient) + ->setApp($this->appName) + ->setDateTime($this->timeFactory->getDateTime()) + ->setSubject('transferownershipRequest', [ + 'sourceUser' => $this->userId, + 'targetUser' => $recipient, + 'nodeName' => $node->getName(), + ]) + ->setObject('transfer', (string)$transferOwnership->getId()); + + $this->notificationManager->notify($notification); + + return new DataResponse([]); + } + + /** + * Accept an ownership transfer + * + * @param int $id ID of the ownership transfer + * + * @return DataResponse<Http::STATUS_OK|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Ownership transfer accepted successfully + * 403: Accepting ownership transfer is not allowed + * 404: Ownership transfer not found + */ + #[NoAdminRequired] + public function accept(int $id): DataResponse { + try { + $transferOwnership = $this->mapper->getById($id); + } catch (DoesNotExistException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if ($transferOwnership->getTargetUser() !== $this->userId) { + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + + $this->jobList->add(TransferOwnership::class, [ + 'id' => $transferOwnership->getId(), + ]); + + $notification = $this->notificationManager->createNotification(); + $notification->setApp('files') + ->setObject('transfer', (string)$id); + $this->notificationManager->markProcessed($notification); + + return new DataResponse([], Http::STATUS_OK); + } + + /** + * Reject an ownership transfer + * + * @param int $id ID of the ownership transfer + * + * @return DataResponse<Http::STATUS_OK|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Ownership transfer rejected successfully + * 403: Rejecting ownership transfer is not allowed + * 404: Ownership transfer not found + */ + #[NoAdminRequired] + public function reject(int $id): DataResponse { + try { + $transferOwnership = $this->mapper->getById($id); + } catch (DoesNotExistException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if ($transferOwnership->getTargetUser() !== $this->userId) { + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + + $notification = $this->notificationManager->createNotification(); + $notification->setApp('files') + ->setObject('transfer', (string)$id); + $this->notificationManager->markProcessed($notification); + + $this->mapper->delete($transferOwnership); + + // A "request denied" notification will be created by Notifier::dismissNotification + + return new DataResponse([], Http::STATUS_OK); + } +} diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php new file mode 100644 index 00000000000..ecf21cef313 --- /dev/null +++ b/apps/files/lib/Controller/ViewController.php @@ -0,0 +1,306 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files\Controller; + +use OC\Files\FilenameValidator; +use OC\Files\Filesystem; +use OCA\Files\AppInfo\Application; +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\Files\Event\LoadSearchPlugins; +use OCA\Files\Event\LoadSidebar; +use OCA\Files\Service\UserConfig; +use OCA\Files\Service\ViewConfig; +use OCA\Viewer\Event\LoadViewer; +use OCP\App\IAppManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Authentication\TwoFactorAuth\IRegistry; +use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent as ResourcesLoadAdditionalScriptsEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Template\ITemplateManager; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Util; + +/** + * @package OCA\Files\Controller + */ +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class ViewController extends Controller { + + public function __construct( + string $appName, + IRequest $request, + private IURLGenerator $urlGenerator, + private IL10N $l10n, + private IConfig $config, + private IEventDispatcher $eventDispatcher, + private IUserSession $userSession, + private IAppManager $appManager, + private IRootFolder $rootFolder, + private IInitialState $initialState, + private ITemplateManager $templateManager, + private UserConfig $userConfig, + private ViewConfig $viewConfig, + private FilenameValidator $filenameValidator, + private IRegistry $twoFactorRegistry, + ) { + parent::__construct($appName, $request); + } + + /** + * FIXME: Replace with non static code + * + * @return array + * @throws NotFoundException + */ + protected function getStorageInfo(string $dir = '/') { + $rootInfo = Filesystem::getFileInfo('/', false); + + return \OC_Helper::getStorageInfo($dir, $rootInfo ?: null); + } + + /** + * @param string $fileid + * @return TemplateResponse|RedirectResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function showFile(?string $fileid = null, ?string $opendetails = null, ?string $openfile = null): Response { + if (!$fileid) { + return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index')); + } + + // This is the entry point from the `/f/{fileid}` URL which is hardcoded in the server. + try { + return $this->redirectToFile((int)$fileid, $opendetails, $openfile); + } catch (NotFoundException $e) { + // Keep the fileid even if not found, it will be used + // to detect the file could not be found and warn the user + return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', ['fileid' => $fileid, 'view' => 'files'])); + } + } + + + /** + * @param string $dir + * @param string $view + * @param string $fileid + * @return TemplateResponse|RedirectResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function indexView($dir = '', $view = '', $fileid = null) { + return $this->index($dir, $view, $fileid); + } + + /** + * @param string $dir + * @param string $view + * @param string $fileid + * @return TemplateResponse|RedirectResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function indexViewFileid($dir = '', $view = '', $fileid = null) { + return $this->index($dir, $view, $fileid); + } + + /** + * @param string $dir + * @param string $view + * @param string $fileid + * @return TemplateResponse|RedirectResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index($dir = '', $view = '', $fileid = null) { + if ($fileid !== null && $view !== 'trashbin') { + try { + return $this->redirectToFileIfInTrashbin((int)$fileid); + } catch (NotFoundException $e) { + } + } + + // Load the files we need + Util::addInitScript('files', 'init'); + Util::addScript('files', 'main'); + + $user = $this->userSession->getUser(); + $userId = $user->getUID(); + + // If the file doesn't exists in the folder and + // exists in only one occurrence, redirect to that file + // in the correct folder + if ($fileid && $dir !== '') { + $baseFolder = $this->rootFolder->getUserFolder($userId); + $nodes = $baseFolder->getById((int)$fileid); + if (!empty($nodes)) { + $nodePath = $baseFolder->getRelativePath($nodes[0]->getPath()); + $relativePath = $nodePath ? dirname($nodePath) : ''; + // If the requested path does not contain the file id + // or if the requested path is not the file id itself + if (count($nodes) === 1 && $relativePath !== $dir && $nodePath !== $dir) { + return $this->redirectToFile((int)$fileid); + } + } + } + + try { + // If view is files, we use the directory, otherwise we use the root storage + $storageInfo = $this->getStorageInfo(($view === 'files' && $dir) ? $dir : '/'); + } catch (\Exception $e) { + $storageInfo = $this->getStorageInfo(); + } + + $this->initialState->provideInitialState('storageStats', $storageInfo); + $this->initialState->provideInitialState('config', $this->userConfig->getConfigs()); + $this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs()); + + // File sorting user config + $filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true); + $this->initialState->provideInitialState('filesSortingConfig', $filesSortingConfig); + + // Forbidden file characters (deprecated use capabilities) + // TODO: Remove with next release of `@nextcloud/files` + $forbiddenCharacters = $this->filenameValidator->getForbiddenCharacters(); + $this->initialState->provideInitialState('forbiddenCharacters', $forbiddenCharacters); + + $event = new LoadAdditionalScriptsEvent(); + $this->eventDispatcher->dispatchTyped($event); + $this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent()); + $this->eventDispatcher->dispatchTyped(new LoadSidebar()); + $this->eventDispatcher->dispatchTyped(new LoadSearchPlugins()); + // Load Viewer scripts + if (class_exists(LoadViewer::class)) { + $this->eventDispatcher->dispatchTyped(new LoadViewer()); + } + + $this->initialState->provideInitialState('templates_enabled', ($this->config->getSystemValueString('skeletondirectory', \OC::$SERVERROOT . '/core/skeleton') !== '') || ($this->config->getSystemValueString('templatedirectory', \OC::$SERVERROOT . '/core/skeleton/Templates') !== '')); + $this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false); + $this->initialState->provideInitialState('templates', $this->templateManager->listCreators()); + + $isTwoFactorEnabled = false; + foreach ($this->twoFactorRegistry->getProviderStates($user) as $providerId => $providerState) { + if ($providerId !== 'backup_codes' && $providerState === true) { + $isTwoFactorEnabled = true; + } + } + + $this->initialState->provideInitialState('isTwoFactorEnabled', $isTwoFactorEnabled); + + $response = new TemplateResponse( + Application::APP_ID, + 'index', + ); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedFrameDomain('\'self\''); + // Allow preview service worker + $policy->addAllowedWorkerSrcDomain('\'self\''); + $response->setContentSecurityPolicy($policy); + + return $response; + } + + /** + * Redirects to the trashbin file list and highlight the given file id + * + * @param int $fileId file id to show + * @return RedirectResponse redirect response or not found response + * @throws NotFoundException + */ + private function redirectToFileIfInTrashbin($fileId): RedirectResponse { + $uid = $this->userSession->getUser()->getUID(); + $baseFolder = $this->rootFolder->getUserFolder($uid); + $node = $baseFolder->getFirstNodeById($fileId); + $params = []; + + if (!$node && $this->appManager->isEnabledForUser('files_trashbin')) { + /** @var Folder */ + $baseFolder = $this->rootFolder->get($uid . '/files_trashbin/files/'); + $node = $baseFolder->getFirstNodeById($fileId); + $params['view'] = 'trashbin'; + + if ($node) { + $params['fileid'] = $fileId; + if ($node instanceof Folder) { + // set the full path to enter the folder + $params['dir'] = $baseFolder->getRelativePath($node->getPath()); + } else { + // set parent path as dir + $params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath()); + } + return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params)); + } + } + throw new NotFoundException(); + } + + /** + * Redirects to the file list and highlight the given file id + * + * @param int $fileId file id to show + * @param string|null $openDetails open details parameter + * @param string|null $openFile open file parameter + * @return RedirectResponse redirect response or not found response + * @throws NotFoundException + */ + private function redirectToFile(int $fileId, ?string $openDetails = null, ?string $openFile = null): RedirectResponse { + $uid = $this->userSession->getUser()->getUID(); + $baseFolder = $this->rootFolder->getUserFolder($uid); + $node = $baseFolder->getFirstNodeById($fileId); + $params = ['view' => 'files']; + + try { + $this->redirectToFileIfInTrashbin($fileId); + } catch (NotFoundException $e) { + } + + if ($node) { + $params['fileid'] = $fileId; + if ($node instanceof Folder) { + // set the full path to enter the folder + $params['dir'] = $baseFolder->getRelativePath($node->getPath()); + } else { + // set parent path as dir + $params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath()); + // open the file by default (opening the viewer) + $params['openfile'] = 'true'; + } + + // Forward open parameters if any. + // - openfile is true by default + // - opendetails is undefined by default + // - both will be evaluated as truthy + if ($openDetails !== null) { + $params['opendetails'] = $openDetails !== 'false' ? 'true' : 'false'; + } + + if ($openFile !== null) { + $params['openfile'] = $openFile !== 'false' ? 'true' : 'false'; + } + + return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params)); + } + + throw new NotFoundException(); + } +} diff --git a/apps/files/lib/Dashboard/FavoriteWidget.php b/apps/files/lib/Dashboard/FavoriteWidget.php new file mode 100644 index 00000000000..b68b8a56b2e --- /dev/null +++ b/apps/files/lib/Dashboard/FavoriteWidget.php @@ -0,0 +1,141 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Dashboard; + +use OCA\Files\AppInfo\Application; +use OCP\Dashboard\IAPIWidgetV2; +use OCP\Dashboard\IButtonWidget; +use OCP\Dashboard\IIconWidget; +use OCP\Dashboard\IOptionWidget; +use OCP\Dashboard\Model\WidgetButton; +use OCP\Dashboard\Model\WidgetItem; +use OCP\Dashboard\Model\WidgetItems; +use OCP\Dashboard\Model\WidgetOptions; +use OCP\Files\File; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; +use OCP\IL10N; +use OCP\IPreview; +use OCP\ITagManager; +use OCP\IURLGenerator; +use OCP\IUserManager; + +class FavoriteWidget implements IIconWidget, IAPIWidgetV2, IButtonWidget, IOptionWidget { + + public function __construct( + private readonly IL10N $l10n, + private readonly IURLGenerator $urlGenerator, + private readonly IMimeTypeDetector $mimeTypeDetector, + private readonly IUserManager $userManager, + private readonly ITagManager $tagManager, + private readonly IRootFolder $rootFolder, + private readonly IPreview $previewManager, + ) { + } + + public function getId(): string { + return Application::APP_ID . '-favorites'; + } + + public function getTitle(): string { + return $this->l10n->t('Favorite files'); + } + + public function getOrder(): int { + return 0; + } + + public function getIconClass(): string { + return 'icon-star-dark'; + } + + public function getIconUrl(): string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath('core', 'actions/star.svg') + ); + } + + public function getUrl(): ?string { + return $this->urlGenerator->linkToRouteAbsolute('files.View.indexView', ['view' => 'favorites']); + } + + public function load(): void { + } + + public function getItems(string $userId, int $limit = 7): array { + $user = $this->userManager->get($userId); + + if (!$user) { + return []; + } + $tags = $this->tagManager->load('files', [], false, $userId); + $favorites = $tags->getFavorites(); + if (empty($favorites)) { + return []; + } + $favoriteNodes = []; + $userFolder = $this->rootFolder->getUserFolder($userId); + $count = 0; + foreach ($favorites as $favorite) { + $node = $userFolder->getFirstNodeById($favorite); + if ($node) { + $url = $this->urlGenerator->linkToRouteAbsolute( + 'files.view.showFile', ['fileid' => $node->getId()] + ); + if ($node instanceof File) { + $icon = $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', [ + 'x' => 256, + 'y' => 256, + 'fileId' => $node->getId(), + 'c' => $node->getEtag(), + 'mimeFallback' => true, + ]); + } else { + $icon = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'filetypes/folder.svg')); + } + $favoriteNodes[] = new WidgetItem( + $node->getName(), + '', + $url, + $icon, + (string)$node->getCreationTime() + ); + $count++; + if ($count >= $limit) { + break; + } + } + } + + return $favoriteNodes; + } + + public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems { + $items = $this->getItems($userId, $limit); + return new WidgetItems( + $items, + count($items) === 0 ? $this->l10n->t('No favorites') : '', + ); + } + + public function getWidgetButtons(string $userId): array { + return [ + new WidgetButton( + WidgetButton::TYPE_MORE, + $this->urlGenerator->linkToRouteAbsolute('files.View.indexView', ['view' => 'favorites']), + $this->l10n->t('More favorites') + ), + ]; + } + + public function getWidgetOptions(): WidgetOptions { + return new WidgetOptions(roundItemIcons: false); + } +} diff --git a/apps/files/lib/Db/OpenLocalEditor.php b/apps/files/lib/Db/OpenLocalEditor.php new file mode 100644 index 00000000000..da7f5d13206 --- /dev/null +++ b/apps/files/lib/Db/OpenLocalEditor.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method void setUserId(string $userId) + * @method string getUserId() + * @method void setPathHash(string $pathHash) + * @method string getPathHash() + * @method void setExpirationTime(int $expirationTime) + * @method int getExpirationTime() + * @method void setToken(string $token) + * @method string getToken() + */ +class OpenLocalEditor extends Entity { + /** @var string */ + protected $userId; + + /** @var string */ + protected $pathHash; + + /** @var int */ + protected $expirationTime; + + /** @var string */ + protected $token; + + public function __construct() { + $this->addType('userId', 'string'); + $this->addType('pathHash', 'string'); + $this->addType('expirationTime', 'integer'); + $this->addType('token', 'string'); + } +} diff --git a/apps/files/lib/Db/OpenLocalEditorMapper.php b/apps/files/lib/Db/OpenLocalEditorMapper.php new file mode 100644 index 00000000000..6ae8b79c258 --- /dev/null +++ b/apps/files/lib/Db/OpenLocalEditorMapper.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<OpenLocalEditor> + */ +class OpenLocalEditorMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'open_local_editor', OpenLocalEditor::class); + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function verifyToken(string $userId, string $pathHash, string $token): OpenLocalEditor { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('path_hash', $qb->createNamedParameter($pathHash))) + ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token))); + + return $this->findEntity($qb); + } + + public function deleteExpiredTokens(int $time): void { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('expiration_time', $qb->createNamedParameter($time))); + + $qb->executeStatement(); + } +} diff --git a/apps/files/lib/Db/TransferOwnership.php b/apps/files/lib/Db/TransferOwnership.php new file mode 100644 index 00000000000..ae78c19b76d --- /dev/null +++ b/apps/files/lib/Db/TransferOwnership.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method void setSourceUser(string $uid) + * @method string getSourceUser() + * @method void setTargetUser(string $uid) + * @method string getTargetUser() + * @method void setFileId(int $fileId) + * @method int getFileId() + * @method void setNodeName(string $name) + * @method string getNodeName() + */ +class TransferOwnership extends Entity { + /** @var string */ + protected $sourceUser; + + /** @var string */ + protected $targetUser; + + /** @var integer */ + protected $fileId; + + /** @var string */ + protected $nodeName; + + public function __construct() { + $this->addType('sourceUser', 'string'); + $this->addType('targetUser', 'string'); + $this->addType('fileId', 'integer'); + $this->addType('nodeName', 'string'); + } +} diff --git a/apps/files/lib/Db/TransferOwnershipMapper.php b/apps/files/lib/Db/TransferOwnershipMapper.php new file mode 100644 index 00000000000..8b29399f768 --- /dev/null +++ b/apps/files/lib/Db/TransferOwnershipMapper.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<TransferOwnership> + */ +class TransferOwnershipMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'user_transfer_owner', TransferOwnership::class); + } + + public function getById(int $id): TransferOwnership { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id)) + ); + + return $this->findEntity($qb); + } +} diff --git a/apps/files/lib/DirectEditingCapabilities.php b/apps/files/lib/DirectEditingCapabilities.php new file mode 100644 index 00000000000..5bceef9305f --- /dev/null +++ b/apps/files/lib/DirectEditingCapabilities.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files; + +use OCA\Files\Service\DirectEditingService; +use OCP\Capabilities\ICapability; +use OCP\Capabilities\IInitialStateExcludedCapability; +use OCP\IURLGenerator; + +class DirectEditingCapabilities implements ICapability, IInitialStateExcludedCapability { + public function __construct( + protected DirectEditingService $directEditingService, + protected IURLGenerator $urlGenerator, + ) { + } + + /** + * @return array{files: array{directEditing: array{url: string, etag: string, supportsFileId: bool}}} + */ + public function getCapabilities() { + return [ + 'files' => [ + 'directEditing' => [ + 'url' => $this->urlGenerator->linkToOCSRouteAbsolute('files.DirectEditing.info'), + 'etag' => $this->directEditingService->getDirectEditingETag(), + 'supportsFileId' => true, + ] + ], + ]; + } +} diff --git a/apps/files/lib/Event/LoadAdditionalScriptsEvent.php b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php new file mode 100644 index 00000000000..d1cf7f4016e --- /dev/null +++ b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Event; + +use OCP\EventDispatcher\Event; + +/** + * This event is triggered when the files app is rendered. + * + * @since 17.0.0 + */ +class LoadAdditionalScriptsEvent extends Event { +} diff --git a/apps/files/lib/Event/LoadSearchPlugins.php b/apps/files/lib/Event/LoadSearchPlugins.php new file mode 100644 index 00000000000..9c6c81fcca5 --- /dev/null +++ b/apps/files/lib/Event/LoadSearchPlugins.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Event; + +use OCP\EventDispatcher\Event; + +class LoadSearchPlugins extends Event { +} diff --git a/apps/files/lib/Event/LoadSidebar.php b/apps/files/lib/Event/LoadSidebar.php new file mode 100644 index 00000000000..01db57bb562 --- /dev/null +++ b/apps/files/lib/Event/LoadSidebar.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Event; + +use OCP\EventDispatcher\Event; + +class LoadSidebar extends Event { +} diff --git a/apps/files/lib/Exception/TransferOwnershipException.php b/apps/files/lib/Exception/TransferOwnershipException.php new file mode 100644 index 00000000000..531c5d513da --- /dev/null +++ b/apps/files/lib/Exception/TransferOwnershipException.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Exception; + +use Exception; + +class TransferOwnershipException extends Exception { +} diff --git a/apps/files/lib/Helper.php b/apps/files/lib/Helper.php new file mode 100644 index 00000000000..b1439ac7fa5 --- /dev/null +++ b/apps/files/lib/Helper.php @@ -0,0 +1,147 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files; + +use OC\Files\Filesystem; +use OCP\Files\FileInfo; +use OCP\Util; + +/** + * Helper class for manipulating file information + */ +class Helper { + /** + * Comparator function to sort files alphabetically and have + * the directories appear first + * + * @param FileInfo $a file + * @param FileInfo $b file + * @return int -1 if $a must come before $b, 1 otherwise + */ + public static function compareFileNames(FileInfo $a, FileInfo $b) { + $aType = $a->getType(); + $bType = $b->getType(); + if ($aType === 'dir' and $bType !== 'dir') { + return -1; + } elseif ($aType !== 'dir' and $bType === 'dir') { + return 1; + } else { + return Util::naturalSortCompare($a->getName(), $b->getName()); + } + } + + /** + * Comparator function to sort files by date + * + * @param FileInfo $a file + * @param FileInfo $b file + * @return int -1 if $a must come before $b, 1 otherwise + */ + public static function compareTimestamp(FileInfo $a, FileInfo $b) { + $aTime = $a->getMTime(); + $bTime = $b->getMTime(); + return ($aTime < $bTime) ? -1 : 1; + } + + /** + * Comparator function to sort files by size + * + * @param FileInfo $a file + * @param FileInfo $b file + * @return int -1 if $a must come before $b, 1 otherwise + */ + public static function compareSize(FileInfo $a, FileInfo $b) { + $aSize = $a->getSize(); + $bSize = $b->getSize(); + return ($aSize < $bSize) ? -1 : 1; + } + + /** + * Formats the file info to be returned as JSON to the client. + * + * @param FileInfo $i + * @return array formatted file info + */ + public static function formatFileInfo(FileInfo $i) { + $entry = []; + + $entry['id'] = $i->getId(); + $entry['parentId'] = $i->getParentId(); + $entry['mtime'] = $i->getMtime() * 1000; + // only pick out the needed attributes + $entry['name'] = $i->getName(); + $entry['permissions'] = $i->getPermissions(); + $entry['mimetype'] = $i->getMimetype(); + $entry['size'] = $i->getSize(); + $entry['type'] = $i->getType(); + $entry['etag'] = $i->getEtag(); + // TODO: this is using the private implementation of FileInfo + // the array access is not part of the public interface + if (isset($i['tags'])) { + $entry['tags'] = $i['tags']; + } + if (isset($i['displayname_owner'])) { + $entry['shareOwner'] = $i['displayname_owner']; + } + if (isset($i['is_share_mount_point'])) { + $entry['isShareMountPoint'] = $i['is_share_mount_point']; + } + if (isset($i['extraData'])) { + $entry['extraData'] = $i['extraData']; + } + + $mountType = null; + $mount = $i->getMountPoint(); + $mountType = $mount->getMountType(); + if ($mountType !== '') { + if ($i->getInternalPath() === '') { + $mountType .= '-root'; + } + $entry['mountType'] = $mountType; + } + return $entry; + } + + /** + * Retrieves the contents of the given directory and + * returns it as a sorted array of FileInfo. + * + * @param string $dir path to the directory + * @param string $sortAttribute attribute to sort on + * @param bool $sortDescending true for descending sort, false otherwise + * @param string $mimetypeFilter limit returned content to this mimetype or mimepart + * @return FileInfo[] files + */ + public static function getFiles($dir, $sortAttribute = 'name', $sortDescending = false, $mimetypeFilter = '') { + $content = Filesystem::getDirectoryContent($dir, $mimetypeFilter); + + return self::sortFiles($content, $sortAttribute, $sortDescending); + } + + /** + * Sort the given file info array + * + * @param FileInfo[] $files files to sort + * @param string $sortAttribute attribute to sort on + * @param bool $sortDescending true for descending sort, false otherwise + * @return FileInfo[] sorted files + */ + public static function sortFiles($files, $sortAttribute = 'name', $sortDescending = false) { + $sortFunc = 'compareFileNames'; + if ($sortAttribute === 'mtime') { + $sortFunc = 'compareTimestamp'; + } elseif ($sortAttribute === 'size') { + $sortFunc = 'compareSize'; + } + usort($files, [Helper::class, $sortFunc]); + if ($sortDescending) { + $files = array_reverse($files); + } + return $files; + } +} diff --git a/apps/files/lib/Listener/LoadSearchPluginsListener.php b/apps/files/lib/Listener/LoadSearchPluginsListener.php new file mode 100644 index 00000000000..4cc4ca22f83 --- /dev/null +++ b/apps/files/lib/Listener/LoadSearchPluginsListener.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Listener; + +use OCA\Files\Event\LoadSearchPlugins; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** @template-implements IEventListener<LoadSearchPlugins> */ +class LoadSearchPluginsListener implements IEventListener { + public function handle(Event $event): void { + if (!$event instanceof LoadSearchPlugins) { + return; + } + + Util::addScript('files', 'search'); + } +} diff --git a/apps/files/lib/Listener/LoadSidebarListener.php b/apps/files/lib/Listener/LoadSidebarListener.php new file mode 100644 index 00000000000..78b48ab1ce0 --- /dev/null +++ b/apps/files/lib/Listener/LoadSidebarListener.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Listener; + +use OCA\Files\AppInfo\Application; +use OCA\Files\Event\LoadSidebar; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** @template-implements IEventListener<LoadSidebar> */ +class LoadSidebarListener implements IEventListener { + public function handle(Event $event): void { + if (!($event instanceof LoadSidebar)) { + return; + } + + Util::addScript(Application::APP_ID, 'sidebar'); + } +} diff --git a/apps/files/lib/Listener/NodeAddedToFavoriteListener.php b/apps/files/lib/Listener/NodeAddedToFavoriteListener.php new file mode 100644 index 00000000000..827c1851d3d --- /dev/null +++ b/apps/files/lib/Listener/NodeAddedToFavoriteListener.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Listener; + +use OCA\Files\Activity\FavoriteProvider; +use OCP\Activity\IManager as IActivityManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\NodeAddedToFavorite; + +/** @template-implements IEventListener<NodeAddedToFavorite> */ +class NodeAddedToFavoriteListener implements IEventListener { + public function __construct( + private IActivityManager $activityManager, + ) { + } + public function handle(Event $event):void { + if (!($event instanceof NodeAddedToFavorite)) { + return; + } + $activityEvent = $this->activityManager->generateEvent(); + try { + $activityEvent->setApp('files') + ->setObject('files', $event->getFileId(), $event->getPath()) + ->setType('favorite') + ->setAuthor($event->getUser()->getUID()) + ->setAffectedUser($event->getUser()->getUID()) + ->setTimestamp(time()) + ->setSubject( + FavoriteProvider::SUBJECT_ADDED, + ['id' => $event->getFileId(), 'path' => $event->getPath()] + ); + $this->activityManager->publish($activityEvent); + } catch (\InvalidArgumentException $e) { + } catch (\BadMethodCallException $e) { + } + } +} diff --git a/apps/files/lib/Listener/NodeRemovedFromFavoriteListener.php b/apps/files/lib/Listener/NodeRemovedFromFavoriteListener.php new file mode 100644 index 00000000000..fe39d4af540 --- /dev/null +++ b/apps/files/lib/Listener/NodeRemovedFromFavoriteListener.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Listener; + +use OCA\Files\Activity\FavoriteProvider; +use OCP\Activity\IManager as IActivityManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\NodeRemovedFromFavorite; + +/** @template-implements IEventListener<NodeRemovedFromFavorite> */ +class NodeRemovedFromFavoriteListener implements IEventListener { + public function __construct( + private IActivityManager $activityManager, + ) { + } + public function handle(Event $event):void { + if (!($event instanceof NodeRemovedFromFavorite)) { + return; + } + $activityEvent = $this->activityManager->generateEvent(); + try { + $activityEvent->setApp('files') + ->setObject('files', $event->getFileId(), $event->getPath()) + ->setType('favorite') + ->setAuthor($event->getUser()->getUID()) + ->setAffectedUser($event->getUser()->getUID()) + ->setTimestamp(time()) + ->setSubject( + FavoriteProvider::SUBJECT_REMOVED, + ['id' => $event->getFileId(), 'path' => $event->getPath()] + ); + $this->activityManager->publish($activityEvent); + } catch (\InvalidArgumentException $e) { + } catch (\BadMethodCallException $e) { + } + } +} diff --git a/apps/files/lib/Listener/RenderReferenceEventListener.php b/apps/files/lib/Listener/RenderReferenceEventListener.php new file mode 100644 index 00000000000..b7470e5acf5 --- /dev/null +++ b/apps/files/lib/Listener/RenderReferenceEventListener.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Listener; + +use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** @template-implements IEventListener<RenderReferenceEvent> */ +class RenderReferenceEventListener implements IEventListener { + public function handle(Event $event): void { + if (!$event instanceof RenderReferenceEvent) { + return; + } + + Util::addScript('files', 'reference-files'); + } +} diff --git a/apps/files/lib/Listener/SyncLivePhotosListener.php b/apps/files/lib/Listener/SyncLivePhotosListener.php new file mode 100644 index 00000000000..b6773e8c452 --- /dev/null +++ b/apps/files/lib/Listener/SyncLivePhotosListener.php @@ -0,0 +1,254 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Listener; + +use Exception; +use OC\Files\Node\NonExistingFile; +use OC\Files\Node\NonExistingFolder; +use OC\Files\View; +use OC\FilesMetadata\Model\FilesMetadata; +use OCA\Files\Service\LivePhotosService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Exceptions\AbortedEventException; +use OCP\Files\Cache\CacheEntryRemovedEvent; +use OCP\Files\Events\Node\BeforeNodeCopiedEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\FilesMetadata\IFilesMetadataManager; + +/** + * @template-implements IEventListener<Event> + */ +class SyncLivePhotosListener implements IEventListener { + /** @var Array<int> */ + private array $pendingRenames = []; + /** @var Array<int, bool> */ + private array $pendingDeletion = []; + /** @var Array<int> */ + private array $pendingCopies = []; + + public function __construct( + private ?Folder $userFolder, + private IFilesMetadataManager $filesMetadataManager, + private LivePhotosService $livePhotosService, + private IRootFolder $rootFolder, + private View $view, + ) { + } + + public function handle(Event $event): void { + if ($this->userFolder === null) { + return; + } + + if ($event instanceof BeforeNodeCopiedEvent || $event instanceof NodeCopiedEvent) { + $this->handleCopyRecursive($event, $event->getSource(), $event->getTarget()); + } else { + $peerFileId = null; + + if ($event instanceof BeforeNodeRenamedEvent) { + $peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getSource()->getId()); + } elseif ($event instanceof BeforeNodeDeletedEvent) { + $peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getNode()->getId()); + } elseif ($event instanceof CacheEntryRemovedEvent) { + $peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getFileId()); + } + + if ($peerFileId === null) { + return; // Not a live photo. + } + + // Check the user's folder. + $peerFile = $this->userFolder->getFirstNodeById($peerFileId); + + if ($peerFile === null) { + return; // Peer file not found. + } + + if ($event instanceof BeforeNodeRenamedEvent) { + $this->runMoveOrCopyChecks($event->getSource(), $event->getTarget(), $peerFile); + $this->handleMove($event->getSource(), $event->getTarget(), $peerFile); + } elseif ($event instanceof BeforeNodeDeletedEvent) { + $this->handleDeletion($event, $peerFile); + } elseif ($event instanceof CacheEntryRemovedEvent) { + $peerFile->delete(); + } + } + } + + private function runMoveOrCopyChecks(Node $sourceFile, Node $targetFile, Node $peerFile): void { + $targetParent = $targetFile->getParent(); + $sourceExtension = $sourceFile->getExtension(); + $peerFileExtension = $peerFile->getExtension(); + $targetName = $targetFile->getName(); + $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension; + + if (!str_ends_with($targetName, '.' . $sourceExtension)) { + throw new AbortedEventException('Cannot change the extension of a Live Photo'); + } + + try { + $targetParent->get($targetName); + throw new AbortedEventException('A file already exist at destination path of the Live Photo'); + } catch (NotFoundException) { + } + + if (!($targetParent instanceof NonExistingFolder)) { + try { + $targetParent->get($peerTargetName); + throw new AbortedEventException('A file already exist at destination path of the Live Photo'); + } catch (NotFoundException) { + } + } + } + + /** + * During rename events, which also include move operations, + * we rename the peer file using the same name. + * The event listener being singleton, we can store the current state + * of pending renames inside the 'pendingRenames' property, + * to prevent infinite recursive. + */ + private function handleMove(Node $sourceFile, Node $targetFile, Node $peerFile): void { + $targetParent = $targetFile->getParent(); + $sourceExtension = $sourceFile->getExtension(); + $peerFileExtension = $peerFile->getExtension(); + $targetName = $targetFile->getName(); + $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension; + + // in case the rename was initiated from this listener, we stop right now + if (in_array($peerFile->getId(), $this->pendingRenames)) { + return; + } + + $this->pendingRenames[] = $sourceFile->getId(); + try { + $peerFile->move($targetParent->getPath() . '/' . $peerTargetName); + } catch (\Throwable $ex) { + throw new AbortedEventException($ex->getMessage()); + } + + $this->pendingRenames = array_diff($this->pendingRenames, [$sourceFile->getId()]); + } + + + /** + * handle copy, we already know if it is doable from BeforeNodeCopiedEvent, so we just copy the linked file + */ + private function handleCopy(File $sourceFile, File $targetFile, File $peerFile): void { + $sourceExtension = $sourceFile->getExtension(); + $peerFileExtension = $peerFile->getExtension(); + $targetParent = $targetFile->getParent(); + $targetName = $targetFile->getName(); + $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension; + + if ($targetParent->nodeExists($peerTargetName)) { + // If the copy was a folder copy, then the peer file already exists. + $targetPeerFile = $targetParent->get($peerTargetName); + } else { + // If the copy was a file copy, then we need to create the peer file. + $targetPeerFile = $peerFile->copy($targetParent->getPath() . '/' . $peerTargetName); + } + + /** @var FilesMetadata $targetMetadata */ + $targetMetadata = $this->filesMetadataManager->getMetadata($targetFile->getId(), true); + $targetMetadata->setStorageId($targetFile->getStorage()->getCache()->getNumericStorageId()); + $targetMetadata->setString('files-live-photo', (string)$targetPeerFile->getId()); + $this->filesMetadataManager->saveMetadata($targetMetadata); + /** @var FilesMetadata $peerMetadata */ + $peerMetadata = $this->filesMetadataManager->getMetadata($targetPeerFile->getId(), true); + $peerMetadata->setStorageId($targetPeerFile->getStorage()->getCache()->getNumericStorageId()); + $peerMetadata->setString('files-live-photo', (string)$targetFile->getId()); + $this->filesMetadataManager->saveMetadata($peerMetadata); + } + + /** + * During deletion event, we trigger another recursive delete on the peer file. + * Delete operations on the .mov file directly are currently blocked. + * The event listener being singleton, we can store the current state + * of pending deletions inside the 'pendingDeletions' property, + * to prevent infinite recursivity. + */ + private function handleDeletion(BeforeNodeDeletedEvent $event, Node $peerFile): void { + $deletedFile = $event->getNode(); + if ($deletedFile->getMimetype() === 'video/quicktime') { + if (isset($this->pendingDeletion[$peerFile->getId()])) { + unset($this->pendingDeletion[$peerFile->getId()]); + return; + } else { + throw new AbortedEventException('Cannot delete the video part of a live photo'); + } + } else { + $this->pendingDeletion[$deletedFile->getId()] = true; + try { + $peerFile->delete(); + } catch (\Throwable $ex) { + throw new AbortedEventException($ex->getMessage()); + } + } + return; + } + + /* + * Recursively get all the peer ids of a live photo. + * Needed when coping a folder. + * + * @param BeforeNodeCopiedEvent|NodeCopiedEvent $event + */ + private function handleCopyRecursive(Event $event, Node $sourceNode, Node $targetNode): void { + if ($sourceNode instanceof Folder && $targetNode instanceof Folder) { + foreach ($sourceNode->getDirectoryListing() as $sourceChild) { + if ($event instanceof BeforeNodeCopiedEvent) { + if ($sourceChild instanceof Folder) { + $targetChild = new NonExistingFolder($this->rootFolder, $this->view, $targetNode->getPath() . '/' . $sourceChild->getName(), null, $targetNode); + } else { + $targetChild = new NonExistingFile($this->rootFolder, $this->view, $targetNode->getPath() . '/' . $sourceChild->getName(), null, $targetNode); + } + } elseif ($event instanceof NodeCopiedEvent) { + $targetChild = $targetNode->get($sourceChild->getName()); + } else { + throw new Exception('Event is type is not supported'); + } + + $this->handleCopyRecursive($event, $sourceChild, $targetChild); + } + } elseif ($sourceNode instanceof File && $targetNode instanceof File) { + // in case the copy was initiated from this listener, we stop right now + if (in_array($sourceNode->getId(), $this->pendingCopies)) { + return; + } + + $peerFileId = $this->livePhotosService->getLivePhotoPeerId($sourceNode->getId()); + if ($peerFileId === null) { + return; + } + $peerFile = $this->userFolder->getFirstNodeById($peerFileId); + if ($peerFile === null) { + return; + } + + $this->pendingCopies[] = $peerFileId; + if ($event instanceof BeforeNodeCopiedEvent) { + $this->runMoveOrCopyChecks($sourceNode, $targetNode, $peerFile); + } elseif ($event instanceof NodeCopiedEvent) { + $this->handleCopy($sourceNode, $targetNode, $peerFile); + } + $this->pendingCopies = array_diff($this->pendingCopies, [$peerFileId]); + } else { + throw new Exception('Source and target type are not matching'); + } + } +} diff --git a/apps/files/lib/Migration/Version11301Date20191205150729.php b/apps/files/lib/Migration/Version11301Date20191205150729.php new file mode 100644 index 00000000000..2e3d72c7ece --- /dev/null +++ b/apps/files/lib/Migration/Version11301Date20191205150729.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version11301Date20191205150729 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->createTable('user_transfer_owner'); + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('source_user', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('target_user', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('file_id', 'bigint', [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('node_name', 'string', [ + 'notnull' => true, + 'length' => 255, + ]); + $table->setPrimaryKey(['id']); + + // Quite radical, we just assume no one updates cross beta with a pending request. + // Do not try this at home + if ($schema->hasTable('user_transfer_ownership')) { + $schema->dropTable('user_transfer_ownership'); + } + + return $schema; + } +} diff --git a/apps/files/lib/Migration/Version12101Date20221011153334.php b/apps/files/lib/Migration/Version12101Date20221011153334.php new file mode 100644 index 00000000000..ed4d8bef90b --- /dev/null +++ b/apps/files/lib/Migration/Version12101Date20221011153334.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version12101Date20221011153334 extends SimpleMigrationStep { + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->createTable('open_local_editor'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('path_hash', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('expiration_time', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('token', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id', 'path_hash', 'token'], 'openlocal_user_path_token'); + + return $schema; + } +} diff --git a/apps/files/lib/Migration/Version2003Date20241021095629.php b/apps/files/lib/Migration/Version2003Date20241021095629.php new file mode 100644 index 00000000000..30d05fa12ad --- /dev/null +++ b/apps/files/lib/Migration/Version2003Date20241021095629.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\Migration; + +use Closure; +use OCA\Files\Service\ChunkedUploadConfig; +use OCP\DB\ISchemaWrapper; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use OCP\Server; + +class Version2003Date20241021095629 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $maxChunkSize = Server::get(IConfig::class)->getAppValue('files', 'max_chunk_size'); + if ($maxChunkSize === '') { + // Skip if no value was configured before + return; + } + + ChunkedUploadConfig::setMaxChunkSize((int)$maxChunkSize); + Server::get(IConfig::class)->deleteAppValue('files', 'max_chunk_size'); + } +} diff --git a/apps/files/lib/Notification/Notifier.php b/apps/files/lib/Notification/Notifier.php new file mode 100644 index 00000000000..6acc312c126 --- /dev/null +++ b/apps/files/lib/Notification/Notifier.php @@ -0,0 +1,290 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Notification; + +use OCA\Files\BackgroundJob\TransferOwnership; +use OCA\Files\Db\TransferOwnershipMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Notification\IAction; +use OCP\Notification\IDismissableNotifier; +use OCP\Notification\IManager; +use OCP\Notification\INotification; +use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; + +class Notifier implements INotifier, IDismissableNotifier { + public function __construct( + protected IFactory $l10nFactory, + protected IURLGenerator $urlGenerator, + private TransferOwnershipMapper $mapper, + private IManager $notificationManager, + private IUserManager $userManager, + private IJobList $jobList, + private ITimeFactory $timeFactory, + ) { + } + + public function getID(): string { + return 'files'; + } + + public function getName(): string { + return $this->l10nFactory->get('files')->t('Files'); + } + + /** + * @param INotification $notification + * @param string $languageCode The code of the language that should be used to prepare the notification + * @return INotification + * @throws UnknownNotificationException When the notification was not prepared by a notifier + */ + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== 'files') { + throw new UnknownNotificationException('Unhandled app'); + } + + $imagePath = $this->urlGenerator->imagePath('files', 'folder-move.svg'); + $iconUrl = $this->urlGenerator->getAbsoluteURL($imagePath); + $notification->setIcon($iconUrl); + + return match($notification->getSubject()) { + 'transferownershipRequest' => $this->handleTransferownershipRequest($notification, $languageCode), + 'transferownershipRequestDenied' => $this->handleTransferOwnershipRequestDenied($notification, $languageCode), + 'transferOwnershipFailedSource' => $this->handleTransferOwnershipFailedSource($notification, $languageCode), + 'transferOwnershipFailedTarget' => $this->handleTransferOwnershipFailedTarget($notification, $languageCode), + 'transferOwnershipDoneSource' => $this->handleTransferOwnershipDoneSource($notification, $languageCode), + 'transferOwnershipDoneTarget' => $this->handleTransferOwnershipDoneTarget($notification, $languageCode), + default => throw new UnknownNotificationException('Unhandled subject') + }; + } + + public function handleTransferownershipRequest(INotification $notification, string $languageCode): INotification { + $l = $this->l10nFactory->get('files', $languageCode); + $id = $notification->getObjectId(); + $param = $notification->getSubjectParameters(); + + $approveAction = $notification->createAction() + ->setParsedLabel($l->t('Accept')) + ->setPrimary(true) + ->setLink( + $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkTo( + '', + 'ocs/v2.php/apps/files/api/v1/transferownership/' . $id + ) + ), + IAction::TYPE_POST + ); + + $disapproveAction = $notification->createAction() + ->setParsedLabel($l->t('Reject')) + ->setPrimary(false) + ->setLink( + $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkTo( + '', + 'ocs/v2.php/apps/files/api/v1/transferownership/' . $id + ) + ), + IAction::TYPE_DELETE + ); + + $sourceUser = $this->getUser($param['sourceUser']); + $notification->addParsedAction($approveAction) + ->addParsedAction($disapproveAction) + ->setRichSubject( + $l->t('Incoming ownership transfer from {user}'), + [ + 'user' => [ + 'type' => 'user', + 'id' => $sourceUser->getUID(), + 'name' => $sourceUser->getDisplayName(), + ], + ]) + ->setRichMessage( + $l->t("Do you want to accept {path}?\n\nNote: The transfer process after accepting may take up to 1 hour."), + [ + 'path' => [ + 'type' => 'highlight', + 'id' => $param['targetUser'] . '::' . $param['nodeName'], + 'name' => $param['nodeName'], + ] + ]); + + return $notification; + } + + public function handleTransferOwnershipRequestDenied(INotification $notification, string $languageCode): INotification { + $l = $this->l10nFactory->get('files', $languageCode); + $param = $notification->getSubjectParameters(); + + $targetUser = $this->getUser($param['targetUser']); + $notification->setRichSubject($l->t('Ownership transfer denied')) + ->setRichMessage( + $l->t('Your ownership transfer of {path} was denied by {user}.'), + [ + 'path' => [ + 'type' => 'highlight', + 'id' => $param['targetUser'] . '::' . $param['nodeName'], + 'name' => $param['nodeName'], + ], + 'user' => [ + 'type' => 'user', + 'id' => $targetUser->getUID(), + 'name' => $targetUser->getDisplayName(), + ], + ]); + return $notification; + } + + public function handleTransferOwnershipFailedSource(INotification $notification, string $languageCode): INotification { + $l = $this->l10nFactory->get('files', $languageCode); + $param = $notification->getSubjectParameters(); + + $targetUser = $this->getUser($param['targetUser']); + $notification->setRichSubject($l->t('Ownership transfer failed')) + ->setRichMessage( + $l->t('Your ownership transfer of {path} to {user} failed.'), + [ + 'path' => [ + 'type' => 'highlight', + 'id' => $param['targetUser'] . '::' . $param['nodeName'], + 'name' => $param['nodeName'], + ], + 'user' => [ + 'type' => 'user', + 'id' => $targetUser->getUID(), + 'name' => $targetUser->getDisplayName(), + ], + ]); + return $notification; + } + + public function handleTransferOwnershipFailedTarget(INotification $notification, string $languageCode): INotification { + $l = $this->l10nFactory->get('files', $languageCode); + $param = $notification->getSubjectParameters(); + + $sourceUser = $this->getUser($param['sourceUser']); + $notification->setRichSubject($l->t('Ownership transfer failed')) + ->setRichMessage( + $l->t('The ownership transfer of {path} from {user} failed.'), + [ + 'path' => [ + 'type' => 'highlight', + 'id' => $param['sourceUser'] . '::' . $param['nodeName'], + 'name' => $param['nodeName'], + ], + 'user' => [ + 'type' => 'user', + 'id' => $sourceUser->getUID(), + 'name' => $sourceUser->getDisplayName(), + ], + ]); + + return $notification; + } + + public function handleTransferOwnershipDoneSource(INotification $notification, string $languageCode): INotification { + $l = $this->l10nFactory->get('files', $languageCode); + $param = $notification->getSubjectParameters(); + + $targetUser = $this->getUser($param['targetUser']); + $notification->setRichSubject($l->t('Ownership transfer done')) + ->setRichMessage( + $l->t('Your ownership transfer of {path} to {user} has completed.'), + [ + 'path' => [ + 'type' => 'highlight', + 'id' => $param['targetUser'] . '::' . $param['nodeName'], + 'name' => $param['nodeName'], + ], + 'user' => [ + 'type' => 'user', + 'id' => $targetUser->getUID(), + 'name' => $targetUser->getDisplayName(), + ], + ]); + + return $notification; + } + + public function handleTransferOwnershipDoneTarget(INotification $notification, string $languageCode): INotification { + $l = $this->l10nFactory->get('files', $languageCode); + $param = $notification->getSubjectParameters(); + + $sourceUser = $this->getUser($param['sourceUser']); + $notification->setRichSubject($l->t('Ownership transfer done')) + ->setRichMessage( + $l->t('The ownership transfer of {path} from {user} has completed.'), + [ + 'path' => [ + 'type' => 'highlight', + 'id' => $param['sourceUser'] . '::' . $param['nodeName'], + 'name' => $param['nodeName'], + ], + 'user' => [ + 'type' => 'user', + 'id' => $sourceUser->getUID(), + 'name' => $sourceUser->getDisplayName(), + ], + ]); + + return $notification; + } + + public function dismissNotification(INotification $notification): void { + if ($notification->getApp() !== 'files') { + throw new UnknownNotificationException('Unhandled app'); + } + if ($notification->getSubject() !== 'transferownershipRequest') { + throw new UnknownNotificationException('Unhandled notification type'); + } + + // TODO: This should all be moved to a service that also the transferownershipController uses. + try { + $transferOwnership = $this->mapper->getById((int)$notification->getObjectId()); + } catch (DoesNotExistException $e) { + return; + } + + if ($this->jobList->has(TransferOwnership::class, [ + 'id' => $transferOwnership->getId(), + ])) { + return; + } + + $notification = $this->notificationManager->createNotification(); + $notification->setUser($transferOwnership->getSourceUser()) + ->setApp('files') + ->setDateTime($this->timeFactory->getDateTime()) + ->setSubject('transferownershipRequestDenied', [ + 'sourceUser' => $transferOwnership->getSourceUser(), + 'targetUser' => $transferOwnership->getTargetUser(), + 'nodeName' => $transferOwnership->getNodeName() + ]) + ->setObject('transfer', (string)$transferOwnership->getId()); + $this->notificationManager->notify($notification); + + $this->mapper->delete($transferOwnership); + } + + protected function getUser(string $userId): IUser { + $user = $this->userManager->get($userId); + if ($user instanceof IUser) { + return $user; + } + throw new \InvalidArgumentException('User not found'); + } +} diff --git a/apps/files/lib/ResponseDefinitions.php b/apps/files/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..c5d094e7bd8 --- /dev/null +++ b/apps/files/lib/ResponseDefinitions.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files; + +/** + * @psalm-type FilesTemplateFile = array{ + * basename: string, + * etag: string, + * fileid: int, + * filename: ?string, + * lastmod: int, + * mime: string, + * size: int, + * type: string, + * hasPreview: bool, + * } + * + * @psalm-type FilesTemplateField = array{ + * index: string, + * type: string, + * alias: ?string, + * tag: ?string, + * id: ?int, + * content?: string, + * checked?: bool, + * } + * + * @psalm-type FilesTemplate = array{ + * templateType: string, + * templateId: string, + * basename: string, + * etag: string, + * fileid: int, + * filename: string, + * lastmod: int, + * mime: string, + * size: int|float, + * type: string, + * hasPreview: bool, + * previewUrl: ?string, + * fields: list<FilesTemplateField>, + * } + * + * @psalm-type FilesTemplateFileCreator = array{ + * app: string, + * label: string, + * extension: string, + * iconClass: ?string, + * iconSvgInline: ?string, + * mimetypes: list<string>, + * ratio: ?float, + * actionLabel: string, + * } + * + * @psalm-type FilesTemplateFileCreatorWithTemplates = FilesTemplateFileCreator&array{ + * templates: list<FilesTemplate>, + * } + * + * @psalm-type FilesFolderTree = list<array{ + * id: int, + * basename: string, + * displayName?: string, + * children: list<array{}>, + * }> + * + */ +class ResponseDefinitions { +} diff --git a/apps/files/lib/Search/FilesSearchProvider.php b/apps/files/lib/Search/FilesSearchProvider.php new file mode 100644 index 00000000000..f71d58c6fae --- /dev/null +++ b/apps/files/lib/Search/FilesSearchProvider.php @@ -0,0 +1,202 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Search; + +use InvalidArgumentException; +use OC\Files\Search\SearchBinaryOperator; +use OC\Files\Search\SearchComparison; +use OC\Files\Search\SearchOrder; +use OC\Files\Search\SearchQuery; +use OC\Search\Filter\GroupFilter; +use OC\Search\Filter\UserFilter; +use OCP\Files\FileInfo; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\Search\ISearchComparison; +use OCP\Files\Search\ISearchOperator; +use OCP\Files\Search\ISearchOrder; +use OCP\IL10N; +use OCP\IPreview; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\Search\FilterDefinition; +use OCP\Search\IFilter; +use OCP\Search\IFilteringProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use OCP\Search\SearchResultEntry; +use OCP\Share\IShare; + +class FilesSearchProvider implements IFilteringProvider { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IMimeTypeDetector $mimeTypeDetector, + private IRootFolder $rootFolder, + private IPreview $previewManager, + ) { + } + + /** + * @inheritDoc + */ + public function getId(): string { + return 'files'; + } + + /** + * @inheritDoc + */ + public function getName(): string { + return $this->l10n->t('Files'); + } + + /** + * @inheritDoc + */ + public function getOrder(string $route, array $routeParameters): int { + if ($route === 'files.View.index') { + // Before comments + return -5; + } + return 5; + } + + public function getSupportedFilters(): array { + return [ + 'term', + 'since', + 'until', + 'person', + 'min-size', + 'max-size', + 'mime', + 'type', + 'path', + 'is-favorite', + 'title-only', + ]; + } + + public function getAlternateIds(): array { + return []; + } + + public function getCustomFilters(): array { + return [ + new FilterDefinition('min-size', FilterDefinition::TYPE_INT), + new FilterDefinition('max-size', FilterDefinition::TYPE_INT), + new FilterDefinition('mime', FilterDefinition::TYPE_STRING), + new FilterDefinition('type', FilterDefinition::TYPE_STRING), + new FilterDefinition('path', FilterDefinition::TYPE_STRING), + new FilterDefinition('is-favorite', FilterDefinition::TYPE_BOOL), + ]; + } + + public function search(IUser $user, ISearchQuery $query): SearchResult { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $fileQuery = $this->buildSearchQuery($query, $user); + return SearchResult::paginated( + $this->l10n->t('Files'), + array_map(function (Node $result) use ($userFolder) { + $thumbnailUrl = $this->previewManager->isMimeSupported($result->getMimetype()) + ? $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->getId()]) + : ''; + $icon = $result->getMimetype() === FileInfo::MIMETYPE_FOLDER + ? 'icon-folder' + : $this->mimeTypeDetector->mimeTypeIcon($result->getMimetype()); + $path = $userFolder->getRelativePath($result->getPath()); + + // Use shortened link to centralize the various + // files/folder url redirection in files.View.showFile + $link = $this->urlGenerator->linkToRoute( + 'files.View.showFile', + ['fileid' => $result->getId()] + ); + + $searchResultEntry = new SearchResultEntry( + $thumbnailUrl, + $result->getName(), + $this->formatSubline($path), + $this->urlGenerator->getAbsoluteURL($link), + $icon, + ); + $searchResultEntry->addAttribute('fileId', (string)$result->getId()); + $searchResultEntry->addAttribute('path', $path); + return $searchResultEntry; + }, $userFolder->search($fileQuery)), + $query->getCursor() + $query->getLimit() + ); + } + + private function buildSearchQuery(ISearchQuery $query, IUser $user): SearchQuery { + $comparisons = []; + foreach ($query->getFilters() as $name => $filter) { + $comparisons[] = match ($name) { + 'term' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', '%' . $filter->get() . '%'), + 'since' => new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN_EQUAL, 'mtime', $filter->get()->getTimestamp()), + 'until' => new SearchComparison(ISearchComparison::COMPARE_LESS_THAN_EQUAL, 'mtime', $filter->get()->getTimestamp()), + 'min-size' => new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN_EQUAL, 'size', $filter->get()), + 'max-size' => new SearchComparison(ISearchComparison::COMPARE_LESS_THAN_EQUAL, 'size', $filter->get()), + 'mime' => new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', $filter->get()), + 'type' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', $filter->get() . '/%'), + 'path' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'path', 'files/' . ltrim($filter->get(), '/') . '%'), + 'person' => $this->buildPersonSearchQuery($filter), + default => throw new InvalidArgumentException('Unsupported comparison'), + }; + } + + return new SearchQuery( + new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, $comparisons), + $query->getLimit(), + (int)$query->getCursor(), + $query->getSortOrder() === ISearchQuery::SORT_DATE_DESC + ? [new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')] + : [], + $user + ); + } + + private function buildPersonSearchQuery(IFilter $person): ISearchOperator { + if ($person instanceof UserFilter) { + return new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_OR, [ + new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, [ + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_with', $person->get()->getUID()), + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_type', IShare::TYPE_USER), + ]), + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'owner', $person->get()->getUID()), + ]); + } + if ($person instanceof GroupFilter) { + return new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, [ + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_with', $person->get()->getGID()), + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_type', IShare::TYPE_GROUP), + ]); + } + + throw new InvalidArgumentException('Unsupported filter type'); + } + + /** + * Format subline for files + * + * @param string $path + * @return string + */ + private function formatSubline(string $path): string { + // Do not show the location if the file is in root + if (strrpos($path, '/') > 0) { + $path = ltrim(dirname($path), '/'); + return $this->l10n->t('in %s', [$path]); + } else { + return ''; + } + } +} 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 new file mode 100644 index 00000000000..63c54d01fd0 --- /dev/null +++ b/apps/files/lib/Service/TagService.php @@ -0,0 +1,68 @@ +<?php + +/** + * 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 OCP\Activity\IManager; +use OCP\Files\Folder; +use OCP\Files\NotFoundException; +use OCP\ITags; +use OCP\IUserSession; + +/** + * Service class to manage tags on files. + */ +class TagService { + + public function __construct( + private IUserSession $userSession, + private IManager $activityManager, + private ?ITags $tagger, + private ?Folder $homeFolder, + ) { + } + + /** + * Updates the tags of the specified file path. + * The passed tags are absolute, which means they will + * replace the actual tag selection. + * + * @param string $path path + * @param array $tags array of tags + * @return array list of tags + * @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([$fileId]); + + if (!empty($currentTags)) { + $currentTags = current($currentTags); + } + + $newTags = array_diff($tags, $currentTags); + foreach ($newTags as $tag) { + $this->tagger->tagAs($fileId, $tag); + } + $deletedTags = array_diff($currentTags, $tags); + foreach ($deletedTags as $tag) { + $this->tagger->unTag($fileId, $tag); + } + + // TODO: re-read from tagger to make sure the + // list is up to date, in case of concurrent changes ? + return $tags; + } +} 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; + }, []); + } +} diff --git a/apps/files/lib/Settings/DeclarativeAdminSettings.php b/apps/files/lib/Settings/DeclarativeAdminSettings.php new file mode 100644 index 00000000000..bbf97cc4d32 --- /dev/null +++ b/apps/files/lib/Settings/DeclarativeAdminSettings.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Settings; + +use OCA\Files\Service\SettingsService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\Settings\DeclarativeSettingsTypes; +use OCP\Settings\IDeclarativeSettingsFormWithHandlers; + +class DeclarativeAdminSettings implements IDeclarativeSettingsFormWithHandlers { + + public function __construct( + private IL10N $l, + private SettingsService $service, + private IURLGenerator $urlGenerator, + ) { + } + + public function getValue(string $fieldId, IUser $user): mixed { + return match($fieldId) { + 'windows_support' => $this->service->hasFilesWindowsSupport(), + default => throw new \InvalidArgumentException('Unexpected field id ' . $fieldId), + }; + } + + public function setValue(string $fieldId, mixed $value, IUser $user): void { + switch ($fieldId) { + case 'windows_support': + $this->service->setFilesWindowsSupport((bool)$value); + break; + } + } + + public function getSchema(): array { + return [ + 'id' => 'files-filename-support', + 'priority' => 10, + 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, + 'section_id' => 'server', + 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL, + 'title' => $this->l->t('Files compatibility'), + 'doc_url' => $this->urlGenerator->linkToDocs('admin-windows-compatible-filenames'), + 'description' => ( + $this->l->t('Allow to restrict filenames to ensure files can be synced with all clients. By default all filenames valid on POSIX (e.g. Linux or macOS) are allowed.') + . "\n" . $this->l->t('After enabling the Windows compatible filenames, existing files cannot be modified anymore but can be renamed to valid new names by their owner.') + . "\n" . $this->l->t('It is also possible to migrate files automatically after enabling this setting, please refer to the documentation about the occ command.') + ), + + 'fields' => [ + [ + 'id' => 'windows_support', + 'title' => $this->l->t('Enforce Windows compatibility'), + 'description' => $this->l->t('This will block filenames not valid on Windows systems, like using reserved names or special characters. But this will not enforce compatibility of case sensitivity.'), + 'type' => DeclarativeSettingsTypes::CHECKBOX, + 'default' => false, + ], + ], + ]; + } +} diff --git a/apps/files/lib/Settings/PersonalSettings.php b/apps/files/lib/Settings/PersonalSettings.php new file mode 100644 index 00000000000..fe43265bc13 --- /dev/null +++ b/apps/files/lib/Settings/PersonalSettings.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Settings; + +use OCA\Files\AppInfo\Application; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Settings\ISettings; +use OCP\Util; + +class PersonalSettings implements ISettings { + public function getForm(): TemplateResponse { + Util::addScript(Application::APP_ID, 'settings-personal'); + return new TemplateResponse(Application::APP_ID, 'settings-personal'); + } + + public function getSection(): string { + return 'sharing'; + } + + public function getPriority(): int { + return 90; + } +} diff --git a/apps/files/lib/activity.php b/apps/files/lib/activity.php deleted file mode 100644 index 1cbd6c3b973..00000000000 --- a/apps/files/lib/activity.php +++ /dev/null @@ -1,424 +0,0 @@ -<?php -/** - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - -namespace OCA\Files; - -use OCP\IDBConnection; -use OCP\L10N\IFactory; -use OCP\Activity\IExtension; -use OCP\Activity\IManager; -use OCP\IConfig; -use OCP\IL10N; -use OCP\IURLGenerator; - -class Activity implements IExtension { - const APP_FILES = 'files'; - const FILTER_FILES = 'files'; - const FILTER_FAVORITES = 'files_favorites'; - - const TYPE_SHARE_CREATED = 'file_created'; - const TYPE_SHARE_CHANGED = 'file_changed'; - const TYPE_SHARE_DELETED = 'file_deleted'; - const TYPE_SHARE_RESTORED = 'file_restored'; - const TYPE_FAVORITES = 'files_favorites'; - - /** @var IL10N */ - protected $l; - - /** @var IFactory */ - protected $languageFactory; - - /** @var IURLGenerator */ - protected $URLGenerator; - - /** @var \OCP\Activity\IManager */ - protected $activityManager; - - /** @var \OCP\IDBConnection */ - protected $connection; - - /** @var \OCP\IConfig */ - protected $config; - - /** @var \OCA\Files\ActivityHelper */ - protected $helper; - - /** - * @param IFactory $languageFactory - * @param IURLGenerator $URLGenerator - * @param IManager $activityManager - * @param ActivityHelper $helper - * @param IDBConnection $connection - * @param IConfig $config - */ - public function __construct(IFactory $languageFactory, IURLGenerator $URLGenerator, IManager $activityManager, ActivityHelper $helper, IDBConnection $connection, IConfig $config) { - $this->languageFactory = $languageFactory; - $this->URLGenerator = $URLGenerator; - $this->l = $this->getL10N(); - $this->activityManager = $activityManager; - $this->helper = $helper; - $this->connection = $connection; - $this->config = $config; - } - - /** - * @param string|null $languageCode - * @return IL10N - */ - protected function getL10N($languageCode = null) { - return $this->languageFactory->get(self::APP_FILES, $languageCode); - } - - /** - * The extension can return an array of additional notification types. - * If no additional types are to be added false is to be returned - * - * @param string $languageCode - * @return array|false Array "stringID of the type" => "translated string description for the setting" - * or Array "stringID of the type" => [ - * 'desc' => "translated string description for the setting" - * 'methods' => [self::METHOD_*], - * ] - */ - public function getNotificationTypes($languageCode) { - $l = $this->getL10N($languageCode); - return [ - self::TYPE_SHARE_CREATED => (string) $l->t('A new file or folder has been <strong>created</strong>'), - self::TYPE_SHARE_CHANGED => (string) $l->t('A file or folder has been <strong>changed</strong>'), - self::TYPE_FAVORITES => [ - 'desc' => (string) $l->t('Limit notifications about creation and changes to your <strong>favorite files</strong> <em>(Stream only)</em>'), - 'methods' => [self::METHOD_STREAM], - ], - self::TYPE_SHARE_DELETED => (string) $l->t('A file or folder has been <strong>deleted</strong>'), - self::TYPE_SHARE_RESTORED => (string) $l->t('A file or folder has been <strong>restored</strong>'), - ]; - } - - /** - * For a given method additional types to be displayed in the settings can be returned. - * In case no additional types are to be added false is to be returned. - * - * @param string $method - * @return array|false - */ - public function getDefaultTypes($method) { - if ($method === self::METHOD_STREAM) { - $settings = array(); - $settings[] = self::TYPE_SHARE_CREATED; - $settings[] = self::TYPE_SHARE_CHANGED; - $settings[] = self::TYPE_SHARE_DELETED; - $settings[] = self::TYPE_SHARE_RESTORED; - return $settings; - } - - return false; - } - - /** - * The extension can translate a given message to the requested languages. - * If no translation is available false is to be returned. - * - * @param string $app - * @param string $text - * @param array $params - * @param boolean $stripPath - * @param boolean $highlightParams - * @param string $languageCode - * @return string|false - */ - public function translate($app, $text, $params, $stripPath, $highlightParams, $languageCode) { - if ($app !== self::APP_FILES) { - return false; - } - - $l = $this->getL10N($languageCode); - - if ($this->activityManager->isFormattingFilteredObject()) { - $translation = $this->translateShort($text, $l, $params); - if ($translation !== false) { - return $translation; - } - } - - return $this->translateLong($text, $l, $params); - } - - /** - * @param string $text - * @param IL10N $l - * @param array $params - * @return string|false - */ - protected function translateLong($text, IL10N $l, array $params) { - switch ($text) { - case 'created_self': - return (string) $l->t('You created %1$s', $params); - case 'created_by': - return (string) $l->t('%2$s created %1$s', $params); - case 'created_public': - return (string) $l->t('%1$s was created in a public folder', $params); - case 'changed_self': - return (string) $l->t('You changed %1$s', $params); - case 'changed_by': - return (string) $l->t('%2$s changed %1$s', $params); - case 'deleted_self': - return (string) $l->t('You deleted %1$s', $params); - case 'deleted_by': - return (string) $l->t('%2$s deleted %1$s', $params); - case 'restored_self': - return (string) $l->t('You restored %1$s', $params); - case 'restored_by': - return (string) $l->t('%2$s restored %1$s', $params); - - default: - return false; - } - } - - /** - * @param string $text - * @param IL10N $l - * @param array $params - * @return string|false - */ - protected function translateShort($text, IL10N $l, array $params) { - switch ($text) { - case 'changed_by': - return (string) $l->t('Changed by %2$s', $params); - case 'deleted_by': - return (string) $l->t('Deleted by %2$s', $params); - case 'restored_by': - return (string) $l->t('Restored by %2$s', $params); - - default: - return false; - } - } - - /** - * The extension can define the type of parameters for translation - * - * Currently known types are: - * * file => will strip away the path of the file and add a tooltip with it - * * username => will add the avatar of the user - * - * @param string $app - * @param string $text - * @return array|false - */ - function getSpecialParameterList($app, $text) { - if ($app === self::APP_FILES) { - switch ($text) { - case 'created_self': - case 'created_by': - case 'created_public': - case 'changed_self': - case 'changed_by': - case 'deleted_self': - case 'deleted_by': - case 'restored_self': - case 'restored_by': - return [ - 0 => 'file', - 1 => 'username', - ]; - } - } - - return false; - } - - /** - * A string naming the css class for the icon to be used can be returned. - * If no icon is known for the given type false is to be returned. - * - * @param string $type - * @return string|false - */ - public function getTypeIcon($type) { - switch ($type) { - case self::TYPE_SHARE_CHANGED: - return 'icon-change'; - case self::TYPE_SHARE_CREATED: - return 'icon-add-color'; - case self::TYPE_SHARE_DELETED: - return 'icon-delete-color'; - - default: - return false; - } - } - - /** - * The extension can define the parameter grouping by returning the index as integer. - * In case no grouping is required false is to be returned. - * - * @param array $activity - * @return integer|false - */ - public function getGroupParameter($activity) { - if ($activity['app'] === self::APP_FILES) { - switch ($activity['subject']) { - case 'created_self': - case 'created_by': - case 'changed_self': - case 'changed_by': - case 'deleted_self': - case 'deleted_by': - case 'restored_self': - case 'restored_by': - return 0; - } - } - - return false; - } - - /** - * The extension can define additional navigation entries. The array returned has to contain two keys 'top' - * and 'apps' which hold arrays with the relevant entries. - * If no further entries are to be added false is no be returned. - * - * @return array|false - */ - public function getNavigation() { - return [ - 'top' => [ - self::FILTER_FAVORITES => [ - 'id' => self::FILTER_FAVORITES, - 'name' => (string) $this->l->t('Favorites'), - 'url' => $this->URLGenerator->linkToRoute('activity.Activities.showList', ['filter' => self::FILTER_FAVORITES]), - ], - ], - 'apps' => [ - self::FILTER_FILES => [ - 'id' => self::FILTER_FILES, - 'name' => (string) $this->l->t('Files'), - 'url' => $this->URLGenerator->linkToRoute('activity.Activities.showList', ['filter' => self::FILTER_FILES]), - ], - ], - ]; - } - - /** - * The extension can check if a customer filter (given by a query string like filter=abc) is valid or not. - * - * @param string $filterValue - * @return boolean - */ - public function isFilterValid($filterValue) { - return $filterValue === self::FILTER_FILES || $filterValue === self::FILTER_FAVORITES; - } - - /** - * The extension can filter the types based on the filter if required. - * In case no filter is to be applied false is to be returned unchanged. - * - * @param array $types - * @param string $filter - * @return array|false - */ - public function filterNotificationTypes($types, $filter) { - if ($filter === self::FILTER_FILES || $filter === self::FILTER_FAVORITES) { - return array_intersect([ - self::TYPE_SHARE_CREATED, - self::TYPE_SHARE_CHANGED, - self::TYPE_SHARE_DELETED, - self::TYPE_SHARE_RESTORED, - ], $types); - } - return false; - } - - /** - * For a given filter the extension can specify the sql query conditions including parameters for that query. - * In case the extension does not know the filter false is to be returned. - * The query condition and the parameters are to be returned as array with two elements. - * E.g. return array('`app` = ? and `message` like ?', array('mail', 'ownCloud%')); - * - * @param string $filter - * @return array|false - */ - public function getQueryForFilter($filter) { - $user = $this->activityManager->getCurrentUserId(); - // Display actions from all files - if ($filter === self::FILTER_FILES) { - return ['`app` = ?', [self::APP_FILES]]; - } - - if (!$user) { - // Remaining filters only work with a user/token - return false; - } - - // Display actions from favorites only - if ($filter === self::FILTER_FAVORITES || in_array($filter, ['all', 'by', 'self']) && $this->userSettingFavoritesOnly($user)) { - try { - $favorites = $this->helper->getFavoriteFilePaths($user); - } catch (\RuntimeException $e) { - // Too many favorites, can not put them into one query anymore... - return ['`app` = ?', [self::APP_FILES]]; - } - - /* - * Display activities only, when they are not `type` create/change - * or `file` is a favorite or in a favorite folder - */ - $parameters = $fileQueryList = []; - $parameters[] = self::APP_FILES; - $parameters[] = self::APP_FILES; - - $fileQueryList[] = '(`type` <> ? AND `type` <> ?)'; - $parameters[] = self::TYPE_SHARE_CREATED; - $parameters[] = self::TYPE_SHARE_CHANGED; - - foreach ($favorites['items'] as $favorite) { - $fileQueryList[] = '`file` = ?'; - $parameters[] = $favorite; - } - foreach ($favorites['folders'] as $favorite) { - $fileQueryList[] = '`file` LIKE ?'; - $parameters[] = $this->connection->escapeLikeParameter($favorite) . '/%'; - } - - return [ - ' CASE ' - . 'WHEN `app` <> ? THEN 1 ' - . 'WHEN `app` = ? AND (' . implode(' OR ', $fileQueryList) . ') THEN 1 ' - . 'ELSE 0 ' - . 'END = 1 ', - $parameters, - ]; - } - return false; - } - - /** - * Is the file actions favorite limitation enabled? - * - * @param string $user - * @return bool - */ - protected function userSettingFavoritesOnly($user) { - return (bool) $this->config->getUserValue($user, 'activity', 'notify_' . self::METHOD_STREAM . '_' . self::TYPE_FAVORITES, false); - } -} diff --git a/apps/files/lib/activityhelper.php b/apps/files/lib/activityhelper.php deleted file mode 100644 index 046dd59bc76..00000000000 --- a/apps/files/lib/activityhelper.php +++ /dev/null @@ -1,84 +0,0 @@ -<?php -/** - * @author Joas Schilling <nickvergessen@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - -namespace OCA\Files; - -use OCP\Files\Folder; -use OCP\ITagManager; - -class ActivityHelper { - /** If a user has a lot of favorites the query might get too slow and long */ - const FAVORITE_LIMIT = 50; - - /** @var \OCP\ITagManager */ - protected $tagManager; - - /** - * @param ITagManager $tagManager - */ - public function __construct(ITagManager $tagManager) { - $this->tagManager = $tagManager; - } - - /** - * Returns an array with the favorites - * - * @param string $user - * @return array - * @throws \RuntimeException when too many or no favorites where found - */ - public function getFavoriteFilePaths($user) { - $tags = $this->tagManager->load('files', [], false, $user); - $favorites = $tags->getFavorites(); - - if (empty($favorites)) { - throw new \RuntimeException('No favorites', 1); - } else if (isset($favorites[self::FAVORITE_LIMIT])) { - throw new \RuntimeException('Too many favorites', 2); - } - - // Can not DI because the user is not known on instantiation - $rootFolder = \OC::$server->getUserFolder($user); - $folders = $items = []; - foreach ($favorites as $favorite) { - $nodes = $rootFolder->getById($favorite); - if (!empty($nodes)) { - /** @var \OCP\Files\Node $node */ - $node = array_shift($nodes); - $path = substr($node->getPath(), strlen($user . '/files/')); - - $items[] = $path; - if ($node instanceof Folder) { - $folders[] = $path; - } - } - } - - if (empty($items)) { - throw new \RuntimeException('No favorites', 1); - } - - return [ - 'items' => $items, - 'folders' => $folders, - ]; - } -} diff --git a/apps/files/lib/app.php b/apps/files/lib/app.php deleted file mode 100644 index 981c41ff413..00000000000 --- a/apps/files/lib/app.php +++ /dev/null @@ -1,47 +0,0 @@ -<?php -/** - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - - -namespace OCA\Files; - -class App { - /** - * @var \OCP\INavigationManager - */ - private static $navigationManager; - - /** - * Returns the app's navigation manager - * - * @return \OCP\INavigationManager - */ - public static function getNavigationManager() { - // TODO: move this into a service in the Application class - if (self::$navigationManager === null) { - self::$navigationManager = new \OC\NavigationManager(); - } - return self::$navigationManager; - } - -} diff --git a/apps/files/lib/backgroundjob/cleanupfilelocks.php b/apps/files/lib/backgroundjob/cleanupfilelocks.php deleted file mode 100644 index b5cf8e94551..00000000000 --- a/apps/files/lib/backgroundjob/cleanupfilelocks.php +++ /dev/null @@ -1,57 +0,0 @@ -<?php -/** - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - -namespace OCA\Files\BackgroundJob; - -use OC\BackgroundJob\TimedJob; -use OC\Lock\DBLockingProvider; - -/** - * Clean up all file locks that are expired for the DB file locking provider - */ -class CleanupFileLocks extends TimedJob { - - /** - * Default interval in minutes - * - * @var int $defaultIntervalMin - **/ - protected $defaultIntervalMin = 5; - - /** - * sets the correct interval for this timed job - */ - public function __construct() { - $this->interval = $this->defaultIntervalMin * 60; - } - - /** - * Makes the background job do its work - * - * @param array $argument unused argument - */ - public function run($argument) { - $lockingProvider = \OC::$server->getLockingProvider(); - if($lockingProvider instanceof DBLockingProvider) { - $lockingProvider->cleanExpiredLocks(); - } - } -} diff --git a/apps/files/lib/backgroundjob/deleteorphaneditems.php b/apps/files/lib/backgroundjob/deleteorphaneditems.php deleted file mode 100644 index 1eef9c24e0c..00000000000 --- a/apps/files/lib/backgroundjob/deleteorphaneditems.php +++ /dev/null @@ -1,153 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - -namespace OCA\Files\BackgroundJob; - -use OC\BackgroundJob\TimedJob; -use OCP\DB\QueryBuilder\IQueryBuilder; - -/** - * Delete all share entries that have no matching entries in the file cache table. - */ -class DeleteOrphanedItems extends TimedJob { - - const CHUNK_SIZE = 200; - - /** @var \OCP\IDBConnection */ - protected $connection; - - /** @var \OCP\ILogger */ - protected $logger; - - /** - * Default interval in minutes - * - * @var int $defaultIntervalMin - **/ - protected $defaultIntervalMin = 60; - - /** - * sets the correct interval for this timed job - */ - public function __construct() { - $this->interval = $this->defaultIntervalMin * 60; - $this->connection = \OC::$server->getDatabaseConnection(); - $this->logger = \OC::$server->getLogger(); - } - - /** - * Makes the background job do its work - * - * @param array $argument unused argument - */ - public function run($argument) { - $this->cleanSystemTags(); - $this->cleanUserTags(); - $this->cleanComments(); - $this->cleanCommentMarkers(); - } - - /** - * Deleting orphaned system tag mappings - * - * @param string $table - * @param string $idCol - * @param string $typeCol - * @return int Number of deleted entries - */ - protected function cleanUp($table, $idCol, $typeCol) { - $deletedEntries = 0; - - $query = $this->connection->getQueryBuilder(); - $query->select('t1.' . $idCol) - ->from($table, 't1') - ->where($query->expr()->eq($typeCol, $query->expr()->literal('files'))) - ->andWhere($query->expr()->isNull('t2.fileid')) - ->leftJoin('t1', 'filecache', 't2', $query->expr()->eq($query->expr()->castColumn('t1.' . $idCol, IQueryBuilder::PARAM_INT), 't2.fileid')) - ->groupBy('t1.' . $idCol) - ->setMaxResults(self::CHUNK_SIZE); - - $deleteQuery = $this->connection->getQueryBuilder(); - $deleteQuery->delete($table) - ->where($deleteQuery->expr()->eq($idCol, $deleteQuery->createParameter('objectid'))); - - $deletedInLastChunk = self::CHUNK_SIZE; - while ($deletedInLastChunk === self::CHUNK_SIZE) { - $result = $query->execute(); - $deletedInLastChunk = 0; - while ($row = $result->fetch()) { - $deletedInLastChunk++; - $deletedEntries += $deleteQuery->setParameter('objectid', (int) $row[$idCol]) - ->execute(); - } - $result->closeCursor(); - } - - return $deletedEntries; - } - - /** - * Deleting orphaned system tag mappings - * - * @return int Number of deleted entries - */ - protected function cleanSystemTags() { - $deletedEntries = $this->cleanUp('systemtag_object_mapping', 'objectid', 'objecttype'); - $this->logger->debug("$deletedEntries orphaned system tag relations deleted", ['app' => 'DeleteOrphanedItems']); - return $deletedEntries; - } - - /** - * Deleting orphaned user tag mappings - * - * @return int Number of deleted entries - */ - protected function cleanUserTags() { - $deletedEntries = $this->cleanUp('vcategory_to_object', 'objid', 'type'); - $this->logger->debug("$deletedEntries orphaned user tag relations deleted", ['app' => 'DeleteOrphanedItems']); - return $deletedEntries; - } - - /** - * Deleting orphaned comments - * - * @return int Number of deleted entries - */ - protected function cleanComments() { - $deletedEntries = $this->cleanUp('comments', 'object_id', 'object_type'); - $this->logger->debug("$deletedEntries orphaned comments deleted", ['app' => 'DeleteOrphanedItems']); - return $deletedEntries; - } - - /** - * Deleting orphaned comment read markers - * - * @return int Number of deleted entries - */ - protected function cleanCommentMarkers() { - $deletedEntries = $this->cleanUp('comments_read_markers', 'object_id', 'object_type'); - $this->logger->debug("$deletedEntries orphaned comment read marks deleted", ['app' => 'DeleteOrphanedItems']); - return $deletedEntries; - } - -} diff --git a/apps/files/lib/backgroundjob/scanfiles.php b/apps/files/lib/backgroundjob/scanfiles.php deleted file mode 100644 index dcc180bcfbe..00000000000 --- a/apps/files/lib/backgroundjob/scanfiles.php +++ /dev/null @@ -1,114 +0,0 @@ -<?php -/** - * @author Lukas Reschke <lukas@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - -namespace OCA\Files\BackgroundJob; - -use OC\Files\Utils\Scanner; -use OCP\IConfig; -use OCP\IDBConnection; -use OCP\ILogger; -use OCP\IUser; -use OCP\IUserManager; - -/** - * Class ScanFiles is a background job used to run the file scanner over the user - * accounts to ensure integrity of the file cache. - * - * @package OCA\Files\BackgroundJob - */ -class ScanFiles extends \OC\BackgroundJob\TimedJob { - /** @var IConfig */ - private $config; - /** @var IUserManager */ - private $userManager; - /** @var IDBConnection */ - private $dbConnection; - /** @var ILogger */ - private $logger; - /** Amount of users that should get scanned per execution */ - const USERS_PER_SESSION = 500; - - /** - * @param IConfig|null $config - * @param IUserManager|null $userManager - * @param IDBConnection|null $dbConnection - * @param ILogger|null $logger - */ - public function __construct(IConfig $config = null, - IUserManager $userManager = null, - IDBConnection $dbConnection = null, - ILogger $logger = null) { - // Run once per 10 minutes - $this->setInterval(60 * 10); - - if (is_null($userManager) || is_null($config)) { - $this->fixDIForJobs(); - } else { - $this->config = $config; - $this->userManager = $userManager; - $this->logger = $logger; - } - } - - protected function fixDIForJobs() { - $this->config = \OC::$server->getConfig(); - $this->userManager = \OC::$server->getUserManager(); - $this->logger = \OC::$server->getLogger(); - } - - /** - * @param IUser $user - */ - protected function runScanner(IUser $user) { - try { - $scanner = new Scanner( - $user->getUID(), - $this->dbConnection, - $this->logger - ); - $scanner->backgroundScan(''); - } catch (\Exception $e) { - $this->logger->logException($e, ['app' => 'files']); - } - \OC_Util::tearDownFS(); - } - - /** - * @param $argument - * @throws \Exception - */ - protected function run($argument) { - $offset = $this->config->getAppValue('files', 'cronjob_scan_files', 0); - $users = $this->userManager->search('', self::USERS_PER_SESSION, $offset); - if (!count($users)) { - // No users found, reset offset and retry - $offset = 0; - $users = $this->userManager->search('', self::USERS_PER_SESSION); - } - - $offset += self::USERS_PER_SESSION; - $this->config->setAppValue('files', 'cronjob_scan_files', $offset); - - foreach ($users as $user) { - $this->runScanner($user); - } - } -} diff --git a/apps/files/lib/capabilities.php b/apps/files/lib/capabilities.php deleted file mode 100644 index 7d50b51bb97..00000000000 --- a/apps/files/lib/capabilities.php +++ /dev/null @@ -1,47 +0,0 @@ -<?php -/** - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Roeland Jago Douma <rullzer@owncloud.com> - * @author Tom Needham <tom@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - -namespace OCA\Files; - -use OCP\Capabilities\ICapability; - -/** - * Class Capabilities - * - * @package OCA\Files - */ -class Capabilities implements ICapability { - - /** - * Return this classes capabilities - * - * @return array - */ - public function getCapabilities() { - return [ - 'files' => [ - 'bigfilechunking' => true, - ], - ]; - } -} diff --git a/apps/files/lib/helper.php b/apps/files/lib/helper.php deleted file mode 100644 index d21a65afcee..00000000000 --- a/apps/files/lib/helper.php +++ /dev/null @@ -1,248 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author brumsel <brumsel@losecatcher.de> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <rullzer@owncloud.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @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/> - * - */ - -namespace OCA\Files; - -use OCP\Files\FileInfo; - -/** - * Helper class for manipulating file information - */ -class Helper { - /** - * @param string $dir - * @return array - * @throws \OCP\Files\NotFoundException - */ - public static function buildFileStorageStatistics($dir) { - // information about storage capacities - $storageInfo = \OC_Helper::getStorageInfo($dir); - $l = new \OC_L10N('files'); - $maxUploadFileSize = \OCP\Util::maxUploadFilesize($dir, $storageInfo['free']); - $maxHumanFileSize = \OCP\Util::humanFileSize($maxUploadFileSize); - $maxHumanFileSize = $l->t('Upload (max. %s)', array($maxHumanFileSize)); - - return [ - 'uploadMaxFilesize' => $maxUploadFileSize, - 'maxHumanFilesize' => $maxHumanFileSize, - 'freeSpace' => $storageInfo['free'], - 'usedSpacePercent' => (int)$storageInfo['relative'], - 'owner' => $storageInfo['owner'], - 'ownerDisplayName' => $storageInfo['ownerDisplayName'], - ]; - } - - /** - * Determine icon for a given file - * - * @param \OCP\Files\FileInfo $file file info - * @return string icon URL - */ - public static function determineIcon($file) { - if($file['type'] === 'dir') { - $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon('dir'); - // TODO: move this part to the client side, using mountType - if ($file->isShared()) { - $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon('dir-shared'); - } elseif ($file->isMounted()) { - $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon('dir-external'); - } - }else{ - $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon($file->getMimetype()); - } - - return substr($icon, 0, -3) . 'svg'; - } - - /** - * Comparator function to sort files alphabetically and have - * the directories appear first - * - * @param \OCP\Files\FileInfo $a file - * @param \OCP\Files\FileInfo $b file - * @return int -1 if $a must come before $b, 1 otherwise - */ - public static function compareFileNames(FileInfo $a, FileInfo $b) { - $aType = $a->getType(); - $bType = $b->getType(); - if ($aType === 'dir' and $bType !== 'dir') { - return -1; - } elseif ($aType !== 'dir' and $bType === 'dir') { - return 1; - } else { - return \OCP\Util::naturalSortCompare($a->getName(), $b->getName()); - } - } - - /** - * Comparator function to sort files by date - * - * @param \OCP\Files\FileInfo $a file - * @param \OCP\Files\FileInfo $b file - * @return int -1 if $a must come before $b, 1 otherwise - */ - public static function compareTimestamp(FileInfo $a, FileInfo $b) { - $aTime = $a->getMTime(); - $bTime = $b->getMTime(); - return ($aTime < $bTime) ? -1 : 1; - } - - /** - * Comparator function to sort files by size - * - * @param \OCP\Files\FileInfo $a file - * @param \OCP\Files\FileInfo $b file - * @return int -1 if $a must come before $b, 1 otherwise - */ - public static function compareSize(FileInfo $a, FileInfo $b) { - $aSize = $a->getSize(); - $bSize = $b->getSize(); - return ($aSize < $bSize) ? -1 : 1; - } - - /** - * Formats the file info to be returned as JSON to the client. - * - * @param \OCP\Files\FileInfo $i - * @return array formatted file info - */ - public static function formatFileInfo(FileInfo $i) { - $entry = array(); - - $entry['id'] = $i['fileid']; - $entry['parentId'] = $i['parent']; - $entry['mtime'] = $i['mtime'] * 1000; - // only pick out the needed attributes - $entry['name'] = $i->getName(); - $entry['permissions'] = $i['permissions']; - $entry['mimetype'] = $i['mimetype']; - $entry['size'] = $i['size']; - $entry['type'] = $i['type']; - $entry['etag'] = $i['etag']; - if (isset($i['tags'])) { - $entry['tags'] = $i['tags']; - } - if (isset($i['displayname_owner'])) { - $entry['shareOwner'] = $i['displayname_owner']; - } - if (isset($i['is_share_mount_point'])) { - $entry['isShareMountPoint'] = $i['is_share_mount_point']; - } - $mountType = null; - if ($i->isShared()) { - $mountType = 'shared'; - } else if ($i->isMounted()) { - $mountType = 'external'; - } - if ($mountType !== null) { - if ($i->getInternalPath() === '') { - $mountType .= '-root'; - } - $entry['mountType'] = $mountType; - } - if (isset($i['extraData'])) { - $entry['extraData'] = $i['extraData']; - } - return $entry; - } - - /** - * Format file info for JSON - * @param \OCP\Files\FileInfo[] $fileInfos file infos - * @return array - */ - public static function formatFileInfos($fileInfos) { - $files = array(); - foreach ($fileInfos as $i) { - $files[] = self::formatFileInfo($i); - } - - return $files; - } - - /** - * Retrieves the contents of the given directory and - * returns it as a sorted array of FileInfo. - * - * @param string $dir path to the directory - * @param string $sortAttribute attribute to sort on - * @param bool $sortDescending true for descending sort, false otherwise - * @param string $mimetypeFilter limit returned content to this mimetype or mimepart - * @return \OCP\Files\FileInfo[] files - */ - public static function getFiles($dir, $sortAttribute = 'name', $sortDescending = false, $mimetypeFilter = '') { - $content = \OC\Files\Filesystem::getDirectoryContent($dir, $mimetypeFilter); - - return self::sortFiles($content, $sortAttribute, $sortDescending); - } - - /** - * Populate the result set with file tags - * - * @param array $fileList - * @return array file list populated with tags - */ - public static function populateTags(array $fileList) { - $filesById = array(); - foreach ($fileList as $fileData) { - $filesById[$fileData['fileid']] = $fileData; - } - $tagger = \OC::$server->getTagManager()->load('files'); - $tags = $tagger->getTagsForObjects(array_keys($filesById)); - if ($tags) { - foreach ($tags as $fileId => $fileTags) { - $filesById[$fileId]['tags'] = $fileTags; - } - } - return $fileList; - } - - /** - * Sort the given file info array - * - * @param \OCP\Files\FileInfo[] $files files to sort - * @param string $sortAttribute attribute to sort on - * @param bool $sortDescending true for descending sort, false otherwise - * @return \OCP\Files\FileInfo[] sorted files - */ - public static function sortFiles($files, $sortAttribute = 'name', $sortDescending = false) { - $sortFunc = 'compareFileNames'; - if ($sortAttribute === 'mtime') { - $sortFunc = 'compareTimestamp'; - } else if ($sortAttribute === 'size') { - $sortFunc = 'compareSize'; - } - usort($files, array('\OCA\Files\Helper', $sortFunc)); - if ($sortDescending) { - $files = array_reverse($files); - } - return $files; - } -} |