diff options
Diffstat (limited to 'apps/files/lib')
84 files changed, 4622 insertions, 2755 deletions
diff --git a/apps/files/lib/Activity/FavoriteProvider.php b/apps/files/lib/Activity/FavoriteProvider.php index 9c7018e6a5c..e56b13b902a 100644 --- a/apps/files/lib/Activity/FavoriteProvider.php +++ b/apps/files/lib/Activity/FavoriteProvider.php @@ -1,28 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -35,32 +19,21 @@ class FavoriteProvider implements IProvider { public const SUBJECT_ADDED = 'added_favorite'; public const SUBJECT_REMOVED = 'removed_favorite'; - /** @var IFactory */ - protected $languageFactory; - /** @var IL10N */ protected $l; - /** @var IURLGenerator */ - protected $url; - - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - /** * @param IFactory $languageFactory * @param IURLGenerator $url * @param IManager $activityManager * @param IEventMerger $eventMerger */ - public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IEventMerger $eventMerger) { - $this->languageFactory = $languageFactory; - $this->url = $url; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; + public function __construct( + protected IFactory $languageFactory, + protected IURLGenerator $url, + protected IManager $activityManager, + protected IEventMerger $eventMerger, + ) { } /** @@ -68,12 +41,12 @@ class FavoriteProvider implements IProvider { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'files' || $event->getType() !== 'favorite') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('files', $language); @@ -81,7 +54,7 @@ class FavoriteProvider implements IProvider { if ($this->activityManager->isFormattingFilteredObject()) { try { return $this->parseShortVersion($event); - } catch (\InvalidArgumentException $e) { + } catch (UnknownActivityException) { // Ignore and simply use the long version... } } @@ -92,10 +65,10 @@ class FavoriteProvider implements IProvider { /** * @param IEvent $event * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parseShortVersion(IEvent $event) { + public function parseShortVersion(IEvent $event): IEvent { if ($event->getSubject() === self::SUBJECT_ADDED) { $event->setParsedSubject($this->l->t('Added to favorites')); if ($this->activityManager->getRequirePNG()) { @@ -112,7 +85,7 @@ class FavoriteProvider implements IProvider { $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.svg'))); } } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } return $event; @@ -122,10 +95,10 @@ class FavoriteProvider implements IProvider { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parseLongVersion(IEvent $event, IEvent $previousEvent = null) { + 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()) { @@ -142,7 +115,7 @@ class FavoriteProvider implements IProvider { $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.svg'))); } } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->setSubjects($event, $subject); @@ -166,7 +139,7 @@ class FavoriteProvider implements IProvider { } $parameter = [ 'type' => 'file', - 'id' => $subjectParams['id'], + 'id' => (string)$subjectParams['id'], 'name' => basename($subjectParams['path']), 'path' => trim($subjectParams['path'], '/'), 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $subjectParams['id']]), diff --git a/apps/files/lib/Activity/Filter/Favorites.php b/apps/files/lib/Activity/Filter/Favorites.php index 04f906b2f97..0159dd20b82 100644 --- a/apps/files/lib/Activity/Filter/Favorites.php +++ b/apps/files/lib/Activity/Filter/Favorites.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity\Filter; @@ -32,21 +16,6 @@ use OCP\IURLGenerator; class Favorites implements IFilter { - /** @var IL10N */ - protected $l; - - /** @var IURLGenerator */ - protected $url; - - /** @var IManager */ - protected $activityManager; - - /** @var Helper */ - protected $helper; - - /** @var IDBConnection */ - protected $db; - /** * @param IL10N $l * @param IURLGenerator $url @@ -54,12 +23,13 @@ class Favorites implements IFilter { * @param Helper $helper * @param IDBConnection $db */ - public function __construct(IL10N $l, IURLGenerator $url, IManager $activityManager, Helper $helper, IDBConnection $db) { - $this->l = $l; - $this->url = $url; - $this->activityManager = $activityManager; - $this->helper = $helper; - $this->db = $db; + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + protected IManager $activityManager, + protected Helper $helper, + protected IDBConnection $db, + ) { } /** diff --git a/apps/files/lib/Activity/Filter/FileChanges.php b/apps/files/lib/Activity/Filter/FileChanges.php index 2950e7c46df..0ca8f6792e0 100644 --- a/apps/files/lib/Activity/Filter/FileChanges.php +++ b/apps/files/lib/Activity/Filter/FileChanges.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity\Filter; @@ -29,19 +12,14 @@ use OCP\IURLGenerator; class FileChanges implements IFilter { - /** @var IL10N */ - protected $l; - - /** @var IURLGenerator */ - protected $url; - /** * @param IL10N $l * @param IURLGenerator $url */ - public function __construct(IL10N $l, IURLGenerator $url) { - $this->l = $l; - $this->url = $url; + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { } /** diff --git a/apps/files/lib/Activity/Helper.php b/apps/files/lib/Activity/Helper.php index b9a5ae887ec..9b8ad9cd442 100644 --- a/apps/files/lib/Activity/Helper.php +++ b/apps/files/lib/Activity/Helper.php @@ -1,52 +1,37 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 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; - /** @var ITagManager */ - protected $tagManager; - - /** - * @param ITagManager $tagManager - */ - public function __construct(ITagManager $tagManager) { - $this->tagManager = $tagManager; + public function __construct( + protected ITagManager $tagManager, + protected IRootFolder $rootFolder, + ) { } /** - * Returns an array with the favorites + * Return an array with nodes marked as favorites * - * @param string $user - * @return array + * @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 getFavoriteFilePaths($user) { + public function getFavoriteNodes(string $user, bool $foldersOnly = false): array { $tags = $this->tagManager->load('files', [], false, $user); $favorites = $tags->getFavorites(); @@ -57,26 +42,44 @@ class Helper { } // Can not DI because the user is not known on instantiation - $rootFolder = \OC::$server->getUserFolder($user); - $folders = $items = []; + $userFolder = $this->rootFolder->getUserFolder($user); + $favoriteNodes = []; 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; + $node = $userFolder->getFirstNodeById($favorite); + if ($node) { + if (!$foldersOnly || $node instanceof Folder) { + $favoriteNodes[] = $node; } } } - if (empty($items)) { + 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 index 2cfd48ede3b..3ef79ac107f 100644 --- a/apps/files/lib/Activity/Provider.php +++ b/apps/files/lib/Activity/Provider.php @@ -1,30 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -42,56 +24,24 @@ use OCP\IUserManager; use OCP\L10N\IFactory; class Provider implements IProvider { - /** @var IFactory */ - protected $languageFactory; - /** @var IL10N */ protected $l; - /** @var IL10N */ - protected $activityLang; - - /** @var IURLGenerator */ - protected $url; - - /** @var IManager */ - protected $activityManager; - - /** @var IUserManager */ - protected $userManager; - - /** @var IRootFolder */ - protected $rootFolder; - - /** @var IEventMerger */ - protected $eventMerger; - - /** @var ICloudIdManager */ - protected $cloudIdManager; - - /** @var IContactsManager */ - protected $contactsManager; /** @var string[] cached displayNames - key is the cloud id and value the displayname */ protected $displayNames = []; protected $fileIsEncrypted = false; - public function __construct(IFactory $languageFactory, - IURLGenerator $url, - IManager $activityManager, - IUserManager $userManager, - IRootFolder $rootFolder, - ICloudIdManager $cloudIdManager, - IContactsManager $contactsManager, - IEventMerger $eventMerger) { - $this->languageFactory = $languageFactory; - $this->url = $url; - $this->activityManager = $activityManager; - $this->userManager = $userManager; - $this->rootFolder = $rootFolder; - $this->cloudIdManager = $cloudIdManager; - $this->contactsManager = $contactsManager; - $this->eventMerger = $eventMerger; + 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, + ) { } /** @@ -99,21 +49,20 @@ class Provider implements IProvider { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'files') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('files', $language); - $this->activityLang = $this->languageFactory->get('activity', $language); if ($this->activityManager->isFormattingFilteredObject()) { try { return $this->parseShortVersion($event, $previousEvent); - } catch (\InvalidArgumentException $e) { + } catch (UnknownActivityException) { // Ignore and simply use the long version... } } @@ -133,10 +82,10 @@ class Provider implements IProvider { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parseShortVersion(IEvent $event, IEvent $previousEvent = null) { + public function parseShortVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent { $parsedParameters = $this->getParameters($event); if ($event->getSubject() === 'created_by') { @@ -158,12 +107,12 @@ class Provider implements IProvider { $subject = $this->l->t('Moved by {user}'); $this->setIcon($event, 'change'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } if (!isset($parsedParameters['user'])) { // External user via public link share - $subject = str_replace('{user}', $this->activityLang->t('"remote user"'), $subject); + $subject = str_replace('{user}', $this->l->t('"remote account"'), $subject); } $this->setSubjects($event, $subject, $parsedParameters); @@ -175,10 +124,10 @@ class Provider implements IProvider { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parseLongVersion(IEvent $event, IEvent $previousEvent = null) { + public function parseLongVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent { $this->fileIsEncrypted = false; $parsedParameters = $this->getParameters($event); @@ -272,7 +221,7 @@ class Provider implements IProvider { $subject = $this->l->t('{user} moved {oldfile} to {newfile}'); $this->setIcon($event, 'change'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } if ($this->fileIsEncrypted) { @@ -281,7 +230,7 @@ class Provider implements IProvider { if (!isset($parsedParameters['user'])) { // External user via public link share - $subject = str_replace('{user}', $this->activityLang->t('"remote user"'), $subject); + $subject = str_replace('{user}', $this->l->t('"remote account"'), $subject); } $this->setSubjects($event, $subject, $parsedParameters); @@ -311,9 +260,9 @@ class Provider implements IProvider { /** * @param IEvent $event * @return array - * @throws \InvalidArgumentException + * @throws UnknownActivityException */ - protected function getParameters(IEvent $event) { + protected function getParameters(IEvent $event): array { $parameters = $event->getSubjectParameters(); switch ($event->getSubject()) { case 'created_self': @@ -366,18 +315,18 @@ class Provider implements IProvider { * @param array|string $parameter * @param IEvent|null $event * @return array - * @throws \InvalidArgumentException + * @throws UnknownActivityException */ - protected function getFile($parameter, IEvent $event = null) { + protected function getFile($parameter, ?IEvent $event = null): array { if (is_array($parameter)) { $path = reset($parameter); - $id = (string) key($parameter); + $id = (int)key($parameter); } elseif ($event !== null) { // Legacy from before ownCloud 8.2 $path = $parameter; $id = $event->getObjectId(); } else { - throw new \InvalidArgumentException('Could not generate file parameter'); + throw new UnknownActivityException('Could not generate file parameter'); } $encryptionContainer = $this->getEndToEndEncryptionContainer($id, $path); @@ -393,7 +342,7 @@ class Provider implements IProvider { return [ 'type' => 'file', - 'id' => $encryptionContainer->getId(), + 'id' => (string)$encryptionContainer->getId(), 'name' => $encryptionContainer->getName(), 'path' => $path, 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $encryptionContainer->getId()]), @@ -406,7 +355,7 @@ class Provider implements IProvider { return [ 'type' => 'file', - 'id' => $id, + 'id' => (string)$id, 'name' => basename($path), 'path' => trim($path, '/'), 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $id]), @@ -433,8 +382,8 @@ class Provider implements IProvider { } $userFolder = $this->rootFolder->getUserFolder($this->activityManager->getCurrentUserId()); - $files = $userFolder->getById($fileId); - if (empty($files)) { + $file = $userFolder->getFirstNodeById($fileId); + if (!$file) { try { // Deleted, try with parent $file = $this->findExistingParent($userFolder, dirname($path)); @@ -450,8 +399,6 @@ class Provider implements IProvider { return $file; } - $file = array_shift($files); - if ($file instanceof Folder && $file->isEncrypted()) { // If the folder is encrypted, it is the Container, // but can be the name is just fine. diff --git a/apps/files/lib/Activity/Settings/FavoriteAction.php b/apps/files/lib/Activity/Settings/FavoriteAction.php index 3c6ceb23959..73b200341ec 100644 --- a/apps/files/lib/Activity/Settings/FavoriteAction.php +++ b/apps/files/lib/Activity/Settings/FavoriteAction.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity\Settings; @@ -42,8 +25,8 @@ class FavoriteAction extends FileActivitySettings { /** * @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. + * 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() { diff --git a/apps/files/lib/Activity/Settings/FileActivitySettings.php b/apps/files/lib/Activity/Settings/FileActivitySettings.php index 8bb97a81cc8..0ca7100832f 100644 --- a/apps/files/lib/Activity/Settings/FileActivitySettings.php +++ b/apps/files/lib/Activity/Settings/FileActivitySettings.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity\Settings; @@ -29,14 +12,12 @@ use OCP\Activity\ActivitySettings; use OCP\IL10N; abstract class FileActivitySettings extends ActivitySettings { - /** @var IL10N */ - protected $l; - /** * @param IL10N $l */ - public function __construct(IL10N $l) { - $this->l = $l; + public function __construct( + protected IL10N $l, + ) { } public function getGroupIdentifier() { diff --git a/apps/files/lib/Activity/Settings/FileChanged.php b/apps/files/lib/Activity/Settings/FileChanged.php index 2d826c30b5b..c33ed5e1eba 100644 --- a/apps/files/lib/Activity/Settings/FileChanged.php +++ b/apps/files/lib/Activity/Settings/FileChanged.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity\Settings; @@ -42,8 +25,8 @@ class FileChanged extends FileActivitySettings { /** * @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. + * 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() { diff --git a/apps/files/lib/Activity/Settings/FileFavoriteChanged.php b/apps/files/lib/Activity/Settings/FileFavoriteChanged.php index 829f3e0d9d0..5000902ed3f 100644 --- a/apps/files/lib/Activity/Settings/FileFavoriteChanged.php +++ b/apps/files/lib/Activity/Settings/FileFavoriteChanged.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Activity\Settings; @@ -42,8 +25,8 @@ class FileFavoriteChanged extends FileActivitySettings { /** * @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. + * 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() { 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 index e172f0ae826..9e6d35a7538 100644 --- a/apps/files/lib/App.php +++ b/apps/files/lib/App.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Carl Schwan <carl@carlschwan.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files; 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; @@ -34,6 +18,7 @@ use OCP\IURLGenerator; use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\Server; +use Psr\Log\LoggerInterface; class App { private static ?INavigationManager $navigationManager = null; @@ -50,7 +35,9 @@ class App { Server::get(IFactory::class), Server::get(IUserSession::class), Server::get(IGroupManager::class), - Server::get(IConfig::class) + Server::get(IConfig::class), + Server::get(LoggerInterface::class), + Server::get(IEventDispatcher::class), ); self::$navigationManager->clear(false); } @@ -60,9 +47,8 @@ class App { public static function extendJsConfig($settings): void { $appConfig = json_decode($settings['array']['oc_appconfig'], true); - $maxChunkSize = (int)Server::get(IConfig::class)->getAppValue('files', 'max_chunk_size', (string)(10 * 1024 * 1024)); $appConfig['files'] = [ - 'max_chunk_size' => $maxChunkSize + '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 index 01fe46bb877..2761b44ecf9 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -1,63 +1,53 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\AppInfo; use Closure; -use OC\Search\Provider\File; +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\LoadAdditionalScriptsEvent; +use OCA\Files\Event\LoadSearchPlugins; use OCA\Files\Event\LoadSidebar; -use OCA\Files\Listener\LegacyLoadAdditionalScriptsAdapter; +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\ISearch; use OCP\IRequest; use OCP\IServerContainer; use OCP\ITagManager; @@ -65,6 +55,7 @@ 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'; @@ -91,6 +82,10 @@ class Application extends App implements IBootstrap { $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), ); }); @@ -106,7 +101,6 @@ class Application extends App implements IBootstrap { $c->get(IActivityManager::class), $c->get(ITagManager::class)->load(self::APP_ID), $server->getUserFolder(), - $server->getEventDispatcher() ); }); @@ -114,22 +108,30 @@ class Application extends App implements IBootstrap { * Register capabilities */ $context->registerCapability(Capabilities::class); + $context->registerCapability(AdvancedCapabilities::class); $context->registerCapability(DirectEditingCapabilities::class); - $context->registerEventListener(LoadAdditionalScriptsEvent::class, LegacyLoadAdditionalScriptsAdapter::class); - $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::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']); - $context->injectFn(Closure::fromCallable([$this, 'registerSearchProvider'])); - $this->registerTemplates(); - $context->injectFn(Closure::fromCallable([$this, 'registerNavigation'])); $this->registerHooks(); } @@ -137,47 +139,6 @@ class Application extends App implements IBootstrap { $providerManager->registerResourceProvider(ResourceProvider::class); } - private function registerSearchProvider(ISearch $search): void { - $search->registerProvider(File::class, ['apps' => ['files']]); - } - - private function registerTemplates(): void { - $templateManager = \OC_Helper::getFileTemplateManager(); - $templateManager->registerTemplate('application/vnd.oasis.opendocument.presentation', 'core/templates/filetemplates/template.odp'); - $templateManager->registerTemplate('application/vnd.oasis.opendocument.text', 'core/templates/filetemplates/template.odt'); - $templateManager->registerTemplate('application/vnd.oasis.opendocument.spreadsheet', 'core/templates/filetemplates/template.ods'); - } - - private function registerNavigation(IL10N $l10n): void { - \OCA\Files\App::getNavigationManager()->add(function () use ($l10n) { - return [ - 'id' => 'files', - 'appname' => 'files', - 'script' => 'list.php', - 'order' => 0, - 'name' => $l10n->t('All files') - ]; - }); - \OCA\Files\App::getNavigationManager()->add(function () use ($l10n) { - return [ - 'id' => 'recent', - 'appname' => 'files', - 'script' => 'recentlist.php', - 'order' => 2, - 'name' => $l10n->t('Recent') - ]; - }); - \OCA\Files\App::getNavigationManager()->add(function () use ($l10n) { - return [ - 'id' => 'favorites', - 'appname' => 'files', - 'script' => 'simplelist.php', - 'order' => 5, - 'name' => $l10n->t('Favorites'), - ]; - }); - } - 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 index a9b5b1446b2..a1032b2787d 100644 --- a/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php +++ b/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\BackgroundJob; @@ -31,15 +13,12 @@ use OCP\BackgroundJob\TimedJob; use OCP\DirectEditing\IManager; class CleanupDirectEditingTokens extends TimedJob { - private const INTERVAL_MINUTES = 15 * 60; - - private IManager $manager; - - public function __construct(ITimeFactory $time, - IManager $manager) { + public function __construct( + ITimeFactory $time, + private IManager $manager, + ) { parent::__construct($time); - $this->interval = self::INTERVAL_MINUTES; - $this->manager = $manager; + $this->setInterval(15 * 60); } /** diff --git a/apps/files/lib/BackgroundJob/CleanupFileLocks.php b/apps/files/lib/BackgroundJob/CleanupFileLocks.php index e0ad72eaaf0..91bb145884b 100644 --- a/apps/files/lib/BackgroundJob/CleanupFileLocks.php +++ b/apps/files/lib/BackgroundJob/CleanupFileLocks.php @@ -1,50 +1,28 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Max Kovalenko <mxss1998@yandex.ru> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 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 OC\Lock\DBLockingProvider; +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 { /** - * Default interval in minutes - * - * @var int $defaultIntervalMin - **/ - protected $defaultIntervalMin = 5; - - /** * sets the correct interval for this timed job */ public function __construct(ITimeFactory $time) { parent::__construct($time); - - $this->interval = $this->defaultIntervalMin * 60; + $this->setInterval(5 * 60); } /** @@ -54,7 +32,7 @@ class CleanupFileLocks extends TimedJob { * @throws \Exception */ public function run($argument) { - $lockingProvider = \OC::$server->getLockingProvider(); + $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 index d96728fc713..8a20b6dfb0c 100644 --- a/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php +++ b/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php @@ -3,51 +3,29 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\BackgroundJob; -use OCA\Files\Controller\OpenLocalEditorController; use OCA\Files\Db\OpenLocalEditorMapper; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\TimedJob; /** * Delete all expired "Open local editor" token */ class DeleteExpiredOpenLocalEditor extends TimedJob { - protected OpenLocalEditorMapper $mapper; - public function __construct( ITimeFactory $time, - OpenLocalEditorMapper $mapper + protected OpenLocalEditorMapper $mapper, ) { parent::__construct($time); - $this->mapper = $mapper; // Run every 12h $this->interval = 12 * 3600; - $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); } /** diff --git a/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php b/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php index 669c2a4cde6..b925974f24a 100644 --- a/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php +++ b/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php @@ -1,32 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 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. @@ -34,27 +19,16 @@ use OCP\DB\QueryBuilder\IQueryBuilder; class DeleteOrphanedItems extends TimedJob { public 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(ITimeFactory $time) { + public function __construct( + ITimeFactory $time, + protected IDBConnection $connection, + protected LoggerInterface $logger, + ) { parent::__construct($time); - $this->interval = $this->defaultIntervalMin * 60; - $this->connection = \OC::$server->getDatabaseConnection(); - $this->logger = \OC::$server->getLogger(); + $this->setInterval(60 * 60); } /** @@ -77,38 +51,87 @@ class DeleteOrphanedItems extends TimedJob { * @param string $typeCol * @return int Number of deleted entries */ - protected function cleanUp($table, $idCol, $typeCol) { + protected function cleanUp(string $table, string $idCol, string $typeCol): int { $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(); + 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(); } - $result->closeCursor(); } 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 diff --git a/apps/files/lib/BackgroundJob/ScanFiles.php b/apps/files/lib/BackgroundJob/ScanFiles.php index 8dc9dcf37ff..f3f9093d648 100644 --- a/apps/files/lib/BackgroundJob/ScanFiles.php +++ b/apps/files/lib/BackgroundJob/ScanFiles.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\BackgroundJob; @@ -40,29 +24,19 @@ use Psr\Log\LoggerInterface; * @package OCA\Files\BackgroundJob */ class ScanFiles extends TimedJob { - private IConfig $config; - private IEventDispatcher $dispatcher; - private LoggerInterface $logger; - private IDBConnection $connection; - /** Amount of users that should get scanned per execution */ public const USERS_PER_SESSION = 500; public function __construct( - IConfig $config, - IEventDispatcher $dispatcher, - LoggerInterface $logger, - IDBConnection $connection, - ITimeFactory $time + 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); - - $this->config = $config; - $this->dispatcher = $dispatcher; - $this->logger = $logger; - $this->connection = $connection; } protected function runScanner(string $user): void { @@ -86,15 +60,61 @@ class ScanFiles extends TimedJob { * @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->select('user_id') - ->from('filecache', 'f') - ->innerJoin('f', 'mounts', 'm', $query->expr()->eq('storage_id', 'storage')) - ->where($query->expr()->lt('size', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))) - ->andWhere($query->expr()->gt('parent', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) - ->setMaxResults(1); - - return $query->executeQuery()->fetchOne(); + $query->selectDistinct('storage_id') + ->from('mounts'); + return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); } /** diff --git a/apps/files/lib/BackgroundJob/TransferOwnership.php b/apps/files/lib/BackgroundJob/TransferOwnership.php index 8c96fcf8385..de8d1989733 100644 --- a/apps/files/lib/BackgroundJob/TransferOwnership.php +++ b/apps/files/lib/BackgroundJob/TransferOwnership.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\BackgroundJob; @@ -33,46 +16,23 @@ use OCA\Files\Service\OwnershipTransferService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\QueuedJob; use OCP\Files\IRootFolder; -use OCP\ILogger; use OCP\IUser; use OCP\IUserManager; use OCP\Notification\IManager as NotificationManager; +use Psr\Log\LoggerInterface; use function ltrim; class TransferOwnership extends QueuedJob { - - /** @var IUserManager $userManager */ - private $userManager; - - /** @var OwnershipTransferService */ - private $transferService; - - /** @var ILogger */ - private $logger; - - /** @var NotificationManager */ - private $notificationManager; - - /** @var TransferOwnershipMapper */ - private $mapper; - /** @var IRootFolder */ - private $rootFolder; - - public function __construct(ITimeFactory $timeFactory, - IUserManager $userManager, - OwnershipTransferService $transferService, - ILogger $logger, - NotificationManager $notificationManager, - TransferOwnershipMapper $mapper, - IRootFolder $rootFolder) { + 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); - - $this->userManager = $userManager; - $this->transferService = $transferService; - $this->logger = $logger; - $this->notificationManager = $notificationManager; - $this->mapper = $mapper; - $this->rootFolder = $rootFolder; } protected function run($argument) { @@ -84,14 +44,14 @@ class TransferOwnership extends QueuedJob { $fileId = $transfer->getFileId(); $userFolder = $this->rootFolder->getUserFolder($sourceUser); - $nodes = $userFolder->getById($fileId); + $node = $userFolder->getFirstNodeById($fileId); - if (empty($nodes)) { + if (!$node) { $this->logger->alert('Could not transfer ownership: Node not found'); $this->failedNotication($transfer); return; } - $path = $userFolder->getRelativePath($nodes[0]->getPath()); + $path = $userFolder->getRelativePath($node->getPath()); $sourceUserObject = $this->userManager->get($sourceUser); $destinationUserObject = $this->userManager->get($destinationUser); @@ -116,7 +76,12 @@ class TransferOwnership extends QueuedJob { ); $this->successNotification($transfer); } catch (TransferOwnershipException $e) { - $this->logger->logException($e); + $this->logger->error( + $e->getMessage(), + [ + 'exception' => $e, + ], + ); $this->failedNotication($transfer); } @@ -136,7 +101,6 @@ class TransferOwnership extends QueuedJob { ]) ->setObject('transfer', (string)$transfer->getId()); $this->notificationManager->notify($notification); - // Send notification to source user $notification = $this->notificationManager->createNotification(); $notification->setUser($transfer->getTargetUser()) diff --git a/apps/files/lib/Capabilities.php b/apps/files/lib/Capabilities.php index 3eb43578351..6b50e5807a5 100644 --- a/apps/files/lib/Capabilities.php +++ b/apps/files/lib/Capabilities.php @@ -1,51 +1,50 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tom Needham <tom@owncloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files; +use OC\Files\FilenameValidator; +use OCA\Files\Service\ChunkedUploadConfig; use OCP\Capabilities\ICapability; -use OCP\IConfig; +use OCP\Files\Conversion\ConversionMimeProvider; +use OCP\Files\Conversion\IConversionManager; class Capabilities implements ICapability { - protected IConfig $config; - - public function __construct(IConfig $config) { - $this->config = $config; + public function __construct( + protected FilenameValidator $filenameValidator, + protected IConversionManager $fileConversionManager, + ) { } /** * Return this classes capabilities * - * @return array + * @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() { + 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, - 'blacklisted_files' => $this->config->getSystemValue('blacklisted_files', ['.htaccess']) + '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 index 64dd693a4da..e4ff5d83b7a 100644 --- a/apps/files/lib/Collaboration/Resources/Listener.php +++ b/apps/files/lib/Collaboration/Resources/Listener.php @@ -3,37 +3,23 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Collaboration\Resources; -use OCP\Server; use OCP\Collaboration\Resources\IManager; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +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(EventDispatcherInterface $dispatcher): void { - $dispatcher->addListener('OCP\Share::postShare', [self::class, 'shareModification']); - $dispatcher->addListener('OCP\Share::postUnshare', [self::class, 'shareModification']); - $dispatcher->addListener('OCP\Share::postUnshareFromSelf', [self::class, 'shareModification']); + 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 { diff --git a/apps/files/lib/Collaboration/Resources/ResourceProvider.php b/apps/files/lib/Collaboration/Resources/ResourceProvider.php index 841a2bdd4f7..73883bc4c6a 100644 --- a/apps/files/lib/Collaboration/Resources/ResourceProvider.php +++ b/apps/files/lib/Collaboration/Resources/ResourceProvider.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Collaboration\Resources; @@ -38,32 +20,24 @@ use OCP\IUser; class ResourceProvider implements IProvider { public const RESOURCE_TYPE = 'file'; - /** @var IRootFolder */ - protected $rootFolder; - /** @var IPreview */ - private $preview; - /** @var IURLGenerator */ - private $urlGenerator; - /** @var array */ protected $nodes = []; - public function __construct(IRootFolder $rootFolder, - IPreview $preview, - IURLGenerator $urlGenerator) { - $this->rootFolder = $rootFolder; - $this->preview = $preview; - $this->urlGenerator = $urlGenerator; + 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()]; + if (isset($this->nodes[(int)$resource->getId()])) { + return $this->nodes[(int)$resource->getId()]; } - $nodes = $this->rootFolder->getById((int) $resource->getId()); - if (!empty($nodes)) { - $this->nodes[(int) $resource->getId()] = array_shift($nodes); - 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; } @@ -74,8 +48,8 @@ class ResourceProvider implements IProvider { * @since 16.0.0 */ public function getResourceRichObject(IResource $resource): array { - if (isset($this->nodes[(int) $resource->getId()])) { - $node = $this->nodes[(int) $resource->getId()]->getPath(); + if (isset($this->nodes[(int)$resource->getId()])) { + $node = $this->nodes[(int)$resource->getId()]->getPath(); } else { $node = $this->getNode($resource); } @@ -107,16 +81,16 @@ class ResourceProvider implements IProvider { * @return bool * @since 16.0.0 */ - public function canAccessResource(IResource $resource, IUser $user = null): bool { + public function canAccessResource(IResource $resource, ?IUser $user = null): bool { if (!$user instanceof IUser) { return false; } $userFolder = $this->rootFolder->getUserFolder($user->getUID()); - $nodes = $userFolder->getById((int) $resource->getId()); + $node = $userFolder->getById((int)$resource->getId()); - if (!empty($nodes)) { - $this->nodes[(int) $resource->getId()] = array_shift($nodes); + if ($node) { + $this->nodes[(int)$resource->getId()] = $node; return true; } diff --git a/apps/files/lib/Command/Copy.php b/apps/files/lib/Command/Copy.php new file mode 100644 index 00000000000..ad0dfa90de1 --- /dev/null +++ b/apps/files/lib/Command/Copy.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command; + +use OC\Core\Command\Info\FileUtils; +use OCP\Files\Folder; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class Copy extends Command { + public function __construct( + private FileUtils $fileUtils, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:copy') + ->setDescription('Copy a file or folder') + ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path') + ->addArgument('target', InputArgument::REQUIRED, 'Target path') + ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for confirmation and don't output any warnings") + ->addOption('no-target-directory', 'T', InputOption::VALUE_NONE, 'When target path is folder, overwrite the folder instead of copying into the folder'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $sourceInput = $input->getArgument('source'); + $targetInput = $input->getArgument('target'); + $force = $input->getOption('force'); + $noTargetDir = $input->getOption('no-target-directory'); + + $node = $this->fileUtils->getNode($sourceInput); + $targetNode = $this->fileUtils->getNode($targetInput); + + if (!$node) { + $output->writeln("<error>file $sourceInput not found</error>"); + return 1; + } + + $targetParentPath = dirname(rtrim($targetInput, '/')); + $targetParent = $this->fileUtils->getNode($targetParentPath); + if (!$targetParent) { + $output->writeln("<error>Target parent path $targetParentPath doesn't exist</error>"); + return 1; + } + + $wouldRequireDelete = false; + + if ($targetNode) { + if (!$targetNode->isUpdateable()) { + $output->writeln("<error>$targetInput isn't writable</error>"); + return 1; + } + + if ($targetNode instanceof Folder) { + if ($noTargetDir) { + if (!$force) { + $output->writeln("Warning: <info>$sourceInput</info> is a file, but <info>$targetInput</info> is a folder"); + } + $wouldRequireDelete = true; + } else { + $targetInput = $targetNode->getFullPath($node->getName()); + $targetNode = $this->fileUtils->getNode($targetInput); + } + } else { + if ($node instanceof Folder) { + if (!$force) { + $output->writeln("Warning: <info>$sourceInput</info> is a folder, but <info>$targetInput</info> is a file"); + } + $wouldRequireDelete = true; + } + } + + if ($wouldRequireDelete && $targetNode->getInternalPath() === '') { + $output->writeln("<error>Mount root can't be overwritten with a different type</error>"); + return 1; + } + + if ($wouldRequireDelete && !$targetNode->isDeletable()) { + $output->writeln("<error>$targetInput can't be deleted to be replaced with $sourceInput</error>"); + return 1; + } + + if (!$force && $targetNode) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false); + if (!$helper->ask($input, $output, $question)) { + return 1; + } + } + } + + if ($wouldRequireDelete && $targetNode) { + $targetNode->delete(); + } + + $node->copy($targetInput); + + return 0; + } + +} diff --git a/apps/files/lib/Command/Delete.php b/apps/files/lib/Command/Delete.php new file mode 100644 index 00000000000..d984f839c91 --- /dev/null +++ b/apps/files/lib/Command/Delete.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command; + +use OC\Core\Command\Info\FileUtils; +use OCA\Files_Sharing\SharedStorage; +use OCP\Files\Folder; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class Delete extends Command { + public function __construct( + private FileUtils $fileUtils, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:delete') + ->setDescription('Delete a file or folder') + ->addArgument('file', InputArgument::REQUIRED, 'File id or path') + ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings"); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $fileInput = $input->getArgument('file'); + $inputIsId = is_numeric($fileInput); + $force = $input->getOption('force'); + $node = $this->fileUtils->getNode($fileInput); + + if (!$node) { + $output->writeln("<error>file $fileInput not found</error>"); + return self::FAILURE; + } + + $deleteConfirmed = $force; + if (!$deleteConfirmed) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $storage = $node->getStorage(); + if (!$inputIsId && $storage->instanceOfStorage(SharedStorage::class) && $node->getInternalPath() === '') { + /** @var SharedStorage $storage */ + [,$user] = explode('/', $fileInput, 3); + $question = new ConfirmationQuestion("<info>$fileInput</info> in a shared file, do you want to unshare the file from <info>$user</info> instead of deleting the source file? [Y/n] ", true); + if ($helper->ask($input, $output, $question)) { + $storage->unshareStorage(); + return self::SUCCESS; + } else { + $node = $storage->getShare()->getNode(); + $output->writeln(''); + } + } + + $filesByUsers = $this->fileUtils->getFilesByUser($node); + if (count($filesByUsers) > 1) { + $output->writeln('Warning: the provided file is accessible by more than one user'); + $output->writeln(' all of the following users will lose access to the file when deleted:'); + $output->writeln(''); + foreach ($filesByUsers as $user => $filesByUser) { + $output->writeln($user . ':'); + foreach ($filesByUser as $file) { + $output->writeln(' - ' . $file->getPath()); + } + } + $output->writeln(''); + } + + if ($node instanceof Folder) { + $maybeContents = " and all it's contents"; + } else { + $maybeContents = ''; + } + $question = new ConfirmationQuestion('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false); + $deleteConfirmed = $helper->ask($input, $output, $question); + } + + if ($deleteConfirmed) { + if ($node->isDeletable()) { + $node->delete(); + } else { + $output->writeln('<error>File cannot be deleted, insufficient permissions.</error>'); + } + } + + return self::SUCCESS; + } +} diff --git a/apps/files/lib/Command/DeleteOrphanedFiles.php b/apps/files/lib/Command/DeleteOrphanedFiles.php index e3305fe3b9b..37cb3159f4a 100644 --- a/apps/files/lib/Command/DeleteOrphanedFiles.php +++ b/apps/files/lib/Command/DeleteOrphanedFiles.php @@ -1,32 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\Command; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** @@ -35,56 +20,123 @@ use Symfony\Component\Console\Output\OutputInterface; class DeleteOrphanedFiles extends Command { public const CHUNK_SIZE = 200; - /** - * @var IDBConnection - */ - protected $connection; - - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + protected IDBConnection $connection, + ) { parent::__construct(); } - protected function configure() { + protected function configure(): void { $this ->setName('files:cleanup') - ->setDescription('cleanup filecache'); + ->setDescription('Clean up orphaned filecache and mount entries') + ->setHelp('Deletes orphaned filecache and mount entries (those without an existing storage).') + ->addOption('skip-filecache-extended', null, InputOption::VALUE_NONE, 'don\'t remove orphaned entries from filecache_extended'); } public function execute(InputInterface $input, OutputInterface $output): int { - $deletedEntries = 0; + $fileIdsByStorage = []; + + $deletedStorages = array_diff($this->getReferencedStorages(), $this->getExistingStorages()); + + $deleteExtended = !$input->getOption('skip-filecache-extended'); + if ($deleteExtended) { + $fileIdsByStorage = $this->getFileIdsForStorages($deletedStorages); + } + + $deletedEntries = $this->cleanupOrphanedFileCache($deletedStorages); + $output->writeln("$deletedEntries orphaned file cache entries deleted"); + if ($deleteExtended) { + $deletedFileCacheExtended = $this->cleanupOrphanedFileCacheExtended($fileIdsByStorage); + $output->writeln("$deletedFileCacheExtended orphaned file cache extended entries deleted"); + } + + $deletedMounts = $this->cleanupOrphanedMounts(); + $output->writeln("$deletedMounts orphaned mount entries deleted"); + + return self::SUCCESS; + } + + private function getReferencedStorages(): array { $query = $this->connection->getQueryBuilder(); - $query->select('fc.fileid') - ->from('filecache', 'fc') - ->where($query->expr()->isNull('s.numeric_id')) - ->leftJoin('fc', 'storages', 's', $query->expr()->eq('fc.storage', 's.numeric_id')) - ->setMaxResults(self::CHUNK_SIZE); + $query->select('storage') + ->from('filecache') + ->groupBy('storage') + ->runAcrossAllShards(); + return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + } + + private function getExistingStorages(): array { + $query = $this->connection->getQueryBuilder(); + $query->select('numeric_id') + ->from('storages') + ->groupBy('numeric_id'); + return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * @param int[] $storageIds + * @return array<int, int[]> + */ + private function getFileIdsForStorages(array $storageIds): array { + $query = $this->connection->getQueryBuilder(); + $query->select('storage', 'fileid') + ->from('filecache') + ->where($query->expr()->in('storage', $query->createParameter('storage_ids'))); + + $result = []; + $storageIdChunks = array_chunk($storageIds, self::CHUNK_SIZE); + foreach ($storageIdChunks as $storageIdChunk) { + $query->setParameter('storage_ids', $storageIdChunk, IQueryBuilder::PARAM_INT_ARRAY); + $chunk = $query->executeQuery()->fetchAll(); + foreach ($chunk as $row) { + $result[$row['storage']][] = $row['fileid']; + } + } + return $result; + } + + private function cleanupOrphanedFileCache(array $deletedStorages): int { + $deletedEntries = 0; $deleteQuery = $this->connection->getQueryBuilder(); $deleteQuery->delete('filecache') - ->where($deleteQuery->expr()->eq('fileid', $deleteQuery->createParameter('objectid'))); + ->where($deleteQuery->expr()->in('storage', $deleteQuery->createParameter('storage_ids'))); - $deletedInLastChunk = self::CHUNK_SIZE; - while ($deletedInLastChunk === self::CHUNK_SIZE) { - $deletedInLastChunk = 0; - $result = $query->execute(); - while ($row = $result->fetch()) { - $deletedInLastChunk++; - $deletedEntries += $deleteQuery->setParameter('objectid', (int) $row['fileid']) - ->execute(); - } - $result->closeCursor(); + $deletedStorageChunks = array_chunk($deletedStorages, self::CHUNK_SIZE); + foreach ($deletedStorageChunks as $deletedStorageChunk) { + $deleteQuery->setParameter('storage_ids', $deletedStorageChunk, IQueryBuilder::PARAM_INT_ARRAY); + $deletedEntries += $deleteQuery->executeStatement(); } - $output->writeln("$deletedEntries orphaned file cache entries deleted"); + return $deletedEntries; + } - $deletedMounts = $this->cleanupOrphanedMounts(); - $output->writeln("$deletedMounts orphaned mount entries deleted"); - return 0; + /** + * @param array<int, int[]> $fileIdsByStorage + * @return int + */ + private function cleanupOrphanedFileCacheExtended(array $fileIdsByStorage): int { + $deletedEntries = 0; + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('filecache_extended') + ->where($deleteQuery->expr()->in('fileid', $deleteQuery->createParameter('file_ids'))); + + foreach ($fileIdsByStorage as $storageId => $fileIds) { + $deleteQuery->hintShardKey('storage', $storageId, true); + $fileChunks = array_chunk($fileIds, self::CHUNK_SIZE); + foreach ($fileChunks as $fileChunk) { + $deleteQuery->setParameter('file_ids', $fileChunk, IQueryBuilder::PARAM_INT_ARRAY); + $deletedEntries += $deleteQuery->executeStatement(); + } + } + + return $deletedEntries; } - private function cleanupOrphanedMounts() { + private function cleanupOrphanedMounts(): int { $deletedEntries = 0; $query = $this->connection->getQueryBuilder(); @@ -102,11 +154,11 @@ class DeleteOrphanedFiles extends Command { $deletedInLastChunk = self::CHUNK_SIZE; while ($deletedInLastChunk === self::CHUNK_SIZE) { $deletedInLastChunk = 0; - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $deletedInLastChunk++; - $deletedEntries += $deleteQuery->setParameter('storageid', (int) $row['storage_id']) - ->execute(); + $deletedEntries += $deleteQuery->setParameter('storageid', (int)$row['storage_id']) + ->executeStatement(); } $result->closeCursor(); } diff --git a/apps/files/lib/Command/Get.php b/apps/files/lib/Command/Get.php new file mode 100644 index 00000000000..60e028f615e --- /dev/null +++ b/apps/files/lib/Command/Get.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command; + +use OC\Core\Command\Info\FileUtils; +use OCP\Files\File; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class Get extends Command { + public function __construct( + private FileUtils $fileUtils, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:get') + ->setDescription('Get the contents of a file') + ->addArgument('file', InputArgument::REQUIRED, 'Source file id or Nextcloud path') + ->addArgument('output', InputArgument::OPTIONAL, 'Target local file to output to, defaults to STDOUT'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $fileInput = $input->getArgument('file'); + $outputName = $input->getArgument('output'); + $node = $this->fileUtils->getNode($fileInput); + + if (!$node) { + $output->writeln("<error>file $fileInput not found</error>"); + return self::FAILURE; + } + + if (!($node instanceof File)) { + $output->writeln("<error>$fileInput is a directory</error>"); + return self::FAILURE; + } + + $isTTY = stream_isatty(STDOUT); + if ($outputName === null && $isTTY && $node->getMimePart() !== 'text') { + $output->writeln([ + '<error>Warning: Binary output can mess up your terminal</error>', + " Use <info>occ files:get $fileInput -</info> to output it to the terminal anyway", + " Or <info>occ files:get $fileInput <FILE></info> to save to a file instead" + ]); + return self::FAILURE; + } + $source = $node->fopen('r'); + if (!$source) { + $output->writeln("<error>Failed to open $fileInput for reading</error>"); + return self::FAILURE; + } + $target = ($outputName === null || $outputName === '-') ? STDOUT : fopen($outputName, 'w'); + if (!$target) { + $output->writeln("<error>Failed to open $outputName for reading</error>"); + return self::FAILURE; + } + + stream_copy_to_stream($source, $target); + return self::SUCCESS; + } +} diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php new file mode 100644 index 00000000000..29dd8860b2a --- /dev/null +++ b/apps/files/lib/Command/Move.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command; + +use OC\Core\Command\Info\FileUtils; +use OCP\Files\File; +use OCP\Files\Folder; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class Move extends Command { + public function __construct( + private FileUtils $fileUtils, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:move') + ->setDescription('Move a file or folder') + ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path') + ->addArgument('target', InputArgument::REQUIRED, 'Target path') + ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings"); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $sourceInput = $input->getArgument('source'); + $targetInput = $input->getArgument('target'); + $force = $input->getOption('force'); + + $node = $this->fileUtils->getNode($sourceInput); + $targetNode = $this->fileUtils->getNode($targetInput); + + if (!$node) { + $output->writeln("<error>file $sourceInput not found</error>"); + return 1; + } + + $targetParentPath = dirname(rtrim($targetInput, '/')); + $targetParent = $this->fileUtils->getNode($targetParentPath); + if (!$targetParent) { + $output->writeln("<error>Target parent path $targetParentPath doesn't exist</error>"); + return 1; + } + + $wouldRequireDelete = false; + + if ($targetNode) { + if (!$targetNode->isUpdateable()) { + $output->writeln("<error>$targetInput already exists and isn't writable</error>"); + return 1; + } + + if ($node instanceof Folder && $targetNode instanceof File) { + $output->writeln("Warning: <info>$sourceInput</info> is a folder, but <info>$targetInput</info> is a file"); + $wouldRequireDelete = true; + } + + if ($node instanceof File && $targetNode instanceof Folder) { + $output->writeln("Warning: <info>$sourceInput</info> is a file, but <info>$targetInput</info> is a folder"); + $wouldRequireDelete = true; + } + + if ($wouldRequireDelete && $targetNode->getInternalPath() === '') { + $output->writeln("<error>Mount root can't be overwritten with a different type</error>"); + return 1; + } + + if ($wouldRequireDelete && !$targetNode->isDeletable()) { + $output->writeln("<error>$targetInput can't be deleted to be replaced with $sourceInput</error>"); + return 1; + } + + if (!$force) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false); + if (!$helper->ask($input, $output, $question)) { + return 1; + } + } + } + + if ($wouldRequireDelete && $targetNode) { + $targetNode->delete(); + } + + $node->move($targetInput); + + return 0; + } + +} diff --git a/apps/files/lib/Command/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php new file mode 100644 index 00000000000..07613ecc616 --- /dev/null +++ b/apps/files/lib/Command/Object/Delete.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class Delete extends Command { + public function __construct( + private ObjectUtil $objectUtils, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:object:delete') + ->setDescription('Delete an object from the object store') + ->addArgument('object', InputArgument::REQUIRED, 'Object to delete') + ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to delete the object from, only required in cases where it can't be determined from the config"); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $object = $input->getArgument('object'); + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + if (!$objectStore) { + return -1; + } + + if ($fileId = $this->objectUtils->objectExistsInDb($object)) { + $output->writeln("<error>Warning, object $object belongs to an existing file, deleting the object will lead to unexpected behavior if not replaced</error>"); + $output->writeln(" Note: use <info>occ files:delete $fileId</info> to delete the file cleanly or <info>occ info:file $fileId</info> for more information about the file"); + $output->writeln(''); + } + + if (!$objectStore->objectExists($object)) { + $output->writeln("<error>Object $object does not exist</error>"); + return -1; + } + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion("Delete $object? [y/N] ", false); + if ($helper->ask($input, $output, $question)) { + $objectStore->deleteObject($object); + } + return self::SUCCESS; + } +} diff --git a/apps/files/lib/Command/Object/Get.php b/apps/files/lib/Command/Object/Get.php new file mode 100644 index 00000000000..c32de020c5a --- /dev/null +++ b/apps/files/lib/Command/Object/Get.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Get extends Command { + public function __construct( + private ObjectUtil $objectUtils, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:object:get') + ->setDescription('Get the contents of an object') + ->addArgument('object', InputArgument::REQUIRED, 'Object to get') + ->addArgument('output', InputArgument::REQUIRED, 'Target local file to output to, use - for STDOUT') + ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config"); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $object = $input->getArgument('object'); + $outputName = $input->getArgument('output'); + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + if (!$objectStore) { + return self::FAILURE; + } + + if (!$objectStore->objectExists($object)) { + $output->writeln("<error>Object $object does not exist</error>"); + return self::FAILURE; + } + + try { + $source = $objectStore->readObject($object); + } catch (\Exception $e) { + $msg = $e->getMessage(); + $output->writeln("<error>Failed to read $object from object store: $msg</error>"); + return self::FAILURE; + } + $target = $outputName === '-' ? STDOUT : fopen($outputName, 'w'); + if (!$target) { + $output->writeln("<error>Failed to open $outputName for writing</error>"); + return self::FAILURE; + } + + stream_copy_to_stream($source, $target); + return self::SUCCESS; + } + +} diff --git a/apps/files/lib/Command/Object/Info.php b/apps/files/lib/Command/Object/Info.php new file mode 100644 index 00000000000..6748de37cfe --- /dev/null +++ b/apps/files/lib/Command/Object/Info.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object; + +use OC\Core\Command\Base; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\ObjectStore\IObjectStoreMetaData; +use OCP\Util; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Info extends Base { + public function __construct( + private ObjectUtil $objectUtils, + private IMimeTypeDetector $mimeTypeDetector, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + $this + ->setName('files:object:info') + ->setDescription('Get the metadata of an object') + ->addArgument('object', InputArgument::REQUIRED, 'Object to get') + ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config"); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $object = $input->getArgument('object'); + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + if (!$objectStore) { + return self::FAILURE; + } + + if (!$objectStore instanceof IObjectStoreMetaData) { + $output->writeln('<error>Configured object store does currently not support retrieve metadata</error>'); + return self::FAILURE; + } + + if (!$objectStore->objectExists($object)) { + $output->writeln("<error>Object $object does not exist</error>"); + return self::FAILURE; + } + + try { + $meta = $objectStore->getObjectMetaData($object); + } catch (\Exception $e) { + $msg = $e->getMessage(); + $output->writeln("<error>Failed to read $object from object store: $msg</error>"); + return self::FAILURE; + } + + if ($input->getOption('output') === 'plain' && isset($meta['size'])) { + $meta['size'] = Util::humanFileSize($meta['size']); + } + if (isset($meta['mtime'])) { + $meta['mtime'] = $meta['mtime']->format(\DateTimeImmutable::ATOM); + } + if (!isset($meta['mimetype'])) { + $handle = $objectStore->readObject($object); + $head = fread($handle, 8192); + fclose($handle); + $meta['mimetype'] = $this->mimeTypeDetector->detectString($head); + } + + $this->writeArrayInOutputFormat($input, $output, $meta); + + return self::SUCCESS; + } + +} diff --git a/apps/files/lib/Command/Object/ListObject.php b/apps/files/lib/Command/Object/ListObject.php new file mode 100644 index 00000000000..5d30232e09f --- /dev/null +++ b/apps/files/lib/Command/Object/ListObject.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object; + +use OC\Core\Command\Base; +use OCP\Files\ObjectStore\IObjectStoreMetaData; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ListObject extends Base { + private const CHUNK_SIZE = 100; + + public function __construct( + private readonly ObjectUtil $objectUtils, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + $this + ->setName('files:object:list') + ->setDescription('List all objects in the object store') + ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config"); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + if (!$objectStore) { + return self::FAILURE; + } + + if (!$objectStore instanceof IObjectStoreMetaData) { + $output->writeln('<error>Configured object store does currently not support listing objects</error>'); + return self::FAILURE; + } + $objects = $objectStore->listObjects(); + $objects = $this->objectUtils->formatObjects($objects, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN); + $this->writeStreamingTableInOutputFormat($input, $output, $objects, self::CHUNK_SIZE); + + return self::SUCCESS; + } +} diff --git a/apps/files/lib/Command/Object/Multi/Rename.php b/apps/files/lib/Command/Object/Multi/Rename.php new file mode 100644 index 00000000000..562c68eb07f --- /dev/null +++ b/apps/files/lib/Command/Object/Multi/Rename.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object\Multi; + +use OC\Core\Command\Base; +use OC\Files\ObjectStore\PrimaryObjectStoreConfig; +use OCP\IConfig; +use OCP\IDBConnection; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class Rename extends Base { + public function __construct( + private readonly IDBConnection $connection, + private readonly PrimaryObjectStoreConfig $objectStoreConfig, + private readonly IConfig $config, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + $this + ->setName('files:object:multi:rename-config') + ->setDescription('Rename an object store configuration and move all users over to the new configuration,') + ->addArgument('source', InputArgument::REQUIRED, 'Object store configuration to rename') + ->addArgument('target', InputArgument::REQUIRED, 'New name for the object store configuration'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $source = $input->getArgument('source'); + $target = $input->getArgument('target'); + + $configs = $this->objectStoreConfig->getObjectStoreConfigs(); + if (!isset($configs[$source])) { + $output->writeln('<error>Unknown object store configuration: ' . $source . '</error>'); + return 1; + } + + if ($source === 'root') { + $output->writeln('<error>Renaming the root configuration is not supported.</error>'); + return 1; + } + + if ($source === 'default') { + $output->writeln('<error>Renaming the default configuration is not supported.</error>'); + return 1; + } + + if (!isset($configs[$target])) { + $output->writeln('<comment>Target object store configuration ' . $target . ' doesn\'t exist yet.</comment>'); + $output->writeln('The target configuration can be created automatically.'); + $output->writeln('However, as this depends on modifying the config.php, this only works as long as the instance runs on a single node or all nodes in a clustered setup have a shared config file (such as from a shared network mount).'); + $output->writeln('If the different nodes have a separate copy of the config.php file, the automatic object store configuration creation will lead to the configuration going out of sync.'); + $output->writeln('If these requirements are not met, you can manually create the target object store configuration in each node\'s configuration before running the command.'); + $output->writeln(''); + $output->writeln('<error>Failure to check these requirements will lead to data loss for users.</error>'); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Automatically create target object store configuration? [y/N] ', false); + if ($helper->ask($input, $output, $question)) { + $configs[$target] = $configs[$source]; + + // update all aliases + foreach ($configs as &$config) { + if ($config === $source) { + $config = $target; + } + } + $this->config->setSystemValue('objectstore', $configs); + } else { + return 0; + } + } elseif (($configs[$source] !== $configs[$target]) || $configs[$source] !== $target) { + $output->writeln('<error>Source and target configuration differ.</error>'); + $output->writeln(''); + $output->writeln('To ensure proper migration of users, the source and target configuration must be the same to ensure that the objects for the moved users exist on the target configuration.'); + $output->writeln('The usual migration process consists of creating a clone of the old configuration, moving the users from the old configuration to the new one, and then adjust the old configuration that is longer used.'); + return 1; + } + + $query = $this->connection->getQueryBuilder(); + $query->update('preferences') + ->set('configvalue', $query->createNamedParameter($target)) + ->where($query->expr()->eq('appid', $query->createNamedParameter('homeobjectstore'))) + ->andWhere($query->expr()->eq('configkey', $query->createNamedParameter('objectstore'))) + ->andWhere($query->expr()->eq('configvalue', $query->createNamedParameter($source))); + $count = $query->executeStatement(); + + if ($count > 0) { + $output->writeln('Moved <info>' . $count . '</info> users'); + } else { + $output->writeln('No users moved'); + } + + return 0; + } +} diff --git a/apps/files/lib/Command/Object/Multi/Users.php b/apps/files/lib/Command/Object/Multi/Users.php new file mode 100644 index 00000000000..e8f7d012641 --- /dev/null +++ b/apps/files/lib/Command/Object/Multi/Users.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object\Multi; + +use OC\Core\Command\Base; +use OC\Files\ObjectStore\PrimaryObjectStoreConfig; +use OCP\IConfig; +use OCP\IUser; +use OCP\IUserManager; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Users extends Base { + public function __construct( + private readonly IUserManager $userManager, + private readonly PrimaryObjectStoreConfig $objectStoreConfig, + private readonly IConfig $config, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + $this + ->setName('files:object:multi:users') + ->setDescription('Get the mapping between users and object store buckets') + ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, 'Only list users using the specified bucket') + ->addOption('object-store', 'o', InputOption::VALUE_REQUIRED, 'Only list users using the specified object store configuration') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Only show the mapping for the specified user, ignores all other options'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + if ($userId = $input->getOption('user')) { + $user = $this->userManager->get($userId); + if (!$user) { + $output->writeln("<error>User $userId not found</error>"); + return 1; + } + $users = new \ArrayIterator([$user]); + } else { + $bucket = (string)$input->getOption('bucket'); + $objectStore = (string)$input->getOption('object-store'); + if ($bucket !== '' && $objectStore === '') { + $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket)); + } elseif ($bucket === '' && $objectStore !== '') { + $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore)); + } elseif ($bucket) { + $users = $this->getUsers(array_intersect( + $this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket), + $this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore) + )); + } else { + $users = $this->userManager->getSeenUsers(); + } + } + + $this->writeStreamingTableInOutputFormat($input, $output, $this->infoForUsers($users), 100); + return 0; + } + + /** + * @param string[] $userIds + * @return \Iterator<IUser> + */ + private function getUsers(array $userIds): \Iterator { + foreach ($userIds as $userId) { + $user = $this->userManager->get($userId); + if ($user) { + yield $user; + } + } + } + + /** + * @param \Iterator<IUser> $users + * @return \Iterator<array> + */ + private function infoForUsers(\Iterator $users): \Iterator { + foreach ($users as $user) { + yield $this->infoForUser($user); + } + } + + private function infoForUser(IUser $user): array { + return [ + 'user' => $user->getUID(), + 'object-store' => $this->objectStoreConfig->getObjectStoreForUser($user), + 'bucket' => $this->objectStoreConfig->getSetBucketForUser($user) ?? 'unset', + ]; + } +} diff --git a/apps/files/lib/Command/Object/ObjectUtil.php b/apps/files/lib/Command/Object/ObjectUtil.php new file mode 100644 index 00000000000..5f053c2c42f --- /dev/null +++ b/apps/files/lib/Command/Object/ObjectUtil.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\ObjectStore\IObjectStore; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Util; +use Symfony\Component\Console\Output\OutputInterface; + +class ObjectUtil { + public function __construct( + private IConfig $config, + private IDBConnection $connection, + ) { + } + + private function getObjectStoreConfig(): ?array { + $config = $this->config->getSystemValue('objectstore_multibucket'); + if (is_array($config)) { + $config['multibucket'] = true; + return $config; + } + $config = $this->config->getSystemValue('objectstore'); + if (is_array($config)) { + if (!isset($config['multibucket'])) { + $config['multibucket'] = false; + } + return $config; + } + + return null; + } + + public function getObjectStore(?string $bucket, OutputInterface $output): ?IObjectStore { + $config = $this->getObjectStoreConfig(); + if (!$config) { + $output->writeln('<error>Instance is not using primary object store</error>'); + return null; + } + if ($config['multibucket'] && !$bucket) { + $output->writeln('<error>--bucket option required</error> because <info>multi bucket</info> is enabled.'); + return null; + } + + if (!isset($config['arguments'])) { + throw new \Exception('no arguments configured for object store configuration'); + } + if (!isset($config['class'])) { + throw new \Exception('no class configured for object store configuration'); + } + + if ($bucket) { + // s3, swift + $config['arguments']['bucket'] = $bucket; + // azure + $config['arguments']['container'] = $bucket; + } + + $store = new $config['class']($config['arguments']); + if (!$store instanceof IObjectStore) { + throw new \Exception('configured object store class is not an object store implementation'); + } + return $store; + } + + /** + * Check if an object is referenced in the database + */ + public function objectExistsInDb(string $object): int|false { + if (!str_starts_with($object, 'urn:oid:')) { + return false; + } + + $fileId = (int)substr($object, strlen('urn:oid:')); + $query = $this->connection->getQueryBuilder(); + $query->select('fileid') + ->from('filecache') + ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + $result = $query->executeQuery(); + + if ($result->fetchOne() === false) { + return false; + } + + return $fileId; + } + + public function formatObjects(\Iterator $objects, bool $humanOutput): \Iterator { + foreach ($objects as $object) { + yield $this->formatObject($object, $humanOutput); + } + } + + public function formatObject(array $object, bool $humanOutput): array { + $row = array_merge([ + 'urn' => $object['urn'], + ], ($object['metadata'] ?? [])); + + if ($humanOutput && isset($row['size'])) { + $row['size'] = Util::humanFileSize($row['size']); + } + if (isset($row['mtime'])) { + $row['mtime'] = $row['mtime']->format(\DateTimeImmutable::ATOM); + } + return $row; + } +} diff --git a/apps/files/lib/Command/Object/Orphans.php b/apps/files/lib/Command/Object/Orphans.php new file mode 100644 index 00000000000..f7132540fc8 --- /dev/null +++ b/apps/files/lib/Command/Object/Orphans.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object; + +use OC\Core\Command\Base; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\ObjectStore\IObjectStoreMetaData; +use OCP\IDBConnection; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Orphans extends Base { + private const CHUNK_SIZE = 100; + + private ?IQueryBuilder $query = null; + + public function __construct( + private readonly ObjectUtil $objectUtils, + private readonly IDBConnection $connection, + ) { + parent::__construct(); + } + + private function getQuery(): IQueryBuilder { + if (!$this->query) { + $this->query = $this->connection->getQueryBuilder(); + $this->query->select('fileid') + ->from('filecache') + ->where($this->query->expr()->eq('fileid', $this->query->createParameter('file_id'))); + } + return $this->query; + } + + protected function configure(): void { + parent::configure(); + $this + ->setName('files:object:orphans') + ->setDescription('List all objects in the object store that don\'t have a matching entry in the database') + ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config"); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + if (!$objectStore) { + return self::FAILURE; + } + + if (!$objectStore instanceof IObjectStoreMetaData) { + $output->writeln('<error>Configured object store does currently not support listing objects</error>'); + return self::FAILURE; + } + $prefixLength = strlen('urn:oid:'); + + $objects = $objectStore->listObjects('urn:oid:'); + $orphans = new \CallbackFilterIterator($objects, function (array $object) use ($prefixLength) { + $fileId = (int)substr($object['urn'], $prefixLength); + return !$this->fileIdInDb($fileId); + }); + + $orphans = $this->objectUtils->formatObjects($orphans, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN); + $this->writeStreamingTableInOutputFormat($input, $output, $orphans, self::CHUNK_SIZE); + + return self::SUCCESS; + } + + private function fileIdInDb(int $fileId): bool { + $query = $this->getQuery(); + $query->setParameter('file_id', $fileId, IQueryBuilder::PARAM_INT); + $result = $query->executeQuery(); + return $result->fetchOne() !== false; + } +} diff --git a/apps/files/lib/Command/Object/Put.php b/apps/files/lib/Command/Object/Put.php new file mode 100644 index 00000000000..8516eb51183 --- /dev/null +++ b/apps/files/lib/Command/Object/Put.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command\Object; + +use OCP\Files\IMimeTypeDetector; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class Put extends Command { + public function __construct( + private ObjectUtil $objectUtils, + private IMimeTypeDetector $mimeTypeDetector, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:object:put') + ->setDescription('Write a file to the object store') + ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN') + ->addArgument('object', InputArgument::REQUIRED, 'Object to write') + ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket where to store the object, only required in cases where it can't be determined from the config"); + ; + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $object = $input->getArgument('object'); + $inputName = (string)$input->getArgument('input'); + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + if (!$objectStore) { + return -1; + } + + if ($fileId = $this->objectUtils->objectExistsInDb($object)) { + $output->writeln("<error>Warning, object $object belongs to an existing file, overwriting the object contents can lead to unexpected behavior.</error>"); + $output->writeln("You can use <info>occ files:put $inputName $fileId</info> to write to the file safely."); + $output->writeln(''); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Write to the object anyway? [y/N] ', false); + if (!$helper->ask($input, $output, $question)) { + return -1; + } + } + + $source = $inputName === '-' ? STDIN : fopen($inputName, 'r'); + if (!$source) { + $output->writeln("<error>Failed to open $inputName</error>"); + return self::FAILURE; + } + $objectStore->writeObject($object, $source, $this->mimeTypeDetector->detectPath($inputName)); + return self::SUCCESS; + } + +} diff --git a/apps/files/lib/Command/Put.php b/apps/files/lib/Command/Put.php new file mode 100644 index 00000000000..fd9d75db78c --- /dev/null +++ b/apps/files/lib/Command/Put.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Command; + +use OC\Core\Command\Info\FileUtils; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class Put extends Command { + public function __construct( + private FileUtils $fileUtils, + private IRootFolder $rootFolder, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:put') + ->setDescription('Write contents of a file') + ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN') + ->addArgument('file', InputArgument::REQUIRED, 'Target Nextcloud file path to write to or fileid of existing file'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $fileOutput = $input->getArgument('file'); + $inputName = $input->getArgument('input'); + $node = $this->fileUtils->getNode($fileOutput); + + if ($node instanceof Folder) { + $output->writeln("<error>$fileOutput is a folder</error>"); + return self::FAILURE; + } + if (!$node and is_numeric($fileOutput)) { + $output->writeln("<error>$fileOutput not found</error>"); + return self::FAILURE; + } + + $source = ($inputName === null || $inputName === '-') ? STDIN : fopen($inputName, 'r'); + if (!$source) { + $output->writeln("<error>Failed to open $inputName</error>"); + return self::FAILURE; + } + if ($node instanceof File) { + $target = $node->fopen('w'); + if (!$target) { + $output->writeln("<error>Failed to open $fileOutput</error>"); + return self::FAILURE; + } + stream_copy_to_stream($source, $target); + } else { + $this->rootFolder->newFile($fileOutput, $source); + } + return self::SUCCESS; + } +} diff --git a/apps/files/lib/Command/RepairTree.php b/apps/files/lib/Command/RepairTree.php index 521fe3d6a4a..622ccba48a3 100644 --- a/apps/files/lib/Command/RepairTree.php +++ b/apps/files/lib/Command/RepairTree.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command; @@ -33,17 +16,13 @@ use Symfony\Component\Console\Output\OutputInterface; class RepairTree extends Command { public const CHUNK_SIZE = 200; - /** - * @var IDBConnection - */ - protected $connection; - - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + protected IDBConnection $connection, + ) { parent::__construct(); } - protected function configure() { + protected function configure(): void { $this ->setName('files:repair-tree') ->setDescription('Try and repair malformed filesystem tree structures') @@ -54,7 +33,7 @@ class RepairTree extends Command { $rows = $this->findBrokenTreeBits(); $fix = !$input->getOption('dry-run'); - $output->writeln("Found " . count($rows) . " file entries with an invalid path"); + $output->writeln('Found ' . count($rows) . ' file entries with an invalid path'); if ($fix) { $this->connection->beginTransaction(); @@ -90,7 +69,7 @@ class RepairTree extends Command { $this->connection->commit(); } - return 0; + return self::SUCCESS; } private function getFileId(int $storage, string $path) { @@ -102,7 +81,7 @@ class RepairTree extends Command { return $query->execute()->fetch(\PDO::FETCH_COLUMN); } - private function deleteById(int $fileId) { + private function deleteById(int $fileId): void { $query = $this->connection->getQueryBuilder(); $query->delete('filecache') ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId))); diff --git a/apps/files/lib/Command/SanitizeFilenames.php b/apps/files/lib/Command/SanitizeFilenames.php new file mode 100644 index 00000000000..88d41d1cb5e --- /dev/null +++ b/apps/files/lib/Command/SanitizeFilenames.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Command; + +use Exception; +use OC\Core\Command\Base; +use OC\Files\FilenameValidator; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Lock\LockedException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class SanitizeFilenames extends Base { + + private OutputInterface $output; + private ?string $charReplacement; + private bool $dryRun; + + public function __construct( + private IUserManager $userManager, + private IRootFolder $rootFolder, + private IUserSession $session, + private IFactory $l10nFactory, + private FilenameValidator $filenameValidator, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + + $this + ->setName('files:sanitize-filenames') + ->setDescription('Renames files to match naming constraints') + ->addArgument( + 'user_id', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'will only rename files the given user(s) have access to' + ) + ->addOption( + 'dry-run', + mode: InputOption::VALUE_NONE, + description: 'Do not actually rename any files but just check filenames.', + ) + ->addOption( + 'char-replacement', + 'c', + mode: InputOption::VALUE_REQUIRED, + description: 'Replacement for invalid character (by default space, underscore or dash is used)', + ); + + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->charReplacement = $input->getOption('char-replacement'); + // check if replacement is needed + $c = $this->filenameValidator->getForbiddenCharacters(); + if (count($c) > 0) { + try { + $this->filenameValidator->sanitizeFilename($c[0], $this->charReplacement); + } catch (\InvalidArgumentException) { + if ($this->charReplacement === null) { + $output->writeln('<error>Character replacement required</error>'); + } else { + $output->writeln('<error>Invalid character replacement given</error>'); + } + return 1; + } + } + + $this->dryRun = $input->getOption('dry-run'); + if ($this->dryRun) { + $output->writeln('<info>Dry run is enabled, no actual renaming will be applied.</>'); + } + + $this->output = $output; + $users = $input->getArgument('user_id'); + if (!empty($users)) { + foreach ($users as $userId) { + $user = $this->userManager->get($userId); + if ($user === null) { + $output->writeln("<error>User '$userId' does not exist - skipping</>"); + continue; + } + $this->sanitizeUserFiles($user); + } + } else { + $this->userManager->callForSeenUsers($this->sanitizeUserFiles(...)); + } + return self::SUCCESS; + } + + private function sanitizeUserFiles(IUser $user): void { + // Set an active user so that event listeners can correctly work (e.g. files versions) + $this->session->setVolatileActiveUser($user); + + $this->output->writeln('<info>Analyzing files of ' . $user->getUID() . '</>'); + + $folder = $this->rootFolder->getUserFolder($user->getUID()); + $this->sanitizeFiles($folder); + } + + private function sanitizeFiles(Folder $folder): void { + foreach ($folder->getDirectoryListing() as $node) { + $this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE); + + try { + $oldName = $node->getName(); + $newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement); + if ($oldName !== $newName) { + $newName = $folder->getNonExistingName($newName); + $path = rtrim(dirname($node->getPath()), '/'); + + if (!$this->dryRun) { + $node->move("$path/$newName"); + } elseif (!$folder->isCreatable()) { + // simulate error for dry run + throw new NotPermittedException(); + } + $this->output->writeln('renamed: "' . $oldName . '" to "' . $newName . '"'); + } + } catch (LockedException) { + $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (file is locked)</>'); + } catch (NotPermittedException) { + $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (no permissions)</>'); + } catch (Exception $error) { + $this->output->writeln('<error>failed: ' . $node->getPath() . '</>'); + $this->output->writeln('<error>' . $error->getMessage() . '</>', OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_VERBOSE); + } + + if ($node instanceof Folder) { + $this->sanitizeFiles($node); + } + } + } + +} diff --git a/apps/files/lib/Command/Scan.php b/apps/files/lib/Command/Scan.php index 710c76de493..b9057139b0e 100644 --- a/apps/files/lib/Command/Scan.php +++ b/apps/files/lib/Command/Scan.php @@ -1,35 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Blaok <i@blaok.me> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Joel S <joel.devbox@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author martin.mattel@diemattels.at <martin.mattel@diemattels.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\Command; @@ -37,15 +11,22 @@ use OC\Core\Command\Base; use OC\Core\Command\InterruptedException; use OC\DB\Connection; use OC\DB\ConnectionAdapter; -use OCP\Files\File; +use OC\Files\Storage\Wrapper\Jail; +use OC\Files\Utils\Scanner; +use OC\FilesMetadata\FilesMetadataManager; use OC\ForbiddenException; -use OC\Metadata\MetadataManager; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Events\FileCacheUpdated; +use OCP\Files\Events\NodeAddedToCache; +use OCP\Files\Events\NodeRemovedFromCache; use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; use OCP\Files\StorageNotAvailableException; +use OCP\FilesMetadata\IFilesMetadataManager; use OCP\IUserManager; +use OCP\Lock\LockedException; +use OCP\Server; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputArgument; @@ -54,25 +35,25 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class Scan extends Base { - private IUserManager $userManager; protected float $execTime = 0; protected int $foldersCounter = 0; protected int $filesCounter = 0; - private IRootFolder $root; - private MetadataManager $metadataManager; + protected int $errorsCounter = 0; + protected int $newCounter = 0; + protected int $updatedCounter = 0; + protected int $removedCounter = 0; public function __construct( - IUserManager $userManager, - IRootFolder $rootFolder, - MetadataManager $metadataManager + private IUserManager $userManager, + private IRootFolder $rootFolder, + private FilesMetadataManager $filesMetadataManager, + private IEventDispatcher $eventDispatcher, + private LoggerInterface $logger, ) { - $this->userManager = $userManager; parent::__construct(); - $this->root = $rootFolder; - $this->metadataManager = $metadataManager; } - protected function configure() { + protected function configure(): void { parent::configure(); $this @@ -86,14 +67,15 @@ class Scan extends Base { ->addOption( 'path', 'p', - InputArgument::OPTIONAL, + InputOption::VALUE_REQUIRED, 'limit rescan to this path, eg. --path="/alice/files/Music", the user_id is determined by the path and the user_id parameter and --all are ignored' ) ->addOption( 'generate-metadata', null, - InputOption::VALUE_NONE, - 'Generate metadata for all scanned files' + InputOption::VALUE_OPTIONAL, + 'Generate metadata for all scanned files; if specified only generate for named value', + '' ) ->addOption( 'all', @@ -118,63 +100,95 @@ class Scan extends Base { ); } - protected function scanFiles(string $user, string $path, bool $scanMetadata, OutputInterface $output, bool $backgroundScan = false, bool $recursive = true, bool $homeOnly = false): void { + protected function scanFiles( + string $user, + string $path, + ?string $scanMetadata, + OutputInterface $output, + callable $mountFilter, + bool $backgroundScan = false, + bool $recursive = true, + ): void { $connection = $this->reconnectToDatabase($output); - $scanner = new \OC\Files\Utils\Scanner( + $scanner = new Scanner( $user, new ConnectionAdapter($connection), - \OC::$server->get(IEventDispatcher::class), - \OC::$server->get(LoggerInterface::class) + Server::get(IEventDispatcher::class), + Server::get(LoggerInterface::class) ); # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata) { + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata): void { $output->writeln("\tFile\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE); ++$this->filesCounter; $this->abortIfInterrupted(); - if ($scanMetadata) { - $node = $this->root->get($path); - if ($node instanceof File) { - $this->metadataManager->generateMetadata($node, false); - } + if ($scanMetadata !== null) { + $node = $this->rootFolder->get($path); + $this->filesMetadataManager->refreshMetadata( + $node, + ($scanMetadata !== '') ? IFilesMetadataManager::PROCESS_NAMED : IFilesMetadataManager::PROCESS_LIVE | IFilesMetadataManager::PROCESS_BACKGROUND, + $scanMetadata + ); } }); - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output) { + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void { $output->writeln("\tFolder\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE); ++$this->foldersCounter; $this->abortIfInterrupted(); }); - $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output) { + $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void { $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE); + ++$this->errorsCounter; }); - $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output) { + $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output): void { $output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>'); + ++$this->errorsCounter; + }); + + $this->eventDispatcher->addListener(NodeAddedToCache::class, function (): void { + ++$this->newCounter; + }); + $this->eventDispatcher->addListener(FileCacheUpdated::class, function (): void { + ++$this->updatedCounter; + }); + $this->eventDispatcher->addListener(NodeRemovedFromCache::class, function (): void { + ++$this->removedCounter; }); try { if ($backgroundScan) { $scanner->backgroundScan($path); } else { - $scanner->scan($path, $recursive, $homeOnly ? [$this, 'filterHomeMount'] : null); + $scanner->scan($path, $recursive, $mountFilter); } } catch (ForbiddenException $e) { $output->writeln("<error>Home storage for user $user not writable or 'files' subdirectory missing</error>"); + $output->writeln(' ' . $e->getMessage()); $output->writeln('Make sure you\'re running the scan command only as the user the web server runs as'); + ++$this->errorsCounter; } catch (InterruptedException $e) { # exit the function if ctrl-c has been pressed $output->writeln('Interrupted by user'); } catch (NotFoundException $e) { $output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>'); + ++$this->errorsCounter; + } catch (LockedException $e) { + if (str_starts_with($e->getPath(), 'scanner::')) { + $output->writeln('<error>Another process is already scanning \'' . substr($e->getPath(), strlen('scanner::')) . '\'</error>'); + } else { + throw $e; + } } catch (\Exception $e) { $output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>'); $output->writeln('<error>' . $e->getTraceAsString() . '</error>'); + ++$this->errorsCounter; } } - public function filterHomeMount(IMountPoint $mountPoint) { + public function isHomeMount(IMountPoint $mountPoint): bool { // any mountpoint inside '/$user/files/' return substr_count($mountPoint->getMountPoint(), '/') <= 3; } @@ -191,30 +205,62 @@ class Scan extends Base { $users = $input->getArgument('user_id'); } - # restrict the verbosity level to VERBOSITY_VERBOSE - if ($output->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) { - $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); - } - # check quantity of users to be process and show it on the command line $users_total = count($users); if ($users_total === 0) { $output->writeln('<error>Please specify the user id to scan, --all to scan for all users or --path=...</error>'); - return 1; + return self::FAILURE; + } + + $this->initTools($output); + + // getOption() logic on VALUE_OPTIONAL + $metadata = null; // null if --generate-metadata is not set, empty if option have no value, value if set + if ($input->getOption('generate-metadata') !== '') { + $metadata = $input->getOption('generate-metadata') ?? ''; } - $this->initTools(); + $homeOnly = $input->getOption('home-only'); + $scannedStorages = []; + $mountFilter = function (IMountPoint $mount) use ($homeOnly, &$scannedStorages) { + if ($homeOnly && !$this->isHomeMount($mount)) { + return false; + } + + // when scanning multiple users, the scanner might encounter the same storage multiple times (e.g. external storages, or group folders) + // we can filter out any storage we've already scanned to avoid double work + $storage = $mount->getStorage(); + $storageKey = $storage->getId(); + while ($storage->instanceOfStorage(Jail::class)) { + $storageKey .= '/' . $storage->getUnjailedPath(''); + $storage = $storage->getUnjailedStorage(); + } + if (array_key_exists($storageKey, $scannedStorages)) { + return false; + } + + $scannedStorages[$storageKey] = true; + return true; + }; $user_count = 0; foreach ($users as $user) { if (is_object($user)) { $user = $user->getUID(); } - $path = $inputPath ? $inputPath : '/' . $user; + $path = $inputPath ?: '/' . $user; ++$user_count; if ($this->userManager->userExists($user)) { $output->writeln("Starting scan for user $user_count out of $users_total ($user)"); - $this->scanFiles($user, $path, $input->getOption('generate-metadata'), $output, $input->getOption('unscanned'), !$input->getOption('shallow'), $input->getOption('home-only')); + $this->scanFiles( + $user, + $path, + $metadata, + $output, + $mountFilter, + $input->getOption('unscanned'), + !$input->getOption('shallow'), + ); $output->writeln('', OutputInterface::VERBOSITY_VERBOSE); } else { $output->writeln("<error>Unknown user $user_count $user</error>"); @@ -229,21 +275,25 @@ class Scan extends Base { } $this->presentStats($output); - return 0; + return self::SUCCESS; } /** * Initialises some useful tools for the Command */ - protected function initTools() { + protected function initTools(OutputInterface $output): void { // Start the timer $this->execTime = -microtime(true); // Convert PHP errors to exceptions - set_error_handler([$this, 'exceptionErrorHandler'], E_ALL); + set_error_handler( + fn (int $severity, string $message, string $file, int $line): bool + => $this->exceptionErrorHandler($output, $severity, $message, $file, $line), + E_ALL + ); } /** - * Processes PHP errors as exceptions in order to be able to keep track of problems + * Processes PHP errors in order to be able to show them in the output * * @see https://www.php.net/manual/en/function.set-error-handler.php * @@ -251,47 +301,44 @@ class Scan extends Base { * @param string $message * @param string $file the filename that the error was raised in * @param int $line the line number the error was raised - * - * @throws \ErrorException */ - public function exceptionErrorHandler($severity, $message, $file, $line) { - if (!(error_reporting() & $severity)) { - // This error code is not included in error_reporting - return; + public function exceptionErrorHandler(OutputInterface $output, int $severity, string $message, string $file, int $line): bool { + if (($severity === E_DEPRECATED) || ($severity === E_USER_DEPRECATED)) { + // Do not show deprecation warnings + return false; } - throw new \ErrorException($message, 0, $severity, $file, $line); + $e = new \ErrorException($message, 0, $severity, $file, $line); + $output->writeln('<error>Error during scan: ' . $e->getMessage() . '</error>'); + $output->writeln('<error>' . $e->getTraceAsString() . '</error>', OutputInterface::VERBOSITY_VERY_VERBOSE); + ++$this->errorsCounter; + return true; } - /** - * @param OutputInterface $output - */ - protected function presentStats(OutputInterface $output) { + protected function presentStats(OutputInterface $output): void { // Stop the timer $this->execTime += microtime(true); + $this->logger->info("Completed scan of {$this->filesCounter} files in {$this->foldersCounter} folder. Found {$this->newCounter} new, {$this->updatedCounter} updated and {$this->removedCounter} removed items"); + $headers = [ - 'Folders', 'Files', 'Elapsed time' + 'Folders', + 'Files', + 'New', + 'Updated', + 'Removed', + 'Errors', + 'Elapsed time', ]; - - $this->showSummary($headers, null, $output); - } - - /** - * Shows a summary of operations - * - * @param string[] $headers - * @param string[] $rows - * @param OutputInterface $output - */ - protected function showSummary($headers, $rows, OutputInterface $output) { $niceDate = $this->formatExecTime(); - if (!$rows) { - $rows = [ - $this->foldersCounter, - $this->filesCounter, - $niceDate, - ]; - } + $rows = [ + $this->foldersCounter, + $this->filesCounter, + $this->newCounter, + $this->updatedCounter, + $this->removedCounter, + $this->errorsCounter, + $niceDate, + ]; $table = new Table($output); $table ->setHeaders($headers) @@ -301,11 +348,9 @@ class Scan extends Base { /** - * Formats microtime into a human readable format - * - * @return string + * Formats microtime into a human-readable format */ - protected function formatExecTime() { + protected function formatExecTime(): string { $secs = (int)round($this->execTime); # convert seconds into HH:MM:SS form return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), $secs % 60); @@ -313,7 +358,7 @@ class Scan extends Base { protected function reconnectToDatabase(OutputInterface $output): Connection { /** @var Connection $connection */ - $connection = \OC::$server->get(Connection::class); + $connection = Server::get(Connection::class); try { $connection->close(); } catch (\Exception $ex) { diff --git a/apps/files/lib/Command/ScanAppData.php b/apps/files/lib/Command/ScanAppData.php index 63e13733b2a..0e08c6a8cfe 100644 --- a/apps/files/lib/Command/ScanAppData.php +++ b/apps/files/lib/Command/ScanAppData.php @@ -1,31 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Joel S <joel.devbox@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Erik Wouters <6179932+EWouters@users.noreply.github.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command; @@ -33,12 +10,16 @@ use OC\Core\Command\Base; use OC\Core\Command\InterruptedException; use OC\DB\Connection; use OC\DB\ConnectionAdapter; +use OC\Files\Utils\Scanner; use OC\ForbiddenException; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Folder; use OCP\Files\IRootFolder; +use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\Files\StorageNotAvailableException; use OCP\IConfig; +use OCP\Server; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputArgument; @@ -46,26 +27,20 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class ScanAppData extends Base { + protected float $execTime = 0; - /** @var IRootFolder */ - protected $root; - /** @var IConfig */ - protected $config; - /** @var float */ - protected $execTime = 0; - /** @var int */ - protected $foldersCounter = 0; - /** @var int */ - protected $filesCounter = 0; - - public function __construct(IRootFolder $rootFolder, IConfig $config) { - parent::__construct(); + protected int $foldersCounter = 0; - $this->root = $rootFolder; - $this->config = $config; + protected int $filesCounter = 0; + + public function __construct( + protected IRootFolder $rootFolder, + protected IConfig $config, + ) { + parent::__construct(); } - protected function configure() { + protected function configure(): void { parent::configure(); $this @@ -77,10 +52,11 @@ class ScanAppData extends Base { protected function scanFiles(OutputInterface $output, string $folder): int { try { + /** @var Folder $appData */ $appData = $this->getAppDataFolder(); } catch (NotFoundException $e) { $output->writeln('<error>NoAppData folder found</error>'); - return 1; + return self::FAILURE; } if ($folder !== '') { @@ -88,36 +64,36 @@ class ScanAppData extends Base { $appData = $appData->get($folder); } catch (NotFoundException $e) { $output->writeln('<error>Could not find folder: ' . $folder . '</error>'); - return 1; + return self::FAILURE; } } $connection = $this->reconnectToDatabase($output); - $scanner = new \OC\Files\Utils\Scanner( + $scanner = new Scanner( null, new ConnectionAdapter($connection), - \OC::$server->query(IEventDispatcher::class), - \OC::$server->get(LoggerInterface::class) + Server::get(IEventDispatcher::class), + Server::get(LoggerInterface::class) ); # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output) { + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output): void { $output->writeln("\tFile <info>$path</info>", OutputInterface::VERBOSITY_VERBOSE); ++$this->filesCounter; $this->abortIfInterrupted(); }); - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output) { + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void { $output->writeln("\tFolder <info>$path</info>", OutputInterface::VERBOSITY_VERBOSE); ++$this->foldersCounter; $this->abortIfInterrupted(); }); - $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output) { + $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void { $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE); }); - $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output) { + $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output): void { $output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>'); }); @@ -126,21 +102,21 @@ class ScanAppData extends Base { } catch (ForbiddenException $e) { $output->writeln('<error>Storage not writable</error>'); $output->writeln('<info>Make sure you\'re running the scan command only as the user the web server runs as</info>'); - return 1; + return self::FAILURE; } catch (InterruptedException $e) { # exit the function if ctrl-c has been pressed $output->writeln('<info>Interrupted by user</info>'); - return 1; + return self::FAILURE; } catch (NotFoundException $e) { $output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>'); - return 1; + return self::FAILURE; } catch (\Exception $e) { $output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>'); $output->writeln('<error>' . $e->getTraceAsString() . '</error>'); - return 1; + return self::FAILURE; } - return 0; + return self::SUCCESS; } @@ -167,7 +143,7 @@ class ScanAppData extends Base { /** * Initialises some useful tools for the Command */ - protected function initTools() { + protected function initTools(): void { // Start the timer $this->execTime = -microtime(true); // Convert PHP errors to exceptions @@ -194,10 +170,7 @@ class ScanAppData extends Base { throw new \ErrorException($message, 0, $severity, $file, $line); } - /** - * @param OutputInterface $output - */ - protected function presentStats(OutputInterface $output) { + protected function presentStats(OutputInterface $output): void { // Stop the timer $this->execTime += microtime(true); @@ -213,9 +186,8 @@ class ScanAppData extends Base { * * @param string[] $headers * @param string[] $rows - * @param OutputInterface $output */ - protected function showSummary($headers, $rows, OutputInterface $output) { + protected function showSummary($headers, $rows, OutputInterface $output): void { $niceDate = $this->formatExecTime(); if (!$rows) { $rows = [ @@ -233,19 +205,17 @@ class ScanAppData extends Base { /** - * Formats microtime into a human readable format - * - * @return string + * Formats microtime into a human-readable format */ - protected function formatExecTime() { + protected function formatExecTime(): string { $secs = round($this->execTime); # convert seconds into HH:MM:SS form return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), (int)$secs % 60); } protected function reconnectToDatabase(OutputInterface $output): Connection { - /** @var Connection $connection*/ - $connection = \OC::$server->get(Connection::class); + /** @var Connection $connection */ + $connection = Server::get(Connection::class); try { $connection->close(); } catch (\Exception $ex) { @@ -263,16 +233,15 @@ class ScanAppData extends Base { } /** - * @return \OCP\Files\Folder * @throws NotFoundException */ - private function getAppDataFolder() { + private function getAppDataFolder(): Node { $instanceId = $this->config->getSystemValue('instanceid', null); if ($instanceId === null) { throw new NotFoundException(); } - return $this->root->get('appdata_'.$instanceId); + return $this->rootFolder->get('appdata_' . $instanceId); } } diff --git a/apps/files/lib/Command/TransferOwnership.php b/apps/files/lib/Command/TransferOwnership.php index 50aa0b21a5f..f7663e26f28 100644 --- a/apps/files/lib/Command/TransferOwnership.php +++ b/apps/files/lib/Command/TransferOwnership.php @@ -1,71 +1,41 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Carla Schroder <carla@owncloud.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sujith Haridasan <sujith.h@gmail.com> - * @author Sujith H <sharidasan@owncloud.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobia De Koninck <LEDfan@users.noreply.github.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\Command; use OCA\Files\Exception\TransferOwnershipException; use OCA\Files\Service\OwnershipTransferService; +use OCA\Files_External\Config\ConfigAdapter; +use OCP\Files\Mount\IMountManager; +use OCP\Files\Mount\IMountPoint; +use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; -use OCP\IConfig; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; class TransferOwnership extends Command { - - /** @var IUserManager */ - private $userManager; - - /** @var OwnershipTransferService */ - private $transferService; - - /** @var IConfig */ - private $config; - - public function __construct(IUserManager $userManager, - OwnershipTransferService $transferService, - IConfig $config) { + public function __construct( + private IUserManager $userManager, + private OwnershipTransferService $transferService, + private IConfig $config, + private IMountManager $mountManager, + ) { parent::__construct(); - $this->userManager = $userManager; - $this->transferService = $transferService; - $this->config = $config; } - protected function configure() { + protected function configure(): void { $this ->setName('files:transfer-ownership') ->setDescription('All files and folders are moved to another user - outgoing shares and incoming user file shares (optionally) are moved as well.') @@ -94,9 +64,19 @@ class TransferOwnership extends Command { 'transfer-incoming-shares', null, InputOption::VALUE_OPTIONAL, - 'transfer incoming user file shares to destination user. Usage: --transfer-incoming-shares=1 (value required)', + 'Incoming shares are always transferred now, so this option does not affect the ownership transfer anymore', '2' - ); + )->addOption( + 'include-external-storage', + null, + InputOption::VALUE_NONE, + 'include files on external storages, this will _not_ setup an external storage for the target user, but instead moves all the files from the external storages into the target users home directory', + )->addOption( + 'force-include-external-storage', + null, + InputOption::VALUE_NONE, + 'don\'t ask for confirmation for transferring external storages', + ); } protected function execute(InputInterface $input, OutputInterface $output): int { @@ -107,59 +87,62 @@ class TransferOwnership extends Command { if ($input->getArgument(('source-user')) === $input->getArgument('destination-user')) { $output->writeln("<error>Ownership can't be transferred when Source and Destination users are the same user. Please check your input.</error>"); - return 1; + return self::FAILURE; } $sourceUserObject = $this->userManager->get($input->getArgument('source-user')); $destinationUserObject = $this->userManager->get($input->getArgument('destination-user')); if (!$sourceUserObject instanceof IUser) { - $output->writeln("<error>Unknown source user " . $input->getArgument('source-user') . "</error>"); - return 1; + $output->writeln('<error>Unknown source user ' . $input->getArgument('source-user') . '</error>'); + return self::FAILURE; } if (!$destinationUserObject instanceof IUser) { - $output->writeln("<error>Unknown destination user " . $input->getArgument('destination-user') . "</error>"); - return 1; + $output->writeln('<error>Unknown destination user ' . $input->getArgument('destination-user') . '</error>'); + return self::FAILURE; } - try { - $includeIncomingArgument = $input->getOption('transfer-incoming-shares'); - - switch ($includeIncomingArgument) { - case '0': - $includeIncoming = false; - break; - case '1': - $includeIncoming = true; - break; - case '2': - $includeIncoming = $this->config->getSystemValue('transferIncomingShares', false); - if (gettype($includeIncoming) !== 'boolean') { - $output->writeln("<error> config.php: 'transfer-incoming-shares': wrong usage. Transfer aborted.</error>"); - return 1; + $path = ltrim($input->getOption('path'), '/'); + $includeExternalStorage = $input->getOption('include-external-storage'); + if ($includeExternalStorage) { + $mounts = $this->mountManager->findIn('/' . rtrim($sourceUserObject->getUID() . '/files/' . $path, '/')); + /** @var IMountPoint[] $mounts */ + $mounts = array_filter($mounts, fn ($mount) => $mount->getMountProvider() === ConfigAdapter::class); + if (count($mounts) > 0) { + $output->writeln(count($mounts) . ' external storages will be transferred:'); + foreach ($mounts as $mount) { + $output->writeln(' - <info>' . $mount->getMountPoint() . '</info>'); + } + $output->writeln(''); + $output->writeln('<comment>Any other users with access to these external storages will lose access to the files.</comment>'); + $output->writeln(''); + if (!$input->getOption('force-include-external-storage')) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Are you sure you want to transfer external storages? (y/N) ', false); + if (!$helper->ask($input, $output, $question)) { + return self::FAILURE; } - break; - default: - $output->writeln("<error>Option --transfer-incoming-shares: wrong usage. Transfer aborted.</error>"); - return 1; - break; + } } + } + try { $this->transferService->transfer( $sourceUserObject, $destinationUserObject, - ltrim($input->getOption('path'), '/'), + $path, $output, $input->getOption('move') === true, false, - $includeIncoming + $includeExternalStorage, ); } catch (TransferOwnershipException $e) { - $output->writeln("<error>" . $e->getMessage() . "</error>"); - return $e->getCode() !== 0 ? $e->getCode() : 1; + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return $e->getCode() !== 0 ? $e->getCode() : self::FAILURE; } - return 0; + return self::SUCCESS; } } diff --git a/apps/files/lib/Command/WindowsCompatibleFilenames.php b/apps/files/lib/Command/WindowsCompatibleFilenames.php new file mode 100644 index 00000000000..84a1b277824 --- /dev/null +++ b/apps/files/lib/Command/WindowsCompatibleFilenames.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Command; + +use OC\Core\Command\Base; +use OCA\Files\Service\SettingsService; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class WindowsCompatibleFilenames extends Base { + + public function __construct( + private SettingsService $service, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + + $this + ->setName('files:windows-compatible-filenames') + ->setDescription('Enforce naming constraints for windows compatible filenames') + ->addOption('enable', description: 'Enable windows naming constraints') + ->addOption('disable', description: 'Disable windows naming constraints'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('enable')) { + if ($this->service->hasFilesWindowsSupport()) { + $output->writeln('<error>Windows compatible filenames already enforced.</error>', OutputInterface::VERBOSITY_VERBOSE); + } + $this->service->setFilesWindowsSupport(true); + $output->writeln('Windows compatible filenames enforced.'); + } elseif ($input->getOption('disable')) { + if (!$this->service->hasFilesWindowsSupport()) { + $output->writeln('<error>Windows compatible filenames already disabled.</error>', OutputInterface::VERBOSITY_VERBOSE); + } + $this->service->setFilesWindowsSupport(false); + $output->writeln('Windows compatible filename constraints removed.'); + } else { + $output->writeln('Windows compatible filenames are ' . ($this->service->hasFilesWindowsSupport() ? 'enforced' : 'disabled')); + } + return self::SUCCESS; + } +} diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index 604cf9a3c64..8bb024fb698 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -1,134 +1,124 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Felix Nüsse <Felix.nuesse@t-online.de> - * @author fnuesse <felix.nuesse@t-online.de> - * @author fnuesse <fnuesse@techfak.uni-bielefeld.de> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Max Kovalenko <mxss1998@yandex.ru> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Nina Pypchenko <22447785+nina-py@users.noreply.github.com> - * @author Richard Steinmetz <richard@steinmetz.cloud> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\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; /** - * Class ApiController + * @psalm-import-type FilesFolderTree from ResponseDefinitions * * @package OCA\Files\Controller */ class ApiController extends Controller { - private TagService $tagService; - private IManager $shareManager; - private IPreview $previewManager; - private IUserSession $userSession; - private IConfig $config; - private Folder $userFolder; - private UserConfig $userConfig; - - /** - * @param string $appName - * @param IRequest $request - * @param IUserSession $userSession - * @param TagService $tagService - * @param IPreview $previewManager - * @param IManager $shareManager - * @param IConfig $config - * @param Folder $userFolder - */ - public function __construct($appName, - IRequest $request, - IUserSession $userSession, - TagService $tagService, - IPreview $previewManager, - IManager $shareManager, - IConfig $config, - Folder $userFolder, - UserConfig $userConfig) { + 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); - $this->userSession = $userSession; - $this->tagService = $tagService; - $this->previewManager = $previewManager; - $this->shareManager = $shareManager; - $this->config = $config; - $this->userFolder = $userFolder; - $this->userConfig = $userConfig; } /** * Gets a thumbnail of the specified file * * @since API version 1.0 + * @deprecated 32.0.0 Use the preview endpoint provided by core instead * - * @NoAdminRequired - * @NoCSRFRequired - * @StrictCookieRequired - * - * @param int $x - * @param int $y + * @param int $x Width of the thumbnail + * @param int $y Height of the thumbnail * @param string $file URL-encoded filename - * @return DataResponse|FileDisplayResponse + * @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 instanceof Folder) { + $file = $this->userFolder?->get($file); + if ($file === null + || !($file instanceof File) + || ($file->getId() <= 0) + ) { throw new NotFoundException(); } - /** @var File $file */ + // 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 $e) { + } 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); @@ -140,23 +130,22 @@ class ApiController extends Controller { * The passed tags are absolute, which means they will * replace the actual tag selection. * - * @NoAdminRequired - * * @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 (\OCP\Files\NotFoundException $e) { + } catch (NotFoundException $e) { return new DataResponse([ 'message' => $e->getMessage() ], Http::STATUS_NOT_FOUND); - } catch (\OCP\Files\StorageNotAvailableException $e) { + } catch (StorageNotAvailableException $e) { return new DataResponse([ 'message' => $e->getMessage() ], Http::STATUS_SERVICE_UNAVAILABLE); @@ -178,7 +167,7 @@ class ApiController extends Controller { $shareTypesForNodes = $this->getShareTypesForNodes($nodes); return array_values(array_map(function (Node $node) use ($shareTypesForNodes) { $shareTypes = $shareTypesForNodes[$node->getId()] ?? []; - $file = \OCA\Files\Helper::formatFileInfo($node->getFileInfo()); + $file = Helper::formatFileInfo($node->getFileInfo()); $file['hasPreview'] = $this->previewManager->isAvailable($node); $parts = explode('/', dirname($node->getPath()), 4); if (isset($parts[3])) { @@ -209,6 +198,7 @@ class ApiController extends Controller { IShare::TYPE_EMAIL, IShare::TYPE_ROOM, IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, ]; $shareTypes = []; @@ -247,62 +237,143 @@ class ApiController extends Controller { /** * Returns a list of recently modified files. * - * @NoAdminRequired - * * @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 current logged-in user's storage stats. + * 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 * - * @NoAdminRequired + * @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 ?: '/'); - return new JSONResponse(['message' => 'ok', 'data' => $storageInfo]); + $response = new JSONResponse(['message' => 'ok', 'data' => $storageInfo]); + $response->cacheFor(5 * 60); + return $response; } /** - * Change the default sort mode - * - * @NoAdminRequired + * Set a user view config * - * @param string $mode - * @param string $direction - * @return Response - * @throws \OCP\PreConditionNotMetException + * @param string $view + * @param string $key + * @param string|bool $value + * @return JSONResponse */ - public function updateFileSorting($mode, $direction) { - $allowedMode = ['name', 'size', 'mtime']; - $allowedDirection = ['asc', 'desc']; - if (!in_array($mode, $allowedMode) || !in_array($direction, $allowedDirection)) { - $response = new Response(); - $response->setStatus(Http::STATUS_UNPROCESSABLE_ENTITY); - return $response; + #[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); } - $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting', $mode); - $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting_direction', $direction); - return new Response(); + + return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfig($view)]); } + /** - * Toggle default files user config + * Get the user view config * - * @NoAdminRequired + * @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); @@ -317,10 +388,9 @@ class ApiController extends Controller { /** * Get the user config * - * @NoAdminRequired - * * @return JSONResponse */ + #[NoAdminRequired] public function getConfigs(): JSONResponse { return new JSONResponse(['message' => 'ok', 'data' => $this->userConfig->getConfigs()]); } @@ -328,12 +398,11 @@ class ApiController extends Controller { /** * Toggle default for showing/hiding hidden files * - * @NoAdminRequired - * * @param bool $value * @return Response - * @throws \OCP\PreConditionNotMetException + * @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(); @@ -342,12 +411,11 @@ class ApiController extends Controller { /** * Toggle default for cropping preview images * - * @NoAdminRequired - * * @param bool $value * @return Response - * @throws \OCP\PreConditionNotMetException + * @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(); @@ -356,12 +424,11 @@ class ApiController extends Controller { /** * Toggle default for files grid view * - * @NoAdminRequired - * * @param bool $show * @return Response - * @throws \OCP\PreConditionNotMetException + * @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(); @@ -369,51 +436,27 @@ class ApiController extends Controller { /** * Get default settings for the grid view - * - * @NoAdminRequired */ + #[NoAdminRequired] public function getGridView() { $status = $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'show_grid', '0') === '1'; return new JSONResponse(['gridview' => $status]); } - /** - * Toggle default for showing/hiding xxx folder - * - * @NoAdminRequired - * - * @param int $show - * @param string $key the key of the folder - * - * @return Response - * @throws \OCP\PreConditionNotMetException - */ - public function toggleShowFolder(int $show, string $key): Response { - if ($show !== 0 && $show !== 1) { - return new DataResponse([ - 'message' => 'Invalid show value. Only 0 and 1 are allowed.' - ], Http::STATUS_BAD_REQUEST); - } - - $userId = $this->userSession->getUser()->getUID(); - - // Set the new value and return it - // Using a prefix prevents the user from setting arbitrary keys - $this->config->setUserValue($userId, 'files', 'show_' . $key, (string)$show); - return new JSONResponse([$key => $show]); - } - - /** - * Get sorting-order for custom sorting - * - * @NoAdminRequired - * - * @param string $folderpath - * @return string - * @throws \OCP\Files\NotFoundException - */ - public function getNodeType($folderpath) { - $node = $this->userFolder->get($folderpath); - return $node->getType(); + #[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 index c67150be8d4..c8addc33e98 100644 --- a/apps/files/lib/Controller/DirectEditingController.php +++ b/apps/files/lib/Controller/DirectEditingController.php @@ -1,70 +1,47 @@ <?php + /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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\ILogger; use OCP\IRequest; use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; class DirectEditingController extends OCSController { - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var IManager */ - private $directEditingManager; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var ILogger */ - private $logger; - - /** @var DirectEditingService */ - private $directEditingService; - - public function __construct($appName, IRequest $request, $corsMethods, $corsAllowedHeaders, $corsMaxAge, - IEventDispatcher $eventDispatcher, IURLGenerator $urlGenerator, IManager $manager, DirectEditingService $directEditingService, ILogger $logger) { + 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); - - $this->eventDispatcher = $eventDispatcher; - $this->directEditingManager = $manager; - $this->directEditingService = $directEditingService; - $this->logger = $logger; - $this->urlGenerator = $urlGenerator; } /** - * @NoAdminRequired + * 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()); @@ -72,9 +49,20 @@ class DirectEditingController extends OCSController { } /** - * @NoAdminRequired + * 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 */ - public function create(string $path, string $editorId, string $creatorId, string $templateId = null): DataResponse { + #[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); } @@ -86,27 +74,47 @@ class DirectEditingController extends OCSController { 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', ['token' => $token]) ]); } catch (Exception $e) { - $this->logger->logException($e, ['message' => 'Exception when creating a new file through direct editing']); + $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); } } /** - * @NoAdminRequired + * 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 */ - public function open(string $path, string $editorId = null): DataResponse { + #[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); + $token = $this->directEditingManager->open($path, $editorId, $fileId); return new DataResponse([ 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', ['token' => $token]) ]); } catch (Exception $e) { - $this->logger->logException($e, ['message' => 'Exception when opening a file through direct editing']); + $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); } } @@ -114,8 +122,16 @@ class DirectEditingController extends OCSController { /** - * @NoAdminRequired + * 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); @@ -125,7 +141,12 @@ class DirectEditingController extends OCSController { try { return new DataResponse($this->directEditingManager->getTemplates($editorId, $creatorId)); } catch (Exception $e) { - $this->logger->logException($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 index 06bde8d63d7..b13e68f7766 100644 --- a/apps/files/lib/Controller/DirectEditingViewController.php +++ b/apps/files/lib/Controller/DirectEditingViewController.php @@ -1,69 +1,50 @@ <?php + /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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\ILogger; use OCP\IRequest; +use Psr\Log\LoggerInterface; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class DirectEditingViewController extends Controller { - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var IManager */ - private $directEditingManager; - - /** @var ILogger */ - private $logger; - - public function __construct($appName, IRequest $request, IEventDispatcher $eventDispatcher, IManager $manager, ILogger $logger) { + public function __construct( + $appName, + IRequest $request, + private IEventDispatcher $eventDispatcher, + private IManager $directEditingManager, + private LoggerInterface $logger, + ) { parent::__construct($appName, $request); - - $this->eventDispatcher = $eventDispatcher; - $this->directEditingManager = $manager; - $this->logger = $logger; } /** - * @PublicPage - * @NoCSRFRequired - * * @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->logException($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 index 7d784196361..b000304eef6 100644 --- a/apps/files/lib/Controller/OpenLocalEditorController.php +++ b/apps/files/lib/Controller/OpenLocalEditorController.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Controller; @@ -30,6 +13,9 @@ 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; @@ -43,34 +29,29 @@ class OpenLocalEditorController extends OCSController { public const TOKEN_DURATION = 600; // 10 Minutes public const TOKEN_RETRIES = 50; - protected ITimeFactory $timeFactory; - protected OpenLocalEditorMapper $mapper; - protected ISecureRandom $secureRandom; - protected LoggerInterface $logger; - protected ?string $userId; - public function __construct( string $appName, IRequest $request, - ITimeFactory $timeFactory, - OpenLocalEditorMapper $mapper, - ISecureRandom $secureRandom, - LoggerInterface $logger, - ?string $userId + protected ITimeFactory $timeFactory, + protected OpenLocalEditorMapper $mapper, + protected ISecureRandom $secureRandom, + protected LoggerInterface $logger, + protected ?string $userId, ) { parent::__construct($appName, $request); - - $this->timeFactory = $timeFactory; - $this->mapper = $mapper; - $this->secureRandom = $secureRandom; - $this->logger = $logger; - $this->userId = $userId; } /** - * @NoAdminRequired - * @UserRateThrottle(limit=10, period=120) + * 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); @@ -105,9 +86,18 @@ class OpenLocalEditorController extends OCSController { } /** - * @NoAdminRequired - * @BruteForceProtection(action=openLocalEditor) + * 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); diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php index d04d86760e6..ee4c86941c7 100644 --- a/apps/files/lib/Controller/TemplateController.php +++ b/apps/files/lib/Controller/TemplateController.php @@ -3,72 +3,123 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\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 { - protected $templateManager; - - public function __construct($appName, IRequest $request, ITemplateManager $templateManager) { + public function __construct( + $appName, + IRequest $request, + protected ITemplateManager $templateManager, + ) { parent::__construct($appName, $request); - $this->templateManager = $templateManager; } /** - * @NoAdminRequired + * List the available templates + * + * @return DataResponse<Http::STATUS_OK, list<FilesTemplateFileCreatorWithTemplates>, array{}> + * + * 200: Available templates returned */ + #[NoAdminRequired] public function list(): DataResponse { - return new DataResponse($this->templateManager->listTemplates()); + /* 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 + ); } /** - * @NoAdminRequired - * @throws OCSForbiddenException + * 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 */ - public function create(string $filePath, string $templatePath = '', string $templateType = 'user'): DataResponse { + #[NoAdminRequired] + public function create( + string $filePath, + string $templatePath = '', + string $templateType = 'user', + array $templateFields = [], + ): DataResponse { try { - return new DataResponse($this->templateManager->createFromTemplate($filePath, $templatePath, $templateType)); + return new DataResponse($this->templateManager->createFromTemplate( + $filePath, + $templatePath, + $templateType, + $templateFields)); } catch (GenericFileException $e) { throw new OCSForbiddenException($e->getMessage()); } } /** - * @NoAdminRequired + * 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' => $this->templateManager->listCreators() + '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 index 5abd65444bf..51a25400efb 100644 --- a/apps/files/lib/Controller/TransferOwnershipController.php +++ b/apps/files/lib/Controller/TransferOwnershipController.php @@ -3,40 +3,22 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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\Files\IHomeStorage; 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; @@ -44,45 +26,34 @@ use OCP\Notification\IManager as NotificationManager; class TransferOwnershipController extends OCSController { - /** @var string */ - private $userId; - /** @var NotificationManager */ - private $notificationManager; - /** @var ITimeFactory */ - private $timeFactory; - /** @var IJobList */ - private $jobList; - /** @var TransferOwnershipMapper */ - private $mapper; - /** @var IUserManager */ - private $userManager; - /** @var IRootFolder */ - private $rootFolder; - - public function __construct(string $appName, - IRequest $request, - string $userId, - NotificationManager $notificationManager, - ITimeFactory $timeFactory, - IJobList $jobList, - TransferOwnershipMapper $mapper, - IUserManager $userManager, - IRootFolder $rootFolder) { + 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); - - $this->userId = $userId; - $this->notificationManager = $notificationManager; - $this->timeFactory = $timeFactory; - $this->jobList = $jobList; - $this->mapper = $mapper; - $this->userManager = $userManager; - $this->rootFolder = $rootFolder; } /** - * @NoAdminRequired + * 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); @@ -126,8 +97,17 @@ class TransferOwnershipController extends OCSController { } /** - * @NoAdminRequired + * 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); @@ -139,28 +119,30 @@ class TransferOwnershipController extends OCSController { 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); - $newTransferOwnership = new TransferOwnershipEntity(); - $newTransferOwnership->setNodeName($transferOwnership->getNodeName()); - $newTransferOwnership->setFileId($transferOwnership->getFileId()); - $newTransferOwnership->setSourceUser($transferOwnership->getSourceUser()); - $newTransferOwnership->setTargetUser($transferOwnership->getTargetUser()); - $this->mapper->insert($newTransferOwnership); - - $this->jobList->add(TransferOwnership::class, [ - 'id' => $newTransferOwnership->getId(), - ]); - return new DataResponse([], Http::STATUS_OK); } /** - * @NoAdminRequired + * 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); @@ -177,20 +159,10 @@ class TransferOwnershipController extends OCSController { ->setObject('transfer', (string)$id); $this->notificationManager->markProcessed($notification); - $notification = $this->notificationManager->createNotification(); - $notification->setUser($transferOwnership->getSourceUser()) - ->setApp($this->appName) - ->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); + // 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 index b607764e602..ecf21cef313 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -1,53 +1,32 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author fnuesse <felix.nuesse@t-online.de> - * @author fnuesse <fnuesse@techfak.uni-bielefeld.de> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Max Kovalenko <mxss1998@yandex.ru> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Nina Pypchenko <22447785+nina-py@users.noreply.github.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\Controller; -use OCA\Files\Activity\Helper; +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; @@ -59,359 +38,269 @@ use OCP\IL10N; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; -use OCP\Share\IManager; +use OCP\Util; /** - * Class ViewController - * * @package OCA\Files\Controller */ +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class ViewController extends Controller { - private IURLGenerator $urlGenerator; - private IL10N $l10n; - private IConfig $config; - private IEventDispatcher $eventDispatcher; - private IUserSession $userSession; - private IAppManager $appManager; - private IRootFolder $rootFolder; - private Helper $activityHelper; - private IInitialState $initialState; - private ITemplateManager $templateManager; - private IManager $shareManager; - private UserConfig $userConfig; - - public function __construct(string $appName, + + public function __construct( + string $appName, IRequest $request, - IURLGenerator $urlGenerator, - IL10N $l10n, - IConfig $config, - IEventDispatcher $eventDispatcher, - IUserSession $userSession, - IAppManager $appManager, - IRootFolder $rootFolder, - Helper $activityHelper, - IInitialState $initialState, - ITemplateManager $templateManager, - IManager $shareManager, - UserConfig $userConfig + 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); - $this->urlGenerator = $urlGenerator; - $this->l10n = $l10n; - $this->config = $config; - $this->eventDispatcher = $eventDispatcher; - $this->userSession = $userSession; - $this->appManager = $appManager; - $this->rootFolder = $rootFolder; - $this->activityHelper = $activityHelper; - $this->initialState = $initialState; - $this->templateManager = $templateManager; - $this->shareManager = $shareManager; - $this->userConfig = $userConfig; - } - - /** - * @param string $appName - * @param string $scriptName - * @return string - */ - protected function renderScript($appName, $scriptName) { - $content = ''; - $appPath = \OC_App::getAppPath($appName); - $scriptPath = $appPath . '/' . $scriptName; - if (file_exists($scriptPath)) { - // TODO: sanitize path / script name ? - ob_start(); - include $scriptPath; - $content = ob_get_contents(); - @ob_end_clean(); - } - - return $content; } /** * FIXME: Replace with non static code * * @return array - * @throws \OCP\Files\NotFoundException + * @throws NotFoundException */ protected function getStorageInfo(string $dir = '/') { - \OC_Util::setupFS(); - $rootInfo = \OC\Files\Filesystem::getFileInfo('/', false); + $rootInfo = Filesystem::getFileInfo('/', false); return \OC_Helper::getStorageInfo($dir, $rootInfo ?: null); } /** - * @NoCSRFRequired - * @NoAdminRequired - * * @param string $fileid * @return TemplateResponse|RedirectResponse - * @throws NotFoundException */ - public function showFile(string $fileid = null, int $openfile = 1): Response { + #[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($fileid, $openfile !== 0); + return $this->redirectToFile((int)$fileid, $opendetails, $openfile); } catch (NotFoundException $e) { - return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true])); + // 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); + } + /** - * @NoCSRFRequired - * @NoAdminRequired - * @UseSession - * * @param string $dir * @param string $view * @param string $fileid - * @param bool $fileNotFound - * @param string $openfile - the openfile URL parameter if it was present in the initial request * @return TemplateResponse|RedirectResponse - * @throws NotFoundException */ - public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false, $openfile = null) { + #[NoAdminRequired] + #[NoCSRFRequired] + public function indexViewFileid($dir = '', $view = '', $fileid = null) { + return $this->index($dir, $view, $fileid); + } - if ($fileid !== null && $dir === '') { + /** + * @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->redirectToFile($fileid); + return $this->redirectToFileIfInTrashbin((int)$fileid); } catch (NotFoundException $e) { - return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true])); } } - $nav = new \OCP\Template('files', 'appnavigation', ''); - // Load the files we need - \OCP\Util::addStyle('files', 'merged'); - \OCP\Util::addScript('files', 'merged-index', 'files'); - \OCP\Util::addScript('files', 'main'); + Util::addInitScript('files', 'init'); + Util::addScript('files', 'main'); - // mostly for the home storage's free space - // FIXME: Make non static - $storageInfo = $this->getStorageInfo(); - - $userId = $this->userSession->getUser()->getUID(); - - // Get all the user favorites to create a submenu - try { - $favElements = $this->activityHelper->getFavoriteFilePaths($userId); - } catch (\RuntimeException $e) { - $favElements['folders'] = []; - } - - $collapseClasses = ''; - if (count($favElements['folders']) > 0) { - $collapseClasses = 'collapsible'; - } - - $favoritesSublistArray = []; - - $navBarPositionPosition = 6; - foreach ($favElements['folders'] as $favElement) { - $element = [ - 'id' => str_replace('/', '-', $favElement), - 'dir' => $favElement, - 'order' => $navBarPositionPosition, - 'name' => basename($favElement), - 'icon' => 'folder', - 'params' => [ - 'view' => 'files', - 'dir' => $favElement, - ], - ]; - - array_push($favoritesSublistArray, $element); - $navBarPositionPosition++; - } - - $navItems = \OCA\Files\App::getNavigationManager()->getAll(); - - // add the favorites entry in menu - $navItems['favorites']['sublist'] = $favoritesSublistArray; - $navItems['favorites']['classes'] = $collapseClasses; - - // parse every menu and add the expanded user value - foreach ($navItems as $key => $item) { - $navItems[$key]['expanded'] = $this->config->getUserValue($userId, 'files', 'show_' . $item['id'], '0') === '1'; + $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); + } + } } - $nav->assign('navigationItems', $navItems); - - $contentItems = []; - 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(($view === 'files' && $dir) ? $dir : '/'); + } catch (\Exception $e) { $storageInfo = $this->getStorageInfo(); } $this->initialState->provideInitialState('storageStats', $storageInfo); - $this->initialState->provideInitialState('navigation', $navItems); $this->initialState->provideInitialState('config', $this->userConfig->getConfigs()); + $this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs()); - // render the container content for every navigation item - foreach ($navItems as $item) { - $content = ''; - if (isset($item['script'])) { - $content = $this->renderScript($item['appname'], $item['script']); - } - // parse submenus - if (isset($item['sublist'])) { - foreach ($item['sublist'] as $subitem) { - $subcontent = ''; - if (isset($subitem['script'])) { - $subcontent = $this->renderScript($subitem['appname'], $subitem['script']); - } - $contentItems[$subitem['id']] = [ - 'id' => $subitem['id'], - 'content' => $subcontent - ]; - } - } - $contentItems[$item['id']] = [ - 'id' => $item['id'], - 'content' => $content - ]; - } + // 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); - $this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent()); $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()); - $params = []; - $params['usedSpacePercent'] = (int) $storageInfo['relative']; - $params['owner'] = $storageInfo['owner'] ?? ''; - $params['ownerDisplayName'] = $storageInfo['ownerDisplayName'] ?? ''; - $params['isPublic'] = false; - $params['allowShareWithLink'] = $this->shareManager->shareApiAllowLinks() ? 'yes' : 'no'; - $params['defaultFileSorting'] = $this->config->getUserValue($userId, 'files', 'file_sorting', 'name'); - $params['defaultFileSortingDirection'] = $this->config->getUserValue($userId, 'files', 'file_sorting_direction', 'asc'); - $params['showgridview'] = $this->config->getUserValue($userId, 'files', 'show_grid', false); - $showHidden = (bool) $this->config->getUserValue($userId, 'files', 'show_hidden', false); - $params['showHiddenFiles'] = $showHidden ? 1 : 0; - $cropImagePreviews = (bool) $this->config->getUserValue($userId, 'files', 'crop_image_previews', true); - $params['cropImagePreviews'] = $cropImagePreviews ? 1 : 0; - $params['fileNotFound'] = $fileNotFound ? 1 : 0; - $params['appNavigation'] = $nav; - $params['appContents'] = $contentItems; - $params['hiddenFields'] = $event->getHiddenFields(); + $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', - $params ); $policy = new ContentSecurityPolicy(); $policy->addAllowedFrameDomain('\'self\''); + // Allow preview service worker + $policy->addAllowedWorkerSrcDomain('\'self\''); $response->setContentSecurityPolicy($policy); - $this->provideInitialState($dir, $openfile); - return $response; } /** - * Add openFileInfo in initialState if $openfile is set. - * @param string $dir - the ?dir= URL param - * @param string $openfile - the ?openfile= URL param - * @return void + * 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 provideInitialState(string $dir, ?string $openfile): void { - if ($openfile === null) { - return; - } - - $user = $this->userSession->getUser(); - - if ($user === null) { - return; - } - - $uid = $user->getUID(); - $userFolder = $this->rootFolder->getUserFolder($uid); - $nodes = $userFolder->getById((int) $openfile); - $node = array_shift($nodes); - - if ($node === null) { - return; - } + private function redirectToFileIfInTrashbin($fileId): RedirectResponse { + $uid = $this->userSession->getUser()->getUID(); + $baseFolder = $this->rootFolder->getUserFolder($uid); + $node = $baseFolder->getFirstNodeById($fileId); + $params = []; - // properly format full path and make sure - // we're relative to the user home folder - $isRoot = $node === $userFolder; - $path = $userFolder->getRelativePath($node->getPath()); - $directory = $userFolder->getRelativePath($node->getParent()->getPath()); + if (!$node && $this->appManager->isEnabledForUser('files_trashbin')) { + /** @var Folder */ + $baseFolder = $this->rootFolder->get($uid . '/files_trashbin/files/'); + $node = $baseFolder->getFirstNodeById($fileId); + $params['view'] = 'trashbin'; - // Prevent opening a file from another folder. - if ($dir !== $directory) { - return; + 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)); + } } - - $this->initialState->provideInitialState( - 'openFileInfo', [ - 'id' => $node->getId(), - 'name' => $isRoot ? '' : $node->getName(), - 'path' => $path, - 'directory' => $directory, - 'mime' => $node->getMimetype(), - 'type' => $node->getType(), - 'permissions' => $node->getPermissions(), - ] - ); + throw new NotFoundException(); } /** * Redirects to the file list and highlight the given file id * - * @param string $fileId file id to show - * @param bool $setOpenfile - whether or not to set the openfile URL parameter + * @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 \OCP\Files\NotFoundException + * @throws NotFoundException */ - private function redirectToFile($fileId, bool $setOpenfile = false) { + private function redirectToFile(int $fileId, ?string $openDetails = null, ?string $openFile = null): RedirectResponse { $uid = $this->userSession->getUser()->getUID(); $baseFolder = $this->rootFolder->getUserFolder($uid); - $files = $baseFolder->getById($fileId); - $params = []; + $node = $baseFolder->getFirstNodeById($fileId); + $params = ['view' => 'files']; - if (empty($files) && $this->appManager->isEnabledForUser('files_trashbin')) { - $baseFolder = $this->rootFolder->get($uid . '/files_trashbin/files/'); - $files = $baseFolder->getById($fileId); - $params['view'] = 'trashbin'; + try { + $this->redirectToFileIfInTrashbin($fileId); + } catch (NotFoundException $e) { } - if (!empty($files)) { - $file = current($files); - if ($file instanceof Folder) { + if ($node) { + $params['fileid'] = $fileId; + if ($node instanceof Folder) { // set the full path to enter the folder - $params['dir'] = $baseFolder->getRelativePath($file->getPath()); + $params['dir'] = $baseFolder->getRelativePath($node->getPath()); } else { // set parent path as dir - $params['dir'] = $baseFolder->getRelativePath($file->getParent()->getPath()); - // and scroll to the entry - $params['scrollto'] = $file->getName(); + $params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath()); + // open the file by default (opening the viewer) + $params['openfile'] = 'true'; + } - if ($setOpenfile) { - // forward the openfile URL parameter. - $params['openfile'] = $fileId; - } + // 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'; } - return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', $params)); + if ($openFile !== null) { + $params['openfile'] = $openFile !== 'false' ? 'true' : 'false'; + } + + return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params)); } - throw new \OCP\Files\NotFoundException(); + + 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 index 81bb55e47f7..da7f5d13206 100644 --- a/apps/files/lib/Db/OpenLocalEditor.php +++ b/apps/files/lib/Db/OpenLocalEditor.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Db; diff --git a/apps/files/lib/Db/OpenLocalEditorMapper.php b/apps/files/lib/Db/OpenLocalEditorMapper.php index 71573264c74..6ae8b79c258 100644 --- a/apps/files/lib/Db/OpenLocalEditorMapper.php +++ b/apps/files/lib/Db/OpenLocalEditorMapper.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Db; @@ -32,6 +15,9 @@ 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); diff --git a/apps/files/lib/Db/TransferOwnership.php b/apps/files/lib/Db/TransferOwnership.php index 1ab94361ac9..ae78c19b76d 100644 --- a/apps/files/lib/Db/TransferOwnership.php +++ b/apps/files/lib/Db/TransferOwnership.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Db; diff --git a/apps/files/lib/Db/TransferOwnershipMapper.php b/apps/files/lib/Db/TransferOwnershipMapper.php index f55f9e733f9..8b29399f768 100644 --- a/apps/files/lib/Db/TransferOwnershipMapper.php +++ b/apps/files/lib/Db/TransferOwnershipMapper.php @@ -3,32 +3,17 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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); diff --git a/apps/files/lib/DirectEditingCapabilities.php b/apps/files/lib/DirectEditingCapabilities.php index 782f7019ac7..5bceef9305f 100644 --- a/apps/files/lib/DirectEditingCapabilities.php +++ b/apps/files/lib/DirectEditingCapabilities.php @@ -1,25 +1,9 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files; @@ -29,21 +13,22 @@ use OCP\Capabilities\IInitialStateExcludedCapability; use OCP\IURLGenerator; class DirectEditingCapabilities implements ICapability, IInitialStateExcludedCapability { - - protected DirectEditingService $directEditingService; - protected IURLGenerator $urlGenerator; - - public function __construct(DirectEditingService $directEditingService, IURLGenerator $urlGenerator) { - $this->directEditingService = $directEditingService; - $this->urlGenerator = $urlGenerator; + 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() + 'etag' => $this->directEditingService->getDirectEditingETag(), + 'supportsFileId' => true, ] ], ]; diff --git a/apps/files/lib/Event/LoadAdditionalScriptsEvent.php b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php index 5291a776e81..d1cf7f4016e 100644 --- a/apps/files/lib/Event/LoadAdditionalScriptsEvent.php +++ b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Event; @@ -31,18 +12,8 @@ use OCP\EventDispatcher\Event; /** * This event is triggered when the files app is rendered. - * It can be used to add additional scripts to the files app. * * @since 17.0.0 */ class LoadAdditionalScriptsEvent extends Event { - private $hiddenFields = []; - - public function addHiddenField(string $name, string $value): void { - $this->hiddenFields[$name] = $value; - } - - public function getHiddenFields(): array { - return $this->hiddenFields; - } } 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 index 8dc30e33632..01db57bb562 100644 --- a/apps/files/lib/Event/LoadSidebar.php +++ b/apps/files/lib/Event/LoadSidebar.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Event; diff --git a/apps/files/lib/Exception/TransferOwnershipException.php b/apps/files/lib/Exception/TransferOwnershipException.php index 1849976b6db..531c5d513da 100644 --- a/apps/files/lib/Exception/TransferOwnershipException.php +++ b/apps/files/lib/Exception/TransferOwnershipException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Exception; diff --git a/apps/files/lib/Helper.php b/apps/files/lib/Helper.php index 1d9591cafe0..b1439ac7fa5 100644 --- a/apps/files/lib/Helper.php +++ b/apps/files/lib/Helper.php @@ -1,101 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author brumsel <brumsel@losecatcher.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Jobst <mjobst+github@tecratech.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 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\ITagManager; +use OCP\Util; /** * 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 = \OC::$server->getL10N('files'); - $maxUploadFileSize = \OCP\Util::maxUploadFilesize($dir, $storageInfo['free']); - $maxHumanFileSize = \OCP\Util::humanFileSize($maxUploadFileSize); - $maxHumanFileSize = $l->t('Upload (max. %s)', [$maxHumanFileSize]); - - return [ - 'uploadMaxFilesize' => $maxUploadFileSize, - 'maxHumanFilesize' => $maxHumanFileSize, - 'freeSpace' => $storageInfo['free'], - 'quota' => $storageInfo['quota'], - 'total' => $storageInfo['total'], - 'used' => $storageInfo['used'], - 'usedSpacePercent' => $storageInfo['relative'], - 'owner' => $storageInfo['owner'], - 'ownerDisplayName' => $storageInfo['ownerDisplayName'], - 'mountType' => $storageInfo['mountType'], - 'mountPoint' => $storageInfo['mountPoint'], - ]; - } - - /** - * 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 + * @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) { @@ -106,15 +31,15 @@ class Helper { } elseif ($aType !== 'dir' and $bType === 'dir') { return 1; } else { - return \OCP\Util::naturalSortCompare($a->getName(), $b->getName()); + return 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 + * @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) { @@ -126,8 +51,8 @@ class Helper { /** * Comparator function to sort files by size * - * @param \OCP\Files\FileInfo $a file - * @param \OCP\Files\FileInfo $b file + * @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) { @@ -139,22 +64,24 @@ class Helper { /** * Formats the file info to be returned as JSON to the client. * - * @param \OCP\Files\FileInfo $i + * @param FileInfo $i * @return array formatted file info */ public static function formatFileInfo(FileInfo $i) { $entry = []; - $entry['id'] = $i['fileid']; - $entry['parentId'] = $i['parent']; - $entry['mtime'] = $i['mtime'] * 1000; + $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['permissions']; - $entry['mimetype'] = $i['mimetype']; - $entry['size'] = $i['size']; - $entry['type'] = $i['type']; - $entry['etag'] = $i['etag']; + $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']; } @@ -164,6 +91,10 @@ class Helper { 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(); @@ -173,27 +104,10 @@ class Helper { } $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 = []; - 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. * @@ -201,61 +115,21 @@ class Helper { * @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 + * @return FileInfo[] files */ public static function getFiles($dir, $sortAttribute = 'name', $sortDescending = false, $mimetypeFilter = '') { - $content = \OC\Files\Filesystem::getDirectoryContent($dir, $mimetypeFilter); + $content = Filesystem::getDirectoryContent($dir, $mimetypeFilter); return self::sortFiles($content, $sortAttribute, $sortDescending); } /** - * Populate the result set with file tags - * - * @param array $fileList - * @param string $fileIdentifier identifier attribute name for values in $fileList - * @param ITagManager $tagManager - * @return array file list populated with tags - */ - public static function populateTags(array $fileList, $fileIdentifier, ITagManager $tagManager) { - $ids = []; - foreach ($fileList as $fileData) { - $ids[] = $fileData[$fileIdentifier]; - } - $tagger = $tagManager->load('files'); - $tags = $tagger->getTagsForObjects($ids); - - if (!is_array($tags)) { - throw new \UnexpectedValueException('$tags must be an array'); - } - - // Set empty tag array - foreach ($fileList as $key => $fileData) { - $fileList[$key]['tags'] = []; - } - - if (!empty($tags)) { - foreach ($tags as $fileId => $fileTags) { - foreach ($fileList as $key => $fileData) { - if ($fileId !== $fileData[$fileIdentifier]) { - continue; - } - - $fileList[$key]['tags'] = $fileTags; - } - } - } - - return $fileList; - } - - /** * Sort the given file info array * - * @param \OCP\Files\FileInfo[] $files files to sort + * @param 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 + * @return FileInfo[] sorted files */ public static function sortFiles($files, $sortAttribute = 'name', $sortDescending = false) { $sortFunc = 'compareFileNames'; diff --git a/apps/files/lib/Listener/LegacyLoadAdditionalScriptsAdapter.php b/apps/files/lib/Listener/LegacyLoadAdditionalScriptsAdapter.php deleted file mode 100644 index f60f9d73d78..00000000000 --- a/apps/files/lib/Listener/LegacyLoadAdditionalScriptsAdapter.php +++ /dev/null @@ -1,57 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -namespace OCA\Files\Listener; - -use OC\EventDispatcher\SymfonyAdapter; -use OCA\Files\Event\LoadAdditionalScriptsEvent; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventListener; -use Symfony\Component\EventDispatcher\GenericEvent; - -class LegacyLoadAdditionalScriptsAdapter implements IEventListener { - - /** @var SymfonyAdapter */ - private $dispatcher; - - public function __construct(SymfonyAdapter $dispatcher) { - $this->dispatcher = $dispatcher; - } - - public function handle(Event $event): void { - if (!($event instanceof LoadAdditionalScriptsEvent)) { - return; - } - - $legacyEvent = new GenericEvent(null, ['hiddenFields' => []]); - $this->dispatcher->dispatch('OCA\Files::loadAdditionalScripts', $legacyEvent); - - $hiddenFields = $legacyEvent->getArgument('hiddenFields'); - foreach ($hiddenFields as $name => $value) { - $event->addHiddenField($name, $value); - } - } -} 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 index 15f24279e0a..78b48ab1ce0 100644 --- a/apps/files/lib/Listener/LoadSidebarListener.php +++ b/apps/files/lib/Listener/LoadSidebarListener.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Listener; @@ -31,6 +14,7 @@ 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)) { @@ -38,8 +22,5 @@ class LoadSidebarListener implements IEventListener { } Util::addScript(Application::APP_ID, 'sidebar'); - // needed by the Sidebar legacy tabs - // TODO: remove when all tabs migrated to the new api - Util::addScript('files', 'fileinfomodel'); } } 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 index e7bda7ef38a..2e3d72c7ece 100644 --- a/apps/files/lib/Migration/Version11301Date20191205150729.php +++ b/apps/files/lib/Migration/Version11301Date20191205150729.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Migration; diff --git a/apps/files/lib/Migration/Version12101Date20221011153334.php b/apps/files/lib/Migration/Version12101Date20221011153334.php index 0c1093f50a6..ed4d8bef90b 100644 --- a/apps/files/lib/Migration/Version12101Date20221011153334.php +++ b/apps/files/lib/Migration/Version12101Date20221011153334.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Migration; @@ -38,7 +21,7 @@ class Version12101Date20221011153334 extends SimpleMigrationStep { $schema = $schemaClosure(); $table = $schema->createTable('open_local_editor'); - $table->addColumn('id',Types::BIGINT, [ + $table->addColumn('id', Types::BIGINT, [ 'autoincrement' => true, 'notnull' => true, 'length' => 20, 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 index 90784749b27..6acc312c126 100644 --- a/apps/files/lib/Notification/Notifier.php +++ b/apps/files/lib/Notification/Notifier.php @@ -3,33 +3,16 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sascha Wiswedel <sascha.wiswedel@nextcloud.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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; @@ -39,34 +22,18 @@ 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 { - /** @var IFactory */ - protected $l10nFactory; - - /** @var IURLGenerator */ - protected $urlGenerator; - /** @var TransferOwnershipMapper */ - private $mapper; - /** @var IManager */ - private $notificationManager; - /** @var IUserManager */ - private $userManager; - /** @var ITimeFactory */ - private $timeFactory; - - public function __construct(IFactory $l10nFactory, - IURLGenerator $urlGenerator, - TransferOwnershipMapper $mapper, - IManager $notificationManager, - IUserManager $userManager, - ITimeFactory $timeFactory) { - $this->l10nFactory = $l10nFactory; - $this->urlGenerator = $urlGenerator; - $this->mapper = $mapper; - $this->notificationManager = $notificationManager; - $this->userManager = $userManager; - $this->timeFactory = $timeFactory; + 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 { @@ -81,30 +48,26 @@ class Notifier implements INotifier, IDismissableNotifier { * @param INotification $notification * @param string $languageCode The code of the language that should be used to prepare the notification * @return INotification - * @throws \InvalidArgumentException When the notification was not prepared by a notifier + * @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 \InvalidArgumentException('Unhandled app'); + throw new UnknownNotificationException('Unhandled app'); } - if ($notification->getSubject() === 'transferownershipRequest') { - return $this->handleTransferownershipRequest($notification, $languageCode); - } - if ($notification->getSubject() === 'transferOwnershipFailedSource') { - return $this->handleTransferOwnershipFailedSource($notification, $languageCode); - } - if ($notification->getSubject() === 'transferOwnershipFailedTarget') { - return $this->handleTransferOwnershipFailedTarget($notification, $languageCode); - } - if ($notification->getSubject() === 'transferOwnershipDoneSource') { - return $this->handleTransferOwnershipDoneSource($notification, $languageCode); - } - if ($notification->getSubject() === 'transferOwnershipDoneTarget') { - return $this->handleTransferOwnershipDoneTarget($notification, $languageCode); - } - - throw new \InvalidArgumentException('Unhandled subject'); + $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 { @@ -163,6 +126,29 @@ class Notifier implements INotifier, IDismissableNotifier { 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(); @@ -260,7 +246,10 @@ class Notifier implements INotifier, IDismissableNotifier { public function dismissNotification(INotification $notification): void { if ($notification->getApp() !== 'files') { - throw new \InvalidArgumentException('Unhandled app'); + 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. @@ -270,6 +259,12 @@ class Notifier implements INotifier, IDismissableNotifier { return; } + if ($this->jobList->has(TransferOwnership::class, [ + 'id' => $transferOwnership->getId(), + ])) { + return; + } + $notification = $this->notificationManager->createNotification(); $notification->setUser($transferOwnership->getSourceUser()) ->setApp('files') 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 index ba2d4bafa30..f71d58c6fae 100644 --- a/apps/files/lib/Search/FilesSearchProvider.php +++ b/apps/files/lib/Search/FilesSearchProvider.php @@ -3,73 +3,45 @@ declare(strict_types=1); /** - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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\Search\ISearchComparison; 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\IProvider; +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 IProvider { - - /** @var IL10N */ - private $l10n; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var IMimeTypeDetector */ - private $mimeTypeDetector; - - /** @var IRootFolder */ - private $rootFolder; - +class FilesSearchProvider implements IFilteringProvider { public function __construct( - IL10N $l10n, - IURLGenerator $urlGenerator, - IMimeTypeDetector $mimeTypeDetector, - IRootFolder $rootFolder + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IMimeTypeDetector $mimeTypeDetector, + private IRootFolder $rootFolder, + private IPreview $previewManager, ) { - $this->l10n = $l10n; - $this->urlGenerator = $urlGenerator; - $this->mimeTypeDetector = $mimeTypeDetector; - $this->rootFolder = $rootFolder; } /** @@ -97,26 +69,49 @@ class FilesSearchProvider implements IProvider { return 5; } - /** - * @inheritDoc - */ + 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 = new SearchQuery( - new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', '%' . $query->getTerm() . '%'), - $query->getLimit(), - (int)$query->getCursor(), - $query->getSortOrder() === ISearchQuery::SORT_DATE_DESC ? [ - new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime'), - ] : [], - $user - ); - + $fileQuery = $this->buildSearchQuery($query, $user); return SearchResult::paginated( $this->l10n->t('Files'), array_map(function (Node $result) use ($userFolder) { - // Generate thumbnail url - $thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->getId()]); + $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 @@ -131,7 +126,7 @@ class FilesSearchProvider implements IProvider { $result->getName(), $this->formatSubline($path), $this->urlGenerator->getAbsoluteURL($link), - $result->getMimetype() === FileInfo::MIMETYPE_FOLDER ? 'icon-folder' : $this->mimeTypeDetector->mimeTypeIcon($result->getMimetype()) + $icon, ); $searchResultEntry->addAttribute('fileId', (string)$result->getId()); $searchResultEntry->addAttribute('path', $path); @@ -141,6 +136,54 @@ class FilesSearchProvider implements IProvider { ); } + 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 * 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 index 1ee3a53d85a..3d756ee56fa 100644 --- a/apps/files/lib/Service/DirectEditingService.php +++ b/apps/files/lib/Service/DirectEditingService.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Service; @@ -32,14 +15,10 @@ use OCP\EventDispatcher\IEventDispatcher; class DirectEditingService { - /** @var IManager */ - private $directEditingManager; - /** @var IEventDispatcher */ - private $eventDispatcher; - - public function __construct(IEventDispatcher $eventDispatcher, IManager $directEditingManager) { - $this->directEditingManager = $directEditingManager; - $this->eventDispatcher = $eventDispatcher; + public function __construct( + private IEventDispatcher $eventDispatcher, + private IManager $directEditingManager, + ) { } public function getDirectEditingETag(): string { 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 index 661a7e66e10..afef5d2093d 100644 --- a/apps/files/lib/Service/OwnershipTransferService.php +++ b/apps/files/lib/Service/OwnershipTransferService.php @@ -3,47 +3,33 @@ declare(strict_types=1); /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sascha Wiswedel <sascha.wiswedel@nextcloud.com> - * @author Tobia De Koninck <LEDfan@users.noreply.github.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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\IHomeStorage; 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; @@ -58,31 +44,15 @@ use function rtrim; class OwnershipTransferService { - /** @var IEncryptionManager */ - private $encryptionManager; - - /** @var IShareManager */ - private $shareManager; - - /** @var IMountManager */ - private $mountManager; - - /** @var IUserMountCache */ - private $userMountCache; - - /** @var IUserManager */ - private $userManager; - - public function __construct(IEncryptionManager $manager, - IShareManager $shareManager, - IMountManager $mountManager, - IUserMountCache $userMountCache, - IUserManager $userManager) { - $this->encryptionManager = $manager; - $this->shareManager = $shareManager; - $this->mountManager = $mountManager; - $this->userMountCache = $userMountCache; - $this->userManager = $userManager; + public function __construct( + private IEncryptionManager $encryptionManager, + private IShareManager $shareManager, + private IMountManager $mountManager, + private IUserMountCache $userMountCache, + private IUserManager $userManager, + private IFactory $l10nFactory, + private IRootFolder $rootFolder, + ) { } /** @@ -93,15 +63,17 @@ class OwnershipTransferService { * @param OutputInterface|null $output * @param bool $move * @throws TransferOwnershipException - * @throws \OC\User\NoUserException + * @throws NoUserException */ - public function transfer(IUser $sourceUser, - IUser $destinationUser, - string $path, - ?OutputInterface $output = null, - bool $move = false, - bool $firstLogin = false, - bool $transferIncomingShares = false): void { + 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(); @@ -110,12 +82,17 @@ class OwnershipTransferService { // 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); + 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 - \OC::$server->getUserFolder($destinationUser->getUID()); + // 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); @@ -124,19 +101,15 @@ class OwnershipTransferService { if ($move) { $finalTarget = "$destinationUid/files/"; } else { + $l = $this->l10nFactory->get('files', $this->l10nFactory->getUserLanguage($destinationUser)); $date = date('Y-m-d H-i-s'); - // Remove some characters which are prone to cause errors - $cleanUserName = str_replace(['\\', '/', ':', '.', '?', '#', '\'', '"'], '-', $sourceUser->getDisplayName()); - // Replace multiple dashes with one dash - $cleanUserName = preg_replace('/-{2,}/s', '-', $cleanUserName); - $cleanUserName = $cleanUserName ?: $sourceUid; - - $finalTarget = "$destinationUid/files/transferred from $cleanUserName on $date"; + $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/transferred from $sourceUid on $date"; + $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$sourceUid, $date])); } } @@ -150,7 +123,7 @@ class OwnershipTransferService { } if ($move && !$firstLogin && count($view->getDirectoryContent($finalTarget)) > 0) { - throw new TransferOwnershipException("Destination path does not exists or is not empty", 1); + throw new TransferOwnershipException('Destination path does not exists or is not empty', 1); } @@ -171,49 +144,62 @@ class OwnershipTransferService { $sourcePath ); + $sourceSize = $view->getFileInfo($sourcePath)->getSize(); + // transfer the files $this->transferFiles( $sourceUid, $sourcePath, $finalTarget, $view, - $output + $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 ); - - // transfer the incoming shares - if ($transferIncomingShares === true) { - $sourceShares = $this->collectIncomingShares( - $sourceUid, - $output, - $view - ); - $destinationShares = $this->collectIncomingShares( - $destinationUid, - $output, - $view, - true - ); - $this->transferIncomingShares( - $sourceUid, - $destinationUid, - $sourceShares, - $destinationShares, - $output, - $path, - $finalTarget, - $move - ); + 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)) { @@ -228,85 +214,130 @@ class OwnershipTransferService { /** * @param OutputInterface $output * - * @throws \Exception + * @throws TransferOwnershipException */ - protected function analyse(string $sourceUid, - string $destinationUid, - string $sourcePath, - View $view, - OutputInterface $output): void { + protected function analyse( + string $sourceUid, + string $destinationUid, + string $sourcePath, + View $view, + OutputInterface $output, + bool $includeExternalStorage = false, + ): void { $output->writeln('Validating quota'); - $size = $view->getFileInfo($sourcePath, false)->getSize(false); + $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) { - $output->writeln('<error>Target user does not have enough free space available.</error>'); - throw new \Exception('Execution terminated.'); + 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 = []; - $this->walkFiles($view, $sourcePath, - function (FileInfo $fileInfo) use ($progress) { - if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) { - // only analyze into folders from main storage, - if (!$fileInfo->getStorage()->instanceOfStorage(IHomeStorage::class)) { - return false; - } - return true; - } - $progress->advance(); - if ($fileInfo->isEncrypted()) { - $encryptedFiles[] = $fileInfo; - } - return true; - }); + 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>"); + $output->writeln('<error>Some files are encrypted - please decrypt them first.</error>'); foreach ($encryptedFiles as $encryptedFile) { /** @var FileInfo $encryptedFile */ - $output->writeln(" " . $encryptedFile->getPath()); + $output->writeln(' ' . $encryptedFile->getPath()); } - throw new \Exception('Execution terminated.'); + throw new TransferOwnershipException('Some files are encrypted - please decrypt them first.', 1); } } - private function collectUsersShares(string $sourceUid, - OutputInterface $output, - View $view, - string $path): array { + /** + * @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); - foreach ([IShare::TYPE_GROUP, IShare::TYPE_USER, IShare::TYPE_LINK, IShare::TYPE_REMOTE, IShare::TYPE_ROOM, IShare::TYPE_EMAIL, IShare::TYPE_CIRCLE, IShare::TYPE_DECK] as $shareType) { + $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); + $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, $path) { + $sharePage = array_filter($sharePage, function (IShare $share) use ($view, $normalizedPath) { try { - $relativePath = $view->getPath($share->getNodeId()); - $singleFileTranfer = $view->is_file($path); - if ($singleFileTranfer) { - return Filesystem::normalizePath($relativePath) === Filesystem::normalizePath($path); - } + $sourceNode = $share->getNode(); + $relativePath = $view->getRelativePath($sourceNode->getPath()); - return mb_strpos( - Filesystem::normalizePath($relativePath . '/', false), - Filesystem::normalizePath($path . '/', false)) === 0; - } catch (\Exception $e) { + return str_starts_with($relativePath . '/', $normalizedPath . '/'); + } catch (Exception $e) { return false; } }); @@ -318,17 +349,32 @@ class OwnershipTransferService { $progress->finish(); $output->writeln(''); - return $shares; + + 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, - View $view, - bool $addKeys = false): array { + 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) { @@ -337,14 +383,19 @@ class OwnershipTransferService { if (empty($sharePage)) { break; } - if ($addKeys) { - foreach ($sharePage as $singleShare) { - $shares[$singleShare->getNodeId()] = $singleShare; - } - } else { - foreach ($sharePage as $singleShare) { - $shares[] = $singleShare; - } + + 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; @@ -359,11 +410,14 @@ class OwnershipTransferService { /** * @throws TransferOwnershipException */ - protected function transferFiles(string $sourceUid, - string $sourcePath, - string $finalTarget, - View $view, - OutputInterface $output): void { + 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. @@ -372,26 +426,69 @@ class OwnershipTransferService { $view->mkdir($finalTarget); $finalTarget = $finalTarget . '/' . basename($sourcePath); } - if ($view->rename($sourcePath, $finalTarget) === false) { - throw new TransferOwnershipException("Could not transfer files.", 1); + $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 restoreShares(string $sourceUid, - string $destinationUid, - array $shares, - OutputInterface $output) { - $output->writeln("Restoring shares ..."); + 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) { + foreach ($shares as ['share' => $share, 'suffix' => $suffix]) { try { - if ($share->getShareType() === IShare::TYPE_USER && - $share->getSharedWith() === $destinationUid) { + $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) { @@ -406,8 +503,8 @@ class OwnershipTransferService { $share->setSharedBy($destinationUid); } - if ($share->getShareType() === IShare::TYPE_USER && - !$this->userManager->userExists($share->getSharedWith())) { + 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); @@ -416,12 +513,25 @@ class OwnershipTransferService { // 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); + 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 (\OCP\Files\NotFoundException $e) { + } 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>'); @@ -433,18 +543,18 @@ class OwnershipTransferService { } 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 ..."); + 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 (substr($finalTarget, 0, strlen($prefix)) === $prefix) { + if (str_starts_with($finalTarget, $prefix)) { $finalShareTarget = substr($finalTarget, strlen($prefix)); } foreach ($sourceShares as $share) { @@ -454,13 +564,13 @@ class OwnershipTransferService { if (trim($path, '/') !== '') { $pathToCheck = '/' . trim($path) . '/'; } - if (substr($share->getTarget(), 0, strlen($pathToCheck)) !== $pathToCheck) { + if (!str_starts_with($share->getTarget(), $pathToCheck)) { continue; } $shareTarget = $share->getTarget(); $shareTarget = $finalShareTarget . $shareTarget; - if ($share->getShareType() === IShare::TYPE_USER && - $share->getSharedBy() === $destinationUid) { + if ($share->getShareType() === IShare::TYPE_USER + && $share->getSharedBy() === $destinationUid) { $this->shareManager->deleteShare($share); } elseif (isset($destinationShares[$share->getNodeId()])) { $destinationShare = $destinationShares[$share->getNodeId()]; @@ -501,7 +611,7 @@ class OwnershipTransferService { $this->shareManager->moveShare($share, $destinationUid); continue; } - } catch (\OCP\Files\NotFoundException $e) { + } 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>'); diff --git a/apps/files/lib/Service/SettingsService.php b/apps/files/lib/Service/SettingsService.php new file mode 100644 index 00000000000..d07e907a5f6 --- /dev/null +++ b/apps/files/lib/Service/SettingsService.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Service; + +use OC\Files\FilenameValidator; +use OCP\IConfig; +use Psr\Log\LoggerInterface; + +class SettingsService { + + protected const WINDOWS_EXTENSION = [ + ' ', + '.', + ]; + + protected const WINDOWS_BASENAMES = [ + 'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3', 'com4', 'com5', + 'com6', 'com7', 'com8', 'com9', 'com¹', 'com²', 'com³', 'lpt0', 'lpt1', 'lpt2', + 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', 'lpt¹', 'lpt²', 'lpt³', + ]; + + protected const WINDOWS_CHARACTERS = [ + '<', '>', ':', + '"', '|', '?', + '*', + ]; + + public function __construct( + private IConfig $config, + private FilenameValidator $filenameValidator, + private LoggerInterface $logger, + ) { + } + + public function hasFilesWindowsSupport(): bool { + return empty(array_diff(self::WINDOWS_BASENAMES, $this->filenameValidator->getForbiddenBasenames())) + && empty(array_diff(self::WINDOWS_CHARACTERS, $this->filenameValidator->getForbiddenCharacters())) + && empty(array_diff(self::WINDOWS_EXTENSION, $this->filenameValidator->getForbiddenExtensions())); + } + + public function setFilesWindowsSupport(bool $enabled = true): void { + if ($enabled) { + $basenames = array_unique(array_merge(self::WINDOWS_BASENAMES, $this->filenameValidator->getForbiddenBasenames())); + $characters = array_unique(array_merge(self::WINDOWS_CHARACTERS, $this->filenameValidator->getForbiddenCharacters())); + $extensions = array_unique(array_merge(self::WINDOWS_EXTENSION, $this->filenameValidator->getForbiddenExtensions())); + } else { + $basenames = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenBasenames(), self::WINDOWS_BASENAMES))); + $characters = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenCharacters(), self::WINDOWS_CHARACTERS))); + $extensions = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenExtensions(), self::WINDOWS_EXTENSION))); + } + $values = [ + 'forbidden_filename_basenames' => empty($basenames) ? null : $basenames, + 'forbidden_filename_characters' => empty($characters) ? null : $characters, + 'forbidden_filename_extensions' => empty($extensions) ? null : $extensions, + ]; + $this->config->setSystemValues($values); + } +} diff --git a/apps/files/lib/Service/TagService.php b/apps/files/lib/Service/TagService.php index 29b6fbc2840..63c54d01fd0 100644 --- a/apps/files/lib/Service/TagService.php +++ b/apps/files/lib/Service/TagService.php @@ -1,73 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\Service; -use OCA\Files\Activity\FavoriteProvider; use OCP\Activity\IManager; use OCP\Files\Folder; +use OCP\Files\NotFoundException; use OCP\ITags; -use OCP\IUser; use OCP\IUserSession; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; /** * Service class to manage tags on files. */ class TagService { - /** @var IUserSession */ - private $userSession; - /** @var IManager */ - private $activityManager; - /** @var ITags */ - private $tagger; - /** @var Folder */ - private $homeFolder; - /** @var EventDispatcherInterface */ - private $dispatcher; - - /** - * @param IUserSession $userSession - * @param IManager $activityManager - * @param ITags $tagger - * @param Folder $homeFolder - * @param EventDispatcherInterface $dispatcher - */ public function __construct( - IUserSession $userSession, - IManager $activityManager, - ITags $tagger, - Folder $homeFolder, - EventDispatcherInterface $dispatcher + private IUserSession $userSession, + private IManager $activityManager, + private ?ITags $tagger, + private ?Folder $homeFolder, ) { - $this->userSession = $userSession; - $this->activityManager = $activityManager; - $this->tagger = $tagger; - $this->homeFolder = $homeFolder; - $this->dispatcher = $dispatcher; } /** @@ -76,11 +32,18 @@ class TagService { * replace the actual tag selection. * * @param string $path path - * @param array $tags array of tags + * @param array $tags array of tags * @return array list of tags - * @throws \OCP\Files\NotFoundException if the file does not exist + * @throws NotFoundException if the file does not exist */ public function updateFileTags($path, $tags) { + if ($this->tagger === null) { + throw new \RuntimeException('No tagger set'); + } + if ($this->homeFolder === null) { + throw new \RuntimeException('No homeFolder set'); + } + $fileId = $this->homeFolder->get($path)->getId(); $currentTags = $this->tagger->getTagsForObjects([$fileId]); @@ -91,16 +54,10 @@ class TagService { $newTags = array_diff($tags, $currentTags); foreach ($newTags as $tag) { - if ($tag === ITags::TAG_FAVORITE) { - $this->addActivity(true, $fileId, $path); - } $this->tagger->tagAs($fileId, $tag); } $deletedTags = array_diff($currentTags, $tags); foreach ($deletedTags as $tag) { - if ($tag === ITags::TAG_FAVORITE) { - $this->addActivity(false, $fileId, $path); - } $this->tagger->unTag($fileId, $tag); } @@ -108,40 +65,4 @@ class TagService { // list is up to date, in case of concurrent changes ? return $tags; } - - /** - * @param bool $addToFavorite - * @param int $fileId - * @param string $path - */ - protected function addActivity($addToFavorite, $fileId, $path) { - $user = $this->userSession->getUser(); - if (!$user instanceof IUser) { - return; - } - - $eventName = $addToFavorite ? 'addFavorite' : 'removeFavorite'; - $this->dispatcher->dispatch(self::class . '::' . $eventName, new GenericEvent(null, [ - 'userId' => $user->getUID(), - 'fileId' => $fileId, - 'path' => $path, - ])); - - $event = $this->activityManager->generateEvent(); - try { - $event->setApp('files') - ->setObject('files', $fileId, $path) - ->setType('favorite') - ->setAuthor($user->getUID()) - ->setAffectedUser($user->getUID()) - ->setTimestamp(time()) - ->setSubject( - $addToFavorite ? FavoriteProvider::SUBJECT_ADDED : FavoriteProvider::SUBJECT_REMOVED, - ['id' => $fileId, 'path' => $path] - ); - $this->activityManager->publish($event); - } catch (\InvalidArgumentException $e) { - } catch (\BadMethodCallException $e) { - } - } } diff --git a/apps/files/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php index e405b02c07a..dcf30b7796d 100644 --- a/apps/files/lib/Service/UserConfig.php +++ b/apps/files/lib/Service/UserConfig.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Service; @@ -28,7 +12,7 @@ use OCP\IUser; use OCP\IUserSession; class UserConfig { - const ALLOWED_CONFIGS = [ + public const ALLOWED_CONFIGS = [ [ // Whether to crop the files previews or not in the files list 'key' => 'crop_image_previews', @@ -36,18 +20,72 @@ class UserConfig { '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 IConfig $config; protected ?IUser $user = null; - public function __construct(IConfig $config, IUserSession $userSession) { - $this->config = $config; + public function __construct( + protected IConfig $config, + IUserSession $userSession, + ) { $this->user = $userSession->getUser(); } @@ -56,7 +94,7 @@ class UserConfig { * @return string[] */ public function getAllowedConfigKeys(): array { - return array_map(function($config) { + return array_map(function ($config) { return $config['key']; }, self::ALLOWED_CONFIGS); } @@ -107,7 +145,7 @@ class UserConfig { 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'); } @@ -130,10 +168,10 @@ class UserConfig { } $userId = $this->user->getUID(); - $userConfigs = array_map(function(string $key) use ($userId) { + $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))) { + if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) { return $value === '1'; } return $value; 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 index c62a1b9d62d..fe43265bc13 100644 --- a/apps/files/lib/Settings/PersonalSettings.php +++ b/apps/files/lib/Settings/PersonalSettings.php @@ -3,34 +3,19 @@ declare(strict_types=1); /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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'); } |