diff options
Diffstat (limited to 'apps/files/lib')
83 files changed, 3021 insertions, 2687 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 8b817f92c1e..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 41423a65ca7..2761b44ecf9 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -1,47 +1,27 @@ <?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\LoadSearchPlugins; use OCA\Files\Event\LoadSidebar; +use OCA\Files\Listener\LoadSearchPluginsListener; use OCA\Files\Listener\LoadSidebarListener; +use OCA\Files\Listener\NodeAddedToFavoriteListener; +use OCA\Files\Listener\NodeRemovedFromFavoriteListener; use OCA\Files\Listener\RenderReferenceEventListener; use OCA\Files\Listener\SyncLivePhotosListener; use OCA\Files\Notification\Notifier; @@ -49,7 +29,7 @@ use OCA\Files\Search\FilesSearchProvider; use OCA\Files\Service\TagService; use OCA\Files\Service\UserConfig; use OCA\Files\Service\ViewConfig; -use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent; +use OCA\Files\Settings\DeclarativeAdminSettings; use OCP\Activity\IManager as IActivityManager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -57,20 +37,25 @@ use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Collaboration\Resources\IProviderManager; -use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Cache\CacheEntryRemovedEvent; +use OCP\Files\Events\Node\BeforeNodeCopiedEvent; use OCP\Files\Events\Node\BeforeNodeDeletedEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\NodeAddedToFavorite; +use OCP\Files\Events\NodeRemovedFromFavorite; +use OCP\Files\IRootFolder; use OCP\IConfig; +use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; -use OCP\ISearch; use OCP\IServerContainer; use OCP\ITagManager; use OCP\IUserSession; use OCP\Share\IManager as IShareManager; use OCP\Util; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; class Application extends App implements IBootstrap { public const APP_ID = 'files'; @@ -98,6 +83,9 @@ class Application extends App implements IBootstrap { $server->getUserFolder(), $c->get(UserConfig::class), $c->get(ViewConfig::class), + $c->get(IL10N::class), + $c->get(IRootFolder::class), + $c->get(LoggerInterface::class), ); }); @@ -113,7 +101,6 @@ class Application extends App implements IBootstrap { $c->get(IActivityManager::class), $c->get(ITagManager::class)->load(self::APP_ID), $server->getUserFolder(), - $c->get(IEventDispatcher::class), ); }); @@ -121,25 +108,30 @@ class Application extends App implements IBootstrap { * Register capabilities */ $context->registerCapability(Capabilities::class); + $context->registerCapability(AdvancedCapabilities::class); $context->registerCapability(DirectEditingCapabilities::class); + $context->registerDeclarativeSettings(DeclarativeAdminSettings::class); + $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); $context->registerEventListener(RenderReferenceEvent::class, RenderReferenceEventListener::class); $context->registerEventListener(BeforeNodeRenamedEvent::class, SyncLivePhotosListener::class); $context->registerEventListener(BeforeNodeDeletedEvent::class, SyncLivePhotosListener::class); - $context->registerEventListener(BeforeNodeRestoredEvent::class, SyncLivePhotosListener::class); - $context->registerEventListener(CacheEntryRemovedEvent::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(); $this->registerHooks(); } @@ -147,17 +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 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 2f700b8773e..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 7ff28a50155..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 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 9839bcde5b7..8a20b6dfb0c 100644 --- a/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php +++ b/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php @@ -3,50 +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\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 6163955ddfd..b925974f24a 100644 --- a/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php +++ b/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php @@ -1,26 +1,9 @@ <?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; @@ -35,7 +18,6 @@ use Psr\Log\LoggerInterface; */ class DeleteOrphanedItems extends TimedJob { public const CHUNK_SIZE = 200; - protected $defaultIntervalMin = 60; /** * sets the correct interval for this timed job @@ -46,7 +28,7 @@ class DeleteOrphanedItems extends TimedJob { protected LoggerInterface $logger, ) { parent::__construct($time); - $this->interval = $this->defaultIntervalMin * 60; + $this->setInterval(60 * 60); } /** @@ -69,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 1f182b5e999..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; @@ -61,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); diff --git a/apps/files/lib/Capabilities.php b/apps/files/lib/Capabilities.php index dc2aae6acfc..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{files: array{bigfilechunking: bool, blacklisted_files: array<mixed>}} + * @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' => (array)$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 a368016523d..e4ff5d83b7a 100644 --- a/apps/files/lib/Collaboration/Resources/Listener.php +++ b/apps/files/lib/Collaboration/Resources/Listener.php @@ -3,25 +3,8 @@ 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; diff --git a/apps/files/lib/Collaboration/Resources/ResourceProvider.php b/apps/files/lib/Collaboration/Resources/ResourceProvider.php index 4c5afc76b2b..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 index e9a9f764d94..ad0dfa90de1 100644 --- a/apps/files/lib/Command/Copy.php +++ b/apps/files/lib/Command/Copy.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command; @@ -34,10 +19,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; class Copy extends Command { - private FileUtils $fileUtils; - - public function __construct(FileUtils $fileUtils) { - $this->fileUtils = $fileUtils; + public function __construct( + private FileUtils $fileUtils, + ) { parent::__construct(); } @@ -45,10 +29,10 @@ class Copy extends Command { $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") + ->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"); + ->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 { @@ -113,7 +97,7 @@ class Copy extends Command { /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion("<info>" . $targetInput . "</info> already exists, overwrite? [y/N] ", false); + $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false); if (!$helper->ask($input, $output, $question)) { return 1; } diff --git a/apps/files/lib/Command/Delete.php b/apps/files/lib/Command/Delete.php index f491b67ae1f..d984f839c91 100644 --- a/apps/files/lib/Command/Delete.php +++ b/apps/files/lib/Command/Delete.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command; @@ -45,7 +30,7 @@ class Delete extends Command { $this ->setName('files:delete') ->setDescription('Delete a file or folder') - ->addArgument('file', InputArgument::REQUIRED, "File id or path") + ->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"); } @@ -74,30 +59,30 @@ class Delete extends Command { return self::SUCCESS; } else { $node = $storage->getShare()->getNode(); - $output->writeln(""); + $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(""); + $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($user . ':'); + foreach ($filesByUser as $file) { + $output->writeln(' - ' . $file->getPath()); } } - $output->writeln(""); + $output->writeln(''); } if ($node instanceof Folder) { $maybeContents = " and all it's contents"; } else { - $maybeContents = ""; + $maybeContents = ''; } - $question = new ConfirmationQuestion("Delete " . $node->getPath() . $maybeContents . "? [y/N] ", false); + $question = new ConfirmationQuestion('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false); $deleteConfirmed = $helper->ask($input, $output, $question); } @@ -105,7 +90,7 @@ class Delete extends Command { if ($node->isDeletable()) { $node->delete(); } else { - $output->writeln("<error>File cannot be deleted, insufficient permissions.</error>"); + $output->writeln('<error>File cannot be deleted, insufficient permissions.</error>'); } } diff --git a/apps/files/lib/Command/DeleteOrphanedFiles.php b/apps/files/lib/Command/DeleteOrphanedFiles.php index 4b7179271f5..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; /** @@ -44,42 +29,113 @@ class DeleteOrphanedFiles extends Command { 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 = []; - $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); + $deletedStorages = array_diff($this->getReferencedStorages(), $this->getExistingStorages()); - $deleteQuery = $this->connection->getQueryBuilder(); - $deleteQuery->delete('filecache') - ->where($deleteQuery->expr()->eq('fileid', $deleteQuery->createParameter('objectid'))); - - $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(); + $deleteExtended = !$input->getOption('skip-filecache-extended'); + if ($deleteExtended) { + $fileIdsByStorage = $this->getFileIdsForStorages($deletedStorages); } + $deletedEntries = $this->cleanupOrphanedFileCache($deletedStorages); $output->writeln("$deletedEntries orphaned file cache entries deleted"); + if ($deleteExtended) { + $deletedFileCacheExtended = $this->cleanupOrphanedFileCacheExtended($fileIdsByStorage); + $output->writeln("$deletedFileCacheExtended orphaned file cache extended entries deleted"); + } + $deletedMounts = $this->cleanupOrphanedMounts(); $output->writeln("$deletedMounts orphaned mount entries deleted"); + return self::SUCCESS; } + private function getReferencedStorages(): array { + $query = $this->connection->getQueryBuilder(); + $query->select('storage') + ->from('filecache') + ->groupBy('storage') + ->runAcrossAllShards(); + return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + } + + private function getExistingStorages(): array { + $query = $this->connection->getQueryBuilder(); + $query->select('numeric_id') + ->from('storages') + ->groupBy('numeric_id'); + return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * @param int[] $storageIds + * @return array<int, int[]> + */ + private function getFileIdsForStorages(array $storageIds): array { + $query = $this->connection->getQueryBuilder(); + $query->select('storage', 'fileid') + ->from('filecache') + ->where($query->expr()->in('storage', $query->createParameter('storage_ids'))); + + $result = []; + $storageIdChunks = array_chunk($storageIds, self::CHUNK_SIZE); + foreach ($storageIdChunks as $storageIdChunk) { + $query->setParameter('storage_ids', $storageIdChunk, IQueryBuilder::PARAM_INT_ARRAY); + $chunk = $query->executeQuery()->fetchAll(); + foreach ($chunk as $row) { + $result[$row['storage']][] = $row['fileid']; + } + } + return $result; + } + + private function cleanupOrphanedFileCache(array $deletedStorages): int { + $deletedEntries = 0; + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('filecache') + ->where($deleteQuery->expr()->in('storage', $deleteQuery->createParameter('storage_ids'))); + + $deletedStorageChunks = array_chunk($deletedStorages, self::CHUNK_SIZE); + foreach ($deletedStorageChunks as $deletedStorageChunk) { + $deleteQuery->setParameter('storage_ids', $deletedStorageChunk, IQueryBuilder::PARAM_INT_ARRAY); + $deletedEntries += $deleteQuery->executeStatement(); + } + + return $deletedEntries; + } + + /** + * @param array<int, int[]> $fileIdsByStorage + * @return int + */ + private function cleanupOrphanedFileCacheExtended(array $fileIdsByStorage): int { + $deletedEntries = 0; + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('filecache_extended') + ->where($deleteQuery->expr()->in('fileid', $deleteQuery->createParameter('file_ids'))); + + foreach ($fileIdsByStorage as $storageId => $fileIds) { + $deleteQuery->hintShardKey('storage', $storageId, true); + $fileChunks = array_chunk($fileIds, self::CHUNK_SIZE); + foreach ($fileChunks as $fileChunk) { + $deleteQuery->setParameter('file_ids', $fileChunk, IQueryBuilder::PARAM_INT_ARRAY); + $deletedEntries += $deleteQuery->executeStatement(); + } + } + + return $deletedEntries; + } + private function cleanupOrphanedMounts(): int { $deletedEntries = 0; @@ -98,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 index e46ce29f08d..60e028f615e 100644 --- a/apps/files/lib/Command/Get.php +++ b/apps/files/lib/Command/Get.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command; @@ -41,8 +26,8 @@ class Get extends Command { $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"); + ->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 { @@ -63,7 +48,7 @@ class Get extends Command { $isTTY = stream_isatty(STDOUT); if ($outputName === null && $isTTY && $node->getMimePart() !== 'text') { $output->writeln([ - "<error>Warning: Binary output can mess up your terminal</error>", + '<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" ]); diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php index af97563c816..29dd8860b2a 100644 --- a/apps/files/lib/Command/Move.php +++ b/apps/files/lib/Command/Move.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command; @@ -35,10 +20,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; class Move extends Command { - private FileUtils $fileUtils; - - public function __construct(FileUtils $fileUtils) { - $this->fileUtils = $fileUtils; + public function __construct( + private FileUtils $fileUtils, + ) { parent::__construct(); } @@ -46,8 +30,8 @@ class Move extends Command { $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") + ->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"); } @@ -103,7 +87,7 @@ class Move extends Command { /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion("<info>" . $targetInput . "</info> already exists, overwrite? [y/N] ", false); + $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false); if (!$helper->ask($input, $output, $question)) { return 1; } diff --git a/apps/files/lib/Command/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php index 527292725ab..07613ecc616 100644 --- a/apps/files/lib/Command/Object/Delete.php +++ b/apps/files/lib/Command/Object/Delete.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command\Object; @@ -42,13 +27,13 @@ class Delete extends Command { $this ->setName('files:object:delete') ->setDescription('Delete an object from the object store') - ->addArgument('object', InputArgument::REQUIRED, "Object to delete") + ->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); + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); if (!$objectStore) { return -1; } @@ -56,7 +41,7 @@ class Delete extends Command { 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(""); + $output->writeln(''); } if (!$objectStore->objectExists($object)) { diff --git a/apps/files/lib/Command/Object/Get.php b/apps/files/lib/Command/Object/Get.php index dfd44341638..c32de020c5a 100644 --- a/apps/files/lib/Command/Object/Get.php +++ b/apps/files/lib/Command/Object/Get.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command\Object; @@ -40,15 +25,15 @@ class Get extends Command { $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") + ->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); + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); if (!$objectStore) { return self::FAILURE; } 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 index 5d278cdf668..5f053c2c42f 100644 --- a/apps/files/lib/Command/Object/ObjectUtil.php +++ b/apps/files/lib/Command/Object/ObjectUtil.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command\Object; @@ -27,6 +12,7 @@ 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 { @@ -56,19 +42,19 @@ class ObjectUtil { public function getObjectStore(?string $bucket, OutputInterface $output): ?IObjectStore { $config = $this->getObjectStoreConfig(); if (!$config) { - $output->writeln("<error>Instance is not using primary object store</error>"); + $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."); + $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"); + throw new \Exception('no arguments configured for object store configuration'); } if (!isset($config['class'])) { - throw new \Exception("no class configured for object store configuration"); + throw new \Exception('no class configured for object store configuration'); } if ($bucket) { @@ -80,7 +66,7 @@ class ObjectUtil { $store = new $config['class']($config['arguments']); if (!$store instanceof IObjectStore) { - throw new \Exception("configured object store class is not an object store implementation"); + throw new \Exception('configured object store class is not an object store implementation'); } return $store; } @@ -106,4 +92,24 @@ class ObjectUtil { 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 index b4a7389fb82..8516eb51183 100644 --- a/apps/files/lib/Command/Object/Put.php +++ b/apps/files/lib/Command/Object/Put.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command\Object; @@ -44,8 +29,8 @@ class Put extends Command { $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") + ->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"); ; } @@ -53,7 +38,7 @@ class Put extends Command { 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); + $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); if (!$objectStore) { return -1; } @@ -61,11 +46,11 @@ class Put extends Command { 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(""); + $output->writeln(''); /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion("Write to the object anyway? [y/N] ", false); + $question = new ConfirmationQuestion('Write to the object anyway? [y/N] ', false); if (!$helper->ask($input, $output, $question)) { return -1; } diff --git a/apps/files/lib/Command/Put.php b/apps/files/lib/Command/Put.php index 5539c25665a..fd9d75db78c 100644 --- a/apps/files/lib/Command/Put.php +++ b/apps/files/lib/Command/Put.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Command; @@ -44,8 +29,8 @@ class Put extends Command { $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"); + ->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 { diff --git a/apps/files/lib/Command/RepairTree.php b/apps/files/lib/Command/RepairTree.php index 7e7c40b4e00..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; @@ -50,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(); 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 31c410241cf..b9057139b0e 100644 --- a/apps/files/lib/Command/Scan.php +++ b/apps/files/lib/Command/Scan.php @@ -1,36 +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 Maxence Lange <maxence@artificial-owl.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: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files\Command; @@ -38,6 +11,8 @@ use OC\Core\Command\Base; use OC\Core\Command\InterruptedException; use OC\DB\Connection; use OC\DB\ConnectionAdapter; +use OC\Files\Storage\Wrapper\Jail; +use OC\Files\Utils\Scanner; use OC\FilesMetadata\FilesMetadataManager; use OC\ForbiddenException; use OCP\EventDispatcher\IEventDispatcher; @@ -50,6 +25,8 @@ 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; @@ -90,7 +67,7 @@ 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( @@ -123,17 +100,25 @@ class Scan extends Base { ); } - protected function scanFiles(string $user, string $path, ?string $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(); @@ -147,29 +132,29 @@ class Scan extends Base { } }); - $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 () { + $this->eventDispatcher->addListener(NodeAddedToCache::class, function (): void { ++$this->newCounter; }); - $this->eventDispatcher->addListener(FileCacheUpdated::class, function () { + $this->eventDispatcher->addListener(FileCacheUpdated::class, function (): void { ++$this->updatedCounter; }); - $this->eventDispatcher->addListener(NodeRemovedFromCache::class, function () { + $this->eventDispatcher->addListener(NodeRemovedFromCache::class, function (): void { ++$this->removedCounter; }); @@ -177,7 +162,7 @@ class Scan extends Base { 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>"); @@ -190,6 +175,12 @@ class Scan extends Base { } 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>'); @@ -197,7 +188,7 @@ class Scan extends Base { } } - public function filterHomeMount(IMountPoint $mountPoint): bool { + public function isHomeMount(IMountPoint $mountPoint): bool { // any mountpoint inside '/$user/files/' return substr_count($mountPoint->getMountPoint(), '/') <= 3; } @@ -229,6 +220,29 @@ class Scan extends Base { $metadata = $input->getOption('generate-metadata') ?? ''; } + $homeOnly = $input->getOption('home-only'); + $scannedStorages = []; + $mountFilter = function (IMountPoint $mount) use ($homeOnly, &$scannedStorages) { + if ($homeOnly && !$this->isHomeMount($mount)) { + return false; + } + + // when scanning multiple users, the scanner might encounter the same storage multiple times (e.g. external storages, or group folders) + // we can filter out any storage we've already scanned to avoid double work + $storage = $mount->getStorage(); + $storageKey = $storage->getId(); + while ($storage->instanceOfStorage(Jail::class)) { + $storageKey .= '/' . $storage->getUnjailedPath(''); + $storage = $storage->getUnjailedStorage(); + } + if (array_key_exists($storageKey, $scannedStorages)) { + return false; + } + + $scannedStorages[$storageKey] = true; + return true; + }; + $user_count = 0; foreach ($users as $user) { if (is_object($user)) { @@ -238,7 +252,15 @@ class Scan extends Base { ++$user_count; if ($this->userManager->userExists($user)) { $output->writeln("Starting scan for user $user_count out of $users_total ($user)"); - $this->scanFiles($user, $path, $metadata, $output, $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>"); @@ -264,8 +286,8 @@ class Scan extends Base { $this->execTime = -microtime(true); // Convert PHP errors to exceptions set_error_handler( - fn (int $severity, string $message, string $file, int $line): bool => - $this->exceptionErrorHandler($output, $severity, $message, $file, $line), + fn (int $severity, string $message, string $file, int $line): bool + => $this->exceptionErrorHandler($output, $severity, $message, $file, $line), E_ALL ); } @@ -336,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 0ba26490a78..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,13 +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; @@ -72,7 +52,7 @@ class ScanAppData extends Base { protected function scanFiles(OutputInterface $output, string $folder): int { try { - /** @var \OCP\Files\Folder $appData */ + /** @var Folder $appData */ $appData = $this->getAppDataFolder(); } catch (NotFoundException $e) { $output->writeln('<error>NoAppData folder found</error>'); @@ -89,31 +69,31 @@ class ScanAppData extends Base { } $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>'); }); @@ -234,8 +214,8 @@ class ScanAppData extends Base { } 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) { @@ -262,6 +242,6 @@ class ScanAppData extends Base { throw new NotFoundException(); } - return $this->rootFolder->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 64f7fbbb95e..f7663e26f28 100644 --- a/apps/files/lib/Command/TransferOwnership.php +++ b/apps/files/lib/Command/TransferOwnership.php @@ -1,55 +1,36 @@ <?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 Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; class TransferOwnership extends Command { public function __construct( private IUserManager $userManager, private OwnershipTransferService $transferService, private IConfig $config, + private IMountManager $mountManager, ) { parent::__construct(); } @@ -83,8 +64,18 @@ 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', ); } @@ -103,48 +94,52 @@ class TransferOwnership extends Command { $destinationUserObject = $this->userManager->get($input->getArgument('destination-user')); if (!$sourceUserObject instanceof IUser) { - $output->writeln("<error>Unknown source user " . $input->getArgument('source-user') . "</error>"); + $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>"); + $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>"); + $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 self::FAILURE; + } } + } + 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>"); + $output->writeln('<error>' . $e->getMessage() . '</error>'); return $e->getCode() !== 0 ? $e->getCode() : self::FAILURE; } 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 285857c9a19..8bb024fb698 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -1,49 +1,26 @@ <?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; @@ -52,56 +29,53 @@ use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\StreamResponse; use OCP\Files\File; use OCP\Files\Folder; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\Storage\ISharedStorage; +use OCP\Files\StorageNotAvailableException; use OCP\IConfig; +use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; +use OCP\IUser; use OCP\IUserSession; +use OCP\PreConditionNotMetException; use OCP\Share\IManager; use OCP\Share\IShare; +use Psr\Log\LoggerInterface; +use Throwable; /** + * @psalm-import-type FilesFolderTree from ResponseDefinitions + * * @package OCA\Files\Controller */ class ApiController extends Controller { - private TagService $tagService; - private IManager $shareManager; - private IPreview $previewManager; - private IUserSession $userSession; - private IConfig $config; - private ?Folder $userFolder; - private UserConfig $userConfig; - private ViewConfig $viewConfig; - - public function __construct(string $appName, + public function __construct( + string $appName, IRequest $request, - IUserSession $userSession, - TagService $tagService, - IPreview $previewManager, - IManager $shareManager, - IConfig $config, - ?Folder $userFolder, - UserConfig $userConfig, - ViewConfig $viewConfig) { + 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; - $this->viewConfig = $viewConfig; } /** * Gets a thumbnail of the specified file * * @since API version 1.0 - * - * @NoAdminRequired - * @NoCSRFRequired - * @StrictCookieRequired + * @deprecated 32.0.0 Use the preview endpoint provided by core instead * * @param int $x Width of the thumbnail * @param int $y Height of the thumbnail @@ -112,22 +86,39 @@ class ApiController extends Controller { * 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); @@ -139,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); @@ -177,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])) { @@ -247,40 +237,114 @@ 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 + * + * @return JSONResponse<Http::STATUS_OK, FilesFolderTree, array{}>|JSONResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * - * @NoAdminRequired + * 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; } /** * Set a user view config * - * @NoAdminRequired - * * @param string $view * @param string $key * @param string|bool $value * @return JSONResponse */ + #[NoAdminRequired] public function setViewConfig(string $view, string $key, $value): JSONResponse { try { $this->viewConfig->setConfig($view, $key, (string)$value); @@ -295,10 +359,9 @@ class ApiController extends Controller { /** * Get the user view config * - * @NoAdminRequired - * * @return JSONResponse */ + #[NoAdminRequired] public function getViewConfigs(): JSONResponse { return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfigs()]); } @@ -306,12 +369,11 @@ class ApiController extends Controller { /** * Set a user config * - * @NoAdminRequired - * * @param string $key * @param string|bool $value * @return JSONResponse */ + #[NoAdminRequired] public function setConfig(string $key, $value): JSONResponse { try { $this->userConfig->setConfig($key, (string)$value); @@ -326,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()]); } @@ -337,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(); @@ -351,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(); @@ -365,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(); @@ -378,19 +436,15 @@ 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]); } - /** - * @NoAdminRequired - * @NoCSRFRequired - * @PublicPage - */ + #[PublicPage] + #[NoCSRFRequired] #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] public function serviceWorker(): StreamResponse { $response = new StreamResponse(__DIR__ . '/../../../../dist/preview-service-worker.js'); 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 5d2162c69e0..c8addc33e98 100644 --- a/apps/files/lib/Controller/DirectEditingController.php +++ b/apps/files/lib/Controller/DirectEditingController.php @@ -1,30 +1,15 @@ <?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; @@ -45,19 +30,18 @@ class DirectEditingController extends OCSController { private IURLGenerator $urlGenerator, private IManager $directEditingManager, private DirectEditingService $directEditingService, - private LoggerInterface $logger + private LoggerInterface $logger, ) { parent::__construct($appName, $request, $corsMethods, $corsAllowedHeaders, $corsMaxAge); } /** - * @NoAdminRequired - * * Get the direct editing capabilities - * @return DataResponse<Http::STATUS_OK, array{editors: array<string, array{id: string, name: string, mimetypes: string[], optionalMimetypes: string[], secure: bool}>, creators: array<string, array{id: string, editor: string, name: string, extension: string, templates: bool, mimetypes: string[]}>}, array{}> + * @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()); @@ -65,8 +49,6 @@ class DirectEditingController extends OCSController { } /** - * @NoAdminRequired - * * Create a file for direct editing * * @param string $path Path of the file @@ -79,7 +61,8 @@ class DirectEditingController extends OCSController { * 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); } @@ -102,8 +85,6 @@ class DirectEditingController extends OCSController { } /** - * @NoAdminRequired - * * Open a file for direct editing * * @param string $path Path of the file @@ -115,7 +96,8 @@ class DirectEditingController extends OCSController { * 200: URL for direct editing returned * 403: Opening file is not allowed */ - public function open(string $path, string $editorId = null, ?int $fileId = 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); } @@ -140,8 +122,6 @@ class DirectEditingController extends OCSController { /** - * @NoAdminRequired - * * Get the templates for direct editing * * @param string $editorId ID of the editor @@ -151,6 +131,7 @@ class DirectEditingController extends OCSController { * * 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); diff --git a/apps/files/lib/Controller/DirectEditingViewController.php b/apps/files/lib/Controller/DirectEditingViewController.php index fcd570b9aac..b13e68f7766 100644 --- a/apps/files/lib/Controller/DirectEditingViewController.php +++ b/apps/files/lib/Controller/DirectEditingViewController.php @@ -1,30 +1,17 @@ <?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; @@ -46,13 +33,12 @@ class DirectEditingViewController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired - * @UseSession - * * @param string $token * @return Response */ + #[PublicPage] + #[NoCSRFRequired] + #[UseSession] public function edit(string $token): Response { $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); try { diff --git a/apps/files/lib/Controller/OpenLocalEditorController.php b/apps/files/lib/Controller/OpenLocalEditorController.php index d9fb80f2d2b..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,42 +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, array<empty>, array{}> + * @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); @@ -113,19 +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, array<empty>, array{}> + * @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 69b6a1a42d9..ee4c86941c7 100644 --- a/apps/files/lib/Controller/TemplateController.php +++ b/apps/files/lib/Controller/TemplateController.php @@ -3,106 +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, array<FilesTemplateFileCreator>, array{}> + * @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())); } /** - * @NoAdminRequired + * List the fields for the template specified by the given file ID + * + * @param int $fileId File ID of the template + * @return DataResponse<Http::STATUS_OK, array<string, FilesTemplateField>, array{}> * + * 200: Fields returned + */ + #[NoAdminRequired] + public function listTemplateFields(int $fileId): DataResponse { + $fields = $this->templateManager->listTemplateFields($fileId); + + return new DataResponse( + array_merge([], ...$fields), + Http::STATUS_OK + ); + } + + /** * Create a template * * @param string $filePath Path of the file * @param string $templatePath Name of the template * @param string $templateType Type of the template + * @param list<FilesTemplateField> $templateFields Fields of the template * * @return DataResponse<Http::STATUS_OK, FilesTemplateFile, array{}> * @throws OCSForbiddenException Creating template is not allowed * * 200: Template created successfully */ - 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: FilesTemplateFileCreator[]}, array{}> + * @return DataResponse<Http::STATUS_OK, array{template_path: string, templates: list<FilesTemplateFileCreator>}, array{}> * @throws OCSForbiddenException Initializing the template directory is not allowed * * 200: Template directory initialized successfully */ + #[NoAdminRequired] public function path(string $templatePath = '', bool $copySystemTemplates = false) { try { /** @var string $templatePath */ $templatePath = $this->templateManager->initializeTemplateDirectory($templatePath, null, $copySystemTemplates); return new DataResponse([ 'template_path' => $templatePath, - 'templates' => array_map(fn (TemplateFileCreator $creator) => $creator->jsonSerialize(), $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 2c46b85f9a0..51a25400efb 100644 --- a/apps/files/lib/Controller/TransferOwnershipController.php +++ b/apps/files/lib/Controller/TransferOwnershipController.php @@ -3,27 +3,8 @@ 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; @@ -32,6 +13,7 @@ use OCA\Files\Db\TransferOwnership as TransferOwnershipEntity; use OCA\Files\Db\TransferOwnershipMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\AppFramework\Utility\ITimeFactory; @@ -44,56 +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, + public function __construct( + string $appName, IRequest $request, - string $userId, - NotificationManager $notificationManager, - ITimeFactory $timeFactory, - IJobList $jobList, - TransferOwnershipMapper $mapper, - IUserManager $userManager, - IRootFolder $rootFolder) { + 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, array<empty>, array{}> + * @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); @@ -137,18 +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, array<empty>, array{}> + * @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); @@ -160,38 +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, array<empty>, array{}> + * @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); @@ -208,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 b8090e1cf29..ecf21cef313 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -1,57 +1,33 @@ <?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\Constants; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; use OCP\Files\IRootFolder; @@ -62,237 +38,178 @@ use OCP\IL10N; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; -use OCP\Share\IManager; +use OCP\Util; /** * @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; - private ViewConfig $viewConfig; - - 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, - ViewConfig $viewConfig + 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; - $this->viewConfig = $viewConfig; - } - - /** - * @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 = '/') { - $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 */ - public function showFile(string $fileid = null): 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((int) $fileid); + 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'])); } } /** - * @NoCSRFRequired - * @NoAdminRequired - * @UseSession - * * @param string $dir * @param string $view * @param string $fileid - * @param bool $fileNotFound * @return TemplateResponse|RedirectResponse */ - public function indexView($dir = '', $view = '', $fileid = null, $fileNotFound = false) { - return $this->index($dir, $view, $fileid, $fileNotFound); + #[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 * @return TemplateResponse|RedirectResponse */ - public function indexViewFileid($dir = '', $view = '', $fileid = null, $fileNotFound = false) { - return $this->index($dir, $view, $fileid, $fileNotFound); + #[NoAdminRequired] + #[NoCSRFRequired] + public function indexViewFileid($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 * @return TemplateResponse|RedirectResponse */ - public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false) { + #[NoAdminRequired] + #[NoCSRFRequired] + public function index($dir = '', $view = '', $fileid = null) { if ($fileid !== null && $view !== 'trashbin') { try { - return $this->redirectToFileIfInTrashbin((int) $fileid); + return $this->redirectToFileIfInTrashbin((int)$fileid); } catch (NotFoundException $e) { } } // Load the files we need - \OCP\Util::addInitScript('files', 'init'); - \OCP\Util::addStyle('files', 'merged'); - \OCP\Util::addScript('files', 'main'); - - $userId = $this->userSession->getUser()->getUID(); + Util::addInitScript('files', 'init'); + Util::addScript('files', 'main'); - // Get all the user favorites to create a submenu - try { - $favElements = $this->activityHelper->getFavoriteFilePaths($userId); - } catch (\RuntimeException $e) { - $favElements['folders'] = []; - } + $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); + $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); + return $this->redirectToFile((int)$fileid); } - } else { // fileid does not exist anywhere - $fileNotFound = true; } } 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) { + } catch (\Exception $e) { $storageInfo = $this->getStorageInfo(); } $this->initialState->provideInitialState('storageStats', $storageInfo); $this->initialState->provideInitialState('config', $this->userConfig->getConfigs()); $this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs()); - $this->initialState->provideInitialState('favoriteFolders', $favElements['folders'] ?? []); // File sorting user config $filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true); $this->initialState->provideInitialState('filesSortingConfig', $filesSortingConfig); - // Forbidden file characters - /** @var string[] */ - $forbiddenCharacters = $this->config->getSystemValue('forbidden_chars', []); - $this->initialState->provideInitialState('forbiddenCharacters', Constants::FILENAME_INVALID_CHARS . implode('', $forbiddenCharacters)); + // Forbidden file characters (deprecated use capabilities) + // TODO: Remove with next release of `@nextcloud/files` + $forbiddenCharacters = $this->filenameValidator->getForbiddenCharacters(); + $this->initialState->provideInitialState('forbiddenCharacters', $forbiddenCharacters); $event = new LoadAdditionalScriptsEvent(); $this->eventDispatcher->dispatchTyped($event); $this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent()); $this->eventDispatcher->dispatchTyped(new LoadSidebar()); + $this->eventDispatcher->dispatchTyped(new LoadSearchPlugins()); // Load Viewer scripts if (class_exists(LoadViewer::class)) { $this->eventDispatcher->dispatchTyped(new LoadViewer()); } + $this->initialState->provideInitialState('templates_enabled', ($this->config->getSystemValueString('skeletondirectory', \OC::$SERVERROOT . '/core/skeleton') !== '') || ($this->config->getSystemValueString('templatedirectory', \OC::$SERVERROOT . '/core/skeleton/Templates') !== '')); $this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false); $this->initialState->provideInitialState('templates', $this->templateManager->listCreators()); - $params = [ - 'fileNotFound' => $fileNotFound ? 1 : 0, - 'id-app-content' => '#app-content-vue', - 'id-app-navigation' => '#app-navigation-vue', - ]; + $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\''); @@ -300,62 +217,10 @@ class ViewController extends Controller { $policy->addAllowedWorkerSrcDomain('\'self\''); $response->setContentSecurityPolicy($policy); - $this->provideInitialState($dir, $fileid); - return $response; } /** - * Add openFileInfo in initialState. - * @param string $dir - the ?dir= URL param - * @param string $fileid - the fileid URL param - * @return void - */ - private function provideInitialState(string $dir, ?string $fileid): void { - if ($fileid === null) { - return; - } - - $user = $this->userSession->getUser(); - - if ($user === null) { - return; - } - - $uid = $user->getUID(); - $userFolder = $this->rootFolder->getUserFolder($uid); - $nodes = $userFolder->getById((int) $fileid); - $node = array_shift($nodes); - - if ($node === null) { - return; - } - - // 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()); - - // Prevent opening a file from another folder. - if ($dir !== $directory) { - return; - } - - $this->initialState->provideInitialState( - 'openFileInfo', [ - 'id' => $node->getId(), - 'name' => $isRoot ? '' : $node->getName(), - 'path' => $path, - 'directory' => $directory, - 'mime' => $node->getMimetype(), - 'type' => $node->getType(), - 'permissions' => $node->getPermissions(), - ] - ); - } - - /** * Redirects to the trashbin file list and highlight the given file id * * @param int $fileId file id to show @@ -365,17 +230,16 @@ class ViewController extends Controller { private function redirectToFileIfInTrashbin($fileId): RedirectResponse { $uid = $this->userSession->getUser()->getUID(); $baseFolder = $this->rootFolder->getUserFolder($uid); - $nodes = $baseFolder->getById($fileId); + $node = $baseFolder->getFirstNodeById($fileId); $params = []; - if (empty($nodes) && $this->appManager->isEnabledForUser('files_trashbin')) { + if (!$node && $this->appManager->isEnabledForUser('files_trashbin')) { /** @var Folder */ $baseFolder = $this->rootFolder->get($uid . '/files_trashbin/files/'); - $nodes = $baseFolder->getById($fileId); + $node = $baseFolder->getFirstNodeById($fileId); $params['view'] = 'trashbin'; - if (!empty($nodes)) { - $node = current($nodes); + if ($node) { $params['fileid'] = $fileId; if ($node instanceof Folder) { // set the full path to enter the folder @@ -394,13 +258,15 @@ class ViewController extends Controller { * Redirects to the file list and highlight the given file id * * @param int $fileId file id to show + * @param string|null $openDetails open details parameter + * @param string|null $openFile open file parameter * @return RedirectResponse redirect response or not found response * @throws NotFoundException */ - private function redirectToFile(int $fileId) { + private function redirectToFile(int $fileId, ?string $openDetails = null, ?string $openFile = null): RedirectResponse { $uid = $this->userSession->getUser()->getUID(); $baseFolder = $this->rootFolder->getUserFolder($uid); - $nodes = $baseFolder->getById($fileId); + $node = $baseFolder->getFirstNodeById($fileId); $params = ['view' => 'files']; try { @@ -408,8 +274,7 @@ class ViewController extends Controller { } catch (NotFoundException $e) { } - if (!empty($nodes)) { - $node = current($nodes); + if ($node) { $params['fileid'] = $fileId; if ($node instanceof Folder) { // set the full path to enter the folder @@ -417,7 +282,22 @@ class ViewController extends Controller { } else { // set parent path as dir $params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath()); + // open the file by default (opening the viewer) + $params['openfile'] = 'true'; } + + // Forward open parameters if any. + // - openfile is true by default + // - opendetails is undefined by default + // - both will be evaluated as truthy + if ($openDetails !== null) { + $params['opendetails'] = $openDetails !== 'false' ? 'true' : 'false'; + } + + if ($openFile !== null) { + $params['openfile'] = $openFile !== 'false' ? 'true' : 'false'; + } + return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params)); } 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 00988adc9cc..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; 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 e195a5182a0..8b29399f768 100644 --- a/apps/files/lib/Db/TransferOwnershipMapper.php +++ b/apps/files/lib/Db/TransferOwnershipMapper.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\Db; diff --git a/apps/files/lib/DirectEditingCapabilities.php b/apps/files/lib/DirectEditingCapabilities.php index 1bc00519ae8..5bceef9305f 100644 --- a/apps/files/lib/DirectEditingCapabilities.php +++ b/apps/files/lib/DirectEditingCapabilities.php @@ -2,25 +2,8 @@ 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; @@ -30,12 +13,10 @@ 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, + ) { } /** diff --git a/apps/files/lib/Event/LoadAdditionalScriptsEvent.php b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php index 2e9e4764807..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; 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/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 index 121ff745065..b7470e5acf5 100644 --- a/apps/files/lib/Listener/RenderReferenceEventListener.php +++ b/apps/files/lib/Listener/RenderReferenceEventListener.php @@ -2,24 +2,8 @@ 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\Listener; @@ -27,13 +11,15 @@ 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; } - \OCP\Util::addScript('files', 'reference-files'); + Util::addScript('files', 'reference-files'); } } diff --git a/apps/files/lib/Listener/SyncLivePhotosListener.php b/apps/files/lib/Listener/SyncLivePhotosListener.php index b188ad24073..b6773e8c452 100644 --- a/apps/files/lib/Listener/SyncLivePhotosListener.php +++ b/apps/files/lib/Listener/SyncLivePhotosListener.php @@ -2,92 +2,116 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Listener; -use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent; -use OCA\Files_Trashbin\Trash\ITrashItem; -use OCA\Files_Trashbin\Trash\ITrashManager; +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\Files\NotPermittedException; -use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException; use OCP\FilesMetadata\IFilesMetadataManager; -use OCP\IUserSession; /** * @template-implements IEventListener<Event> */ class SyncLivePhotosListener implements IEventListener { - /** @var Array<int, string> */ + /** @var Array<int> */ private array $pendingRenames = []; /** @var Array<int, bool> */ private array $pendingDeletion = []; - /** @var Array<int, bool> */ - private array $pendingRestores = []; + /** @var Array<int> */ + private array $pendingCopies = []; public function __construct( private ?Folder $userFolder, - private ?IUserSession $userSession, - private ITrashManager $trashManager, private IFilesMetadataManager $filesMetadataManager, + private LivePhotosService $livePhotosService, + private IRootFolder $rootFolder, + private View $view, ) { } public function handle(Event $event): void { - if ($this->userFolder === null || $this->userSession === null) { + if ($this->userFolder === null) { return; } - $peerFile = null; + 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 ($event instanceof BeforeNodeRenamedEvent) { - $peerFile = $this->getLivePhotoPeer($event->getSource()->getId()); - } elseif ($event instanceof BeforeNodeRestoredEvent) { - $peerFile = $this->getLivePhotoPeer($event->getSource()->getId()); - } elseif ($event instanceof BeforeNodeDeletedEvent) { - $peerFile = $this->getLivePhotoPeer($event->getNode()->getId()); - } elseif ($event instanceof CacheEntryRemovedEvent) { - $peerFile = $this->getLivePhotoPeer($event->getFileId()); + if (!str_ends_with($targetName, '.' . $sourceExtension)) { + throw new AbortedEventException('Cannot change the extension of a Live Photo'); } - if ($peerFile === null) { - return; // not a Live Photo + try { + $targetParent->get($targetName); + throw new AbortedEventException('A file already exist at destination path of the Live Photo'); + } catch (NotFoundException) { } - if ($event instanceof BeforeNodeRenamedEvent) { - $this->handleMove($event, $peerFile); - } elseif ($event instanceof BeforeNodeDeletedEvent) { - $this->handleDeletion($event, $peerFile); - } elseif ($event instanceof CacheEntryRemovedEvent) { - $peerFile->delete(); - } elseif ($event instanceof BeforeNodeRestoredEvent) { - $this->handleRestore($event, $peerFile); + if (!($targetParent instanceof NonExistingFolder)) { + try { + $targetParent->get($peerTargetName); + throw new AbortedEventException('A file already exist at destination path of the Live Photo'); + } catch (NotFoundException) { + } } } @@ -98,44 +122,57 @@ class SyncLivePhotosListener implements IEventListener { * of pending renames inside the 'pendingRenames' property, * to prevent infinite recursive. */ - private function handleMove(BeforeNodeRenamedEvent $event, Node $peerFile): void { - $sourceFile = $event->getSource(); - $targetFile = $event->getTarget(); + private function handleMove(Node $sourceFile, Node $targetFile, Node $peerFile): void { $targetParent = $targetFile->getParent(); $sourceExtension = $sourceFile->getExtension(); $peerFileExtension = $peerFile->getExtension(); $targetName = $targetFile->getName(); - $targetPath = $targetFile->getPath(); - - if (!str_ends_with($targetName, ".".$sourceExtension)) { - $event->abortOperation(new NotPermittedException("Cannot change the extension of a Live Photo")); - } - - try { - $targetParent->get($targetName); - $event->abortOperation(new NotPermittedException("A file already exist at destination path of the Live Photo")); - } catch (NotFoundException $ex) { - } - $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension; - try { - $targetParent->get($peerTargetName); - $event->abortOperation(new NotPermittedException("A file already exist at destination path of the Live Photo")); - } catch (NotFoundException $ex) { - } // in case the rename was initiated from this listener, we stop right now - if (array_key_exists($peerFile->getId(), $this->pendingRenames)) { + if (in_array($peerFile->getId(), $this->pendingRenames)) { return; } - $this->pendingRenames[$sourceFile->getId()] = $targetPath; + $this->pendingRenames[] = $sourceFile->getId(); try { $peerFile->move($targetParent->getPath() . '/' . $peerTargetName); } catch (\Throwable $ex) { - $event->abortOperation($ex); + throw new AbortedEventException($ex->getMessage()); } - unset($this->pendingRenames[$sourceFile->getId()]); + + $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); } /** @@ -152,126 +189,66 @@ class SyncLivePhotosListener implements IEventListener { unset($this->pendingDeletion[$peerFile->getId()]); return; } else { - $event->abortOperation(new NotPermittedException("Cannot delete the video part of a live photo")); + throw new AbortedEventException('Cannot delete the video part of a live photo'); } } else { $this->pendingDeletion[$deletedFile->getId()] = true; try { $peerFile->delete(); } catch (\Throwable $ex) { - $event->abortOperation($ex); + throw new AbortedEventException($ex->getMessage()); } } return; } - /** - * During restore event, we trigger another recursive restore on the peer file. - * Restore operations on the .mov file directly are currently blocked. - * The event listener being singleton, we can store the current state - * of pending restores inside the 'pendingRestores' property, - * to prevent infinite recursivity. + /* + * Recursively get all the peer ids of a live photo. + * Needed when coping a folder. + * + * @param BeforeNodeCopiedEvent|NodeCopiedEvent $event */ - private function handleRestore(BeforeNodeRestoredEvent $event, Node $peerFile): void { - $sourceFile = $event->getSource(); + 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'); + } - if ($sourceFile->getMimetype() === 'video/quicktime') { - if (isset($this->pendingRestores[$peerFile->getId()])) { - unset($this->pendingRestores[$peerFile->getId()]); - return; - } else { - $event->abortOperation(new NotPermittedException("Cannot restore the video part of a live photo")); + $this->handleCopyRecursive($event, $sourceChild, $targetChild); } - } else { - $user = $this->userSession->getUser(); - if ($user === null) { + } 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; } - $peerTrashItem = $this->trashManager->getTrashNodeById($user, $peerFile->getId()); - // Peer file is not in the bin, no need to restore it. - if ($peerTrashItem === null) { + $peerFileId = $this->livePhotosService->getLivePhotoPeerId($sourceNode->getId()); + if ($peerFileId === null) { return; } - - $trashRoot = $this->trashManager->listTrashRoot($user); - $trashItem = $this->getTrashItem($trashRoot, $peerFile->getInternalPath()); - - if ($trashItem === null) { - $event->abortOperation(new NotFoundException("Couldn't find peer file in trashbin")); - } - - $this->pendingRestores[$sourceFile->getId()] = true; - try { - $this->trashManager->restoreItem($trashItem); - } catch (\Throwable $ex) { - $event->abortOperation($ex); - } - } - } - - /** - * Helper method to get the associated live photo file. - * We first look for it in the user folder, and if we - * cannot find it here, we look for it in the user's trashbin. - */ - private function getLivePhotoPeer(int $nodeId): ?Node { - if ($this->userFolder === null || $this->userSession === null) { - return null; - } - - try { - $metadata = $this->filesMetadataManager->getMetadata($nodeId); - } catch (FilesMetadataNotFoundException $ex) { - return null; - } - - if (!$metadata->hasKey('files-live-photo')) { - return null; - } - - $peerFileId = (int)$metadata->getString('files-live-photo'); - - // Check the user's folder. - $nodes = $this->userFolder->getById($peerFileId); - if (count($nodes) !== 0) { - return $nodes[0]; - } - - // Check the user's trashbin. - $user = $this->userSession->getUser(); - if ($user !== null) { - $peerFile = $this->trashManager->getTrashNodeById($user, $peerFileId); - if ($peerFile !== null) { - return $peerFile; + $peerFile = $this->userFolder->getFirstNodeById($peerFileId); + if ($peerFile === null) { + return; } - } - $metadata->unset('files-live-photo'); - return null; - } - - /** - * There is currently no method to restore a file based on its fileId or path. - * So we have to manually find a ITrashItem from the trash item list. - * TODO: This should be replaced by a proper method in the TrashManager. - */ - private function getTrashItem(array $trashFolder, string $path): ?ITrashItem { - foreach($trashFolder as $trashItem) { - if (str_starts_with($path, "files_trashbin/files".$trashItem->getTrashPath())) { - if ($path === "files_trashbin/files".$trashItem->getTrashPath()) { - return $trashItem; - } - - if ($trashItem instanceof Folder) { - $node = $this->getTrashItem($trashItem->getDirectoryListing(), $path); - if ($node !== null) { - return $node; - } - } + $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'); } - - return null; } } 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 587616ff2d5..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; 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 0968ae15f54..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 index 5e565cc356d..c5d094e7bd8 100644 --- a/apps/files/lib/ResponseDefinitions.php +++ b/apps/files/lib/ResponseDefinitions.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 Kate Döen <kate.doeen@nextcloud.com> - * - * @author Kate Döen <kate.doeen@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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files; @@ -39,15 +22,54 @@ namespace OCA\Files; * 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, - * mimetypes: 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 b587fdf32de..f71d58c6fae 100644 --- a/apps/files/lib/Search/FilesSearchProvider.php +++ b/apps/files/lib/Search/FilesSearchProvider.php @@ -3,29 +3,8 @@ 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; @@ -56,29 +35,13 @@ use OCP\Search\SearchResultEntry; use OCP\Share\IShare; class FilesSearchProvider implements IFilteringProvider { - /** @var IL10N */ - private $l10n; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var IMimeTypeDetector */ - private $mimeTypeDetector; - - /** @var IRootFolder */ - private $rootFolder; - 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; } /** @@ -116,6 +79,7 @@ class FilesSearchProvider implements IFilteringProvider { 'max-size', 'mime', 'type', + 'path', 'is-favorite', 'title-only', ]; @@ -131,6 +95,7 @@ class FilesSearchProvider implements IFilteringProvider { 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), ]; } @@ -182,6 +147,7 @@ class FilesSearchProvider implements IFilteringProvider { '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'), }; @@ -190,7 +156,7 @@ class FilesSearchProvider implements IFilteringProvider { return new SearchQuery( new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, $comparisons), $query->getLimit(), - (int) $query->getCursor(), + (int)$query->getCursor(), $query->getSortOrder() === ISearchQuery::SORT_DATE_DESC ? [new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')] : [], 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 3499d809f2c..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, + public function transfer( + IUser $sourceUser, IUser $destinationUser, string $path, ?OutputInterface $output = null, bool $move = false, bool $firstLogin = false, - bool $transferIncomingShares = false): void { + bool $includeExternalStorage = false, + ): void { $output = $output ?? new NullOutput(); $sourceUid = $sourceUser->getUID(); $destinationUid = $destinationUser->getUID(); @@ -110,15 +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 // 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()); - \OC::$server->getUserFolder($destinationUser->getUID()); + $this->rootFolder->getUserFolder($sourceUser->getUID()); + $this->rootFolder->getUserFolder($destinationUser->getUID()); Filesystem::initMountPoints($sourceUid); Filesystem::initMountPoints($destinationUid); @@ -127,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])); } } @@ -153,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); } @@ -174,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)) { @@ -231,85 +214,130 @@ class OwnershipTransferService { /** * @param OutputInterface $output * - * @throws \Exception + * @throws TransferOwnershipException */ - protected function analyse(string $sourceUid, + protected function analyse( + string $sourceUid, string $destinationUid, string $sourcePath, View $view, - OutputInterface $output): void { + 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, + /** + * @return array<array{share: IShare, suffix: string}> + */ + private function collectUsersShares( + string $sourceUid, OutputInterface $output, View $view, - string $path): array { + 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, IShare::TYPE_SCIENCEMESH] 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; } }); @@ -321,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, + private function collectIncomingShares( + string $sourceUid, OutputInterface $output, - View $view, - bool $addKeys = false): array { + ?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) { @@ -340,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; @@ -362,11 +410,14 @@ class OwnershipTransferService { /** * @throws TransferOwnershipException */ - protected function transferFiles(string $sourceUid, + protected function transferFiles( + string $sourceUid, string $sourcePath, string $finalTarget, View $view, - OutputInterface $output): void { + OutputInterface $output, + bool $includeExternalStorage, + ): void { $output->writeln("Transferring files to $finalTarget ..."); // This change will help user to transfer the folder specified using --path option. @@ -375,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, + 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) { - $output->writeln("Restoring 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) { @@ -409,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); @@ -419,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>'); @@ -443,11 +550,11 @@ class OwnershipTransferService { string $path, string $finalTarget, bool $move): void { - $output->writeln("Restoring incoming shares ..."); + $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) { @@ -457,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()]; @@ -504,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 e29848bf21d..63c54d01fd0 100644 --- a/apps/files/lib/Service/TagService.php +++ b/apps/files/lib/Service/TagService.php @@ -1,37 +1,16 @@ <?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\EventDispatcher\IEventDispatcher; -use OCP\Files\Events\NodeAddedToFavorite; -use OCP\Files\Events\NodeRemovedFromFavorite; use OCP\Files\Folder; +use OCP\Files\NotFoundException; use OCP\ITags; -use OCP\IUser; use OCP\IUserSession; /** @@ -39,29 +18,12 @@ use OCP\IUserSession; */ class TagService { - /** @var IUserSession */ - private $userSession; - /** @var IManager */ - private $activityManager; - /** @var ITags|null */ - private $tagger; - /** @var Folder|null */ - private $homeFolder; - /** @var IEventDispatcher */ - private $dispatcher; - public function __construct( - IUserSession $userSession, - IManager $activityManager, - ?ITags $tagger, - ?Folder $homeFolder, - IEventDispatcher $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; } /** @@ -70,9 +32,9 @@ 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) { @@ -92,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); } @@ -109,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; - } - - if ($addToFavorite) { - $event = new NodeAddedToFavorite($user, $fileId, $path); - } else { - $event = new NodeRemovedFromFavorite($user, $fileId, $path); - } - $this->dispatcher->dispatchTyped($event); - - $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 00569dc6aeb..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; @@ -36,30 +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 show the files list in grid view or not - 'key' => 'grid_view', - 'default' => 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(); } @@ -119,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'); } diff --git a/apps/files/lib/Service/ViewConfig.php b/apps/files/lib/Service/ViewConfig.php index fefc3f6a7de..cf8bebd5372 100644 --- a/apps/files/lib/Service/ViewConfig.php +++ b/apps/files/lib/Service/ViewConfig.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Service; @@ -50,12 +34,12 @@ class ViewConfig { '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(); } @@ -120,7 +104,7 @@ class ViewConfig { 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'); @@ -149,7 +133,7 @@ class ViewConfig { $userId = $this->user->getUID(); $configs = json_decode($this->config->getUserValue($userId, Application::APP_ID, self::CONFIG_KEY, '[]'), true); - + if (!isset($configs[$view])) { $configs[$view] = []; } @@ -175,7 +159,7 @@ class ViewConfig { $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'); } |