aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/lib')
-rw-r--r--apps/files/lib/Activity/FavoriteProvider.php150
-rw-r--r--apps/files/lib/Activity/Filter/Favorites.php130
-rw-r--r--apps/files/lib/Activity/Filter/FileChanges.php78
-rw-r--r--apps/files/lib/Activity/Helper.php88
-rw-r--r--apps/files/lib/Activity/Provider.php526
-rw-r--r--apps/files/lib/Activity/Settings/FavoriteAction.php75
-rw-r--r--apps/files/lib/Activity/Settings/FileActivitySettings.php30
-rw-r--r--apps/files/lib/Activity/Settings/FileChanged.php51
-rw-r--r--apps/files/lib/Activity/Settings/FileFavoriteChanged.php75
-rw-r--r--apps/files/lib/AdvancedCapabilities.php38
-rw-r--r--apps/files/lib/App.php56
-rw-r--r--apps/files/lib/AppInfo/Application.php145
-rw-r--r--apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php33
-rw-r--r--apps/files/lib/BackgroundJob/CleanupFileLocks.php40
-rw-r--r--apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php39
-rw-r--r--apps/files/lib/BackgroundJob/DeleteOrphanedItems.php177
-rw-r--r--apps/files/lib/BackgroundJob/ScanFiles.php143
-rw-r--r--apps/files/lib/BackgroundJob/TransferOwnership.php145
-rw-r--r--apps/files/lib/Capabilities.php51
-rw-r--r--apps/files/lib/Collaboration/Resources/Listener.php33
-rw-r--r--apps/files/lib/Collaboration/Resources/ResourceProvider.php109
-rw-r--r--apps/files/lib/Command/Copy.php116
-rw-r--r--apps/files/lib/Command/Delete.php99
-rw-r--r--apps/files/lib/Command/DeleteOrphanedFiles.php168
-rw-r--r--apps/files/lib/Command/Get.php71
-rw-r--r--apps/files/lib/Command/Move.php106
-rw-r--r--apps/files/lib/Command/Object/Delete.php60
-rw-r--r--apps/files/lib/Command/Object/Get.php63
-rw-r--r--apps/files/lib/Command/Object/Info.php80
-rw-r--r--apps/files/lib/Command/Object/ListObject.php50
-rw-r--r--apps/files/lib/Command/Object/Multi/Rename.php108
-rw-r--r--apps/files/lib/Command/Object/Multi/Users.php98
-rw-r--r--apps/files/lib/Command/Object/ObjectUtil.php115
-rw-r--r--apps/files/lib/Command/Object/Orphans.php79
-rw-r--r--apps/files/lib/Command/Object/Put.php68
-rw-r--r--apps/files/lib/Command/Put.php67
-rw-r--r--apps/files/lib/Command/RepairTree.php113
-rw-r--r--apps/files/lib/Command/SanitizeFilenames.php151
-rw-r--r--apps/files/lib/Command/Scan.php377
-rw-r--r--apps/files/lib/Command/ScanAppData.php247
-rw-r--r--apps/files/lib/Command/TransferOwnership.php148
-rw-r--r--apps/files/lib/Command/WindowsCompatibleFilenames.php52
-rw-r--r--apps/files/lib/Controller/ApiController.php462
-rw-r--r--apps/files/lib/Controller/ConversionApiController.php109
-rw-r--r--apps/files/lib/Controller/DirectEditingController.php153
-rw-r--r--apps/files/lib/Controller/DirectEditingViewController.php51
-rw-r--r--apps/files/lib/Controller/OpenLocalEditorController.php128
-rw-r--r--apps/files/lib/Controller/TemplateController.php128
-rw-r--r--apps/files/lib/Controller/TransferOwnershipController.php168
-rw-r--r--apps/files/lib/Controller/ViewController.php306
-rw-r--r--apps/files/lib/Dashboard/FavoriteWidget.php141
-rw-r--r--apps/files/lib/Db/OpenLocalEditor.php43
-rw-r--r--apps/files/lib/Db/OpenLocalEditorMapper.php51
-rw-r--r--apps/files/lib/Db/TransferOwnership.php42
-rw-r--r--apps/files/lib/Db/TransferOwnershipMapper.php33
-rw-r--r--apps/files/lib/DirectEditingCapabilities.php36
-rw-r--r--apps/files/lib/Event/LoadAdditionalScriptsEvent.php19
-rw-r--r--apps/files/lib/Event/LoadSearchPlugins.php14
-rw-r--r--apps/files/lib/Event/LoadSidebar.php14
-rw-r--r--apps/files/lib/Exception/TransferOwnershipException.php14
-rw-r--r--apps/files/lib/Helper.php147
-rw-r--r--apps/files/lib/Listener/LoadSearchPluginsListener.php25
-rw-r--r--apps/files/lib/Listener/LoadSidebarListener.php26
-rw-r--r--apps/files/lib/Listener/NodeAddedToFavoriteListener.php43
-rw-r--r--apps/files/lib/Listener/NodeRemovedFromFavoriteListener.php43
-rw-r--r--apps/files/lib/Listener/RenderReferenceEventListener.php25
-rw-r--r--apps/files/lib/Listener/SyncLivePhotosListener.php254
-rw-r--r--apps/files/lib/Migration/Version11301Date20191205150729.php61
-rw-r--r--apps/files/lib/Migration/Version12101Date20221011153334.php52
-rw-r--r--apps/files/lib/Migration/Version2003Date20241021095629.php36
-rw-r--r--apps/files/lib/Notification/Notifier.php290
-rw-r--r--apps/files/lib/ResponseDefinitions.php75
-rw-r--r--apps/files/lib/Search/FilesSearchProvider.php202
-rw-r--r--apps/files/lib/Service/ChunkedUploadConfig.php30
-rw-r--r--apps/files/lib/Service/DirectEditingService.php67
-rw-r--r--apps/files/lib/Service/LivePhotosService.php36
-rw-r--r--apps/files/lib/Service/OwnershipTransferService.php624
-rw-r--r--apps/files/lib/Service/SettingsService.php63
-rw-r--r--apps/files/lib/Service/TagService.php68
-rw-r--r--apps/files/lib/Service/UserConfig.php182
-rw-r--r--apps/files/lib/Service/ViewConfig.php168
-rw-r--r--apps/files/lib/Settings/DeclarativeAdminSettings.php67
-rw-r--r--apps/files/lib/Settings/PersonalSettings.php29
-rw-r--r--apps/files/lib/activity.php424
-rw-r--r--apps/files/lib/activityhelper.php84
-rw-r--r--apps/files/lib/app.php47
-rw-r--r--apps/files/lib/backgroundjob/cleanupfilelocks.php57
-rw-r--r--apps/files/lib/backgroundjob/deleteorphaneditems.php153
-rw-r--r--apps/files/lib/backgroundjob/scanfiles.php114
-rw-r--r--apps/files/lib/capabilities.php47
-rw-r--r--apps/files/lib/helper.php248
91 files changed, 9173 insertions, 1174 deletions
diff --git a/apps/files/lib/Activity/FavoriteProvider.php b/apps/files/lib/Activity/FavoriteProvider.php
new file mode 100644
index 00000000000..e56b13b902a
--- /dev/null
+++ b/apps/files/lib/Activity/FavoriteProvider.php
@@ -0,0 +1,150 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity;
+
+use OCP\Activity\Exceptions\UnknownActivityException;
+use OCP\Activity\IEvent;
+use OCP\Activity\IEventMerger;
+use OCP\Activity\IManager;
+use OCP\Activity\IProvider;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\L10N\IFactory;
+
+class FavoriteProvider implements IProvider {
+ public const SUBJECT_ADDED = 'added_favorite';
+ public const SUBJECT_REMOVED = 'removed_favorite';
+
+ /** @var IL10N */
+ protected $l;
+
+ /**
+ * @param IFactory $languageFactory
+ * @param IURLGenerator $url
+ * @param IManager $activityManager
+ * @param IEventMerger $eventMerger
+ */
+ public function __construct(
+ protected IFactory $languageFactory,
+ protected IURLGenerator $url,
+ protected IManager $activityManager,
+ protected IEventMerger $eventMerger,
+ ) {
+ }
+
+ /**
+ * @param string $language
+ * @param IEvent $event
+ * @param IEvent|null $previousEvent
+ * @return IEvent
+ * @throws UnknownActivityException
+ * @since 11.0.0
+ */
+ public function parse($language, IEvent $event, ?IEvent $previousEvent = null) {
+ if ($event->getApp() !== 'files' || $event->getType() !== 'favorite') {
+ throw new UnknownActivityException();
+ }
+
+ $this->l = $this->languageFactory->get('files', $language);
+
+ if ($this->activityManager->isFormattingFilteredObject()) {
+ try {
+ return $this->parseShortVersion($event);
+ } catch (UnknownActivityException) {
+ // Ignore and simply use the long version...
+ }
+ }
+
+ return $this->parseLongVersion($event, $previousEvent);
+ }
+
+ /**
+ * @param IEvent $event
+ * @return IEvent
+ * @throws UnknownActivityException
+ * @since 11.0.0
+ */
+ public function parseShortVersion(IEvent $event): IEvent {
+ if ($event->getSubject() === self::SUBJECT_ADDED) {
+ $event->setParsedSubject($this->l->t('Added to favorites'));
+ if ($this->activityManager->getRequirePNG()) {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.png')));
+ } else {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.svg')));
+ }
+ } elseif ($event->getSubject() === self::SUBJECT_REMOVED) {
+ $event->setType('unfavorite');
+ $event->setParsedSubject($this->l->t('Removed from favorites'));
+ if ($this->activityManager->getRequirePNG()) {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.png')));
+ } else {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.svg')));
+ }
+ } else {
+ throw new UnknownActivityException();
+ }
+
+ return $event;
+ }
+
+ /**
+ * @param IEvent $event
+ * @param IEvent|null $previousEvent
+ * @return IEvent
+ * @throws UnknownActivityException
+ * @since 11.0.0
+ */
+ public function parseLongVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent {
+ if ($event->getSubject() === self::SUBJECT_ADDED) {
+ $subject = $this->l->t('You added {file} to your favorites');
+ if ($this->activityManager->getRequirePNG()) {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.png')));
+ } else {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/starred.svg')));
+ }
+ } elseif ($event->getSubject() === self::SUBJECT_REMOVED) {
+ $event->setType('unfavorite');
+ $subject = $this->l->t('You removed {file} from your favorites');
+ if ($this->activityManager->getRequirePNG()) {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.png')));
+ } else {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star.svg')));
+ }
+ } else {
+ throw new UnknownActivityException();
+ }
+
+ $this->setSubjects($event, $subject);
+ $event = $this->eventMerger->mergeEvents('file', $event, $previousEvent);
+ return $event;
+ }
+
+ /**
+ * @param IEvent $event
+ * @param string $subject
+ */
+ protected function setSubjects(IEvent $event, $subject) {
+ $subjectParams = $event->getSubjectParameters();
+ if (empty($subjectParams)) {
+ // Try to fall back to the old way, but this does not work for emails.
+ // But at least old activities still work.
+ $subjectParams = [
+ 'id' => $event->getObjectId(),
+ 'path' => $event->getObjectName(),
+ ];
+ }
+ $parameter = [
+ 'type' => 'file',
+ 'id' => (string)$subjectParams['id'],
+ 'name' => basename($subjectParams['path']),
+ 'path' => trim($subjectParams['path'], '/'),
+ 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $subjectParams['id']]),
+ ];
+
+ $event->setRichSubject($subject, ['file' => $parameter]);
+ }
+}
diff --git a/apps/files/lib/Activity/Filter/Favorites.php b/apps/files/lib/Activity/Filter/Favorites.php
new file mode 100644
index 00000000000..0159dd20b82
--- /dev/null
+++ b/apps/files/lib/Activity/Filter/Favorites.php
@@ -0,0 +1,130 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity\Filter;
+
+use OCA\Files\Activity\Helper;
+use OCP\Activity\IFilter;
+use OCP\Activity\IManager;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+
+class Favorites implements IFilter {
+
+ /**
+ * @param IL10N $l
+ * @param IURLGenerator $url
+ * @param IManager $activityManager
+ * @param Helper $helper
+ * @param IDBConnection $db
+ */
+ public function __construct(
+ protected IL10N $l,
+ protected IURLGenerator $url,
+ protected IManager $activityManager,
+ protected Helper $helper,
+ protected IDBConnection $db,
+ ) {
+ }
+
+ /**
+ * @return string Lowercase a-z only identifier
+ * @since 11.0.0
+ */
+ public function getIdentifier() {
+ return 'files_favorites';
+ }
+
+ /**
+ * @return string A translated string
+ * @since 11.0.0
+ */
+ public function getName() {
+ return $this->l->t('Favorites');
+ }
+
+ /**
+ * @return int
+ * @since 11.0.0
+ */
+ public function getPriority() {
+ return 10;
+ }
+
+ /**
+ * @return string Full URL to an icon, empty string when none is given
+ * @since 11.0.0
+ */
+ public function getIcon() {
+ return $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/star-dark.svg'));
+ }
+
+ /**
+ * @param string[] $types
+ * @return string[] An array of allowed apps from which activities should be displayed
+ * @since 11.0.0
+ */
+ public function filterTypes(array $types) {
+ return array_intersect([
+ 'file_created',
+ 'file_changed',
+ 'file_deleted',
+ 'file_restored',
+ ], $types);
+ }
+
+ /**
+ * @return string[] An array of allowed apps from which activities should be displayed
+ * @since 11.0.0
+ */
+ public function allowedApps() {
+ return ['files'];
+ }
+
+ /**
+ * @param IQueryBuilder $query
+ */
+ public function filterFavorites(IQueryBuilder $query) {
+ try {
+ $user = $this->activityManager->getCurrentUserId();
+ } catch (\UnexpectedValueException $e) {
+ return;
+ }
+
+ try {
+ $favorites = $this->helper->getFavoriteFilePaths($user);
+ } catch (\RuntimeException $e) {
+ return;
+ }
+
+ $limitations = [];
+ if (!empty($favorites['items'])) {
+ $limitations[] = $query->expr()->in('file', $query->createNamedParameter($favorites['items'], IQueryBuilder::PARAM_STR_ARRAY));
+ }
+ foreach ($favorites['folders'] as $favorite) {
+ $limitations[] = $query->expr()->like('file', $query->createNamedParameter(
+ $this->db->escapeLikeParameter($favorite . '/') . '%'
+ ));
+ }
+
+ if (empty($limitations)) {
+ return;
+ }
+
+ $function = $query->createFunction('
+ CASE
+ WHEN ' . $query->getColumnName('app') . ' <> ' . $query->createNamedParameter('files') . ' THEN 1
+ WHEN ' . $query->getColumnName('app') . ' = ' . $query->createNamedParameter('files') . '
+ AND (' . implode(' OR ', $limitations) . ')
+ THEN 1
+ END = 1'
+ );
+
+ $query->andWhere($function);
+ }
+}
diff --git a/apps/files/lib/Activity/Filter/FileChanges.php b/apps/files/lib/Activity/Filter/FileChanges.php
new file mode 100644
index 00000000000..0ca8f6792e0
--- /dev/null
+++ b/apps/files/lib/Activity/Filter/FileChanges.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity\Filter;
+
+use OCP\Activity\IFilter;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+
+class FileChanges implements IFilter {
+
+ /**
+ * @param IL10N $l
+ * @param IURLGenerator $url
+ */
+ public function __construct(
+ protected IL10N $l,
+ protected IURLGenerator $url,
+ ) {
+ }
+
+ /**
+ * @return string Lowercase a-z only identifier
+ * @since 11.0.0
+ */
+ public function getIdentifier() {
+ return 'files';
+ }
+
+ /**
+ * @return string A translated string
+ * @since 11.0.0
+ */
+ public function getName() {
+ return $this->l->t('File changes');
+ }
+
+ /**
+ * @return int
+ * @since 11.0.0
+ */
+ public function getPriority() {
+ return 30;
+ }
+
+ /**
+ * @return string Full URL to an icon, empty string when none is given
+ * @since 11.0.0
+ */
+ public function getIcon() {
+ return $this->url->getAbsoluteURL($this->url->imagePath('core', 'places/files.svg'));
+ }
+
+ /**
+ * @param string[] $types
+ * @return string[] An array of allowed apps from which activities should be displayed
+ * @since 11.0.0
+ */
+ public function filterTypes(array $types) {
+ return array_intersect([
+ 'file_created',
+ 'file_changed',
+ 'file_deleted',
+ 'file_restored',
+ ], $types);
+ }
+
+ /**
+ * @return string[] An array of allowed apps from which activities should be displayed
+ * @since 11.0.0
+ */
+ public function allowedApps() {
+ return ['files'];
+ }
+}
diff --git a/apps/files/lib/Activity/Helper.php b/apps/files/lib/Activity/Helper.php
new file mode 100644
index 00000000000..9b8ad9cd442
--- /dev/null
+++ b/apps/files/lib/Activity/Helper.php
@@ -0,0 +1,88 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\Activity;
+
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\ITagManager;
+
+class Helper {
+ /** If a user has a lot of favorites the query might get too slow and long */
+ public const FAVORITE_LIMIT = 50;
+
+ public function __construct(
+ protected ITagManager $tagManager,
+ protected IRootFolder $rootFolder,
+ ) {
+ }
+
+ /**
+ * Return an array with nodes marked as favorites
+ *
+ * @param string $user User ID
+ * @param bool $foldersOnly Only return folders (default false)
+ * @return Node[]
+ * @psalm-return ($foldersOnly is true ? Folder[] : Node[])
+ * @throws \RuntimeException when too many or no favorites where found
+ */
+ public function getFavoriteNodes(string $user, bool $foldersOnly = false): array {
+ $tags = $this->tagManager->load('files', [], false, $user);
+ $favorites = $tags->getFavorites();
+
+ if (empty($favorites)) {
+ throw new \RuntimeException('No favorites', 1);
+ } elseif (isset($favorites[self::FAVORITE_LIMIT])) {
+ throw new \RuntimeException('Too many favorites', 2);
+ }
+
+ // Can not DI because the user is not known on instantiation
+ $userFolder = $this->rootFolder->getUserFolder($user);
+ $favoriteNodes = [];
+ foreach ($favorites as $favorite) {
+ $node = $userFolder->getFirstNodeById($favorite);
+ if ($node) {
+ if (!$foldersOnly || $node instanceof Folder) {
+ $favoriteNodes[] = $node;
+ }
+ }
+ }
+
+ if (empty($favoriteNodes)) {
+ throw new \RuntimeException('No favorites', 1);
+ }
+
+ return $favoriteNodes;
+ }
+
+ /**
+ * Returns an array with the favorites
+ *
+ * @param string $user
+ * @return array
+ * @throws \RuntimeException when too many or no favorites where found
+ */
+ public function getFavoriteFilePaths(string $user): array {
+ $userFolder = $this->rootFolder->getUserFolder($user);
+ $nodes = $this->getFavoriteNodes($user);
+ $folders = $items = [];
+ foreach ($nodes as $node) {
+ $path = $userFolder->getRelativePath($node->getPath());
+
+ $items[] = $path;
+ if ($node instanceof Folder) {
+ $folders[] = $path;
+ }
+ }
+
+ return [
+ 'items' => $items,
+ 'folders' => $folders,
+ ];
+ }
+}
diff --git a/apps/files/lib/Activity/Provider.php b/apps/files/lib/Activity/Provider.php
new file mode 100644
index 00000000000..3ef79ac107f
--- /dev/null
+++ b/apps/files/lib/Activity/Provider.php
@@ -0,0 +1,526 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity;
+
+use OCP\Activity\Exceptions\UnknownActivityException;
+use OCP\Activity\IEvent;
+use OCP\Activity\IEventMerger;
+use OCP\Activity\IManager;
+use OCP\Activity\IProvider;
+use OCP\Contacts\IManager as IContactsManager;
+use OCP\Federation\ICloudIdManager;
+use OCP\Files\Folder;
+use OCP\Files\InvalidPathException;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\Files\NotFoundException;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUserManager;
+use OCP\L10N\IFactory;
+
+class Provider implements IProvider {
+ /** @var IL10N */
+ protected $l;
+
+ /** @var string[] cached displayNames - key is the cloud id and value the displayname */
+ protected $displayNames = [];
+
+ protected $fileIsEncrypted = false;
+
+ public function __construct(
+ protected IFactory $languageFactory,
+ protected IURLGenerator $url,
+ protected IManager $activityManager,
+ protected IUserManager $userManager,
+ protected IRootFolder $rootFolder,
+ protected ICloudIdManager $cloudIdManager,
+ protected IContactsManager $contactsManager,
+ protected IEventMerger $eventMerger,
+ ) {
+ }
+
+ /**
+ * @param string $language
+ * @param IEvent $event
+ * @param IEvent|null $previousEvent
+ * @return IEvent
+ * @throws UnknownActivityException
+ * @since 11.0.0
+ */
+ public function parse($language, IEvent $event, ?IEvent $previousEvent = null) {
+ if ($event->getApp() !== 'files') {
+ throw new UnknownActivityException();
+ }
+
+ $this->l = $this->languageFactory->get('files', $language);
+
+ if ($this->activityManager->isFormattingFilteredObject()) {
+ try {
+ return $this->parseShortVersion($event, $previousEvent);
+ } catch (UnknownActivityException) {
+ // Ignore and simply use the long version...
+ }
+ }
+
+ return $this->parseLongVersion($event, $previousEvent);
+ }
+
+ protected function setIcon(IEvent $event, string $icon, string $app = 'files') {
+ if ($this->activityManager->getRequirePNG()) {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath($app, $icon . '.png')));
+ } else {
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath($app, $icon . '.svg')));
+ }
+ }
+
+ /**
+ * @param IEvent $event
+ * @param IEvent|null $previousEvent
+ * @return IEvent
+ * @throws UnknownActivityException
+ * @since 11.0.0
+ */
+ public function parseShortVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent {
+ $parsedParameters = $this->getParameters($event);
+
+ if ($event->getSubject() === 'created_by') {
+ $subject = $this->l->t('Created by {user}');
+ $this->setIcon($event, 'add-color');
+ } elseif ($event->getSubject() === 'changed_by') {
+ $subject = $this->l->t('Changed by {user}');
+ $this->setIcon($event, 'change');
+ } elseif ($event->getSubject() === 'deleted_by') {
+ $subject = $this->l->t('Deleted by {user}');
+ $this->setIcon($event, 'delete-color');
+ } elseif ($event->getSubject() === 'restored_by') {
+ $subject = $this->l->t('Restored by {user}');
+ $this->setIcon($event, 'actions/history', 'core');
+ } elseif ($event->getSubject() === 'renamed_by') {
+ $subject = $this->l->t('Renamed by {user}');
+ $this->setIcon($event, 'change');
+ } elseif ($event->getSubject() === 'moved_by') {
+ $subject = $this->l->t('Moved by {user}');
+ $this->setIcon($event, 'change');
+ } else {
+ throw new UnknownActivityException();
+ }
+
+ if (!isset($parsedParameters['user'])) {
+ // External user via public link share
+ $subject = str_replace('{user}', $this->l->t('"remote account"'), $subject);
+ }
+
+ $this->setSubjects($event, $subject, $parsedParameters);
+
+ return $this->eventMerger->mergeEvents('user', $event, $previousEvent);
+ }
+
+ /**
+ * @param IEvent $event
+ * @param IEvent|null $previousEvent
+ * @return IEvent
+ * @throws UnknownActivityException
+ * @since 11.0.0
+ */
+ public function parseLongVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent {
+ $this->fileIsEncrypted = false;
+ $parsedParameters = $this->getParameters($event);
+
+ if ($event->getSubject() === 'created_self') {
+ $subject = $this->l->t('You created {file}');
+ if ($this->fileIsEncrypted) {
+ $subject = $this->l->t('You created an encrypted file in {file}');
+ }
+ $this->setIcon($event, 'add-color');
+ } elseif ($event->getSubject() === 'created_by') {
+ $subject = $this->l->t('{user} created {file}');
+ if ($this->fileIsEncrypted) {
+ $subject = $this->l->t('{user} created an encrypted file in {file}');
+ }
+ $this->setIcon($event, 'add-color');
+ } elseif ($event->getSubject() === 'created_public') {
+ $subject = $this->l->t('{file} was created in a public folder');
+ $this->setIcon($event, 'add-color');
+ } elseif ($event->getSubject() === 'changed_self') {
+ $subject = $this->l->t('You changed {file}');
+ if ($this->fileIsEncrypted) {
+ $subject = $this->l->t('You changed an encrypted file in {file}');
+ }
+ $this->setIcon($event, 'change');
+ } elseif ($event->getSubject() === 'changed_by') {
+ $subject = $this->l->t('{user} changed {file}');
+ if ($this->fileIsEncrypted) {
+ $subject = $this->l->t('{user} changed an encrypted file in {file}');
+ }
+ $this->setIcon($event, 'change');
+ } elseif ($event->getSubject() === 'deleted_self') {
+ $subject = $this->l->t('You deleted {file}');
+ if ($this->fileIsEncrypted) {
+ $subject = $this->l->t('You deleted an encrypted file in {file}');
+ }
+ $this->setIcon($event, 'delete-color');
+ } elseif ($event->getSubject() === 'deleted_by') {
+ $subject = $this->l->t('{user} deleted {file}');
+ if ($this->fileIsEncrypted) {
+ $subject = $this->l->t('{user} deleted an encrypted file in {file}');
+ }
+ $this->setIcon($event, 'delete-color');
+ } elseif ($event->getSubject() === 'restored_self') {
+ $subject = $this->l->t('You restored {file}');
+ $this->setIcon($event, 'actions/history', 'core');
+ } elseif ($event->getSubject() === 'restored_by') {
+ $subject = $this->l->t('{user} restored {file}');
+ $this->setIcon($event, 'actions/history', 'core');
+ } elseif ($event->getSubject() === 'renamed_self') {
+ $oldFileName = $parsedParameters['oldfile']['name'];
+ $newFileName = $parsedParameters['newfile']['name'];
+
+ if ($this->isHiddenFile($oldFileName)) {
+ if ($this->isHiddenFile($newFileName)) {
+ $subject = $this->l->t('You renamed {oldfile} (hidden) to {newfile} (hidden)');
+ } else {
+ $subject = $this->l->t('You renamed {oldfile} (hidden) to {newfile}');
+ }
+ } else {
+ if ($this->isHiddenFile($newFileName)) {
+ $subject = $this->l->t('You renamed {oldfile} to {newfile} (hidden)');
+ } else {
+ $subject = $this->l->t('You renamed {oldfile} to {newfile}');
+ }
+ }
+
+ $this->setIcon($event, 'change');
+ } elseif ($event->getSubject() === 'renamed_by') {
+ $oldFileName = $parsedParameters['oldfile']['name'];
+ $newFileName = $parsedParameters['newfile']['name'];
+
+ if ($this->isHiddenFile($oldFileName)) {
+ if ($this->isHiddenFile($newFileName)) {
+ $subject = $this->l->t('{user} renamed {oldfile} (hidden) to {newfile} (hidden)');
+ } else {
+ $subject = $this->l->t('{user} renamed {oldfile} (hidden) to {newfile}');
+ }
+ } else {
+ if ($this->isHiddenFile($newFileName)) {
+ $subject = $this->l->t('{user} renamed {oldfile} to {newfile} (hidden)');
+ } else {
+ $subject = $this->l->t('{user} renamed {oldfile} to {newfile}');
+ }
+ }
+
+ $this->setIcon($event, 'change');
+ } elseif ($event->getSubject() === 'moved_self') {
+ $subject = $this->l->t('You moved {oldfile} to {newfile}');
+ $this->setIcon($event, 'change');
+ } elseif ($event->getSubject() === 'moved_by') {
+ $subject = $this->l->t('{user} moved {oldfile} to {newfile}');
+ $this->setIcon($event, 'change');
+ } else {
+ throw new UnknownActivityException();
+ }
+
+ if ($this->fileIsEncrypted) {
+ $event->setSubject($event->getSubject() . '_enc', $event->getSubjectParameters());
+ }
+
+ if (!isset($parsedParameters['user'])) {
+ // External user via public link share
+ $subject = str_replace('{user}', $this->l->t('"remote account"'), $subject);
+ }
+
+ $this->setSubjects($event, $subject, $parsedParameters);
+
+ if ($event->getSubject() === 'moved_self' || $event->getSubject() === 'moved_by') {
+ $event = $this->eventMerger->mergeEvents('oldfile', $event, $previousEvent);
+ } else {
+ $event = $this->eventMerger->mergeEvents('file', $event, $previousEvent);
+ }
+
+ if ($event->getChildEvent() === null) {
+ // Couldn't group by file, maybe we can group by user
+ $event = $this->eventMerger->mergeEvents('user', $event, $previousEvent);
+ }
+
+ return $event;
+ }
+
+ private function isHiddenFile(string $filename): bool {
+ return strlen($filename) > 0 && $filename[0] === '.';
+ }
+
+ protected function setSubjects(IEvent $event, string $subject, array $parameters): void {
+ $event->setRichSubject($subject, $parameters);
+ }
+
+ /**
+ * @param IEvent $event
+ * @return array
+ * @throws UnknownActivityException
+ */
+ protected function getParameters(IEvent $event): array {
+ $parameters = $event->getSubjectParameters();
+ switch ($event->getSubject()) {
+ case 'created_self':
+ case 'created_public':
+ case 'changed_self':
+ case 'deleted_self':
+ case 'restored_self':
+ return [
+ 'file' => $this->getFile($parameters[0], $event),
+ ];
+ case 'created_by':
+ case 'changed_by':
+ case 'deleted_by':
+ case 'restored_by':
+ if ($parameters[1] === '') {
+ // External user via public link share
+ return [
+ 'file' => $this->getFile($parameters[0], $event),
+ ];
+ }
+ return [
+ 'file' => $this->getFile($parameters[0], $event),
+ 'user' => $this->getUser($parameters[1]),
+ ];
+ case 'renamed_self':
+ case 'moved_self':
+ return [
+ 'newfile' => $this->getFile($parameters[0]),
+ 'oldfile' => $this->getFile($parameters[1]),
+ ];
+ case 'renamed_by':
+ case 'moved_by':
+ if ($parameters[1] === '') {
+ // External user via public link share
+ return [
+ 'newfile' => $this->getFile($parameters[0]),
+ 'oldfile' => $this->getFile($parameters[2]),
+ ];
+ }
+ return [
+ 'newfile' => $this->getFile($parameters[0]),
+ 'user' => $this->getUser($parameters[1]),
+ 'oldfile' => $this->getFile($parameters[2]),
+ ];
+ }
+ return [];
+ }
+
+ /**
+ * @param array|string $parameter
+ * @param IEvent|null $event
+ * @return array
+ * @throws UnknownActivityException
+ */
+ protected function getFile($parameter, ?IEvent $event = null): array {
+ if (is_array($parameter)) {
+ $path = reset($parameter);
+ $id = (int)key($parameter);
+ } elseif ($event !== null) {
+ // Legacy from before ownCloud 8.2
+ $path = $parameter;
+ $id = $event->getObjectId();
+ } else {
+ throw new UnknownActivityException('Could not generate file parameter');
+ }
+
+ $encryptionContainer = $this->getEndToEndEncryptionContainer($id, $path);
+ if ($encryptionContainer instanceof Folder) {
+ $this->fileIsEncrypted = true;
+ try {
+ $fullPath = rtrim($encryptionContainer->getPath(), '/');
+ // Remove /user/files/...
+ [,,, $path] = explode('/', $fullPath, 4);
+ if (!$path) {
+ throw new InvalidPathException('Path could not be split correctly');
+ }
+
+ return [
+ 'type' => 'file',
+ 'id' => (string)$encryptionContainer->getId(),
+ 'name' => $encryptionContainer->getName(),
+ 'path' => $path,
+ 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $encryptionContainer->getId()]),
+ ];
+ } catch (\Exception $e) {
+ // fall back to the normal one
+ $this->fileIsEncrypted = false;
+ }
+ }
+
+ return [
+ 'type' => 'file',
+ 'id' => (string)$id,
+ 'name' => basename($path),
+ 'path' => trim($path, '/'),
+ 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $id]),
+ ];
+ }
+
+ protected $fileEncrypted = [];
+
+ /**
+ * Check if a file is end2end encrypted
+ * @param int $fileId
+ * @param string $path
+ * @return Folder|null
+ */
+ protected function getEndToEndEncryptionContainer($fileId, $path) {
+ if (isset($this->fileEncrypted[$fileId])) {
+ return $this->fileEncrypted[$fileId];
+ }
+
+ $fileName = basename($path);
+ if (!preg_match('/^[0-9a-fA-F]{32}$/', $fileName)) {
+ $this->fileEncrypted[$fileId] = false;
+ return $this->fileEncrypted[$fileId];
+ }
+
+ $userFolder = $this->rootFolder->getUserFolder($this->activityManager->getCurrentUserId());
+ $file = $userFolder->getFirstNodeById($fileId);
+ if (!$file) {
+ try {
+ // Deleted, try with parent
+ $file = $this->findExistingParent($userFolder, dirname($path));
+ } catch (NotFoundException $e) {
+ return null;
+ }
+
+ if (!$file instanceof Folder || !$file->isEncrypted()) {
+ return null;
+ }
+
+ $this->fileEncrypted[$fileId] = $file;
+ return $file;
+ }
+
+ if ($file instanceof Folder && $file->isEncrypted()) {
+ // If the folder is encrypted, it is the Container,
+ // but can be the name is just fine.
+ $this->fileEncrypted[$fileId] = true;
+ return null;
+ }
+
+ $this->fileEncrypted[$fileId] = $this->getParentEndToEndEncryptionContainer($userFolder, $file);
+ return $this->fileEncrypted[$fileId];
+ }
+
+ /**
+ * @param Folder $userFolder
+ * @param string $path
+ * @return Folder
+ * @throws NotFoundException
+ */
+ protected function findExistingParent(Folder $userFolder, $path) {
+ if ($path === '/') {
+ throw new NotFoundException('Reached the root');
+ }
+
+ try {
+ $folder = $userFolder->get(dirname($path));
+ } catch (NotFoundException $e) {
+ return $this->findExistingParent($userFolder, dirname($path));
+ }
+
+ return $folder;
+ }
+
+ /**
+ * Check all parents until the user's root folder if one is encrypted
+ *
+ * @param Folder $userFolder
+ * @param Node $file
+ * @return Node|null
+ */
+ protected function getParentEndToEndEncryptionContainer(Folder $userFolder, Node $file) {
+ try {
+ $parent = $file->getParent();
+
+ if ($userFolder->getId() === $parent->getId()) {
+ return null;
+ }
+ } catch (\Exception $e) {
+ return null;
+ }
+
+ if ($parent->isEncrypted()) {
+ return $parent;
+ }
+
+ return $this->getParentEndToEndEncryptionContainer($userFolder, $parent);
+ }
+
+ /**
+ * @param string $uid
+ * @return array
+ */
+ protected function getUser($uid) {
+ // First try local user
+ $displayName = $this->userManager->getDisplayName($uid);
+ if ($displayName !== null) {
+ return [
+ 'type' => 'user',
+ 'id' => $uid,
+ 'name' => $displayName,
+ ];
+ }
+
+ // Then a contact from the addressbook
+ if ($this->cloudIdManager->isValidCloudId($uid)) {
+ $cloudId = $this->cloudIdManager->resolveCloudId($uid);
+ return [
+ 'type' => 'user',
+ 'id' => $cloudId->getUser(),
+ 'name' => $this->getDisplayNameFromAddressBook($cloudId->getDisplayId()),
+ 'server' => $cloudId->getRemote(),
+ ];
+ }
+
+ // Fallback to empty dummy data
+ return [
+ 'type' => 'user',
+ 'id' => $uid,
+ 'name' => $uid,
+ ];
+ }
+
+ protected function getDisplayNameFromAddressBook(string $search): string {
+ if (isset($this->displayNames[$search])) {
+ return $this->displayNames[$search];
+ }
+
+ $addressBookContacts = $this->contactsManager->search($search, ['CLOUD'], [
+ 'limit' => 1,
+ 'enumeration' => false,
+ 'fullmatch' => false,
+ 'strict_search' => true,
+ ]);
+ foreach ($addressBookContacts as $contact) {
+ if (isset($contact['isLocalSystemBook'])) {
+ continue;
+ }
+
+ if (isset($contact['CLOUD'])) {
+ $cloudIds = $contact['CLOUD'];
+ if (is_string($cloudIds)) {
+ $cloudIds = [$cloudIds];
+ }
+
+ $lowerSearch = strtolower($search);
+ foreach ($cloudIds as $cloudId) {
+ if (strtolower($cloudId) === $lowerSearch) {
+ $this->displayNames[$search] = $contact['FN'] . " ($cloudId)";
+ return $this->displayNames[$search];
+ }
+ }
+ }
+ }
+
+ return $search;
+ }
+}
diff --git a/apps/files/lib/Activity/Settings/FavoriteAction.php b/apps/files/lib/Activity/Settings/FavoriteAction.php
new file mode 100644
index 00000000000..73b200341ec
--- /dev/null
+++ b/apps/files/lib/Activity/Settings/FavoriteAction.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity\Settings;
+
+class FavoriteAction extends FileActivitySettings {
+ /**
+ * @return string Lowercase a-z and underscore only identifier
+ * @since 11.0.0
+ */
+ public function getIdentifier() {
+ return 'favorite';
+ }
+
+ /**
+ * @return string A translated string
+ * @since 11.0.0
+ */
+ public function getName() {
+ return $this->l->t('A file has been added to or removed from your <strong>favorites</strong>');
+ }
+
+ /**
+ * @return int whether the filter should be rather on the top or bottom of
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
+ * @since 11.0.0
+ */
+ public function getPriority() {
+ return 5;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the stream
+ * @since 11.0.0
+ */
+ public function canChangeStream() {
+ return false;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the stream
+ * @since 11.0.0
+ */
+ public function isDefaultEnabledStream() {
+ return true;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the mail
+ * @since 11.0.0
+ */
+ public function canChangeMail() {
+ return false;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the stream
+ * @since 11.0.0
+ */
+ public function isDefaultEnabledMail() {
+ return false;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the notification
+ * @since 20.0.0
+ */
+ public function canChangeNotification() {
+ return false;
+ }
+}
diff --git a/apps/files/lib/Activity/Settings/FileActivitySettings.php b/apps/files/lib/Activity/Settings/FileActivitySettings.php
new file mode 100644
index 00000000000..0ca7100832f
--- /dev/null
+++ b/apps/files/lib/Activity/Settings/FileActivitySettings.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity\Settings;
+
+use OCP\Activity\ActivitySettings;
+use OCP\IL10N;
+
+abstract class FileActivitySettings extends ActivitySettings {
+ /**
+ * @param IL10N $l
+ */
+ public function __construct(
+ protected IL10N $l,
+ ) {
+ }
+
+ public function getGroupIdentifier() {
+ return 'files';
+ }
+
+ public function getGroupName() {
+ return $this->l->t('Files');
+ }
+}
diff --git a/apps/files/lib/Activity/Settings/FileChanged.php b/apps/files/lib/Activity/Settings/FileChanged.php
new file mode 100644
index 00000000000..c33ed5e1eba
--- /dev/null
+++ b/apps/files/lib/Activity/Settings/FileChanged.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity\Settings;
+
+class FileChanged extends FileActivitySettings {
+ /**
+ * @return string Lowercase a-z and underscore only identifier
+ * @since 11.0.0
+ */
+ public function getIdentifier() {
+ return 'file_changed';
+ }
+
+ /**
+ * @return string A translated string
+ * @since 11.0.0
+ */
+ public function getName() {
+ return $this->l->t('A file or folder has been <strong>changed</strong>');
+ }
+
+ /**
+ * @return int whether the filter should be rather on the top or bottom of
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
+ * @since 11.0.0
+ */
+ public function getPriority() {
+ return 2;
+ }
+
+ public function canChangeMail() {
+ return true;
+ }
+
+ public function isDefaultEnabledMail() {
+ return false;
+ }
+
+ public function canChangeNotification() {
+ return true;
+ }
+
+ public function isDefaultEnabledNotification() {
+ return false;
+ }
+}
diff --git a/apps/files/lib/Activity/Settings/FileFavoriteChanged.php b/apps/files/lib/Activity/Settings/FileFavoriteChanged.php
new file mode 100644
index 00000000000..5000902ed3f
--- /dev/null
+++ b/apps/files/lib/Activity/Settings/FileFavoriteChanged.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Activity\Settings;
+
+class FileFavoriteChanged extends FileActivitySettings {
+ /**
+ * @return string Lowercase a-z and underscore only identifier
+ * @since 11.0.0
+ */
+ public function getIdentifier() {
+ return 'file_favorite_changed';
+ }
+
+ /**
+ * @return string A translated string
+ * @since 11.0.0
+ */
+ public function getName() {
+ return $this->l->t('A favorite file or folder has been <strong>changed</strong>');
+ }
+
+ /**
+ * @return int whether the filter should be rather on the top or bottom of
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
+ * @since 11.0.0
+ */
+ public function getPriority() {
+ return 1;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the stream
+ * @since 11.0.0
+ */
+ public function canChangeStream() {
+ return true;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the stream
+ * @since 11.0.0
+ */
+ public function isDefaultEnabledStream() {
+ return true;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the mail
+ * @since 11.0.0
+ */
+ public function canChangeMail() {
+ return false;
+ }
+
+ public function canChangeNotification() {
+ return false;
+ }
+
+ /**
+ * @return bool True when the option can be changed for the stream
+ * @since 11.0.0
+ */
+ public function isDefaultEnabledMail() {
+ return false;
+ }
+
+ public function isDefaultEnabledNotification() {
+ return false;
+ }
+}
diff --git a/apps/files/lib/AdvancedCapabilities.php b/apps/files/lib/AdvancedCapabilities.php
new file mode 100644
index 00000000000..22f990f0cf8
--- /dev/null
+++ b/apps/files/lib/AdvancedCapabilities.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files;
+
+use OCA\Files\Service\SettingsService;
+use OCP\Capabilities\ICapability;
+use OCP\Capabilities\IInitialStateExcludedCapability;
+
+/**
+ * Capabilities not needed for every request.
+ * This capabilities might be hard to compute or no used by the webui.
+ */
+class AdvancedCapabilities implements ICapability, IInitialStateExcludedCapability {
+
+ public function __construct(
+ protected SettingsService $service,
+ ) {
+ }
+
+ /**
+ * Return this classes capabilities
+ *
+ * @return array{files: array{'windows_compatible_filenames': bool}}
+ */
+ public function getCapabilities(): array {
+ return [
+ 'files' => [
+ 'windows_compatible_filenames' => $this->service->hasFilesWindowsSupport(),
+ ],
+ ];
+ }
+}
diff --git a/apps/files/lib/App.php b/apps/files/lib/App.php
new file mode 100644
index 00000000000..9e6d35a7538
--- /dev/null
+++ b/apps/files/lib/App.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files;
+
+use OC\NavigationManager;
+use OCA\Files\Service\ChunkedUploadConfig;
+use OCP\App\IAppManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\INavigationManager;
+use OCP\IURLGenerator;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+class App {
+ private static ?INavigationManager $navigationManager = null;
+
+ /**
+ * Returns the app's navigation manager
+ */
+ public static function getNavigationManager(): INavigationManager {
+ // TODO: move this into a service in the Application class
+ if (self::$navigationManager === null) {
+ self::$navigationManager = new NavigationManager(
+ Server::get(IAppManager::class),
+ Server::get(IUrlGenerator::class),
+ Server::get(IFactory::class),
+ Server::get(IUserSession::class),
+ Server::get(IGroupManager::class),
+ Server::get(IConfig::class),
+ Server::get(LoggerInterface::class),
+ Server::get(IEventDispatcher::class),
+ );
+ self::$navigationManager->clear(false);
+ }
+ return self::$navigationManager;
+ }
+
+ public static function extendJsConfig($settings): void {
+ $appConfig = json_decode($settings['array']['oc_appconfig'], true);
+
+ $appConfig['files'] = [
+ 'max_chunk_size' => ChunkedUploadConfig::getMaxChunkSize(),
+ ];
+
+ $settings['array']['oc_appconfig'] = json_encode($appConfig);
+ }
+}
diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php
new file mode 100644
index 00000000000..2761b44ecf9
--- /dev/null
+++ b/apps/files/lib/AppInfo/Application.php
@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\AppInfo;
+
+use Closure;
+use OCA\Files\AdvancedCapabilities;
+use OCA\Files\Capabilities;
+use OCA\Files\Collaboration\Resources\Listener;
+use OCA\Files\Collaboration\Resources\ResourceProvider;
+use OCA\Files\Controller\ApiController;
+use OCA\Files\Dashboard\FavoriteWidget;
+use OCA\Files\DirectEditingCapabilities;
+use OCA\Files\Event\LoadSearchPlugins;
+use OCA\Files\Event\LoadSidebar;
+use OCA\Files\Listener\LoadSearchPluginsListener;
+use OCA\Files\Listener\LoadSidebarListener;
+use OCA\Files\Listener\NodeAddedToFavoriteListener;
+use OCA\Files\Listener\NodeRemovedFromFavoriteListener;
+use OCA\Files\Listener\RenderReferenceEventListener;
+use OCA\Files\Listener\SyncLivePhotosListener;
+use OCA\Files\Notification\Notifier;
+use OCA\Files\Search\FilesSearchProvider;
+use OCA\Files\Service\TagService;
+use OCA\Files\Service\UserConfig;
+use OCA\Files\Service\ViewConfig;
+use OCA\Files\Settings\DeclarativeAdminSettings;
+use OCP\Activity\IManager as IActivityManager;
+use OCP\AppFramework\App;
+use OCP\AppFramework\Bootstrap\IBootContext;
+use OCP\AppFramework\Bootstrap\IBootstrap;
+use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\Collaboration\Reference\RenderReferenceEvent;
+use OCP\Collaboration\Resources\IProviderManager;
+use OCP\Files\Cache\CacheEntryRemovedEvent;
+use OCP\Files\Events\Node\BeforeNodeCopiedEvent;
+use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
+use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
+use OCP\Files\Events\Node\NodeCopiedEvent;
+use OCP\Files\Events\NodeAddedToFavorite;
+use OCP\Files\Events\NodeRemovedFromFavorite;
+use OCP\Files\IRootFolder;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IPreview;
+use OCP\IRequest;
+use OCP\IServerContainer;
+use OCP\ITagManager;
+use OCP\IUserSession;
+use OCP\Share\IManager as IShareManager;
+use OCP\Util;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+class Application extends App implements IBootstrap {
+ public const APP_ID = 'files';
+
+ public function __construct(array $urlParams = []) {
+ parent::__construct(self::APP_ID, $urlParams);
+ }
+
+ public function register(IRegistrationContext $context): void {
+ /**
+ * Controllers
+ */
+ $context->registerService('APIController', function (ContainerInterface $c) {
+ /** @var IServerContainer $server */
+ $server = $c->get(IServerContainer::class);
+
+ return new ApiController(
+ $c->get('AppName'),
+ $c->get(IRequest::class),
+ $c->get(IUserSession::class),
+ $c->get(TagService::class),
+ $c->get(IPreview::class),
+ $c->get(IShareManager::class),
+ $c->get(IConfig::class),
+ $server->getUserFolder(),
+ $c->get(UserConfig::class),
+ $c->get(ViewConfig::class),
+ $c->get(IL10N::class),
+ $c->get(IRootFolder::class),
+ $c->get(LoggerInterface::class),
+ );
+ });
+
+ /**
+ * Services
+ */
+ $context->registerService(TagService::class, function (ContainerInterface $c) {
+ /** @var IServerContainer $server */
+ $server = $c->get(IServerContainer::class);
+
+ return new TagService(
+ $c->get(IUserSession::class),
+ $c->get(IActivityManager::class),
+ $c->get(ITagManager::class)->load(self::APP_ID),
+ $server->getUserFolder(),
+ );
+ });
+
+ /*
+ * Register capabilities
+ */
+ $context->registerCapability(Capabilities::class);
+ $context->registerCapability(AdvancedCapabilities::class);
+ $context->registerCapability(DirectEditingCapabilities::class);
+
+ $context->registerDeclarativeSettings(DeclarativeAdminSettings::class);
+
+ $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class);
+ $context->registerEventListener(RenderReferenceEvent::class, RenderReferenceEventListener::class);
+ $context->registerEventListener(BeforeNodeRenamedEvent::class, SyncLivePhotosListener::class);
+ $context->registerEventListener(BeforeNodeDeletedEvent::class, SyncLivePhotosListener::class);
+ $context->registerEventListener(CacheEntryRemovedEvent::class, SyncLivePhotosListener::class, 1); // Ensure this happen before the metadata are deleted.
+ $context->registerEventListener(BeforeNodeCopiedEvent::class, SyncLivePhotosListener::class);
+ $context->registerEventListener(NodeCopiedEvent::class, SyncLivePhotosListener::class);
+ $context->registerEventListener(LoadSearchPlugins::class, LoadSearchPluginsListener::class);
+ $context->registerEventListener(NodeAddedToFavorite::class, NodeAddedToFavoriteListener::class);
+ $context->registerEventListener(NodeRemovedFromFavorite::class, NodeRemovedFromFavoriteListener::class);
+ $context->registerSearchProvider(FilesSearchProvider::class);
+
+ $context->registerNotifierService(Notifier::class);
+ $context->registerDashboardWidget(FavoriteWidget::class);
+ }
+
+ public function boot(IBootContext $context): void {
+ $context->injectFn(Closure::fromCallable([$this, 'registerCollaboration']));
+ $context->injectFn([Listener::class, 'register']);
+ $this->registerHooks();
+ }
+
+ private function registerCollaboration(IProviderManager $providerManager): void {
+ $providerManager->registerResourceProvider(ResourceProvider::class);
+ }
+
+ private function registerHooks(): void {
+ Util::connectHook('\OCP\Config', 'js', '\OCA\Files\App', 'extendJsConfig');
+ }
+}
diff --git a/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php b/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php
new file mode 100644
index 00000000000..a1032b2787d
--- /dev/null
+++ b/apps/files/lib/BackgroundJob/CleanupDirectEditingTokens.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\BackgroundJob;
+
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\DirectEditing\IManager;
+
+class CleanupDirectEditingTokens extends TimedJob {
+ public function __construct(
+ ITimeFactory $time,
+ private IManager $manager,
+ ) {
+ parent::__construct($time);
+ $this->setInterval(15 * 60);
+ }
+
+ /**
+ * Makes the background job do its work
+ *
+ * @param array $argument unused argument
+ * @throws \Exception
+ */
+ public function run($argument) {
+ $this->manager->cleanup();
+ }
+}
diff --git a/apps/files/lib/BackgroundJob/CleanupFileLocks.php b/apps/files/lib/BackgroundJob/CleanupFileLocks.php
new file mode 100644
index 00000000000..91bb145884b
--- /dev/null
+++ b/apps/files/lib/BackgroundJob/CleanupFileLocks.php
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\BackgroundJob;
+
+use OC\Lock\DBLockingProvider;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\Lock\ILockingProvider;
+use OCP\Server;
+
+/**
+ * Clean up all file locks that are expired for the DB file locking provider
+ */
+class CleanupFileLocks extends TimedJob {
+ /**
+ * sets the correct interval for this timed job
+ */
+ public function __construct(ITimeFactory $time) {
+ parent::__construct($time);
+ $this->setInterval(5 * 60);
+ }
+
+ /**
+ * Makes the background job do its work
+ *
+ * @param array $argument unused argument
+ * @throws \Exception
+ */
+ public function run($argument) {
+ $lockingProvider = Server::get(ILockingProvider::class);
+ if ($lockingProvider instanceof DBLockingProvider) {
+ $lockingProvider->cleanExpiredLocks();
+ }
+ }
+}
diff --git a/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php b/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php
new file mode 100644
index 00000000000..8a20b6dfb0c
--- /dev/null
+++ b/apps/files/lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\BackgroundJob;
+
+use OCA\Files\Db\OpenLocalEditorMapper;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+
+/**
+ * Delete all expired "Open local editor" token
+ */
+class DeleteExpiredOpenLocalEditor extends TimedJob {
+ public function __construct(
+ ITimeFactory $time,
+ protected OpenLocalEditorMapper $mapper,
+ ) {
+ parent::__construct($time);
+
+ // Run every 12h
+ $this->interval = 12 * 3600;
+ $this->setTimeSensitivity(self::TIME_INSENSITIVE);
+ }
+
+ /**
+ * Makes the background job do its work
+ *
+ * @param array $argument unused argument
+ */
+ public function run($argument): void {
+ $this->mapper->deleteExpiredTokens($this->time->getTime());
+ }
+}
diff --git a/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php b/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php
new file mode 100644
index 00000000000..b925974f24a
--- /dev/null
+++ b/apps/files/lib/BackgroundJob/DeleteOrphanedItems.php
@@ -0,0 +1,177 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\BackgroundJob;
+
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Delete all share entries that have no matching entries in the file cache table.
+ */
+class DeleteOrphanedItems extends TimedJob {
+ public const CHUNK_SIZE = 200;
+
+ /**
+ * sets the correct interval for this timed job
+ */
+ public function __construct(
+ ITimeFactory $time,
+ protected IDBConnection $connection,
+ protected LoggerInterface $logger,
+ ) {
+ parent::__construct($time);
+ $this->setInterval(60 * 60);
+ }
+
+ /**
+ * Makes the background job do its work
+ *
+ * @param array $argument unused argument
+ */
+ public function run($argument) {
+ $this->cleanSystemTags();
+ $this->cleanUserTags();
+ $this->cleanComments();
+ $this->cleanCommentMarkers();
+ }
+
+ /**
+ * Deleting orphaned system tag mappings
+ *
+ * @param string $table
+ * @param string $idCol
+ * @param string $typeCol
+ * @return int Number of deleted entries
+ */
+ protected function cleanUp(string $table, string $idCol, string $typeCol): int {
+ $deletedEntries = 0;
+
+ $deleteQuery = $this->connection->getQueryBuilder();
+ $deleteQuery->delete($table)
+ ->where($deleteQuery->expr()->eq($idCol, $deleteQuery->createParameter('objectid')));
+
+ if ($this->connection->getShardDefinition('filecache')) {
+ $sourceIdChunks = $this->getItemIds($table, $idCol, $typeCol, 1000);
+ foreach ($sourceIdChunks as $sourceIdChunk) {
+ $deletedSources = $this->findMissingSources($sourceIdChunk);
+ $deleteQuery->setParameter('objectid', $deletedSources, IQueryBuilder::PARAM_INT_ARRAY);
+ $deletedEntries += $deleteQuery->executeStatement();
+ }
+ } else {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('t1.' . $idCol)
+ ->from($table, 't1')
+ ->where($query->expr()->eq($typeCol, $query->expr()->literal('files')))
+ ->leftJoin('t1', 'filecache', 't2', $query->expr()->eq($query->expr()->castColumn('t1.' . $idCol, IQueryBuilder::PARAM_INT), 't2.fileid'))
+ ->andWhere($query->expr()->isNull('t2.fileid'))
+ ->groupBy('t1.' . $idCol)
+ ->setMaxResults(self::CHUNK_SIZE);
+
+ $deleteQuery = $this->connection->getQueryBuilder();
+ $deleteQuery->delete($table)
+ ->where($deleteQuery->expr()->in($idCol, $deleteQuery->createParameter('objectid')));
+
+ $deletedInLastChunk = self::CHUNK_SIZE;
+ while ($deletedInLastChunk === self::CHUNK_SIZE) {
+ $chunk = $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ $deletedInLastChunk = count($chunk);
+
+ $deleteQuery->setParameter('objectid', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $deletedEntries += $deleteQuery->executeStatement();
+ }
+ }
+
+ return $deletedEntries;
+ }
+
+ /**
+ * @param string $table
+ * @param string $idCol
+ * @param string $typeCol
+ * @param int $chunkSize
+ * @return \Iterator<int[]>
+ * @throws \OCP\DB\Exception
+ */
+ private function getItemIds(string $table, string $idCol, string $typeCol, int $chunkSize): \Iterator {
+ $query = $this->connection->getQueryBuilder();
+ $query->select($idCol)
+ ->from($table)
+ ->where($query->expr()->eq($typeCol, $query->expr()->literal('files')))
+ ->groupBy($idCol)
+ ->andWhere($query->expr()->gt($idCol, $query->createParameter('min_id')))
+ ->setMaxResults($chunkSize);
+
+ $minId = 0;
+ while (true) {
+ $query->setParameter('min_id', $minId);
+ $rows = $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ if (count($rows) > 0) {
+ $minId = $rows[count($rows) - 1];
+ yield $rows;
+ } else {
+ break;
+ }
+ }
+ }
+
+ private function findMissingSources(array $ids): array {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select('fileid')
+ ->from('filecache')
+ ->where($qb->expr()->in('fileid', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
+ $found = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ return array_diff($ids, $found);
+ }
+
+ /**
+ * Deleting orphaned system tag mappings
+ *
+ * @return int Number of deleted entries
+ */
+ protected function cleanSystemTags() {
+ $deletedEntries = $this->cleanUp('systemtag_object_mapping', 'objectid', 'objecttype');
+ $this->logger->debug("$deletedEntries orphaned system tag relations deleted", ['app' => 'DeleteOrphanedItems']);
+ return $deletedEntries;
+ }
+
+ /**
+ * Deleting orphaned user tag mappings
+ *
+ * @return int Number of deleted entries
+ */
+ protected function cleanUserTags() {
+ $deletedEntries = $this->cleanUp('vcategory_to_object', 'objid', 'type');
+ $this->logger->debug("$deletedEntries orphaned user tag relations deleted", ['app' => 'DeleteOrphanedItems']);
+ return $deletedEntries;
+ }
+
+ /**
+ * Deleting orphaned comments
+ *
+ * @return int Number of deleted entries
+ */
+ protected function cleanComments() {
+ $deletedEntries = $this->cleanUp('comments', 'object_id', 'object_type');
+ $this->logger->debug("$deletedEntries orphaned comments deleted", ['app' => 'DeleteOrphanedItems']);
+ return $deletedEntries;
+ }
+
+ /**
+ * Deleting orphaned comment read markers
+ *
+ * @return int Number of deleted entries
+ */
+ protected function cleanCommentMarkers() {
+ $deletedEntries = $this->cleanUp('comments_read_markers', 'object_id', 'object_type');
+ $this->logger->debug("$deletedEntries orphaned comment read marks deleted", ['app' => 'DeleteOrphanedItems']);
+ return $deletedEntries;
+ }
+}
diff --git a/apps/files/lib/BackgroundJob/ScanFiles.php b/apps/files/lib/BackgroundJob/ScanFiles.php
new file mode 100644
index 00000000000..f3f9093d648
--- /dev/null
+++ b/apps/files/lib/BackgroundJob/ScanFiles.php
@@ -0,0 +1,143 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+namespace OCA\Files\BackgroundJob;
+
+use OC\Files\Utils\Scanner;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class ScanFiles is a background job used to run the file scanner over the user
+ * accounts to ensure integrity of the file cache.
+ *
+ * @package OCA\Files\BackgroundJob
+ */
+class ScanFiles extends TimedJob {
+ /** Amount of users that should get scanned per execution */
+ public const USERS_PER_SESSION = 500;
+
+ public function __construct(
+ private IConfig $config,
+ private IEventDispatcher $dispatcher,
+ private LoggerInterface $logger,
+ private IDBConnection $connection,
+ ITimeFactory $time,
+ ) {
+ parent::__construct($time);
+ // Run once per 10 minutes
+ $this->setInterval(60 * 10);
+ }
+
+ protected function runScanner(string $user): void {
+ try {
+ $scanner = new Scanner(
+ $user,
+ null,
+ $this->dispatcher,
+ $this->logger
+ );
+ $scanner->backgroundScan('');
+ } catch (\Exception $e) {
+ $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'files']);
+ }
+ \OC_Util::tearDownFS();
+ }
+
+ /**
+ * Find a storage which have unindexed files and return a user with access to the storage
+ *
+ * @return string|false
+ */
+ private function getUserToScan() {
+ if ($this->connection->getShardDefinition('filecache')) {
+ // for sharded filecache, the "LIMIT" from the normal query doesn't work
+
+ // first we try it with a "LEFT JOIN" on mounts, this is fast, but might return a storage that isn't mounted.
+ // we also ask for up to 10 results from different storages to increase the odds of finding a result that is mounted
+ $query = $this->connection->getQueryBuilder();
+ $query->select('m.user_id')
+ ->from('filecache', 'f')
+ ->leftJoin('f', 'mounts', 'm', $query->expr()->eq('m.storage_id', 'f.storage'))
+ ->where($query->expr()->eq('f.size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT)))
+ ->andWhere($query->expr()->gt('f.parent', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT)))
+ ->setMaxResults(10)
+ ->groupBy('f.storage')
+ ->runAcrossAllShards();
+
+ $result = $query->executeQuery();
+ while ($res = $result->fetch()) {
+ if ($res['user_id']) {
+ return $res['user_id'];
+ }
+ }
+
+ // as a fallback, we try a slower approach where we find all mounted storages first
+ // this is essentially doing the inner join manually
+ $storages = $this->getAllMountedStorages();
+
+ $query = $this->connection->getQueryBuilder();
+ $query->select('m.user_id')
+ ->from('filecache', 'f')
+ ->leftJoin('f', 'mounts', 'm', $query->expr()->eq('m.storage_id', 'f.storage'))
+ ->where($query->expr()->eq('f.size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT)))
+ ->andWhere($query->expr()->gt('f.parent', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT)))
+ ->andWhere($query->expr()->in('f.storage', $query->createNamedParameter($storages, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->setMaxResults(1)
+ ->runAcrossAllShards();
+ return $query->executeQuery()->fetchOne();
+ } else {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('m.user_id')
+ ->from('filecache', 'f')
+ ->innerJoin('f', 'mounts', 'm', $query->expr()->eq('m.storage_id', 'f.storage'))
+ ->where($query->expr()->eq('f.size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT)))
+ ->andWhere($query->expr()->gt('f.parent', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT)))
+ ->setMaxResults(1)
+ ->runAcrossAllShards();
+
+ return $query->executeQuery()->fetchOne();
+ }
+ }
+
+ private function getAllMountedStorages(): array {
+ $query = $this->connection->getQueryBuilder();
+ $query->selectDistinct('storage_id')
+ ->from('mounts');
+ return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ }
+
+ /**
+ * @param $argument
+ * @throws \Exception
+ */
+ protected function run($argument) {
+ if ($this->config->getSystemValueBool('files_no_background_scan', false)) {
+ return;
+ }
+
+ $usersScanned = 0;
+ $lastUser = '';
+ $user = $this->getUserToScan();
+ while ($user && $usersScanned < self::USERS_PER_SESSION && $lastUser !== $user) {
+ $this->runScanner($user);
+ $lastUser = $user;
+ $user = $this->getUserToScan();
+ $usersScanned += 1;
+ }
+
+ if ($lastUser === $user) {
+ $this->logger->warning("User $user still has unscanned files after running background scan, background scan might be stopped prematurely");
+ }
+ }
+}
diff --git a/apps/files/lib/BackgroundJob/TransferOwnership.php b/apps/files/lib/BackgroundJob/TransferOwnership.php
new file mode 100644
index 00000000000..de8d1989733
--- /dev/null
+++ b/apps/files/lib/BackgroundJob/TransferOwnership.php
@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\BackgroundJob;
+
+use OCA\Files\AppInfo\Application;
+use OCA\Files\Db\TransferOwnership as Transfer;
+use OCA\Files\Db\TransferOwnershipMapper;
+use OCA\Files\Exception\TransferOwnershipException;
+use OCA\Files\Service\OwnershipTransferService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\QueuedJob;
+use OCP\Files\IRootFolder;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Notification\IManager as NotificationManager;
+use Psr\Log\LoggerInterface;
+use function ltrim;
+
+class TransferOwnership extends QueuedJob {
+ public function __construct(
+ ITimeFactory $timeFactory,
+ private IUserManager $userManager,
+ private OwnershipTransferService $transferService,
+ private LoggerInterface $logger,
+ private NotificationManager $notificationManager,
+ private TransferOwnershipMapper $mapper,
+ private IRootFolder $rootFolder,
+ ) {
+ parent::__construct($timeFactory);
+ }
+
+ protected function run($argument) {
+ $id = $argument['id'];
+
+ $transfer = $this->mapper->getById($id);
+ $sourceUser = $transfer->getSourceUser();
+ $destinationUser = $transfer->getTargetUser();
+ $fileId = $transfer->getFileId();
+
+ $userFolder = $this->rootFolder->getUserFolder($sourceUser);
+ $node = $userFolder->getFirstNodeById($fileId);
+
+ if (!$node) {
+ $this->logger->alert('Could not transfer ownership: Node not found');
+ $this->failedNotication($transfer);
+ return;
+ }
+ $path = $userFolder->getRelativePath($node->getPath());
+
+ $sourceUserObject = $this->userManager->get($sourceUser);
+ $destinationUserObject = $this->userManager->get($destinationUser);
+
+ if (!$sourceUserObject instanceof IUser) {
+ $this->logger->alert('Could not transfer ownership: Unknown source user ' . $sourceUser);
+ $this->failedNotication($transfer);
+ return;
+ }
+
+ if (!$destinationUserObject instanceof IUser) {
+ $this->logger->alert("Unknown destination user $destinationUser");
+ $this->failedNotication($transfer);
+ return;
+ }
+
+ try {
+ $this->transferService->transfer(
+ $sourceUserObject,
+ $destinationUserObject,
+ ltrim($path, '/')
+ );
+ $this->successNotification($transfer);
+ } catch (TransferOwnershipException $e) {
+ $this->logger->error(
+ $e->getMessage(),
+ [
+ 'exception' => $e,
+ ],
+ );
+ $this->failedNotication($transfer);
+ }
+
+ $this->mapper->delete($transfer);
+ }
+
+ private function failedNotication(Transfer $transfer): void {
+ // Send notification to source user
+ $notification = $this->notificationManager->createNotification();
+ $notification->setUser($transfer->getSourceUser())
+ ->setApp(Application::APP_ID)
+ ->setDateTime($this->time->getDateTime())
+ ->setSubject('transferOwnershipFailedSource', [
+ 'sourceUser' => $transfer->getSourceUser(),
+ 'targetUser' => $transfer->getTargetUser(),
+ 'nodeName' => $transfer->getNodeName(),
+ ])
+ ->setObject('transfer', (string)$transfer->getId());
+ $this->notificationManager->notify($notification);
+ // Send notification to source user
+ $notification = $this->notificationManager->createNotification();
+ $notification->setUser($transfer->getTargetUser())
+ ->setApp(Application::APP_ID)
+ ->setDateTime($this->time->getDateTime())
+ ->setSubject('transferOwnershipFailedTarget', [
+ 'sourceUser' => $transfer->getSourceUser(),
+ 'targetUser' => $transfer->getTargetUser(),
+ 'nodeName' => $transfer->getNodeName(),
+ ])
+ ->setObject('transfer', (string)$transfer->getId());
+ $this->notificationManager->notify($notification);
+ }
+
+ private function successNotification(Transfer $transfer): void {
+ // Send notification to source user
+ $notification = $this->notificationManager->createNotification();
+ $notification->setUser($transfer->getSourceUser())
+ ->setApp(Application::APP_ID)
+ ->setDateTime($this->time->getDateTime())
+ ->setSubject('transferOwnershipDoneSource', [
+ 'sourceUser' => $transfer->getSourceUser(),
+ 'targetUser' => $transfer->getTargetUser(),
+ 'nodeName' => $transfer->getNodeName(),
+ ])
+ ->setObject('transfer', (string)$transfer->getId());
+ $this->notificationManager->notify($notification);
+
+ // Send notification to source user
+ $notification = $this->notificationManager->createNotification();
+ $notification->setUser($transfer->getTargetUser())
+ ->setApp(Application::APP_ID)
+ ->setDateTime($this->time->getDateTime())
+ ->setSubject('transferOwnershipDoneTarget', [
+ 'sourceUser' => $transfer->getSourceUser(),
+ 'targetUser' => $transfer->getTargetUser(),
+ 'nodeName' => $transfer->getNodeName(),
+ ])
+ ->setObject('transfer', (string)$transfer->getId());
+ $this->notificationManager->notify($notification);
+ }
+}
diff --git a/apps/files/lib/Capabilities.php b/apps/files/lib/Capabilities.php
new file mode 100644
index 00000000000..6b50e5807a5
--- /dev/null
+++ b/apps/files/lib/Capabilities.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files;
+
+use OC\Files\FilenameValidator;
+use OCA\Files\Service\ChunkedUploadConfig;
+use OCP\Capabilities\ICapability;
+use OCP\Files\Conversion\ConversionMimeProvider;
+use OCP\Files\Conversion\IConversionManager;
+
+class Capabilities implements ICapability {
+
+ public function __construct(
+ protected FilenameValidator $filenameValidator,
+ protected IConversionManager $fileConversionManager,
+ ) {
+ }
+
+ /**
+ * Return this classes capabilities
+ *
+ * @return array{files: array{'$comment': ?string, bigfilechunking: bool, blacklisted_files: list<mixed>, forbidden_filenames: list<string>, forbidden_filename_basenames: list<string>, forbidden_filename_characters: list<string>, forbidden_filename_extensions: list<string>, chunked_upload: array{max_size: int, max_parallel_count: int}, file_conversions: list<array{from: string, to: string, extension: string, displayName: string}>}}
+ */
+ public function getCapabilities(): array {
+ return [
+ 'files' => [
+ '$comment' => '"blacklisted_files" is deprecated as of Nextcloud 30, use "forbidden_filenames" instead',
+ 'blacklisted_files' => $this->filenameValidator->getForbiddenFilenames(),
+ 'forbidden_filenames' => $this->filenameValidator->getForbiddenFilenames(),
+ 'forbidden_filename_basenames' => $this->filenameValidator->getForbiddenBasenames(),
+ 'forbidden_filename_characters' => $this->filenameValidator->getForbiddenCharacters(),
+ 'forbidden_filename_extensions' => $this->filenameValidator->getForbiddenExtensions(),
+
+ 'bigfilechunking' => true,
+ 'chunked_upload' => [
+ 'max_size' => ChunkedUploadConfig::getMaxChunkSize(),
+ 'max_parallel_count' => ChunkedUploadConfig::getMaxParallelCount(),
+ ],
+
+ 'file_conversions' => array_map(function (ConversionMimeProvider $mimeProvider) {
+ return $mimeProvider->jsonSerialize();
+ }, $this->fileConversionManager->getProviders()),
+ ],
+ ];
+ }
+}
diff --git a/apps/files/lib/Collaboration/Resources/Listener.php b/apps/files/lib/Collaboration/Resources/Listener.php
new file mode 100644
index 00000000000..e4ff5d83b7a
--- /dev/null
+++ b/apps/files/lib/Collaboration/Resources/Listener.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Collaboration\Resources;
+
+use OCP\Collaboration\Resources\IManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Server;
+use OCP\Share\Events\ShareCreatedEvent;
+use OCP\Share\Events\ShareDeletedEvent;
+use OCP\Share\Events\ShareDeletedFromSelfEvent;
+
+class Listener {
+ public static function register(IEventDispatcher $dispatcher): void {
+ $dispatcher->addListener(ShareCreatedEvent::class, [self::class, 'shareModification']);
+ $dispatcher->addListener(ShareDeletedEvent::class, [self::class, 'shareModification']);
+ $dispatcher->addListener(ShareDeletedFromSelfEvent::class, [self::class, 'shareModification']);
+ }
+
+ public static function shareModification(): void {
+ /** @var IManager $resourceManager */
+ $resourceManager = Server::get(IManager::class);
+ /** @var ResourceProvider $resourceProvider */
+ $resourceProvider = Server::get(ResourceProvider::class);
+
+ $resourceManager->invalidateAccessCacheForProvider($resourceProvider);
+ }
+}
diff --git a/apps/files/lib/Collaboration/Resources/ResourceProvider.php b/apps/files/lib/Collaboration/Resources/ResourceProvider.php
new file mode 100644
index 00000000000..73883bc4c6a
--- /dev/null
+++ b/apps/files/lib/Collaboration/Resources/ResourceProvider.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Collaboration\Resources;
+
+use OCP\Collaboration\Resources\IProvider;
+use OCP\Collaboration\Resources\IResource;
+use OCP\Collaboration\Resources\ResourceException;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\IPreview;
+use OCP\IURLGenerator;
+use OCP\IUser;
+
+class ResourceProvider implements IProvider {
+ public const RESOURCE_TYPE = 'file';
+
+ /** @var array */
+ protected $nodes = [];
+
+ public function __construct(
+ protected IRootFolder $rootFolder,
+ private IPreview $preview,
+ private IURLGenerator $urlGenerator,
+ ) {
+ }
+
+ private function getNode(IResource $resource): ?Node {
+ if (isset($this->nodes[(int)$resource->getId()])) {
+ return $this->nodes[(int)$resource->getId()];
+ }
+ $node = $this->rootFolder->getFirstNodeById((int)$resource->getId());
+ if ($node) {
+ $this->nodes[(int)$resource->getId()] = $node;
+ return $this->nodes[(int)$resource->getId()];
+ }
+ return null;
+ }
+
+ /**
+ * @param IResource $resource
+ * @return array
+ * @since 16.0.0
+ */
+ public function getResourceRichObject(IResource $resource): array {
+ if (isset($this->nodes[(int)$resource->getId()])) {
+ $node = $this->nodes[(int)$resource->getId()]->getPath();
+ } else {
+ $node = $this->getNode($resource);
+ }
+
+ if ($node instanceof Node) {
+ $link = $this->urlGenerator->linkToRouteAbsolute(
+ 'files.viewcontroller.showFile',
+ ['fileid' => $resource->getId()]
+ );
+ return [
+ 'type' => 'file',
+ 'id' => $resource->getId(),
+ 'name' => $node->getName(),
+ 'path' => $node->getInternalPath(),
+ 'link' => $link,
+ 'mimetype' => $node->getMimetype(),
+ 'preview-available' => $this->preview->isAvailable($node),
+ ];
+ }
+
+ throw new ResourceException('File not found');
+ }
+
+ /**
+ * Can a user/guest access the collection
+ *
+ * @param IResource $resource
+ * @param IUser $user
+ * @return bool
+ * @since 16.0.0
+ */
+ public function canAccessResource(IResource $resource, ?IUser $user = null): bool {
+ if (!$user instanceof IUser) {
+ return false;
+ }
+
+ $userFolder = $this->rootFolder->getUserFolder($user->getUID());
+ $node = $userFolder->getById((int)$resource->getId());
+
+ if ($node) {
+ $this->nodes[(int)$resource->getId()] = $node;
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the resource type of the provider
+ *
+ * @return string
+ * @since 16.0.0
+ */
+ public function getType(): string {
+ return self::RESOURCE_TYPE;
+ }
+}
diff --git a/apps/files/lib/Command/Copy.php b/apps/files/lib/Command/Copy.php
new file mode 100644
index 00000000000..ad0dfa90de1
--- /dev/null
+++ b/apps/files/lib/Command/Copy.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\Folder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Copy extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:copy')
+ ->setDescription('Copy a file or folder')
+ ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path')
+ ->addArgument('target', InputArgument::REQUIRED, 'Target path')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for confirmation and don't output any warnings")
+ ->addOption('no-target-directory', 'T', InputOption::VALUE_NONE, 'When target path is folder, overwrite the folder instead of copying into the folder');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $sourceInput = $input->getArgument('source');
+ $targetInput = $input->getArgument('target');
+ $force = $input->getOption('force');
+ $noTargetDir = $input->getOption('no-target-directory');
+
+ $node = $this->fileUtils->getNode($sourceInput);
+ $targetNode = $this->fileUtils->getNode($targetInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $sourceInput not found</error>");
+ return 1;
+ }
+
+ $targetParentPath = dirname(rtrim($targetInput, '/'));
+ $targetParent = $this->fileUtils->getNode($targetParentPath);
+ if (!$targetParent) {
+ $output->writeln("<error>Target parent path $targetParentPath doesn't exist</error>");
+ return 1;
+ }
+
+ $wouldRequireDelete = false;
+
+ if ($targetNode) {
+ if (!$targetNode->isUpdateable()) {
+ $output->writeln("<error>$targetInput isn't writable</error>");
+ return 1;
+ }
+
+ if ($targetNode instanceof Folder) {
+ if ($noTargetDir) {
+ if (!$force) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a file, but <info>$targetInput</info> is a folder");
+ }
+ $wouldRequireDelete = true;
+ } else {
+ $targetInput = $targetNode->getFullPath($node->getName());
+ $targetNode = $this->fileUtils->getNode($targetInput);
+ }
+ } else {
+ if ($node instanceof Folder) {
+ if (!$force) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a folder, but <info>$targetInput</info> is a file");
+ }
+ $wouldRequireDelete = true;
+ }
+ }
+
+ if ($wouldRequireDelete && $targetNode->getInternalPath() === '') {
+ $output->writeln("<error>Mount root can't be overwritten with a different type</error>");
+ return 1;
+ }
+
+ if ($wouldRequireDelete && !$targetNode->isDeletable()) {
+ $output->writeln("<error>$targetInput can't be deleted to be replaced with $sourceInput</error>");
+ return 1;
+ }
+
+ if (!$force && $targetNode) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+
+ $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return 1;
+ }
+ }
+ }
+
+ if ($wouldRequireDelete && $targetNode) {
+ $targetNode->delete();
+ }
+
+ $node->copy($targetInput);
+
+ return 0;
+ }
+
+}
diff --git a/apps/files/lib/Command/Delete.php b/apps/files/lib/Command/Delete.php
new file mode 100644
index 00000000000..d984f839c91
--- /dev/null
+++ b/apps/files/lib/Command/Delete.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCA\Files_Sharing\SharedStorage;
+use OCP\Files\Folder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Delete extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:delete')
+ ->setDescription('Delete a file or folder')
+ ->addArgument('file', InputArgument::REQUIRED, 'File id or path')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $fileInput = $input->getArgument('file');
+ $inputIsId = is_numeric($fileInput);
+ $force = $input->getOption('force');
+ $node = $this->fileUtils->getNode($fileInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $fileInput not found</error>");
+ return self::FAILURE;
+ }
+
+ $deleteConfirmed = $force;
+ if (!$deleteConfirmed) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $storage = $node->getStorage();
+ if (!$inputIsId && $storage->instanceOfStorage(SharedStorage::class) && $node->getInternalPath() === '') {
+ /** @var SharedStorage $storage */
+ [,$user] = explode('/', $fileInput, 3);
+ $question = new ConfirmationQuestion("<info>$fileInput</info> in a shared file, do you want to unshare the file from <info>$user</info> instead of deleting the source file? [Y/n] ", true);
+ if ($helper->ask($input, $output, $question)) {
+ $storage->unshareStorage();
+ return self::SUCCESS;
+ } else {
+ $node = $storage->getShare()->getNode();
+ $output->writeln('');
+ }
+ }
+
+ $filesByUsers = $this->fileUtils->getFilesByUser($node);
+ if (count($filesByUsers) > 1) {
+ $output->writeln('Warning: the provided file is accessible by more than one user');
+ $output->writeln(' all of the following users will lose access to the file when deleted:');
+ $output->writeln('');
+ foreach ($filesByUsers as $user => $filesByUser) {
+ $output->writeln($user . ':');
+ foreach ($filesByUser as $file) {
+ $output->writeln(' - ' . $file->getPath());
+ }
+ }
+ $output->writeln('');
+ }
+
+ if ($node instanceof Folder) {
+ $maybeContents = " and all it's contents";
+ } else {
+ $maybeContents = '';
+ }
+ $question = new ConfirmationQuestion('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false);
+ $deleteConfirmed = $helper->ask($input, $output, $question);
+ }
+
+ if ($deleteConfirmed) {
+ if ($node->isDeletable()) {
+ $node->delete();
+ } else {
+ $output->writeln('<error>File cannot be deleted, insufficient permissions.</error>');
+ }
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/DeleteOrphanedFiles.php b/apps/files/lib/Command/DeleteOrphanedFiles.php
new file mode 100644
index 00000000000..37cb3159f4a
--- /dev/null
+++ b/apps/files/lib/Command/DeleteOrphanedFiles.php
@@ -0,0 +1,168 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\Command;
+
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Delete all file entries that have no matching entries in the storage table.
+ */
+class DeleteOrphanedFiles extends Command {
+ public const CHUNK_SIZE = 200;
+
+ public function __construct(
+ protected IDBConnection $connection,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:cleanup')
+ ->setDescription('Clean up orphaned filecache and mount entries')
+ ->setHelp('Deletes orphaned filecache and mount entries (those without an existing storage).')
+ ->addOption('skip-filecache-extended', null, InputOption::VALUE_NONE, 'don\'t remove orphaned entries from filecache_extended');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $fileIdsByStorage = [];
+
+ $deletedStorages = array_diff($this->getReferencedStorages(), $this->getExistingStorages());
+
+ $deleteExtended = !$input->getOption('skip-filecache-extended');
+ if ($deleteExtended) {
+ $fileIdsByStorage = $this->getFileIdsForStorages($deletedStorages);
+ }
+
+ $deletedEntries = $this->cleanupOrphanedFileCache($deletedStorages);
+ $output->writeln("$deletedEntries orphaned file cache entries deleted");
+
+ if ($deleteExtended) {
+ $deletedFileCacheExtended = $this->cleanupOrphanedFileCacheExtended($fileIdsByStorage);
+ $output->writeln("$deletedFileCacheExtended orphaned file cache extended entries deleted");
+ }
+
+ $deletedMounts = $this->cleanupOrphanedMounts();
+ $output->writeln("$deletedMounts orphaned mount entries deleted");
+
+ return self::SUCCESS;
+ }
+
+ private function getReferencedStorages(): array {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('storage')
+ ->from('filecache')
+ ->groupBy('storage')
+ ->runAcrossAllShards();
+ return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ }
+
+ private function getExistingStorages(): array {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('numeric_id')
+ ->from('storages')
+ ->groupBy('numeric_id');
+ return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ }
+
+ /**
+ * @param int[] $storageIds
+ * @return array<int, int[]>
+ */
+ private function getFileIdsForStorages(array $storageIds): array {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('storage', 'fileid')
+ ->from('filecache')
+ ->where($query->expr()->in('storage', $query->createParameter('storage_ids')));
+
+ $result = [];
+ $storageIdChunks = array_chunk($storageIds, self::CHUNK_SIZE);
+ foreach ($storageIdChunks as $storageIdChunk) {
+ $query->setParameter('storage_ids', $storageIdChunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $chunk = $query->executeQuery()->fetchAll();
+ foreach ($chunk as $row) {
+ $result[$row['storage']][] = $row['fileid'];
+ }
+ }
+ return $result;
+ }
+
+ private function cleanupOrphanedFileCache(array $deletedStorages): int {
+ $deletedEntries = 0;
+
+ $deleteQuery = $this->connection->getQueryBuilder();
+ $deleteQuery->delete('filecache')
+ ->where($deleteQuery->expr()->in('storage', $deleteQuery->createParameter('storage_ids')));
+
+ $deletedStorageChunks = array_chunk($deletedStorages, self::CHUNK_SIZE);
+ foreach ($deletedStorageChunks as $deletedStorageChunk) {
+ $deleteQuery->setParameter('storage_ids', $deletedStorageChunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $deletedEntries += $deleteQuery->executeStatement();
+ }
+
+ return $deletedEntries;
+ }
+
+ /**
+ * @param array<int, int[]> $fileIdsByStorage
+ * @return int
+ */
+ private function cleanupOrphanedFileCacheExtended(array $fileIdsByStorage): int {
+ $deletedEntries = 0;
+
+ $deleteQuery = $this->connection->getQueryBuilder();
+ $deleteQuery->delete('filecache_extended')
+ ->where($deleteQuery->expr()->in('fileid', $deleteQuery->createParameter('file_ids')));
+
+ foreach ($fileIdsByStorage as $storageId => $fileIds) {
+ $deleteQuery->hintShardKey('storage', $storageId, true);
+ $fileChunks = array_chunk($fileIds, self::CHUNK_SIZE);
+ foreach ($fileChunks as $fileChunk) {
+ $deleteQuery->setParameter('file_ids', $fileChunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $deletedEntries += $deleteQuery->executeStatement();
+ }
+ }
+
+ return $deletedEntries;
+ }
+
+ private function cleanupOrphanedMounts(): int {
+ $deletedEntries = 0;
+
+ $query = $this->connection->getQueryBuilder();
+ $query->select('m.storage_id')
+ ->from('mounts', 'm')
+ ->where($query->expr()->isNull('s.numeric_id'))
+ ->leftJoin('m', 'storages', 's', $query->expr()->eq('m.storage_id', 's.numeric_id'))
+ ->groupBy('storage_id')
+ ->setMaxResults(self::CHUNK_SIZE);
+
+ $deleteQuery = $this->connection->getQueryBuilder();
+ $deleteQuery->delete('mounts')
+ ->where($deleteQuery->expr()->eq('storage_id', $deleteQuery->createParameter('storageid')));
+
+ $deletedInLastChunk = self::CHUNK_SIZE;
+ while ($deletedInLastChunk === self::CHUNK_SIZE) {
+ $deletedInLastChunk = 0;
+ $result = $query->executeQuery();
+ while ($row = $result->fetch()) {
+ $deletedInLastChunk++;
+ $deletedEntries += $deleteQuery->setParameter('storageid', (int)$row['storage_id'])
+ ->executeStatement();
+ }
+ $result->closeCursor();
+ }
+
+ return $deletedEntries;
+ }
+}
diff --git a/apps/files/lib/Command/Get.php b/apps/files/lib/Command/Get.php
new file mode 100644
index 00000000000..60e028f615e
--- /dev/null
+++ b/apps/files/lib/Command/Get.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\File;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Get extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:get')
+ ->setDescription('Get the contents of a file')
+ ->addArgument('file', InputArgument::REQUIRED, 'Source file id or Nextcloud path')
+ ->addArgument('output', InputArgument::OPTIONAL, 'Target local file to output to, defaults to STDOUT');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $fileInput = $input->getArgument('file');
+ $outputName = $input->getArgument('output');
+ $node = $this->fileUtils->getNode($fileInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $fileInput not found</error>");
+ return self::FAILURE;
+ }
+
+ if (!($node instanceof File)) {
+ $output->writeln("<error>$fileInput is a directory</error>");
+ return self::FAILURE;
+ }
+
+ $isTTY = stream_isatty(STDOUT);
+ if ($outputName === null && $isTTY && $node->getMimePart() !== 'text') {
+ $output->writeln([
+ '<error>Warning: Binary output can mess up your terminal</error>',
+ " Use <info>occ files:get $fileInput -</info> to output it to the terminal anyway",
+ " Or <info>occ files:get $fileInput <FILE></info> to save to a file instead"
+ ]);
+ return self::FAILURE;
+ }
+ $source = $node->fopen('r');
+ if (!$source) {
+ $output->writeln("<error>Failed to open $fileInput for reading</error>");
+ return self::FAILURE;
+ }
+ $target = ($outputName === null || $outputName === '-') ? STDOUT : fopen($outputName, 'w');
+ if (!$target) {
+ $output->writeln("<error>Failed to open $outputName for reading</error>");
+ return self::FAILURE;
+ }
+
+ stream_copy_to_stream($source, $target);
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php
new file mode 100644
index 00000000000..29dd8860b2a
--- /dev/null
+++ b/apps/files/lib/Command/Move.php
@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\File;
+use OCP\Files\Folder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Move extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:move')
+ ->setDescription('Move a file or folder')
+ ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path')
+ ->addArgument('target', InputArgument::REQUIRED, 'Target path')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $sourceInput = $input->getArgument('source');
+ $targetInput = $input->getArgument('target');
+ $force = $input->getOption('force');
+
+ $node = $this->fileUtils->getNode($sourceInput);
+ $targetNode = $this->fileUtils->getNode($targetInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $sourceInput not found</error>");
+ return 1;
+ }
+
+ $targetParentPath = dirname(rtrim($targetInput, '/'));
+ $targetParent = $this->fileUtils->getNode($targetParentPath);
+ if (!$targetParent) {
+ $output->writeln("<error>Target parent path $targetParentPath doesn't exist</error>");
+ return 1;
+ }
+
+ $wouldRequireDelete = false;
+
+ if ($targetNode) {
+ if (!$targetNode->isUpdateable()) {
+ $output->writeln("<error>$targetInput already exists and isn't writable</error>");
+ return 1;
+ }
+
+ if ($node instanceof Folder && $targetNode instanceof File) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a folder, but <info>$targetInput</info> is a file");
+ $wouldRequireDelete = true;
+ }
+
+ if ($node instanceof File && $targetNode instanceof Folder) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a file, but <info>$targetInput</info> is a folder");
+ $wouldRequireDelete = true;
+ }
+
+ if ($wouldRequireDelete && $targetNode->getInternalPath() === '') {
+ $output->writeln("<error>Mount root can't be overwritten with a different type</error>");
+ return 1;
+ }
+
+ if ($wouldRequireDelete && !$targetNode->isDeletable()) {
+ $output->writeln("<error>$targetInput can't be deleted to be replaced with $sourceInput</error>");
+ return 1;
+ }
+
+ if (!$force) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+
+ $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return 1;
+ }
+ }
+ }
+
+ if ($wouldRequireDelete && $targetNode) {
+ $targetNode->delete();
+ }
+
+ $node->move($targetInput);
+
+ return 0;
+ }
+
+}
diff --git a/apps/files/lib/Command/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php
new file mode 100644
index 00000000000..07613ecc616
--- /dev/null
+++ b/apps/files/lib/Command/Object/Delete.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Delete extends Command {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:object:delete')
+ ->setDescription('Delete an object from the object store')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to delete')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to delete the object from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return -1;
+ }
+
+ if ($fileId = $this->objectUtils->objectExistsInDb($object)) {
+ $output->writeln("<error>Warning, object $object belongs to an existing file, deleting the object will lead to unexpected behavior if not replaced</error>");
+ $output->writeln(" Note: use <info>occ files:delete $fileId</info> to delete the file cleanly or <info>occ info:file $fileId</info> for more information about the file");
+ $output->writeln('');
+ }
+
+ if (!$objectStore->objectExists($object)) {
+ $output->writeln("<error>Object $object does not exist</error>");
+ return -1;
+ }
+
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion("Delete $object? [y/N] ", false);
+ if ($helper->ask($input, $output, $question)) {
+ $objectStore->deleteObject($object);
+ }
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Get.php b/apps/files/lib/Command/Object/Get.php
new file mode 100644
index 00000000000..c32de020c5a
--- /dev/null
+++ b/apps/files/lib/Command/Object/Get.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Get extends Command {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:object:get')
+ ->setDescription('Get the contents of an object')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to get')
+ ->addArgument('output', InputArgument::REQUIRED, 'Target local file to output to, use - for STDOUT')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $outputName = $input->getArgument('output');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore->objectExists($object)) {
+ $output->writeln("<error>Object $object does not exist</error>");
+ return self::FAILURE;
+ }
+
+ try {
+ $source = $objectStore->readObject($object);
+ } catch (\Exception $e) {
+ $msg = $e->getMessage();
+ $output->writeln("<error>Failed to read $object from object store: $msg</error>");
+ return self::FAILURE;
+ }
+ $target = $outputName === '-' ? STDOUT : fopen($outputName, 'w');
+ if (!$target) {
+ $output->writeln("<error>Failed to open $outputName for writing</error>");
+ return self::FAILURE;
+ }
+
+ stream_copy_to_stream($source, $target);
+ return self::SUCCESS;
+ }
+
+}
diff --git a/apps/files/lib/Command/Object/Info.php b/apps/files/lib/Command/Object/Info.php
new file mode 100644
index 00000000000..6748de37cfe
--- /dev/null
+++ b/apps/files/lib/Command/Object/Info.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OC\Core\Command\Base;
+use OCP\Files\IMimeTypeDetector;
+use OCP\Files\ObjectStore\IObjectStoreMetaData;
+use OCP\Util;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Info extends Base {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ private IMimeTypeDetector $mimeTypeDetector,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:info')
+ ->setDescription('Get the metadata of an object')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to get')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore instanceof IObjectStoreMetaData) {
+ $output->writeln('<error>Configured object store does currently not support retrieve metadata</error>');
+ return self::FAILURE;
+ }
+
+ if (!$objectStore->objectExists($object)) {
+ $output->writeln("<error>Object $object does not exist</error>");
+ return self::FAILURE;
+ }
+
+ try {
+ $meta = $objectStore->getObjectMetaData($object);
+ } catch (\Exception $e) {
+ $msg = $e->getMessage();
+ $output->writeln("<error>Failed to read $object from object store: $msg</error>");
+ return self::FAILURE;
+ }
+
+ if ($input->getOption('output') === 'plain' && isset($meta['size'])) {
+ $meta['size'] = Util::humanFileSize($meta['size']);
+ }
+ if (isset($meta['mtime'])) {
+ $meta['mtime'] = $meta['mtime']->format(\DateTimeImmutable::ATOM);
+ }
+ if (!isset($meta['mimetype'])) {
+ $handle = $objectStore->readObject($object);
+ $head = fread($handle, 8192);
+ fclose($handle);
+ $meta['mimetype'] = $this->mimeTypeDetector->detectString($head);
+ }
+
+ $this->writeArrayInOutputFormat($input, $output, $meta);
+
+ return self::SUCCESS;
+ }
+
+}
diff --git a/apps/files/lib/Command/Object/ListObject.php b/apps/files/lib/Command/Object/ListObject.php
new file mode 100644
index 00000000000..5d30232e09f
--- /dev/null
+++ b/apps/files/lib/Command/Object/ListObject.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OC\Core\Command\Base;
+use OCP\Files\ObjectStore\IObjectStoreMetaData;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ListObject extends Base {
+ private const CHUNK_SIZE = 100;
+
+ public function __construct(
+ private readonly ObjectUtil $objectUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:list')
+ ->setDescription('List all objects in the object store')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore instanceof IObjectStoreMetaData) {
+ $output->writeln('<error>Configured object store does currently not support listing objects</error>');
+ return self::FAILURE;
+ }
+ $objects = $objectStore->listObjects();
+ $objects = $this->objectUtils->formatObjects($objects, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN);
+ $this->writeStreamingTableInOutputFormat($input, $output, $objects, self::CHUNK_SIZE);
+
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Multi/Rename.php b/apps/files/lib/Command/Object/Multi/Rename.php
new file mode 100644
index 00000000000..562c68eb07f
--- /dev/null
+++ b/apps/files/lib/Command/Object/Multi/Rename.php
@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object\Multi;
+
+use OC\Core\Command\Base;
+use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Rename extends Base {
+ public function __construct(
+ private readonly IDBConnection $connection,
+ private readonly PrimaryObjectStoreConfig $objectStoreConfig,
+ private readonly IConfig $config,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:multi:rename-config')
+ ->setDescription('Rename an object store configuration and move all users over to the new configuration,')
+ ->addArgument('source', InputArgument::REQUIRED, 'Object store configuration to rename')
+ ->addArgument('target', InputArgument::REQUIRED, 'New name for the object store configuration');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $source = $input->getArgument('source');
+ $target = $input->getArgument('target');
+
+ $configs = $this->objectStoreConfig->getObjectStoreConfigs();
+ if (!isset($configs[$source])) {
+ $output->writeln('<error>Unknown object store configuration: ' . $source . '</error>');
+ return 1;
+ }
+
+ if ($source === 'root') {
+ $output->writeln('<error>Renaming the root configuration is not supported.</error>');
+ return 1;
+ }
+
+ if ($source === 'default') {
+ $output->writeln('<error>Renaming the default configuration is not supported.</error>');
+ return 1;
+ }
+
+ if (!isset($configs[$target])) {
+ $output->writeln('<comment>Target object store configuration ' . $target . ' doesn\'t exist yet.</comment>');
+ $output->writeln('The target configuration can be created automatically.');
+ $output->writeln('However, as this depends on modifying the config.php, this only works as long as the instance runs on a single node or all nodes in a clustered setup have a shared config file (such as from a shared network mount).');
+ $output->writeln('If the different nodes have a separate copy of the config.php file, the automatic object store configuration creation will lead to the configuration going out of sync.');
+ $output->writeln('If these requirements are not met, you can manually create the target object store configuration in each node\'s configuration before running the command.');
+ $output->writeln('');
+ $output->writeln('<error>Failure to check these requirements will lead to data loss for users.</error>');
+
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Automatically create target object store configuration? [y/N] ', false);
+ if ($helper->ask($input, $output, $question)) {
+ $configs[$target] = $configs[$source];
+
+ // update all aliases
+ foreach ($configs as &$config) {
+ if ($config === $source) {
+ $config = $target;
+ }
+ }
+ $this->config->setSystemValue('objectstore', $configs);
+ } else {
+ return 0;
+ }
+ } elseif (($configs[$source] !== $configs[$target]) || $configs[$source] !== $target) {
+ $output->writeln('<error>Source and target configuration differ.</error>');
+ $output->writeln('');
+ $output->writeln('To ensure proper migration of users, the source and target configuration must be the same to ensure that the objects for the moved users exist on the target configuration.');
+ $output->writeln('The usual migration process consists of creating a clone of the old configuration, moving the users from the old configuration to the new one, and then adjust the old configuration that is longer used.');
+ return 1;
+ }
+
+ $query = $this->connection->getQueryBuilder();
+ $query->update('preferences')
+ ->set('configvalue', $query->createNamedParameter($target))
+ ->where($query->expr()->eq('appid', $query->createNamedParameter('homeobjectstore')))
+ ->andWhere($query->expr()->eq('configkey', $query->createNamedParameter('objectstore')))
+ ->andWhere($query->expr()->eq('configvalue', $query->createNamedParameter($source)));
+ $count = $query->executeStatement();
+
+ if ($count > 0) {
+ $output->writeln('Moved <info>' . $count . '</info> users');
+ } else {
+ $output->writeln('No users moved');
+ }
+
+ return 0;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Multi/Users.php b/apps/files/lib/Command/Object/Multi/Users.php
new file mode 100644
index 00000000000..e8f7d012641
--- /dev/null
+++ b/apps/files/lib/Command/Object/Multi/Users.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object\Multi;
+
+use OC\Core\Command\Base;
+use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Users extends Base {
+ public function __construct(
+ private readonly IUserManager $userManager,
+ private readonly PrimaryObjectStoreConfig $objectStoreConfig,
+ private readonly IConfig $config,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:multi:users')
+ ->setDescription('Get the mapping between users and object store buckets')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, 'Only list users using the specified bucket')
+ ->addOption('object-store', 'o', InputOption::VALUE_REQUIRED, 'Only list users using the specified object store configuration')
+ ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Only show the mapping for the specified user, ignores all other options');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ if ($userId = $input->getOption('user')) {
+ $user = $this->userManager->get($userId);
+ if (!$user) {
+ $output->writeln("<error>User $userId not found</error>");
+ return 1;
+ }
+ $users = new \ArrayIterator([$user]);
+ } else {
+ $bucket = (string)$input->getOption('bucket');
+ $objectStore = (string)$input->getOption('object-store');
+ if ($bucket !== '' && $objectStore === '') {
+ $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket));
+ } elseif ($bucket === '' && $objectStore !== '') {
+ $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore));
+ } elseif ($bucket) {
+ $users = $this->getUsers(array_intersect(
+ $this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket),
+ $this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore)
+ ));
+ } else {
+ $users = $this->userManager->getSeenUsers();
+ }
+ }
+
+ $this->writeStreamingTableInOutputFormat($input, $output, $this->infoForUsers($users), 100);
+ return 0;
+ }
+
+ /**
+ * @param string[] $userIds
+ * @return \Iterator<IUser>
+ */
+ private function getUsers(array $userIds): \Iterator {
+ foreach ($userIds as $userId) {
+ $user = $this->userManager->get($userId);
+ if ($user) {
+ yield $user;
+ }
+ }
+ }
+
+ /**
+ * @param \Iterator<IUser> $users
+ * @return \Iterator<array>
+ */
+ private function infoForUsers(\Iterator $users): \Iterator {
+ foreach ($users as $user) {
+ yield $this->infoForUser($user);
+ }
+ }
+
+ private function infoForUser(IUser $user): array {
+ return [
+ 'user' => $user->getUID(),
+ 'object-store' => $this->objectStoreConfig->getObjectStoreForUser($user),
+ 'bucket' => $this->objectStoreConfig->getSetBucketForUser($user) ?? 'unset',
+ ];
+ }
+}
diff --git a/apps/files/lib/Command/Object/ObjectUtil.php b/apps/files/lib/Command/Object/ObjectUtil.php
new file mode 100644
index 00000000000..5f053c2c42f
--- /dev/null
+++ b/apps/files/lib/Command/Object/ObjectUtil.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\Files\ObjectStore\IObjectStore;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\Util;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ObjectUtil {
+ public function __construct(
+ private IConfig $config,
+ private IDBConnection $connection,
+ ) {
+ }
+
+ private function getObjectStoreConfig(): ?array {
+ $config = $this->config->getSystemValue('objectstore_multibucket');
+ if (is_array($config)) {
+ $config['multibucket'] = true;
+ return $config;
+ }
+ $config = $this->config->getSystemValue('objectstore');
+ if (is_array($config)) {
+ if (!isset($config['multibucket'])) {
+ $config['multibucket'] = false;
+ }
+ return $config;
+ }
+
+ return null;
+ }
+
+ public function getObjectStore(?string $bucket, OutputInterface $output): ?IObjectStore {
+ $config = $this->getObjectStoreConfig();
+ if (!$config) {
+ $output->writeln('<error>Instance is not using primary object store</error>');
+ return null;
+ }
+ if ($config['multibucket'] && !$bucket) {
+ $output->writeln('<error>--bucket option required</error> because <info>multi bucket</info> is enabled.');
+ return null;
+ }
+
+ if (!isset($config['arguments'])) {
+ throw new \Exception('no arguments configured for object store configuration');
+ }
+ if (!isset($config['class'])) {
+ throw new \Exception('no class configured for object store configuration');
+ }
+
+ if ($bucket) {
+ // s3, swift
+ $config['arguments']['bucket'] = $bucket;
+ // azure
+ $config['arguments']['container'] = $bucket;
+ }
+
+ $store = new $config['class']($config['arguments']);
+ if (!$store instanceof IObjectStore) {
+ throw new \Exception('configured object store class is not an object store implementation');
+ }
+ return $store;
+ }
+
+ /**
+ * Check if an object is referenced in the database
+ */
+ public function objectExistsInDb(string $object): int|false {
+ if (!str_starts_with($object, 'urn:oid:')) {
+ return false;
+ }
+
+ $fileId = (int)substr($object, strlen('urn:oid:'));
+ $query = $this->connection->getQueryBuilder();
+ $query->select('fileid')
+ ->from('filecache')
+ ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
+ $result = $query->executeQuery();
+
+ if ($result->fetchOne() === false) {
+ return false;
+ }
+
+ return $fileId;
+ }
+
+ public function formatObjects(\Iterator $objects, bool $humanOutput): \Iterator {
+ foreach ($objects as $object) {
+ yield $this->formatObject($object, $humanOutput);
+ }
+ }
+
+ public function formatObject(array $object, bool $humanOutput): array {
+ $row = array_merge([
+ 'urn' => $object['urn'],
+ ], ($object['metadata'] ?? []));
+
+ if ($humanOutput && isset($row['size'])) {
+ $row['size'] = Util::humanFileSize($row['size']);
+ }
+ if (isset($row['mtime'])) {
+ $row['mtime'] = $row['mtime']->format(\DateTimeImmutable::ATOM);
+ }
+ return $row;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Orphans.php b/apps/files/lib/Command/Object/Orphans.php
new file mode 100644
index 00000000000..f7132540fc8
--- /dev/null
+++ b/apps/files/lib/Command/Object/Orphans.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OC\Core\Command\Base;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\Files\ObjectStore\IObjectStoreMetaData;
+use OCP\IDBConnection;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Orphans extends Base {
+ private const CHUNK_SIZE = 100;
+
+ private ?IQueryBuilder $query = null;
+
+ public function __construct(
+ private readonly ObjectUtil $objectUtils,
+ private readonly IDBConnection $connection,
+ ) {
+ parent::__construct();
+ }
+
+ private function getQuery(): IQueryBuilder {
+ if (!$this->query) {
+ $this->query = $this->connection->getQueryBuilder();
+ $this->query->select('fileid')
+ ->from('filecache')
+ ->where($this->query->expr()->eq('fileid', $this->query->createParameter('file_id')));
+ }
+ return $this->query;
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:orphans')
+ ->setDescription('List all objects in the object store that don\'t have a matching entry in the database')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore instanceof IObjectStoreMetaData) {
+ $output->writeln('<error>Configured object store does currently not support listing objects</error>');
+ return self::FAILURE;
+ }
+ $prefixLength = strlen('urn:oid:');
+
+ $objects = $objectStore->listObjects('urn:oid:');
+ $orphans = new \CallbackFilterIterator($objects, function (array $object) use ($prefixLength) {
+ $fileId = (int)substr($object['urn'], $prefixLength);
+ return !$this->fileIdInDb($fileId);
+ });
+
+ $orphans = $this->objectUtils->formatObjects($orphans, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN);
+ $this->writeStreamingTableInOutputFormat($input, $output, $orphans, self::CHUNK_SIZE);
+
+ return self::SUCCESS;
+ }
+
+ private function fileIdInDb(int $fileId): bool {
+ $query = $this->getQuery();
+ $query->setParameter('file_id', $fileId, IQueryBuilder::PARAM_INT);
+ $result = $query->executeQuery();
+ return $result->fetchOne() !== false;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Put.php b/apps/files/lib/Command/Object/Put.php
new file mode 100644
index 00000000000..8516eb51183
--- /dev/null
+++ b/apps/files/lib/Command/Object/Put.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OCP\Files\IMimeTypeDetector;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Put extends Command {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ private IMimeTypeDetector $mimeTypeDetector,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:object:put')
+ ->setDescription('Write a file to the object store')
+ ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to write')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket where to store the object, only required in cases where it can't be determined from the config");
+ ;
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $inputName = (string)$input->getArgument('input');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return -1;
+ }
+
+ if ($fileId = $this->objectUtils->objectExistsInDb($object)) {
+ $output->writeln("<error>Warning, object $object belongs to an existing file, overwriting the object contents can lead to unexpected behavior.</error>");
+ $output->writeln("You can use <info>occ files:put $inputName $fileId</info> to write to the file safely.");
+ $output->writeln('');
+
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Write to the object anyway? [y/N] ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return -1;
+ }
+ }
+
+ $source = $inputName === '-' ? STDIN : fopen($inputName, 'r');
+ if (!$source) {
+ $output->writeln("<error>Failed to open $inputName</error>");
+ return self::FAILURE;
+ }
+ $objectStore->writeObject($object, $source, $this->mimeTypeDetector->detectPath($inputName));
+ return self::SUCCESS;
+ }
+
+}
diff --git a/apps/files/lib/Command/Put.php b/apps/files/lib/Command/Put.php
new file mode 100644
index 00000000000..fd9d75db78c
--- /dev/null
+++ b/apps/files/lib/Command/Put.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\File;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Put extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ private IRootFolder $rootFolder,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:put')
+ ->setDescription('Write contents of a file')
+ ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN')
+ ->addArgument('file', InputArgument::REQUIRED, 'Target Nextcloud file path to write to or fileid of existing file');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $fileOutput = $input->getArgument('file');
+ $inputName = $input->getArgument('input');
+ $node = $this->fileUtils->getNode($fileOutput);
+
+ if ($node instanceof Folder) {
+ $output->writeln("<error>$fileOutput is a folder</error>");
+ return self::FAILURE;
+ }
+ if (!$node and is_numeric($fileOutput)) {
+ $output->writeln("<error>$fileOutput not found</error>");
+ return self::FAILURE;
+ }
+
+ $source = ($inputName === null || $inputName === '-') ? STDIN : fopen($inputName, 'r');
+ if (!$source) {
+ $output->writeln("<error>Failed to open $inputName</error>");
+ return self::FAILURE;
+ }
+ if ($node instanceof File) {
+ $target = $node->fopen('w');
+ if (!$target) {
+ $output->writeln("<error>Failed to open $fileOutput</error>");
+ return self::FAILURE;
+ }
+ stream_copy_to_stream($source, $target);
+ } else {
+ $this->rootFolder->newFile($fileOutput, $source);
+ }
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/RepairTree.php b/apps/files/lib/Command/RepairTree.php
new file mode 100644
index 00000000000..622ccba48a3
--- /dev/null
+++ b/apps/files/lib/Command/RepairTree.php
@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Command;
+
+use OCP\IDBConnection;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class RepairTree extends Command {
+ public const CHUNK_SIZE = 200;
+
+ public function __construct(
+ protected IDBConnection $connection,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:repair-tree')
+ ->setDescription('Try and repair malformed filesystem tree structures')
+ ->addOption('dry-run');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $rows = $this->findBrokenTreeBits();
+ $fix = !$input->getOption('dry-run');
+
+ $output->writeln('Found ' . count($rows) . ' file entries with an invalid path');
+
+ if ($fix) {
+ $this->connection->beginTransaction();
+ }
+
+ $query = $this->connection->getQueryBuilder();
+ $query->update('filecache')
+ ->set('path', $query->createParameter('path'))
+ ->set('path_hash', $query->func()->md5($query->createParameter('path')))
+ ->set('storage', $query->createParameter('storage'))
+ ->where($query->expr()->eq('fileid', $query->createParameter('fileid')));
+
+ foreach ($rows as $row) {
+ $output->writeln("Path of file {$row['fileid']} is {$row['path']} but should be {$row['parent_path']}/{$row['name']} based on its parent", OutputInterface::VERBOSITY_VERBOSE);
+
+ if ($fix) {
+ $fileId = $this->getFileId((int)$row['parent_storage'], $row['parent_path'] . '/' . $row['name']);
+ if ($fileId > 0) {
+ $output->writeln("Cache entry has already be recreated with id $fileId, deleting instead");
+ $this->deleteById((int)$row['fileid']);
+ } else {
+ $query->setParameters([
+ 'fileid' => $row['fileid'],
+ 'path' => $row['parent_path'] . '/' . $row['name'],
+ 'storage' => $row['parent_storage'],
+ ]);
+ $query->execute();
+ }
+ }
+ }
+
+ if ($fix) {
+ $this->connection->commit();
+ }
+
+ return self::SUCCESS;
+ }
+
+ private function getFileId(int $storage, string $path) {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('fileid')
+ ->from('filecache')
+ ->where($query->expr()->eq('storage', $query->createNamedParameter($storage)))
+ ->andWhere($query->expr()->eq('path_hash', $query->createNamedParameter(md5($path))));
+ return $query->execute()->fetch(\PDO::FETCH_COLUMN);
+ }
+
+ private function deleteById(int $fileId): void {
+ $query = $this->connection->getQueryBuilder();
+ $query->delete('filecache')
+ ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId)));
+ $query->execute();
+ }
+
+ private function findBrokenTreeBits(): array {
+ $query = $this->connection->getQueryBuilder();
+
+ $query->select('f.fileid', 'f.path', 'f.parent', 'f.name')
+ ->selectAlias('p.path', 'parent_path')
+ ->selectAlias('p.storage', 'parent_storage')
+ ->from('filecache', 'f')
+ ->innerJoin('f', 'filecache', 'p', $query->expr()->eq('f.parent', 'p.fileid'))
+ ->where($query->expr()->orX(
+ $query->expr()->andX(
+ $query->expr()->neq('p.path_hash', $query->createNamedParameter(md5(''))),
+ $query->expr()->neq('f.path', $query->func()->concat('p.path', $query->func()->concat($query->createNamedParameter('/'), 'f.name')))
+ ),
+ $query->expr()->andX(
+ $query->expr()->eq('p.path_hash', $query->createNamedParameter(md5(''))),
+ $query->expr()->neq('f.path', 'f.name')
+ ),
+ $query->expr()->neq('f.storage', 'p.storage')
+ ));
+
+ return $query->execute()->fetchAll();
+ }
+}
diff --git a/apps/files/lib/Command/SanitizeFilenames.php b/apps/files/lib/Command/SanitizeFilenames.php
new file mode 100644
index 00000000000..88d41d1cb5e
--- /dev/null
+++ b/apps/files/lib/Command/SanitizeFilenames.php
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Command;
+
+use Exception;
+use OC\Core\Command\Base;
+use OC\Files\FilenameValidator;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotPermittedException;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use OCP\Lock\LockedException;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class SanitizeFilenames extends Base {
+
+ private OutputInterface $output;
+ private ?string $charReplacement;
+ private bool $dryRun;
+
+ public function __construct(
+ private IUserManager $userManager,
+ private IRootFolder $rootFolder,
+ private IUserSession $session,
+ private IFactory $l10nFactory,
+ private FilenameValidator $filenameValidator,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('files:sanitize-filenames')
+ ->setDescription('Renames files to match naming constraints')
+ ->addArgument(
+ 'user_id',
+ InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
+ 'will only rename files the given user(s) have access to'
+ )
+ ->addOption(
+ 'dry-run',
+ mode: InputOption::VALUE_NONE,
+ description: 'Do not actually rename any files but just check filenames.',
+ )
+ ->addOption(
+ 'char-replacement',
+ 'c',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Replacement for invalid character (by default space, underscore or dash is used)',
+ );
+
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $this->charReplacement = $input->getOption('char-replacement');
+ // check if replacement is needed
+ $c = $this->filenameValidator->getForbiddenCharacters();
+ if (count($c) > 0) {
+ try {
+ $this->filenameValidator->sanitizeFilename($c[0], $this->charReplacement);
+ } catch (\InvalidArgumentException) {
+ if ($this->charReplacement === null) {
+ $output->writeln('<error>Character replacement required</error>');
+ } else {
+ $output->writeln('<error>Invalid character replacement given</error>');
+ }
+ return 1;
+ }
+ }
+
+ $this->dryRun = $input->getOption('dry-run');
+ if ($this->dryRun) {
+ $output->writeln('<info>Dry run is enabled, no actual renaming will be applied.</>');
+ }
+
+ $this->output = $output;
+ $users = $input->getArgument('user_id');
+ if (!empty($users)) {
+ foreach ($users as $userId) {
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ $output->writeln("<error>User '$userId' does not exist - skipping</>");
+ continue;
+ }
+ $this->sanitizeUserFiles($user);
+ }
+ } else {
+ $this->userManager->callForSeenUsers($this->sanitizeUserFiles(...));
+ }
+ return self::SUCCESS;
+ }
+
+ private function sanitizeUserFiles(IUser $user): void {
+ // Set an active user so that event listeners can correctly work (e.g. files versions)
+ $this->session->setVolatileActiveUser($user);
+
+ $this->output->writeln('<info>Analyzing files of ' . $user->getUID() . '</>');
+
+ $folder = $this->rootFolder->getUserFolder($user->getUID());
+ $this->sanitizeFiles($folder);
+ }
+
+ private function sanitizeFiles(Folder $folder): void {
+ foreach ($folder->getDirectoryListing() as $node) {
+ $this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE);
+
+ try {
+ $oldName = $node->getName();
+ $newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement);
+ if ($oldName !== $newName) {
+ $newName = $folder->getNonExistingName($newName);
+ $path = rtrim(dirname($node->getPath()), '/');
+
+ if (!$this->dryRun) {
+ $node->move("$path/$newName");
+ } elseif (!$folder->isCreatable()) {
+ // simulate error for dry run
+ throw new NotPermittedException();
+ }
+ $this->output->writeln('renamed: "' . $oldName . '" to "' . $newName . '"');
+ }
+ } catch (LockedException) {
+ $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (file is locked)</>');
+ } catch (NotPermittedException) {
+ $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (no permissions)</>');
+ } catch (Exception $error) {
+ $this->output->writeln('<error>failed: ' . $node->getPath() . '</>');
+ $this->output->writeln('<error>' . $error->getMessage() . '</>', OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_VERBOSE);
+ }
+
+ if ($node instanceof Folder) {
+ $this->sanitizeFiles($node);
+ }
+ }
+ }
+
+}
diff --git a/apps/files/lib/Command/Scan.php b/apps/files/lib/Command/Scan.php
new file mode 100644
index 00000000000..b9057139b0e
--- /dev/null
+++ b/apps/files/lib/Command/Scan.php
@@ -0,0 +1,377 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Base;
+use OC\Core\Command\InterruptedException;
+use OC\DB\Connection;
+use OC\DB\ConnectionAdapter;
+use OC\Files\Storage\Wrapper\Jail;
+use OC\Files\Utils\Scanner;
+use OC\FilesMetadata\FilesMetadataManager;
+use OC\ForbiddenException;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\Events\FileCacheUpdated;
+use OCP\Files\Events\NodeAddedToCache;
+use OCP\Files\Events\NodeRemovedFromCache;
+use OCP\Files\IRootFolder;
+use OCP\Files\Mount\IMountPoint;
+use OCP\Files\NotFoundException;
+use OCP\Files\StorageNotAvailableException;
+use OCP\FilesMetadata\IFilesMetadataManager;
+use OCP\IUserManager;
+use OCP\Lock\LockedException;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Scan extends Base {
+ protected float $execTime = 0;
+ protected int $foldersCounter = 0;
+ protected int $filesCounter = 0;
+ protected int $errorsCounter = 0;
+ protected int $newCounter = 0;
+ protected int $updatedCounter = 0;
+ protected int $removedCounter = 0;
+
+ public function __construct(
+ private IUserManager $userManager,
+ private IRootFolder $rootFolder,
+ private FilesMetadataManager $filesMetadataManager,
+ private IEventDispatcher $eventDispatcher,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('files:scan')
+ ->setDescription('rescan filesystem')
+ ->addArgument(
+ 'user_id',
+ InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
+ 'will rescan all files of the given user(s)'
+ )
+ ->addOption(
+ 'path',
+ 'p',
+ InputOption::VALUE_REQUIRED,
+ 'limit rescan to this path, eg. --path="/alice/files/Music", the user_id is determined by the path and the user_id parameter and --all are ignored'
+ )
+ ->addOption(
+ 'generate-metadata',
+ null,
+ InputOption::VALUE_OPTIONAL,
+ 'Generate metadata for all scanned files; if specified only generate for named value',
+ ''
+ )
+ ->addOption(
+ 'all',
+ null,
+ InputOption::VALUE_NONE,
+ 'will rescan all files of all known users'
+ )->addOption(
+ 'unscanned',
+ null,
+ InputOption::VALUE_NONE,
+ 'only scan files which are marked as not fully scanned'
+ )->addOption(
+ 'shallow',
+ null,
+ InputOption::VALUE_NONE,
+ 'do not scan folders recursively'
+ )->addOption(
+ 'home-only',
+ null,
+ InputOption::VALUE_NONE,
+ 'only scan the home storage, ignoring any mounted external storage or share'
+ );
+ }
+
+ protected function scanFiles(
+ string $user,
+ string $path,
+ ?string $scanMetadata,
+ OutputInterface $output,
+ callable $mountFilter,
+ bool $backgroundScan = false,
+ bool $recursive = true,
+ ): void {
+ $connection = $this->reconnectToDatabase($output);
+ $scanner = new Scanner(
+ $user,
+ new ConnectionAdapter($connection),
+ Server::get(IEventDispatcher::class),
+ Server::get(LoggerInterface::class)
+ );
+
+ # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata): void {
+ $output->writeln("\tFile\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
+ ++$this->filesCounter;
+ $this->abortIfInterrupted();
+ if ($scanMetadata !== null) {
+ $node = $this->rootFolder->get($path);
+ $this->filesMetadataManager->refreshMetadata(
+ $node,
+ ($scanMetadata !== '') ? IFilesMetadataManager::PROCESS_NAMED : IFilesMetadataManager::PROCESS_LIVE | IFilesMetadataManager::PROCESS_BACKGROUND,
+ $scanMetadata
+ );
+ }
+ });
+
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void {
+ $output->writeln("\tFolder\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
+ ++$this->foldersCounter;
+ $this->abortIfInterrupted();
+ });
+
+ $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void {
+ $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE);
+ ++$this->errorsCounter;
+ });
+
+ $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output): void {
+ $output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>');
+ ++$this->errorsCounter;
+ });
+
+ $this->eventDispatcher->addListener(NodeAddedToCache::class, function (): void {
+ ++$this->newCounter;
+ });
+ $this->eventDispatcher->addListener(FileCacheUpdated::class, function (): void {
+ ++$this->updatedCounter;
+ });
+ $this->eventDispatcher->addListener(NodeRemovedFromCache::class, function (): void {
+ ++$this->removedCounter;
+ });
+
+ try {
+ if ($backgroundScan) {
+ $scanner->backgroundScan($path);
+ } else {
+ $scanner->scan($path, $recursive, $mountFilter);
+ }
+ } catch (ForbiddenException $e) {
+ $output->writeln("<error>Home storage for user $user not writable or 'files' subdirectory missing</error>");
+ $output->writeln(' ' . $e->getMessage());
+ $output->writeln('Make sure you\'re running the scan command only as the user the web server runs as');
+ ++$this->errorsCounter;
+ } catch (InterruptedException $e) {
+ # exit the function if ctrl-c has been pressed
+ $output->writeln('Interrupted by user');
+ } catch (NotFoundException $e) {
+ $output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>');
+ ++$this->errorsCounter;
+ } catch (LockedException $e) {
+ if (str_starts_with($e->getPath(), 'scanner::')) {
+ $output->writeln('<error>Another process is already scanning \'' . substr($e->getPath(), strlen('scanner::')) . '\'</error>');
+ } else {
+ throw $e;
+ }
+ } catch (\Exception $e) {
+ $output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>');
+ $output->writeln('<error>' . $e->getTraceAsString() . '</error>');
+ ++$this->errorsCounter;
+ }
+ }
+
+ public function isHomeMount(IMountPoint $mountPoint): bool {
+ // any mountpoint inside '/$user/files/'
+ return substr_count($mountPoint->getMountPoint(), '/') <= 3;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $inputPath = $input->getOption('path');
+ if ($inputPath) {
+ $inputPath = '/' . trim($inputPath, '/');
+ [, $user,] = explode('/', $inputPath, 3);
+ $users = [$user];
+ } elseif ($input->getOption('all')) {
+ $users = $this->userManager->search('');
+ } else {
+ $users = $input->getArgument('user_id');
+ }
+
+ # check quantity of users to be process and show it on the command line
+ $users_total = count($users);
+ if ($users_total === 0) {
+ $output->writeln('<error>Please specify the user id to scan, --all to scan for all users or --path=...</error>');
+ return self::FAILURE;
+ }
+
+ $this->initTools($output);
+
+ // getOption() logic on VALUE_OPTIONAL
+ $metadata = null; // null if --generate-metadata is not set, empty if option have no value, value if set
+ if ($input->getOption('generate-metadata') !== '') {
+ $metadata = $input->getOption('generate-metadata') ?? '';
+ }
+
+ $homeOnly = $input->getOption('home-only');
+ $scannedStorages = [];
+ $mountFilter = function (IMountPoint $mount) use ($homeOnly, &$scannedStorages) {
+ if ($homeOnly && !$this->isHomeMount($mount)) {
+ return false;
+ }
+
+ // when scanning multiple users, the scanner might encounter the same storage multiple times (e.g. external storages, or group folders)
+ // we can filter out any storage we've already scanned to avoid double work
+ $storage = $mount->getStorage();
+ $storageKey = $storage->getId();
+ while ($storage->instanceOfStorage(Jail::class)) {
+ $storageKey .= '/' . $storage->getUnjailedPath('');
+ $storage = $storage->getUnjailedStorage();
+ }
+ if (array_key_exists($storageKey, $scannedStorages)) {
+ return false;
+ }
+
+ $scannedStorages[$storageKey] = true;
+ return true;
+ };
+
+ $user_count = 0;
+ foreach ($users as $user) {
+ if (is_object($user)) {
+ $user = $user->getUID();
+ }
+ $path = $inputPath ?: '/' . $user;
+ ++$user_count;
+ if ($this->userManager->userExists($user)) {
+ $output->writeln("Starting scan for user $user_count out of $users_total ($user)");
+ $this->scanFiles(
+ $user,
+ $path,
+ $metadata,
+ $output,
+ $mountFilter,
+ $input->getOption('unscanned'),
+ !$input->getOption('shallow'),
+ );
+ $output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
+ } else {
+ $output->writeln("<error>Unknown user $user_count $user</error>");
+ $output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
+ }
+
+ try {
+ $this->abortIfInterrupted();
+ } catch (InterruptedException $e) {
+ break;
+ }
+ }
+
+ $this->presentStats($output);
+ return self::SUCCESS;
+ }
+
+ /**
+ * Initialises some useful tools for the Command
+ */
+ protected function initTools(OutputInterface $output): void {
+ // Start the timer
+ $this->execTime = -microtime(true);
+ // Convert PHP errors to exceptions
+ set_error_handler(
+ fn (int $severity, string $message, string $file, int $line): bool
+ => $this->exceptionErrorHandler($output, $severity, $message, $file, $line),
+ E_ALL
+ );
+ }
+
+ /**
+ * Processes PHP errors in order to be able to show them in the output
+ *
+ * @see https://www.php.net/manual/en/function.set-error-handler.php
+ *
+ * @param int $severity the level of the error raised
+ * @param string $message
+ * @param string $file the filename that the error was raised in
+ * @param int $line the line number the error was raised
+ */
+ public function exceptionErrorHandler(OutputInterface $output, int $severity, string $message, string $file, int $line): bool {
+ if (($severity === E_DEPRECATED) || ($severity === E_USER_DEPRECATED)) {
+ // Do not show deprecation warnings
+ return false;
+ }
+ $e = new \ErrorException($message, 0, $severity, $file, $line);
+ $output->writeln('<error>Error during scan: ' . $e->getMessage() . '</error>');
+ $output->writeln('<error>' . $e->getTraceAsString() . '</error>', OutputInterface::VERBOSITY_VERY_VERBOSE);
+ ++$this->errorsCounter;
+ return true;
+ }
+
+ protected function presentStats(OutputInterface $output): void {
+ // Stop the timer
+ $this->execTime += microtime(true);
+
+ $this->logger->info("Completed scan of {$this->filesCounter} files in {$this->foldersCounter} folder. Found {$this->newCounter} new, {$this->updatedCounter} updated and {$this->removedCounter} removed items");
+
+ $headers = [
+ 'Folders',
+ 'Files',
+ 'New',
+ 'Updated',
+ 'Removed',
+ 'Errors',
+ 'Elapsed time',
+ ];
+ $niceDate = $this->formatExecTime();
+ $rows = [
+ $this->foldersCounter,
+ $this->filesCounter,
+ $this->newCounter,
+ $this->updatedCounter,
+ $this->removedCounter,
+ $this->errorsCounter,
+ $niceDate,
+ ];
+ $table = new Table($output);
+ $table
+ ->setHeaders($headers)
+ ->setRows([$rows]);
+ $table->render();
+ }
+
+
+ /**
+ * Formats microtime into a human-readable format
+ */
+ protected function formatExecTime(): string {
+ $secs = (int)round($this->execTime);
+ # convert seconds into HH:MM:SS form
+ return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), $secs % 60);
+ }
+
+ protected function reconnectToDatabase(OutputInterface $output): Connection {
+ /** @var Connection $connection */
+ $connection = Server::get(Connection::class);
+ try {
+ $connection->close();
+ } catch (\Exception $ex) {
+ $output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>");
+ }
+ while (!$connection->isConnected()) {
+ try {
+ $connection->connect();
+ } catch (\Exception $ex) {
+ $output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>");
+ sleep(60);
+ }
+ }
+ return $connection;
+ }
+}
diff --git a/apps/files/lib/Command/ScanAppData.php b/apps/files/lib/Command/ScanAppData.php
new file mode 100644
index 00000000000..0e08c6a8cfe
--- /dev/null
+++ b/apps/files/lib/Command/ScanAppData.php
@@ -0,0 +1,247 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Base;
+use OC\Core\Command\InterruptedException;
+use OC\DB\Connection;
+use OC\DB\ConnectionAdapter;
+use OC\Files\Utils\Scanner;
+use OC\ForbiddenException;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\Files\NotFoundException;
+use OCP\Files\StorageNotAvailableException;
+use OCP\IConfig;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ScanAppData extends Base {
+ protected float $execTime = 0;
+
+ protected int $foldersCounter = 0;
+
+ protected int $filesCounter = 0;
+
+ public function __construct(
+ protected IRootFolder $rootFolder,
+ protected IConfig $config,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('files:scan-app-data')
+ ->setDescription('rescan the AppData folder');
+
+ $this->addArgument('folder', InputArgument::OPTIONAL, 'The appdata subfolder to scan', '');
+ }
+
+ protected function scanFiles(OutputInterface $output, string $folder): int {
+ try {
+ /** @var Folder $appData */
+ $appData = $this->getAppDataFolder();
+ } catch (NotFoundException $e) {
+ $output->writeln('<error>NoAppData folder found</error>');
+ return self::FAILURE;
+ }
+
+ if ($folder !== '') {
+ try {
+ $appData = $appData->get($folder);
+ } catch (NotFoundException $e) {
+ $output->writeln('<error>Could not find folder: ' . $folder . '</error>');
+ return self::FAILURE;
+ }
+ }
+
+ $connection = $this->reconnectToDatabase($output);
+ $scanner = new Scanner(
+ null,
+ new ConnectionAdapter($connection),
+ Server::get(IEventDispatcher::class),
+ Server::get(LoggerInterface::class)
+ );
+
+ # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output): void {
+ $output->writeln("\tFile <info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
+ ++$this->filesCounter;
+ $this->abortIfInterrupted();
+ });
+
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void {
+ $output->writeln("\tFolder <info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
+ ++$this->foldersCounter;
+ $this->abortIfInterrupted();
+ });
+
+ $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void {
+ $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE);
+ });
+
+ $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output): void {
+ $output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>');
+ });
+
+ try {
+ $scanner->scan($appData->getPath());
+ } catch (ForbiddenException $e) {
+ $output->writeln('<error>Storage not writable</error>');
+ $output->writeln('<info>Make sure you\'re running the scan command only as the user the web server runs as</info>');
+ return self::FAILURE;
+ } catch (InterruptedException $e) {
+ # exit the function if ctrl-c has been pressed
+ $output->writeln('<info>Interrupted by user</info>');
+ return self::FAILURE;
+ } catch (NotFoundException $e) {
+ $output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>');
+ return self::FAILURE;
+ } catch (\Exception $e) {
+ $output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>');
+ $output->writeln('<error>' . $e->getTraceAsString() . '</error>');
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ # restrict the verbosity level to VERBOSITY_VERBOSE
+ if ($output->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) {
+ $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE);
+ }
+
+ $output->writeln('Scanning AppData for files');
+ $output->writeln('');
+
+ $folder = $input->getArgument('folder');
+
+ $this->initTools();
+
+ $exitCode = $this->scanFiles($output, $folder);
+ if ($exitCode === 0) {
+ $this->presentStats($output);
+ }
+ return $exitCode;
+ }
+
+ /**
+ * Initialises some useful tools for the Command
+ */
+ protected function initTools(): void {
+ // Start the timer
+ $this->execTime = -microtime(true);
+ // Convert PHP errors to exceptions
+ set_error_handler([$this, 'exceptionErrorHandler'], E_ALL);
+ }
+
+ /**
+ * Processes PHP errors as exceptions in order to be able to keep track of problems
+ *
+ * @see https://www.php.net/manual/en/function.set-error-handler.php
+ *
+ * @param int $severity the level of the error raised
+ * @param string $message
+ * @param string $file the filename that the error was raised in
+ * @param int $line the line number the error was raised
+ *
+ * @throws \ErrorException
+ */
+ public function exceptionErrorHandler($severity, $message, $file, $line) {
+ if (!(error_reporting() & $severity)) {
+ // This error code is not included in error_reporting
+ return;
+ }
+ throw new \ErrorException($message, 0, $severity, $file, $line);
+ }
+
+ protected function presentStats(OutputInterface $output): void {
+ // Stop the timer
+ $this->execTime += microtime(true);
+
+ $headers = [
+ 'Folders', 'Files', 'Elapsed time'
+ ];
+
+ $this->showSummary($headers, null, $output);
+ }
+
+ /**
+ * Shows a summary of operations
+ *
+ * @param string[] $headers
+ * @param string[] $rows
+ */
+ protected function showSummary($headers, $rows, OutputInterface $output): void {
+ $niceDate = $this->formatExecTime();
+ if (!$rows) {
+ $rows = [
+ $this->foldersCounter,
+ $this->filesCounter,
+ $niceDate,
+ ];
+ }
+ $table = new Table($output);
+ $table
+ ->setHeaders($headers)
+ ->setRows([$rows]);
+ $table->render();
+ }
+
+
+ /**
+ * Formats microtime into a human-readable format
+ */
+ protected function formatExecTime(): string {
+ $secs = round($this->execTime);
+ # convert seconds into HH:MM:SS form
+ return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), (int)$secs % 60);
+ }
+
+ protected function reconnectToDatabase(OutputInterface $output): Connection {
+ /** @var Connection $connection */
+ $connection = Server::get(Connection::class);
+ try {
+ $connection->close();
+ } catch (\Exception $ex) {
+ $output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>");
+ }
+ while (!$connection->isConnected()) {
+ try {
+ $connection->connect();
+ } catch (\Exception $ex) {
+ $output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>");
+ sleep(60);
+ }
+ }
+ return $connection;
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ private function getAppDataFolder(): Node {
+ $instanceId = $this->config->getSystemValue('instanceid', null);
+
+ if ($instanceId === null) {
+ throw new NotFoundException();
+ }
+
+ return $this->rootFolder->get('appdata_' . $instanceId);
+ }
+}
diff --git a/apps/files/lib/Command/TransferOwnership.php b/apps/files/lib/Command/TransferOwnership.php
new file mode 100644
index 00000000000..f7663e26f28
--- /dev/null
+++ b/apps/files/lib/Command/TransferOwnership.php
@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+namespace OCA\Files\Command;
+
+use OCA\Files\Exception\TransferOwnershipException;
+use OCA\Files\Service\OwnershipTransferService;
+use OCA\Files_External\Config\ConfigAdapter;
+use OCP\Files\Mount\IMountManager;
+use OCP\Files\Mount\IMountPoint;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class TransferOwnership extends Command {
+ public function __construct(
+ private IUserManager $userManager,
+ private OwnershipTransferService $transferService,
+ private IConfig $config,
+ private IMountManager $mountManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:transfer-ownership')
+ ->setDescription('All files and folders are moved to another user - outgoing shares and incoming user file shares (optionally) are moved as well.')
+ ->addArgument(
+ 'source-user',
+ InputArgument::REQUIRED,
+ 'owner of files which shall be moved'
+ )
+ ->addArgument(
+ 'destination-user',
+ InputArgument::REQUIRED,
+ 'user who will be the new owner of the files'
+ )
+ ->addOption(
+ 'path',
+ null,
+ InputOption::VALUE_REQUIRED,
+ 'selectively provide the path to transfer. For example --path="folder_name"',
+ ''
+ )->addOption(
+ 'move',
+ null,
+ InputOption::VALUE_NONE,
+ 'move data from source user to root directory of destination user, which must be empty'
+ )->addOption(
+ 'transfer-incoming-shares',
+ null,
+ InputOption::VALUE_OPTIONAL,
+ 'Incoming shares are always transferred now, so this option does not affect the ownership transfer anymore',
+ '2'
+ )->addOption(
+ 'include-external-storage',
+ null,
+ InputOption::VALUE_NONE,
+ 'include files on external storages, this will _not_ setup an external storage for the target user, but instead moves all the files from the external storages into the target users home directory',
+ )->addOption(
+ 'force-include-external-storage',
+ null,
+ InputOption::VALUE_NONE,
+ 'don\'t ask for confirmation for transferring external storages',
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+
+ /**
+ * Check if source and destination users are same. If they are same then just ignore the transfer.
+ */
+
+ if ($input->getArgument(('source-user')) === $input->getArgument('destination-user')) {
+ $output->writeln("<error>Ownership can't be transferred when Source and Destination users are the same user. Please check your input.</error>");
+ return self::FAILURE;
+ }
+
+ $sourceUserObject = $this->userManager->get($input->getArgument('source-user'));
+ $destinationUserObject = $this->userManager->get($input->getArgument('destination-user'));
+
+ if (!$sourceUserObject instanceof IUser) {
+ $output->writeln('<error>Unknown source user ' . $input->getArgument('source-user') . '</error>');
+ return self::FAILURE;
+ }
+
+ if (!$destinationUserObject instanceof IUser) {
+ $output->writeln('<error>Unknown destination user ' . $input->getArgument('destination-user') . '</error>');
+ return self::FAILURE;
+ }
+
+ $path = ltrim($input->getOption('path'), '/');
+ $includeExternalStorage = $input->getOption('include-external-storage');
+ if ($includeExternalStorage) {
+ $mounts = $this->mountManager->findIn('/' . rtrim($sourceUserObject->getUID() . '/files/' . $path, '/'));
+ /** @var IMountPoint[] $mounts */
+ $mounts = array_filter($mounts, fn ($mount) => $mount->getMountProvider() === ConfigAdapter::class);
+ if (count($mounts) > 0) {
+ $output->writeln(count($mounts) . ' external storages will be transferred:');
+ foreach ($mounts as $mount) {
+ $output->writeln(' - <info>' . $mount->getMountPoint() . '</info>');
+ }
+ $output->writeln('');
+ $output->writeln('<comment>Any other users with access to these external storages will lose access to the files.</comment>');
+ $output->writeln('');
+ if (!$input->getOption('force-include-external-storage')) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Are you sure you want to transfer external storages? (y/N) ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return self::FAILURE;
+ }
+ }
+ }
+ }
+
+ try {
+ $this->transferService->transfer(
+ $sourceUserObject,
+ $destinationUserObject,
+ $path,
+ $output,
+ $input->getOption('move') === true,
+ false,
+ $includeExternalStorage,
+ );
+ } catch (TransferOwnershipException $e) {
+ $output->writeln('<error>' . $e->getMessage() . '</error>');
+ return $e->getCode() !== 0 ? $e->getCode() : self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/WindowsCompatibleFilenames.php b/apps/files/lib/Command/WindowsCompatibleFilenames.php
new file mode 100644
index 00000000000..84a1b277824
--- /dev/null
+++ b/apps/files/lib/Command/WindowsCompatibleFilenames.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Base;
+use OCA\Files\Service\SettingsService;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class WindowsCompatibleFilenames extends Base {
+
+ public function __construct(
+ private SettingsService $service,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('files:windows-compatible-filenames')
+ ->setDescription('Enforce naming constraints for windows compatible filenames')
+ ->addOption('enable', description: 'Enable windows naming constraints')
+ ->addOption('disable', description: 'Disable windows naming constraints');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ if ($input->getOption('enable')) {
+ if ($this->service->hasFilesWindowsSupport()) {
+ $output->writeln('<error>Windows compatible filenames already enforced.</error>', OutputInterface::VERBOSITY_VERBOSE);
+ }
+ $this->service->setFilesWindowsSupport(true);
+ $output->writeln('Windows compatible filenames enforced.');
+ } elseif ($input->getOption('disable')) {
+ if (!$this->service->hasFilesWindowsSupport()) {
+ $output->writeln('<error>Windows compatible filenames already disabled.</error>', OutputInterface::VERBOSITY_VERBOSE);
+ }
+ $this->service->setFilesWindowsSupport(false);
+ $output->writeln('Windows compatible filename constraints removed.');
+ } else {
+ $output->writeln('Windows compatible filenames are ' . ($this->service->hasFilesWindowsSupport() ? 'enforced' : 'disabled'));
+ }
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php
new file mode 100644
index 00000000000..8bb024fb698
--- /dev/null
+++ b/apps/files/lib/Controller/ApiController.php
@@ -0,0 +1,462 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\Controller;
+
+use OC\Files\Node\Node;
+use OCA\Files\Helper;
+use OCA\Files\ResponseDefinitions;
+use OCA\Files\Service\TagService;
+use OCA\Files\Service\UserConfig;
+use OCA\Files\Service\ViewConfig;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\ApiRoute;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\Attribute\StrictCookiesRequired;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\FileDisplayResponse;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\Response;
+use OCP\AppFramework\Http\StreamResponse;
+use OCP\Files\File;
+use OCP\Files\Folder;
+use OCP\Files\InvalidPathException;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\Files\Storage\ISharedStorage;
+use OCP\Files\StorageNotAvailableException;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IPreview;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\PreConditionNotMetException;
+use OCP\Share\IManager;
+use OCP\Share\IShare;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * @psalm-import-type FilesFolderTree from ResponseDefinitions
+ *
+ * @package OCA\Files\Controller
+ */
+class ApiController extends Controller {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IUserSession $userSession,
+ private TagService $tagService,
+ private IPreview $previewManager,
+ private IManager $shareManager,
+ private IConfig $config,
+ private ?Folder $userFolder,
+ private UserConfig $userConfig,
+ private ViewConfig $viewConfig,
+ private IL10N $l10n,
+ private IRootFolder $rootFolder,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Gets a thumbnail of the specified file
+ *
+ * @since API version 1.0
+ * @deprecated 32.0.0 Use the preview endpoint provided by core instead
+ *
+ * @param int $x Width of the thumbnail
+ * @param int $y Height of the thumbnail
+ * @param string $file URL-encoded filename
+ * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message?: string}, array{}>
+ *
+ * 200: Thumbnail returned
+ * 400: Getting thumbnail is not possible
+ * 404: File not found
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[StrictCookiesRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+ public function getThumbnail($x, $y, $file) {
+ if ($x < 1 || $y < 1) {
+ return new DataResponse(['message' => 'Requested size must be numeric and a positive value.'], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $file = $this->userFolder?->get($file);
+ if ($file === null
+ || !($file instanceof File)
+ || ($file->getId() <= 0)
+ ) {
+ throw new NotFoundException();
+ }
+
+ // Validate the user is allowed to download the file (preview is some kind of download)
+ /** @var ISharedStorage $storage */
+ $storage = $file->getStorage();
+ if ($storage->instanceOfStorage(ISharedStorage::class)) {
+ /** @var IShare $share */
+ $share = $storage->getShare();
+ if (!$share->canSeeContent()) {
+ throw new NotFoundException();
+ }
+ }
+
+ $preview = $this->previewManager->getPreview($file, $x, $y, true);
+
+ return new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]);
+ } catch (NotFoundException|NotPermittedException|InvalidPathException) {
+ return new DataResponse(['message' => 'File not found.'], Http::STATUS_NOT_FOUND);
+ } catch (\Exception $e) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * Updates the info of the specified file path
+ * The passed tags are absolute, which means they will
+ * replace the actual tag selection.
+ *
+ * @param string $path path
+ * @param array|string $tags array of tags
+ * @return DataResponse
+ */
+ #[NoAdminRequired]
+ public function updateFileTags($path, $tags = null) {
+ $result = [];
+ // if tags specified or empty array, update tags
+ if (!is_null($tags)) {
+ try {
+ $this->tagService->updateFileTags($path, $tags);
+ } catch (NotFoundException $e) {
+ return new DataResponse([
+ 'message' => $e->getMessage()
+ ], Http::STATUS_NOT_FOUND);
+ } catch (StorageNotAvailableException $e) {
+ return new DataResponse([
+ 'message' => $e->getMessage()
+ ], Http::STATUS_SERVICE_UNAVAILABLE);
+ } catch (\Exception $e) {
+ return new DataResponse([
+ 'message' => $e->getMessage()
+ ], Http::STATUS_NOT_FOUND);
+ }
+ $result['tags'] = $tags;
+ }
+ return new DataResponse($result);
+ }
+
+ /**
+ * @param \OCP\Files\Node[] $nodes
+ * @return array
+ */
+ private function formatNodes(array $nodes) {
+ $shareTypesForNodes = $this->getShareTypesForNodes($nodes);
+ return array_values(array_map(function (Node $node) use ($shareTypesForNodes) {
+ $shareTypes = $shareTypesForNodes[$node->getId()] ?? [];
+ $file = Helper::formatFileInfo($node->getFileInfo());
+ $file['hasPreview'] = $this->previewManager->isAvailable($node);
+ $parts = explode('/', dirname($node->getPath()), 4);
+ if (isset($parts[3])) {
+ $file['path'] = '/' . $parts[3];
+ } else {
+ $file['path'] = '/';
+ }
+ if (!empty($shareTypes)) {
+ $file['shareTypes'] = $shareTypes;
+ }
+ return $file;
+ }, $nodes));
+ }
+
+ /**
+ * Get the share types for each node
+ *
+ * @param \OCP\Files\Node[] $nodes
+ * @return array<int, int[]> list of share types for each fileid
+ */
+ private function getShareTypesForNodes(array $nodes): array {
+ $userId = $this->userSession->getUser()->getUID();
+ $requestedShareTypes = [
+ IShare::TYPE_USER,
+ IShare::TYPE_GROUP,
+ IShare::TYPE_LINK,
+ IShare::TYPE_REMOTE,
+ IShare::TYPE_EMAIL,
+ IShare::TYPE_ROOM,
+ IShare::TYPE_DECK,
+ IShare::TYPE_SCIENCEMESH,
+ ];
+ $shareTypes = [];
+
+ $nodeIds = array_map(function (Node $node) {
+ return $node->getId();
+ }, $nodes);
+
+ foreach ($requestedShareTypes as $shareType) {
+ $nodesLeft = array_combine($nodeIds, array_fill(0, count($nodeIds), true));
+ $offset = 0;
+
+ // fetch shares until we've either found shares for all nodes or there are no more shares left
+ while (count($nodesLeft) > 0) {
+ $shares = $this->shareManager->getSharesBy($userId, $shareType, null, false, 100, $offset);
+ foreach ($shares as $share) {
+ $fileId = $share->getNodeId();
+ if (isset($nodesLeft[$fileId])) {
+ if (!isset($shareTypes[$fileId])) {
+ $shareTypes[$fileId] = [];
+ }
+ $shareTypes[$fileId][] = $shareType;
+ unset($nodesLeft[$fileId]);
+ }
+ }
+
+ if (count($shares) < 100) {
+ break;
+ } else {
+ $offset += count($shares);
+ }
+ }
+ }
+ return $shareTypes;
+ }
+
+ /**
+ * Returns a list of recently modified files.
+ *
+ * @return DataResponse
+ */
+ #[NoAdminRequired]
+ public function getRecentFiles() {
+ $nodes = $this->userFolder->getRecent(100);
+ $files = $this->formatNodes($nodes);
+ return new DataResponse(['files' => $files]);
+ }
+
+ /**
+ * @param \OCP\Files\Node[] $nodes
+ * @param int $depth The depth to traverse into the contents of each node
+ */
+ private function getChildren(array $nodes, int $depth = 1, int $currentDepth = 0): array {
+ if ($currentDepth >= $depth) {
+ return [];
+ }
+
+ $children = [];
+ foreach ($nodes as $node) {
+ if (!($node instanceof Folder)) {
+ continue;
+ }
+
+ $basename = basename($node->getPath());
+ $entry = [
+ 'id' => $node->getId(),
+ 'basename' => $basename,
+ 'children' => $this->getChildren($node->getDirectoryListing(), $depth, $currentDepth + 1),
+ ];
+ $displayName = $node->getName();
+ if ($basename !== $displayName) {
+ $entry['displayName'] = $displayName;
+ }
+ $children[] = $entry;
+ }
+ return $children;
+ }
+
+ /**
+ * Returns the folder tree of the user
+ *
+ * @param string $path The path relative to the user folder
+ * @param int $depth The depth of the tree
+ *
+ * @return JSONResponse<Http::STATUS_OK, FilesFolderTree, array{}>|JSONResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
+ *
+ * 200: Folder tree returned successfully
+ * 400: Invalid folder path
+ * 401: Unauthorized
+ * 404: Folder not found
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'GET', url: '/api/v1/folder-tree')]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+ public function getFolderTree(string $path = '/', int $depth = 1): JSONResponse {
+ $user = $this->userSession->getUser();
+ if (!($user instanceof IUser)) {
+ return new JSONResponse([
+ 'message' => $this->l10n->t('Failed to authorize'),
+ ], Http::STATUS_UNAUTHORIZED);
+ }
+ try {
+ $userFolder = $this->rootFolder->getUserFolder($user->getUID());
+ $userFolderPath = $userFolder->getPath();
+ $fullPath = implode('/', [$userFolderPath, trim($path, '/')]);
+ $node = $this->rootFolder->get($fullPath);
+ if (!($node instanceof Folder)) {
+ return new JSONResponse([
+ 'message' => $this->l10n->t('Invalid folder path'),
+ ], Http::STATUS_BAD_REQUEST);
+ }
+ $nodes = $node->getDirectoryListing();
+ $tree = $this->getChildren($nodes, $depth);
+ } catch (NotFoundException $e) {
+ return new JSONResponse([
+ 'message' => $this->l10n->t('Folder not found'),
+ ], Http::STATUS_NOT_FOUND);
+ } catch (Throwable $th) {
+ $this->logger->error($th->getMessage(), ['exception' => $th]);
+ $tree = [];
+ }
+ return new JSONResponse($tree);
+ }
+
+ /**
+ * Returns the current logged-in user's storage stats.
+ *
+ * @param ?string $dir the directory to get the storage stats from
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ public function getStorageStats($dir = '/'): JSONResponse {
+ $storageInfo = \OC_Helper::getStorageInfo($dir ?: '/');
+ $response = new JSONResponse(['message' => 'ok', 'data' => $storageInfo]);
+ $response->cacheFor(5 * 60);
+ return $response;
+ }
+
+ /**
+ * Set a user view config
+ *
+ * @param string $view
+ * @param string $key
+ * @param string|bool $value
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ public function setViewConfig(string $view, string $key, $value): JSONResponse {
+ try {
+ $this->viewConfig->setConfig($view, $key, (string)$value);
+ } catch (\InvalidArgumentException $e) {
+ return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
+ }
+
+ return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfig($view)]);
+ }
+
+
+ /**
+ * Get the user view config
+ *
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ public function getViewConfigs(): JSONResponse {
+ return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfigs()]);
+ }
+
+ /**
+ * Set a user config
+ *
+ * @param string $key
+ * @param string|bool $value
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ public function setConfig(string $key, $value): JSONResponse {
+ try {
+ $this->userConfig->setConfig($key, (string)$value);
+ } catch (\InvalidArgumentException $e) {
+ return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
+ }
+
+ return new JSONResponse(['message' => 'ok', 'data' => ['key' => $key, 'value' => $value]]);
+ }
+
+
+ /**
+ * Get the user config
+ *
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ public function getConfigs(): JSONResponse {
+ return new JSONResponse(['message' => 'ok', 'data' => $this->userConfig->getConfigs()]);
+ }
+
+ /**
+ * Toggle default for showing/hiding hidden files
+ *
+ * @param bool $value
+ * @return Response
+ * @throws PreConditionNotMetException
+ */
+ #[NoAdminRequired]
+ public function showHiddenFiles(bool $value): Response {
+ $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'show_hidden', $value ? '1' : '0');
+ return new Response();
+ }
+
+ /**
+ * Toggle default for cropping preview images
+ *
+ * @param bool $value
+ * @return Response
+ * @throws PreConditionNotMetException
+ */
+ #[NoAdminRequired]
+ public function cropImagePreviews(bool $value): Response {
+ $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'crop_image_previews', $value ? '1' : '0');
+ return new Response();
+ }
+
+ /**
+ * Toggle default for files grid view
+ *
+ * @param bool $show
+ * @return Response
+ * @throws PreConditionNotMetException
+ */
+ #[NoAdminRequired]
+ public function showGridView(bool $show): Response {
+ $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'show_grid', $show ? '1' : '0');
+ return new Response();
+ }
+
+ /**
+ * Get default settings for the grid view
+ */
+ #[NoAdminRequired]
+ public function getGridView() {
+ $status = $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'show_grid', '0') === '1';
+ return new JSONResponse(['gridview' => $status]);
+ }
+
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+ public function serviceWorker(): StreamResponse {
+ $response = new StreamResponse(__DIR__ . '/../../../../dist/preview-service-worker.js');
+ $response->setHeaders([
+ 'Content-Type' => 'application/javascript',
+ 'Service-Worker-Allowed' => '/'
+ ]);
+ $policy = new ContentSecurityPolicy();
+ $policy->addAllowedWorkerSrcDomain("'self'");
+ $policy->addAllowedScriptDomain("'self'");
+ $policy->addAllowedConnectDomain("'self'");
+ $response->setContentSecurityPolicy($policy);
+ return $response;
+ }
+}
diff --git a/apps/files/lib/Controller/ConversionApiController.php b/apps/files/lib/Controller/ConversionApiController.php
new file mode 100644
index 00000000000..40a42d6ca4c
--- /dev/null
+++ b/apps/files/lib/Controller/ConversionApiController.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Controller;
+
+use OC\Files\Utils\PathHelper;
+use OC\ForbiddenException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\ApiRoute;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\UserRateLimit;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCS\OCSException;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\AppFramework\OCSController;
+use OCP\Files\Conversion\IConversionManager;
+use OCP\Files\File;
+use OCP\Files\GenericFileException;
+use OCP\Files\IRootFolder;
+use OCP\IL10N;
+use OCP\IRequest;
+use function OCP\Log\logger;
+
+class ConversionApiController extends OCSController {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IConversionManager $fileConversionManager,
+ private IRootFolder $rootFolder,
+ private IL10N $l10n,
+ private ?string $userId,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Converts a file from one MIME type to another
+ *
+ * @param int $fileId ID of the file to be converted
+ * @param string $targetMimeType The MIME type to which you want to convert the file
+ * @param string|null $destination The target path of the converted file. Written to a temporary file if left empty
+ *
+ * @return DataResponse<Http::STATUS_CREATED, array{path: string, fileId: int}, array{}>
+ *
+ * 201: File was converted and written to the destination or temporary file
+ *
+ * @throws OCSException The file was unable to be converted
+ * @throws OCSNotFoundException The file to be converted was not found
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 25, period: 120)]
+ #[ApiRoute(verb: 'POST', url: '/api/v1/convert')]
+ public function convert(int $fileId, string $targetMimeType, ?string $destination = null): DataResponse {
+ $userFolder = $this->rootFolder->getUserFolder($this->userId);
+ $file = $userFolder->getFirstNodeById($fileId);
+
+ // Also throw a 404 if the file is not readable to not leak information
+ if (!($file instanceof File) || $file->isReadable() === false) {
+ throw new OCSNotFoundException($this->l10n->t('The file cannot be found'));
+ }
+
+ if ($destination !== null) {
+ $destination = PathHelper::normalizePath($destination);
+ $parentDir = dirname($destination);
+
+ if (!$userFolder->nodeExists($parentDir)) {
+ throw new OCSNotFoundException($this->l10n->t('The destination path does not exist: %1$s', [$parentDir]));
+ }
+
+ if (!$userFolder->get($parentDir)->isCreatable()) {
+ throw new OCSForbiddenException($this->l10n->t('You do not have permission to create a file at the specified location'));
+ }
+
+ $destination = $userFolder->getFullPath($destination);
+ }
+
+ try {
+ $convertedFile = $this->fileConversionManager->convert($file, $targetMimeType, $destination);
+ } catch (ForbiddenException $e) {
+ throw new OCSForbiddenException($e->getMessage());
+ } catch (GenericFileException $e) {
+ throw new OCSBadRequestException($e->getMessage());
+ } catch (\Exception $e) {
+ logger('files')->error($e->getMessage(), ['exception' => $e]);
+ throw new OCSException($this->l10n->t('The file could not be converted.'));
+ }
+
+ $convertedFileRelativePath = $userFolder->getRelativePath($convertedFile);
+ if ($convertedFileRelativePath === null) {
+ throw new OCSNotFoundException($this->l10n->t('Could not get relative path to converted file'));
+ }
+
+ $file = $userFolder->get($convertedFileRelativePath);
+ $fileId = $file->getId();
+
+ return new DataResponse([
+ 'path' => $convertedFileRelativePath,
+ 'fileId' => $fileId,
+ ], Http::STATUS_CREATED);
+ }
+}
diff --git a/apps/files/lib/Controller/DirectEditingController.php b/apps/files/lib/Controller/DirectEditingController.php
new file mode 100644
index 00000000000..c8addc33e98
--- /dev/null
+++ b/apps/files/lib/Controller/DirectEditingController.php
@@ -0,0 +1,153 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Controller;
+
+use Exception;
+use OCA\Files\Service\DirectEditingService;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\DirectEditing\IManager;
+use OCP\DirectEditing\RegisterDirectEditorEvent;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use Psr\Log\LoggerInterface;
+
+class DirectEditingController extends OCSController {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ string $corsMethods,
+ string $corsAllowedHeaders,
+ int $corsMaxAge,
+ private IEventDispatcher $eventDispatcher,
+ private IURLGenerator $urlGenerator,
+ private IManager $directEditingManager,
+ private DirectEditingService $directEditingService,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($appName, $request, $corsMethods, $corsAllowedHeaders, $corsMaxAge);
+ }
+
+ /**
+ * Get the direct editing capabilities
+ * @return DataResponse<Http::STATUS_OK, array{editors: array<string, array{id: string, name: string, mimetypes: list<string>, optionalMimetypes: list<string>, secure: bool}>, creators: array<string, array{id: string, editor: string, name: string, extension: string, templates: bool, mimetypes: list<string>}>}, array{}>
+ *
+ * 200: Direct editing capabilities returned
+ */
+ #[NoAdminRequired]
+ public function info(): DataResponse {
+ $response = new DataResponse($this->directEditingService->getDirectEditingCapabilitites());
+ $response->setETag($this->directEditingService->getDirectEditingETag());
+ return $response;
+ }
+
+ /**
+ * Create a file for direct editing
+ *
+ * @param string $path Path of the file
+ * @param string $editorId ID of the editor
+ * @param string $creatorId ID of the creator
+ * @param ?string $templateId ID of the template
+ *
+ * @return DataResponse<Http::STATUS_OK, array{url: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
+ *
+ * 200: URL for direct editing returned
+ * 403: Opening file is not allowed
+ */
+ #[NoAdminRequired]
+ public function create(string $path, string $editorId, string $creatorId, ?string $templateId = null): DataResponse {
+ if (!$this->directEditingManager->isEnabled()) {
+ return new DataResponse(['message' => 'Direct editing is not enabled'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager));
+
+ try {
+ $token = $this->directEditingManager->create($path, $editorId, $creatorId, $templateId);
+ return new DataResponse([
+ 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', ['token' => $token])
+ ]);
+ } catch (Exception $e) {
+ $this->logger->error(
+ 'Exception when creating a new file through direct editing',
+ [
+ 'exception' => $e
+ ],
+ );
+ return new DataResponse(['message' => 'Failed to create file: ' . $e->getMessage()], Http::STATUS_FORBIDDEN);
+ }
+ }
+
+ /**
+ * Open a file for direct editing
+ *
+ * @param string $path Path of the file
+ * @param ?string $editorId ID of the editor
+ * @param ?int $fileId ID of the file
+ *
+ * @return DataResponse<Http::STATUS_OK, array{url: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
+ *
+ * 200: URL for direct editing returned
+ * 403: Opening file is not allowed
+ */
+ #[NoAdminRequired]
+ public function open(string $path, ?string $editorId = null, ?int $fileId = null): DataResponse {
+ if (!$this->directEditingManager->isEnabled()) {
+ return new DataResponse(['message' => 'Direct editing is not enabled'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager));
+
+ try {
+ $token = $this->directEditingManager->open($path, $editorId, $fileId);
+ return new DataResponse([
+ 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', ['token' => $token])
+ ]);
+ } catch (Exception $e) {
+ $this->logger->error(
+ 'Exception when opening a file through direct editing',
+ [
+ 'exception' => $e
+ ],
+ );
+ return new DataResponse(['message' => 'Failed to open file: ' . $e->getMessage()], Http::STATUS_FORBIDDEN);
+ }
+ }
+
+
+
+ /**
+ * Get the templates for direct editing
+ *
+ * @param string $editorId ID of the editor
+ * @param string $creatorId ID of the creator
+ *
+ * @return DataResponse<Http::STATUS_OK, array{templates: array<string, array{id: string, title: string, preview: ?string, extension: string, mimetype: string}>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
+ *
+ * 200: Templates returned
+ */
+ #[NoAdminRequired]
+ public function templates(string $editorId, string $creatorId): DataResponse {
+ if (!$this->directEditingManager->isEnabled()) {
+ return new DataResponse(['message' => 'Direct editing is not enabled'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager));
+
+ try {
+ return new DataResponse($this->directEditingManager->getTemplates($editorId, $creatorId));
+ } catch (Exception $e) {
+ $this->logger->error(
+ $e->getMessage(),
+ [
+ 'exception' => $e
+ ],
+ );
+ return new DataResponse(['message' => 'Failed to obtain template list: ' . $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+}
diff --git a/apps/files/lib/Controller/DirectEditingViewController.php b/apps/files/lib/Controller/DirectEditingViewController.php
new file mode 100644
index 00000000000..b13e68f7766
--- /dev/null
+++ b/apps/files/lib/Controller/DirectEditingViewController.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Controller;
+
+use Exception;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\Attribute\UseSession;
+use OCP\AppFramework\Http\NotFoundResponse;
+use OCP\AppFramework\Http\Response;
+use OCP\DirectEditing\IManager;
+use OCP\DirectEditing\RegisterDirectEditorEvent;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class DirectEditingViewController extends Controller {
+ public function __construct(
+ $appName,
+ IRequest $request,
+ private IEventDispatcher $eventDispatcher,
+ private IManager $directEditingManager,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * @param string $token
+ * @return Response
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[UseSession]
+ public function edit(string $token): Response {
+ $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager));
+ try {
+ return $this->directEditingManager->edit($token);
+ } catch (Exception $e) {
+ $this->logger->error($e->getMessage(), ['exception' => $e]);
+ return new NotFoundResponse();
+ }
+ }
+}
diff --git a/apps/files/lib/Controller/OpenLocalEditorController.php b/apps/files/lib/Controller/OpenLocalEditorController.php
new file mode 100644
index 00000000000..b000304eef6
--- /dev/null
+++ b/apps/files/lib/Controller/OpenLocalEditorController.php
@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Controller;
+
+use OCA\Files\Db\OpenLocalEditor;
+use OCA\Files\Db\OpenLocalEditorMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\BruteForceProtection;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\UserRateLimit;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\DB\Exception;
+use OCP\IRequest;
+use OCP\Security\ISecureRandom;
+use Psr\Log\LoggerInterface;
+
+class OpenLocalEditorController extends OCSController {
+ public const TOKEN_LENGTH = 128;
+ public const TOKEN_DURATION = 600; // 10 Minutes
+ public const TOKEN_RETRIES = 50;
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ protected ITimeFactory $timeFactory,
+ protected OpenLocalEditorMapper $mapper,
+ protected ISecureRandom $secureRandom,
+ protected LoggerInterface $logger,
+ protected ?string $userId,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Create a local editor
+ *
+ * @param string $path Path of the file
+ *
+ * @return DataResponse<Http::STATUS_OK, array{userId: ?string, pathHash: string, expirationTime: int, token: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}>
+ *
+ * 200: Local editor returned
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 10, period: 120)]
+ public function create(string $path): DataResponse {
+ $pathHash = sha1($path);
+
+ $entity = new OpenLocalEditor();
+ $entity->setUserId($this->userId);
+ $entity->setPathHash($pathHash);
+ $entity->setExpirationTime($this->timeFactory->getTime() + self::TOKEN_DURATION); // Expire in 10 minutes
+
+ for ($i = 1; $i <= self::TOKEN_RETRIES; $i++) {
+ $token = $this->secureRandom->generate(self::TOKEN_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC);
+ $entity->setToken($token);
+
+ try {
+ $this->mapper->insert($entity);
+
+ return new DataResponse([
+ 'userId' => $this->userId,
+ 'pathHash' => $pathHash,
+ 'expirationTime' => $entity->getExpirationTime(),
+ 'token' => $entity->getToken(),
+ ]);
+ } catch (Exception $e) {
+ if ($e->getCode() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
+ // Only retry on unique constraint violation
+ throw $e;
+ }
+ }
+ }
+
+ $this->logger->error('Giving up after ' . self::TOKEN_RETRIES . ' retries to generate a unique local editor token for path hash: ' . $pathHash);
+ return new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ /**
+ * Validate a local editor
+ *
+ * @param string $path Path of the file
+ * @param string $token Token of the local editor
+ *
+ * @return DataResponse<Http::STATUS_OK, array{userId: string, pathHash: string, expirationTime: int, token: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}>
+ *
+ * 200: Local editor validated successfully
+ * 404: Local editor not found
+ */
+ #[NoAdminRequired]
+ #[BruteForceProtection(action: 'openLocalEditor')]
+ public function validate(string $path, string $token): DataResponse {
+ $pathHash = sha1($path);
+
+ try {
+ $entity = $this->mapper->verifyToken($this->userId, $pathHash, $token);
+ } catch (DoesNotExistException $e) {
+ $response = new DataResponse([], Http::STATUS_NOT_FOUND);
+ $response->throttle(['userId' => $this->userId, 'pathHash' => $pathHash]);
+ return $response;
+ }
+
+ $this->mapper->delete($entity);
+
+ if ($entity->getExpirationTime() <= $this->timeFactory->getTime()) {
+ $response = new DataResponse([], Http::STATUS_NOT_FOUND);
+ $response->throttle(['userId' => $this->userId, 'pathHash' => $pathHash]);
+ return $response;
+ }
+
+ return new DataResponse([
+ 'userId' => $this->userId,
+ 'pathHash' => $pathHash,
+ 'expirationTime' => $entity->getExpirationTime(),
+ 'token' => $entity->getToken(),
+ ]);
+ }
+
+}
diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php
new file mode 100644
index 00000000000..ee4c86941c7
--- /dev/null
+++ b/apps/files/lib/Controller/TemplateController.php
@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Controller;
+
+use OCA\Files\ResponseDefinitions;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\AppFramework\OCSController;
+use OCP\Files\GenericFileException;
+use OCP\Files\Template\ITemplateManager;
+use OCP\Files\Template\Template;
+use OCP\Files\Template\TemplateFileCreator;
+use OCP\IRequest;
+
+/**
+ * @psalm-import-type FilesTemplateFile from ResponseDefinitions
+ * @psalm-import-type FilesTemplateFileCreator from ResponseDefinitions
+ * @psalm-import-type FilesTemplateFileCreatorWithTemplates from ResponseDefinitions
+ * @psalm-import-type FilesTemplateField from ResponseDefinitions
+ * @psalm-import-type FilesTemplate from ResponseDefinitions
+ */
+class TemplateController extends OCSController {
+ public function __construct(
+ $appName,
+ IRequest $request,
+ protected ITemplateManager $templateManager,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * List the available templates
+ *
+ * @return DataResponse<Http::STATUS_OK, list<FilesTemplateFileCreatorWithTemplates>, array{}>
+ *
+ * 200: Available templates returned
+ */
+ #[NoAdminRequired]
+ public function list(): DataResponse {
+ /* Convert embedded Template instances to arrays to match return type */
+ return new DataResponse(array_map(static function (array $templateFileCreator) {
+ $templateFileCreator['templates'] = array_map(static fn (Template $template) => $template->jsonSerialize(), $templateFileCreator['templates']);
+ return $templateFileCreator;
+ }, $this->templateManager->listTemplates()));
+ }
+
+ /**
+ * List the fields for the template specified by the given file ID
+ *
+ * @param int $fileId File ID of the template
+ * @return DataResponse<Http::STATUS_OK, array<string, FilesTemplateField>, array{}>
+ *
+ * 200: Fields returned
+ */
+ #[NoAdminRequired]
+ public function listTemplateFields(int $fileId): DataResponse {
+ $fields = $this->templateManager->listTemplateFields($fileId);
+
+ return new DataResponse(
+ array_merge([], ...$fields),
+ Http::STATUS_OK
+ );
+ }
+
+ /**
+ * Create a template
+ *
+ * @param string $filePath Path of the file
+ * @param string $templatePath Name of the template
+ * @param string $templateType Type of the template
+ * @param list<FilesTemplateField> $templateFields Fields of the template
+ *
+ * @return DataResponse<Http::STATUS_OK, FilesTemplateFile, array{}>
+ * @throws OCSForbiddenException Creating template is not allowed
+ *
+ * 200: Template created successfully
+ */
+ #[NoAdminRequired]
+ public function create(
+ string $filePath,
+ string $templatePath = '',
+ string $templateType = 'user',
+ array $templateFields = [],
+ ): DataResponse {
+ try {
+ return new DataResponse($this->templateManager->createFromTemplate(
+ $filePath,
+ $templatePath,
+ $templateType,
+ $templateFields));
+ } catch (GenericFileException $e) {
+ throw new OCSForbiddenException($e->getMessage());
+ }
+ }
+
+ /**
+ * Initialize the template directory
+ *
+ * @param string $templatePath Path of the template directory
+ * @param bool $copySystemTemplates Whether to copy the system templates to the template directory
+ *
+ * @return DataResponse<Http::STATUS_OK, array{template_path: string, templates: list<FilesTemplateFileCreator>}, array{}>
+ * @throws OCSForbiddenException Initializing the template directory is not allowed
+ *
+ * 200: Template directory initialized successfully
+ */
+ #[NoAdminRequired]
+ public function path(string $templatePath = '', bool $copySystemTemplates = false) {
+ try {
+ /** @var string $templatePath */
+ $templatePath = $this->templateManager->initializeTemplateDirectory($templatePath, null, $copySystemTemplates);
+ return new DataResponse([
+ 'template_path' => $templatePath,
+ 'templates' => array_values(array_map(fn (TemplateFileCreator $creator) => $creator->jsonSerialize(), $this->templateManager->listCreators())),
+ ]);
+ } catch (\Exception $e) {
+ throw new OCSForbiddenException($e->getMessage());
+ }
+ }
+}
diff --git a/apps/files/lib/Controller/TransferOwnershipController.php b/apps/files/lib/Controller/TransferOwnershipController.php
new file mode 100644
index 00000000000..51a25400efb
--- /dev/null
+++ b/apps/files/lib/Controller/TransferOwnershipController.php
@@ -0,0 +1,168 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Controller;
+
+use OCA\Files\BackgroundJob\TransferOwnership;
+use OCA\Files\Db\TransferOwnership as TransferOwnershipEntity;
+use OCA\Files\Db\TransferOwnershipMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJobList;
+use OCP\Files\IHomeStorage;
+use OCP\Files\IRootFolder;
+use OCP\IRequest;
+use OCP\IUserManager;
+use OCP\Notification\IManager as NotificationManager;
+
+class TransferOwnershipController extends OCSController {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private string $userId,
+ private NotificationManager $notificationManager,
+ private ITimeFactory $timeFactory,
+ private IJobList $jobList,
+ private TransferOwnershipMapper $mapper,
+ private IUserManager $userManager,
+ private IRootFolder $rootFolder,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+
+ /**
+ * Transfer the ownership to another user
+ *
+ * @param string $recipient Username of the recipient
+ * @param string $path Path of the file
+ *
+ * @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN, list<empty>, array{}>
+ *
+ * 200: Ownership transferred successfully
+ * 400: Transferring ownership is not possible
+ * 403: Transferring ownership is not allowed
+ */
+ #[NoAdminRequired]
+ public function transfer(string $recipient, string $path): DataResponse {
+ $recipientUser = $this->userManager->get($recipient);
+
+ if ($recipientUser === null) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ $userRoot = $this->rootFolder->getUserFolder($this->userId);
+
+ try {
+ $node = $userRoot->get($path);
+ } catch (\Exception $e) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ if ($node->getOwner()->getUID() !== $this->userId || !$node->getStorage()->instanceOfStorage(IHomeStorage::class)) {
+ return new DataResponse([], Http::STATUS_FORBIDDEN);
+ }
+
+ $transferOwnership = new TransferOwnershipEntity();
+ $transferOwnership->setSourceUser($this->userId);
+ $transferOwnership->setTargetUser($recipient);
+ $transferOwnership->setFileId($node->getId());
+ $transferOwnership->setNodeName($node->getName());
+ $transferOwnership = $this->mapper->insert($transferOwnership);
+
+ $notification = $this->notificationManager->createNotification();
+ $notification->setUser($recipient)
+ ->setApp($this->appName)
+ ->setDateTime($this->timeFactory->getDateTime())
+ ->setSubject('transferownershipRequest', [
+ 'sourceUser' => $this->userId,
+ 'targetUser' => $recipient,
+ 'nodeName' => $node->getName(),
+ ])
+ ->setObject('transfer', (string)$transferOwnership->getId());
+
+ $this->notificationManager->notify($notification);
+
+ return new DataResponse([]);
+ }
+
+ /**
+ * Accept an ownership transfer
+ *
+ * @param int $id ID of the ownership transfer
+ *
+ * @return DataResponse<Http::STATUS_OK|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>
+ *
+ * 200: Ownership transfer accepted successfully
+ * 403: Accepting ownership transfer is not allowed
+ * 404: Ownership transfer not found
+ */
+ #[NoAdminRequired]
+ public function accept(int $id): DataResponse {
+ try {
+ $transferOwnership = $this->mapper->getById($id);
+ } catch (DoesNotExistException $e) {
+ return new DataResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ if ($transferOwnership->getTargetUser() !== $this->userId) {
+ return new DataResponse([], Http::STATUS_FORBIDDEN);
+ }
+
+ $this->jobList->add(TransferOwnership::class, [
+ 'id' => $transferOwnership->getId(),
+ ]);
+
+ $notification = $this->notificationManager->createNotification();
+ $notification->setApp('files')
+ ->setObject('transfer', (string)$id);
+ $this->notificationManager->markProcessed($notification);
+
+ return new DataResponse([], Http::STATUS_OK);
+ }
+
+ /**
+ * Reject an ownership transfer
+ *
+ * @param int $id ID of the ownership transfer
+ *
+ * @return DataResponse<Http::STATUS_OK|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>
+ *
+ * 200: Ownership transfer rejected successfully
+ * 403: Rejecting ownership transfer is not allowed
+ * 404: Ownership transfer not found
+ */
+ #[NoAdminRequired]
+ public function reject(int $id): DataResponse {
+ try {
+ $transferOwnership = $this->mapper->getById($id);
+ } catch (DoesNotExistException $e) {
+ return new DataResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ if ($transferOwnership->getTargetUser() !== $this->userId) {
+ return new DataResponse([], Http::STATUS_FORBIDDEN);
+ }
+
+ $notification = $this->notificationManager->createNotification();
+ $notification->setApp('files')
+ ->setObject('transfer', (string)$id);
+ $this->notificationManager->markProcessed($notification);
+
+ $this->mapper->delete($transferOwnership);
+
+ // A "request denied" notification will be created by Notifier::dismissNotification
+
+ return new DataResponse([], Http::STATUS_OK);
+ }
+}
diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php
new file mode 100644
index 00000000000..ecf21cef313
--- /dev/null
+++ b/apps/files/lib/Controller/ViewController.php
@@ -0,0 +1,306 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\Controller;
+
+use OC\Files\FilenameValidator;
+use OC\Files\Filesystem;
+use OCA\Files\AppInfo\Application;
+use OCA\Files\Event\LoadAdditionalScriptsEvent;
+use OCA\Files\Event\LoadSearchPlugins;
+use OCA\Files\Event\LoadSidebar;
+use OCA\Files\Service\UserConfig;
+use OCA\Files\Service\ViewConfig;
+use OCA\Viewer\Event\LoadViewer;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
+use OCP\AppFramework\Http\RedirectResponse;
+use OCP\AppFramework\Http\Response;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\Authentication\TwoFactorAuth\IRegistry;
+use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent as ResourcesLoadAdditionalScriptsEvent;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\Files\Template\ITemplateManager;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use OCP\IUserSession;
+use OCP\Util;
+
+/**
+ * @package OCA\Files\Controller
+ */
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class ViewController extends Controller {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IURLGenerator $urlGenerator,
+ private IL10N $l10n,
+ private IConfig $config,
+ private IEventDispatcher $eventDispatcher,
+ private IUserSession $userSession,
+ private IAppManager $appManager,
+ private IRootFolder $rootFolder,
+ private IInitialState $initialState,
+ private ITemplateManager $templateManager,
+ private UserConfig $userConfig,
+ private ViewConfig $viewConfig,
+ private FilenameValidator $filenameValidator,
+ private IRegistry $twoFactorRegistry,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * FIXME: Replace with non static code
+ *
+ * @return array
+ * @throws NotFoundException
+ */
+ protected function getStorageInfo(string $dir = '/') {
+ $rootInfo = Filesystem::getFileInfo('/', false);
+
+ return \OC_Helper::getStorageInfo($dir, $rootInfo ?: null);
+ }
+
+ /**
+ * @param string $fileid
+ * @return TemplateResponse|RedirectResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function showFile(?string $fileid = null, ?string $opendetails = null, ?string $openfile = null): Response {
+ if (!$fileid) {
+ return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index'));
+ }
+
+ // This is the entry point from the `/f/{fileid}` URL which is hardcoded in the server.
+ try {
+ return $this->redirectToFile((int)$fileid, $opendetails, $openfile);
+ } catch (NotFoundException $e) {
+ // Keep the fileid even if not found, it will be used
+ // to detect the file could not be found and warn the user
+ return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', ['fileid' => $fileid, 'view' => 'files']));
+ }
+ }
+
+
+ /**
+ * @param string $dir
+ * @param string $view
+ * @param string $fileid
+ * @return TemplateResponse|RedirectResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function indexView($dir = '', $view = '', $fileid = null) {
+ return $this->index($dir, $view, $fileid);
+ }
+
+ /**
+ * @param string $dir
+ * @param string $view
+ * @param string $fileid
+ * @return TemplateResponse|RedirectResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function indexViewFileid($dir = '', $view = '', $fileid = null) {
+ return $this->index($dir, $view, $fileid);
+ }
+
+ /**
+ * @param string $dir
+ * @param string $view
+ * @param string $fileid
+ * @return TemplateResponse|RedirectResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function index($dir = '', $view = '', $fileid = null) {
+ if ($fileid !== null && $view !== 'trashbin') {
+ try {
+ return $this->redirectToFileIfInTrashbin((int)$fileid);
+ } catch (NotFoundException $e) {
+ }
+ }
+
+ // Load the files we need
+ Util::addInitScript('files', 'init');
+ Util::addScript('files', 'main');
+
+ $user = $this->userSession->getUser();
+ $userId = $user->getUID();
+
+ // If the file doesn't exists in the folder and
+ // exists in only one occurrence, redirect to that file
+ // in the correct folder
+ if ($fileid && $dir !== '') {
+ $baseFolder = $this->rootFolder->getUserFolder($userId);
+ $nodes = $baseFolder->getById((int)$fileid);
+ if (!empty($nodes)) {
+ $nodePath = $baseFolder->getRelativePath($nodes[0]->getPath());
+ $relativePath = $nodePath ? dirname($nodePath) : '';
+ // If the requested path does not contain the file id
+ // or if the requested path is not the file id itself
+ if (count($nodes) === 1 && $relativePath !== $dir && $nodePath !== $dir) {
+ return $this->redirectToFile((int)$fileid);
+ }
+ }
+ }
+
+ try {
+ // If view is files, we use the directory, otherwise we use the root storage
+ $storageInfo = $this->getStorageInfo(($view === 'files' && $dir) ? $dir : '/');
+ } catch (\Exception $e) {
+ $storageInfo = $this->getStorageInfo();
+ }
+
+ $this->initialState->provideInitialState('storageStats', $storageInfo);
+ $this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
+ $this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs());
+
+ // File sorting user config
+ $filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true);
+ $this->initialState->provideInitialState('filesSortingConfig', $filesSortingConfig);
+
+ // Forbidden file characters (deprecated use capabilities)
+ // TODO: Remove with next release of `@nextcloud/files`
+ $forbiddenCharacters = $this->filenameValidator->getForbiddenCharacters();
+ $this->initialState->provideInitialState('forbiddenCharacters', $forbiddenCharacters);
+
+ $event = new LoadAdditionalScriptsEvent();
+ $this->eventDispatcher->dispatchTyped($event);
+ $this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent());
+ $this->eventDispatcher->dispatchTyped(new LoadSidebar());
+ $this->eventDispatcher->dispatchTyped(new LoadSearchPlugins());
+ // Load Viewer scripts
+ if (class_exists(LoadViewer::class)) {
+ $this->eventDispatcher->dispatchTyped(new LoadViewer());
+ }
+
+ $this->initialState->provideInitialState('templates_enabled', ($this->config->getSystemValueString('skeletondirectory', \OC::$SERVERROOT . '/core/skeleton') !== '') || ($this->config->getSystemValueString('templatedirectory', \OC::$SERVERROOT . '/core/skeleton/Templates') !== ''));
+ $this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false);
+ $this->initialState->provideInitialState('templates', $this->templateManager->listCreators());
+
+ $isTwoFactorEnabled = false;
+ foreach ($this->twoFactorRegistry->getProviderStates($user) as $providerId => $providerState) {
+ if ($providerId !== 'backup_codes' && $providerState === true) {
+ $isTwoFactorEnabled = true;
+ }
+ }
+
+ $this->initialState->provideInitialState('isTwoFactorEnabled', $isTwoFactorEnabled);
+
+ $response = new TemplateResponse(
+ Application::APP_ID,
+ 'index',
+ );
+ $policy = new ContentSecurityPolicy();
+ $policy->addAllowedFrameDomain('\'self\'');
+ // Allow preview service worker
+ $policy->addAllowedWorkerSrcDomain('\'self\'');
+ $response->setContentSecurityPolicy($policy);
+
+ return $response;
+ }
+
+ /**
+ * Redirects to the trashbin file list and highlight the given file id
+ *
+ * @param int $fileId file id to show
+ * @return RedirectResponse redirect response or not found response
+ * @throws NotFoundException
+ */
+ private function redirectToFileIfInTrashbin($fileId): RedirectResponse {
+ $uid = $this->userSession->getUser()->getUID();
+ $baseFolder = $this->rootFolder->getUserFolder($uid);
+ $node = $baseFolder->getFirstNodeById($fileId);
+ $params = [];
+
+ if (!$node && $this->appManager->isEnabledForUser('files_trashbin')) {
+ /** @var Folder */
+ $baseFolder = $this->rootFolder->get($uid . '/files_trashbin/files/');
+ $node = $baseFolder->getFirstNodeById($fileId);
+ $params['view'] = 'trashbin';
+
+ if ($node) {
+ $params['fileid'] = $fileId;
+ if ($node instanceof Folder) {
+ // set the full path to enter the folder
+ $params['dir'] = $baseFolder->getRelativePath($node->getPath());
+ } else {
+ // set parent path as dir
+ $params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath());
+ }
+ return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params));
+ }
+ }
+ throw new NotFoundException();
+ }
+
+ /**
+ * Redirects to the file list and highlight the given file id
+ *
+ * @param int $fileId file id to show
+ * @param string|null $openDetails open details parameter
+ * @param string|null $openFile open file parameter
+ * @return RedirectResponse redirect response or not found response
+ * @throws NotFoundException
+ */
+ private function redirectToFile(int $fileId, ?string $openDetails = null, ?string $openFile = null): RedirectResponse {
+ $uid = $this->userSession->getUser()->getUID();
+ $baseFolder = $this->rootFolder->getUserFolder($uid);
+ $node = $baseFolder->getFirstNodeById($fileId);
+ $params = ['view' => 'files'];
+
+ try {
+ $this->redirectToFileIfInTrashbin($fileId);
+ } catch (NotFoundException $e) {
+ }
+
+ if ($node) {
+ $params['fileid'] = $fileId;
+ if ($node instanceof Folder) {
+ // set the full path to enter the folder
+ $params['dir'] = $baseFolder->getRelativePath($node->getPath());
+ } else {
+ // set parent path as dir
+ $params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath());
+ // open the file by default (opening the viewer)
+ $params['openfile'] = 'true';
+ }
+
+ // Forward open parameters if any.
+ // - openfile is true by default
+ // - opendetails is undefined by default
+ // - both will be evaluated as truthy
+ if ($openDetails !== null) {
+ $params['opendetails'] = $openDetails !== 'false' ? 'true' : 'false';
+ }
+
+ if ($openFile !== null) {
+ $params['openfile'] = $openFile !== 'false' ? 'true' : 'false';
+ }
+
+ return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params));
+ }
+
+ throw new NotFoundException();
+ }
+}
diff --git a/apps/files/lib/Dashboard/FavoriteWidget.php b/apps/files/lib/Dashboard/FavoriteWidget.php
new file mode 100644
index 00000000000..b68b8a56b2e
--- /dev/null
+++ b/apps/files/lib/Dashboard/FavoriteWidget.php
@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Dashboard;
+
+use OCA\Files\AppInfo\Application;
+use OCP\Dashboard\IAPIWidgetV2;
+use OCP\Dashboard\IButtonWidget;
+use OCP\Dashboard\IIconWidget;
+use OCP\Dashboard\IOptionWidget;
+use OCP\Dashboard\Model\WidgetButton;
+use OCP\Dashboard\Model\WidgetItem;
+use OCP\Dashboard\Model\WidgetItems;
+use OCP\Dashboard\Model\WidgetOptions;
+use OCP\Files\File;
+use OCP\Files\IMimeTypeDetector;
+use OCP\Files\IRootFolder;
+use OCP\IL10N;
+use OCP\IPreview;
+use OCP\ITagManager;
+use OCP\IURLGenerator;
+use OCP\IUserManager;
+
+class FavoriteWidget implements IIconWidget, IAPIWidgetV2, IButtonWidget, IOptionWidget {
+
+ public function __construct(
+ private readonly IL10N $l10n,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly IMimeTypeDetector $mimeTypeDetector,
+ private readonly IUserManager $userManager,
+ private readonly ITagManager $tagManager,
+ private readonly IRootFolder $rootFolder,
+ private readonly IPreview $previewManager,
+ ) {
+ }
+
+ public function getId(): string {
+ return Application::APP_ID . '-favorites';
+ }
+
+ public function getTitle(): string {
+ return $this->l10n->t('Favorite files');
+ }
+
+ public function getOrder(): int {
+ return 0;
+ }
+
+ public function getIconClass(): string {
+ return 'icon-star-dark';
+ }
+
+ public function getIconUrl(): string {
+ return $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->imagePath('core', 'actions/star.svg')
+ );
+ }
+
+ public function getUrl(): ?string {
+ return $this->urlGenerator->linkToRouteAbsolute('files.View.indexView', ['view' => 'favorites']);
+ }
+
+ public function load(): void {
+ }
+
+ public function getItems(string $userId, int $limit = 7): array {
+ $user = $this->userManager->get($userId);
+
+ if (!$user) {
+ return [];
+ }
+ $tags = $this->tagManager->load('files', [], false, $userId);
+ $favorites = $tags->getFavorites();
+ if (empty($favorites)) {
+ return [];
+ }
+ $favoriteNodes = [];
+ $userFolder = $this->rootFolder->getUserFolder($userId);
+ $count = 0;
+ foreach ($favorites as $favorite) {
+ $node = $userFolder->getFirstNodeById($favorite);
+ if ($node) {
+ $url = $this->urlGenerator->linkToRouteAbsolute(
+ 'files.view.showFile', ['fileid' => $node->getId()]
+ );
+ if ($node instanceof File) {
+ $icon = $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', [
+ 'x' => 256,
+ 'y' => 256,
+ 'fileId' => $node->getId(),
+ 'c' => $node->getEtag(),
+ 'mimeFallback' => true,
+ ]);
+ } else {
+ $icon = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'filetypes/folder.svg'));
+ }
+ $favoriteNodes[] = new WidgetItem(
+ $node->getName(),
+ '',
+ $url,
+ $icon,
+ (string)$node->getCreationTime()
+ );
+ $count++;
+ if ($count >= $limit) {
+ break;
+ }
+ }
+ }
+
+ return $favoriteNodes;
+ }
+
+ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
+ $items = $this->getItems($userId, $limit);
+ return new WidgetItems(
+ $items,
+ count($items) === 0 ? $this->l10n->t('No favorites') : '',
+ );
+ }
+
+ public function getWidgetButtons(string $userId): array {
+ return [
+ new WidgetButton(
+ WidgetButton::TYPE_MORE,
+ $this->urlGenerator->linkToRouteAbsolute('files.View.indexView', ['view' => 'favorites']),
+ $this->l10n->t('More favorites')
+ ),
+ ];
+ }
+
+ public function getWidgetOptions(): WidgetOptions {
+ return new WidgetOptions(roundItemIcons: false);
+ }
+}
diff --git a/apps/files/lib/Db/OpenLocalEditor.php b/apps/files/lib/Db/OpenLocalEditor.php
new file mode 100644
index 00000000000..da7f5d13206
--- /dev/null
+++ b/apps/files/lib/Db/OpenLocalEditor.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Db;
+
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * @method void setUserId(string $userId)
+ * @method string getUserId()
+ * @method void setPathHash(string $pathHash)
+ * @method string getPathHash()
+ * @method void setExpirationTime(int $expirationTime)
+ * @method int getExpirationTime()
+ * @method void setToken(string $token)
+ * @method string getToken()
+ */
+class OpenLocalEditor extends Entity {
+ /** @var string */
+ protected $userId;
+
+ /** @var string */
+ protected $pathHash;
+
+ /** @var int */
+ protected $expirationTime;
+
+ /** @var string */
+ protected $token;
+
+ public function __construct() {
+ $this->addType('userId', 'string');
+ $this->addType('pathHash', 'string');
+ $this->addType('expirationTime', 'integer');
+ $this->addType('token', 'string');
+ }
+}
diff --git a/apps/files/lib/Db/OpenLocalEditorMapper.php b/apps/files/lib/Db/OpenLocalEditorMapper.php
new file mode 100644
index 00000000000..6ae8b79c258
--- /dev/null
+++ b/apps/files/lib/Db/OpenLocalEditorMapper.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\MultipleObjectsReturnedException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\Exception;
+use OCP\IDBConnection;
+
+/**
+ * @template-extends QBMapper<OpenLocalEditor>
+ */
+class OpenLocalEditorMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'open_local_editor', OpenLocalEditor::class);
+ }
+
+ /**
+ * @throws DoesNotExistException
+ * @throws MultipleObjectsReturnedException
+ * @throws Exception
+ */
+ public function verifyToken(string $userId, string $pathHash, string $token): OpenLocalEditor {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('path_hash', $qb->createNamedParameter($pathHash)))
+ ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token)));
+
+ return $this->findEntity($qb);
+ }
+
+ public function deleteExpiredTokens(int $time): void {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->delete($this->getTableName())
+ ->where($qb->expr()->lt('expiration_time', $qb->createNamedParameter($time)));
+
+ $qb->executeStatement();
+ }
+}
diff --git a/apps/files/lib/Db/TransferOwnership.php b/apps/files/lib/Db/TransferOwnership.php
new file mode 100644
index 00000000000..ae78c19b76d
--- /dev/null
+++ b/apps/files/lib/Db/TransferOwnership.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Db;
+
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * @method void setSourceUser(string $uid)
+ * @method string getSourceUser()
+ * @method void setTargetUser(string $uid)
+ * @method string getTargetUser()
+ * @method void setFileId(int $fileId)
+ * @method int getFileId()
+ * @method void setNodeName(string $name)
+ * @method string getNodeName()
+ */
+class TransferOwnership extends Entity {
+ /** @var string */
+ protected $sourceUser;
+
+ /** @var string */
+ protected $targetUser;
+
+ /** @var integer */
+ protected $fileId;
+
+ /** @var string */
+ protected $nodeName;
+
+ public function __construct() {
+ $this->addType('sourceUser', 'string');
+ $this->addType('targetUser', 'string');
+ $this->addType('fileId', 'integer');
+ $this->addType('nodeName', 'string');
+ }
+}
diff --git a/apps/files/lib/Db/TransferOwnershipMapper.php b/apps/files/lib/Db/TransferOwnershipMapper.php
new file mode 100644
index 00000000000..8b29399f768
--- /dev/null
+++ b/apps/files/lib/Db/TransferOwnershipMapper.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Db;
+
+use OCP\AppFramework\Db\QBMapper;
+use OCP\IDBConnection;
+
+/**
+ * @template-extends QBMapper<TransferOwnership>
+ */
+class TransferOwnershipMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'user_transfer_owner', TransferOwnership::class);
+ }
+
+ public function getById(int $id): TransferOwnership {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where(
+ $qb->expr()->eq('id', $qb->createNamedParameter($id))
+ );
+
+ return $this->findEntity($qb);
+ }
+}
diff --git a/apps/files/lib/DirectEditingCapabilities.php b/apps/files/lib/DirectEditingCapabilities.php
new file mode 100644
index 00000000000..5bceef9305f
--- /dev/null
+++ b/apps/files/lib/DirectEditingCapabilities.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files;
+
+use OCA\Files\Service\DirectEditingService;
+use OCP\Capabilities\ICapability;
+use OCP\Capabilities\IInitialStateExcludedCapability;
+use OCP\IURLGenerator;
+
+class DirectEditingCapabilities implements ICapability, IInitialStateExcludedCapability {
+ public function __construct(
+ protected DirectEditingService $directEditingService,
+ protected IURLGenerator $urlGenerator,
+ ) {
+ }
+
+ /**
+ * @return array{files: array{directEditing: array{url: string, etag: string, supportsFileId: bool}}}
+ */
+ public function getCapabilities() {
+ return [
+ 'files' => [
+ 'directEditing' => [
+ 'url' => $this->urlGenerator->linkToOCSRouteAbsolute('files.DirectEditing.info'),
+ 'etag' => $this->directEditingService->getDirectEditingETag(),
+ 'supportsFileId' => true,
+ ]
+ ],
+ ];
+ }
+}
diff --git a/apps/files/lib/Event/LoadAdditionalScriptsEvent.php b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php
new file mode 100644
index 00000000000..d1cf7f4016e
--- /dev/null
+++ b/apps/files/lib/Event/LoadAdditionalScriptsEvent.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Event;
+
+use OCP\EventDispatcher\Event;
+
+/**
+ * This event is triggered when the files app is rendered.
+ *
+ * @since 17.0.0
+ */
+class LoadAdditionalScriptsEvent extends Event {
+}
diff --git a/apps/files/lib/Event/LoadSearchPlugins.php b/apps/files/lib/Event/LoadSearchPlugins.php
new file mode 100644
index 00000000000..9c6c81fcca5
--- /dev/null
+++ b/apps/files/lib/Event/LoadSearchPlugins.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Event;
+
+use OCP\EventDispatcher\Event;
+
+class LoadSearchPlugins extends Event {
+}
diff --git a/apps/files/lib/Event/LoadSidebar.php b/apps/files/lib/Event/LoadSidebar.php
new file mode 100644
index 00000000000..01db57bb562
--- /dev/null
+++ b/apps/files/lib/Event/LoadSidebar.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Event;
+
+use OCP\EventDispatcher\Event;
+
+class LoadSidebar extends Event {
+}
diff --git a/apps/files/lib/Exception/TransferOwnershipException.php b/apps/files/lib/Exception/TransferOwnershipException.php
new file mode 100644
index 00000000000..531c5d513da
--- /dev/null
+++ b/apps/files/lib/Exception/TransferOwnershipException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Exception;
+
+use Exception;
+
+class TransferOwnershipException extends Exception {
+}
diff --git a/apps/files/lib/Helper.php b/apps/files/lib/Helper.php
new file mode 100644
index 00000000000..b1439ac7fa5
--- /dev/null
+++ b/apps/files/lib/Helper.php
@@ -0,0 +1,147 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files;
+
+use OC\Files\Filesystem;
+use OCP\Files\FileInfo;
+use OCP\Util;
+
+/**
+ * Helper class for manipulating file information
+ */
+class Helper {
+ /**
+ * Comparator function to sort files alphabetically and have
+ * the directories appear first
+ *
+ * @param FileInfo $a file
+ * @param FileInfo $b file
+ * @return int -1 if $a must come before $b, 1 otherwise
+ */
+ public static function compareFileNames(FileInfo $a, FileInfo $b) {
+ $aType = $a->getType();
+ $bType = $b->getType();
+ if ($aType === 'dir' and $bType !== 'dir') {
+ return -1;
+ } elseif ($aType !== 'dir' and $bType === 'dir') {
+ return 1;
+ } else {
+ return Util::naturalSortCompare($a->getName(), $b->getName());
+ }
+ }
+
+ /**
+ * Comparator function to sort files by date
+ *
+ * @param FileInfo $a file
+ * @param FileInfo $b file
+ * @return int -1 if $a must come before $b, 1 otherwise
+ */
+ public static function compareTimestamp(FileInfo $a, FileInfo $b) {
+ $aTime = $a->getMTime();
+ $bTime = $b->getMTime();
+ return ($aTime < $bTime) ? -1 : 1;
+ }
+
+ /**
+ * Comparator function to sort files by size
+ *
+ * @param FileInfo $a file
+ * @param FileInfo $b file
+ * @return int -1 if $a must come before $b, 1 otherwise
+ */
+ public static function compareSize(FileInfo $a, FileInfo $b) {
+ $aSize = $a->getSize();
+ $bSize = $b->getSize();
+ return ($aSize < $bSize) ? -1 : 1;
+ }
+
+ /**
+ * Formats the file info to be returned as JSON to the client.
+ *
+ * @param FileInfo $i
+ * @return array formatted file info
+ */
+ public static function formatFileInfo(FileInfo $i) {
+ $entry = [];
+
+ $entry['id'] = $i->getId();
+ $entry['parentId'] = $i->getParentId();
+ $entry['mtime'] = $i->getMtime() * 1000;
+ // only pick out the needed attributes
+ $entry['name'] = $i->getName();
+ $entry['permissions'] = $i->getPermissions();
+ $entry['mimetype'] = $i->getMimetype();
+ $entry['size'] = $i->getSize();
+ $entry['type'] = $i->getType();
+ $entry['etag'] = $i->getEtag();
+ // TODO: this is using the private implementation of FileInfo
+ // the array access is not part of the public interface
+ if (isset($i['tags'])) {
+ $entry['tags'] = $i['tags'];
+ }
+ if (isset($i['displayname_owner'])) {
+ $entry['shareOwner'] = $i['displayname_owner'];
+ }
+ if (isset($i['is_share_mount_point'])) {
+ $entry['isShareMountPoint'] = $i['is_share_mount_point'];
+ }
+ if (isset($i['extraData'])) {
+ $entry['extraData'] = $i['extraData'];
+ }
+
+ $mountType = null;
+ $mount = $i->getMountPoint();
+ $mountType = $mount->getMountType();
+ if ($mountType !== '') {
+ if ($i->getInternalPath() === '') {
+ $mountType .= '-root';
+ }
+ $entry['mountType'] = $mountType;
+ }
+ return $entry;
+ }
+
+ /**
+ * Retrieves the contents of the given directory and
+ * returns it as a sorted array of FileInfo.
+ *
+ * @param string $dir path to the directory
+ * @param string $sortAttribute attribute to sort on
+ * @param bool $sortDescending true for descending sort, false otherwise
+ * @param string $mimetypeFilter limit returned content to this mimetype or mimepart
+ * @return FileInfo[] files
+ */
+ public static function getFiles($dir, $sortAttribute = 'name', $sortDescending = false, $mimetypeFilter = '') {
+ $content = Filesystem::getDirectoryContent($dir, $mimetypeFilter);
+
+ return self::sortFiles($content, $sortAttribute, $sortDescending);
+ }
+
+ /**
+ * Sort the given file info array
+ *
+ * @param FileInfo[] $files files to sort
+ * @param string $sortAttribute attribute to sort on
+ * @param bool $sortDescending true for descending sort, false otherwise
+ * @return FileInfo[] sorted files
+ */
+ public static function sortFiles($files, $sortAttribute = 'name', $sortDescending = false) {
+ $sortFunc = 'compareFileNames';
+ if ($sortAttribute === 'mtime') {
+ $sortFunc = 'compareTimestamp';
+ } elseif ($sortAttribute === 'size') {
+ $sortFunc = 'compareSize';
+ }
+ usort($files, [Helper::class, $sortFunc]);
+ if ($sortDescending) {
+ $files = array_reverse($files);
+ }
+ return $files;
+ }
+}
diff --git a/apps/files/lib/Listener/LoadSearchPluginsListener.php b/apps/files/lib/Listener/LoadSearchPluginsListener.php
new file mode 100644
index 00000000000..4cc4ca22f83
--- /dev/null
+++ b/apps/files/lib/Listener/LoadSearchPluginsListener.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Listener;
+
+use OCA\Files\Event\LoadSearchPlugins;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Util;
+
+/** @template-implements IEventListener<LoadSearchPlugins> */
+class LoadSearchPluginsListener implements IEventListener {
+ public function handle(Event $event): void {
+ if (!$event instanceof LoadSearchPlugins) {
+ return;
+ }
+
+ Util::addScript('files', 'search');
+ }
+}
diff --git a/apps/files/lib/Listener/LoadSidebarListener.php b/apps/files/lib/Listener/LoadSidebarListener.php
new file mode 100644
index 00000000000..78b48ab1ce0
--- /dev/null
+++ b/apps/files/lib/Listener/LoadSidebarListener.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Listener;
+
+use OCA\Files\AppInfo\Application;
+use OCA\Files\Event\LoadSidebar;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Util;
+
+/** @template-implements IEventListener<LoadSidebar> */
+class LoadSidebarListener implements IEventListener {
+ public function handle(Event $event): void {
+ if (!($event instanceof LoadSidebar)) {
+ return;
+ }
+
+ Util::addScript(Application::APP_ID, 'sidebar');
+ }
+}
diff --git a/apps/files/lib/Listener/NodeAddedToFavoriteListener.php b/apps/files/lib/Listener/NodeAddedToFavoriteListener.php
new file mode 100644
index 00000000000..827c1851d3d
--- /dev/null
+++ b/apps/files/lib/Listener/NodeAddedToFavoriteListener.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Listener;
+
+use OCA\Files\Activity\FavoriteProvider;
+use OCP\Activity\IManager as IActivityManager;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Files\Events\NodeAddedToFavorite;
+
+/** @template-implements IEventListener<NodeAddedToFavorite> */
+class NodeAddedToFavoriteListener implements IEventListener {
+ public function __construct(
+ private IActivityManager $activityManager,
+ ) {
+ }
+ public function handle(Event $event):void {
+ if (!($event instanceof NodeAddedToFavorite)) {
+ return;
+ }
+ $activityEvent = $this->activityManager->generateEvent();
+ try {
+ $activityEvent->setApp('files')
+ ->setObject('files', $event->getFileId(), $event->getPath())
+ ->setType('favorite')
+ ->setAuthor($event->getUser()->getUID())
+ ->setAffectedUser($event->getUser()->getUID())
+ ->setTimestamp(time())
+ ->setSubject(
+ FavoriteProvider::SUBJECT_ADDED,
+ ['id' => $event->getFileId(), 'path' => $event->getPath()]
+ );
+ $this->activityManager->publish($activityEvent);
+ } catch (\InvalidArgumentException $e) {
+ } catch (\BadMethodCallException $e) {
+ }
+ }
+}
diff --git a/apps/files/lib/Listener/NodeRemovedFromFavoriteListener.php b/apps/files/lib/Listener/NodeRemovedFromFavoriteListener.php
new file mode 100644
index 00000000000..fe39d4af540
--- /dev/null
+++ b/apps/files/lib/Listener/NodeRemovedFromFavoriteListener.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Listener;
+
+use OCA\Files\Activity\FavoriteProvider;
+use OCP\Activity\IManager as IActivityManager;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Files\Events\NodeRemovedFromFavorite;
+
+/** @template-implements IEventListener<NodeRemovedFromFavorite> */
+class NodeRemovedFromFavoriteListener implements IEventListener {
+ public function __construct(
+ private IActivityManager $activityManager,
+ ) {
+ }
+ public function handle(Event $event):void {
+ if (!($event instanceof NodeRemovedFromFavorite)) {
+ return;
+ }
+ $activityEvent = $this->activityManager->generateEvent();
+ try {
+ $activityEvent->setApp('files')
+ ->setObject('files', $event->getFileId(), $event->getPath())
+ ->setType('favorite')
+ ->setAuthor($event->getUser()->getUID())
+ ->setAffectedUser($event->getUser()->getUID())
+ ->setTimestamp(time())
+ ->setSubject(
+ FavoriteProvider::SUBJECT_REMOVED,
+ ['id' => $event->getFileId(), 'path' => $event->getPath()]
+ );
+ $this->activityManager->publish($activityEvent);
+ } catch (\InvalidArgumentException $e) {
+ } catch (\BadMethodCallException $e) {
+ }
+ }
+}
diff --git a/apps/files/lib/Listener/RenderReferenceEventListener.php b/apps/files/lib/Listener/RenderReferenceEventListener.php
new file mode 100644
index 00000000000..b7470e5acf5
--- /dev/null
+++ b/apps/files/lib/Listener/RenderReferenceEventListener.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Listener;
+
+use OCP\Collaboration\Reference\RenderReferenceEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Util;
+
+/** @template-implements IEventListener<RenderReferenceEvent> */
+class RenderReferenceEventListener implements IEventListener {
+ public function handle(Event $event): void {
+ if (!$event instanceof RenderReferenceEvent) {
+ return;
+ }
+
+ Util::addScript('files', 'reference-files');
+ }
+}
diff --git a/apps/files/lib/Listener/SyncLivePhotosListener.php b/apps/files/lib/Listener/SyncLivePhotosListener.php
new file mode 100644
index 00000000000..b6773e8c452
--- /dev/null
+++ b/apps/files/lib/Listener/SyncLivePhotosListener.php
@@ -0,0 +1,254 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Listener;
+
+use Exception;
+use OC\Files\Node\NonExistingFile;
+use OC\Files\Node\NonExistingFolder;
+use OC\Files\View;
+use OC\FilesMetadata\Model\FilesMetadata;
+use OCA\Files\Service\LivePhotosService;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Exceptions\AbortedEventException;
+use OCP\Files\Cache\CacheEntryRemovedEvent;
+use OCP\Files\Events\Node\BeforeNodeCopiedEvent;
+use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
+use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
+use OCP\Files\Events\Node\NodeCopiedEvent;
+use OCP\Files\File;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\Files\NotFoundException;
+use OCP\FilesMetadata\IFilesMetadataManager;
+
+/**
+ * @template-implements IEventListener<Event>
+ */
+class SyncLivePhotosListener implements IEventListener {
+ /** @var Array<int> */
+ private array $pendingRenames = [];
+ /** @var Array<int, bool> */
+ private array $pendingDeletion = [];
+ /** @var Array<int> */
+ private array $pendingCopies = [];
+
+ public function __construct(
+ private ?Folder $userFolder,
+ private IFilesMetadataManager $filesMetadataManager,
+ private LivePhotosService $livePhotosService,
+ private IRootFolder $rootFolder,
+ private View $view,
+ ) {
+ }
+
+ public function handle(Event $event): void {
+ if ($this->userFolder === null) {
+ return;
+ }
+
+ if ($event instanceof BeforeNodeCopiedEvent || $event instanceof NodeCopiedEvent) {
+ $this->handleCopyRecursive($event, $event->getSource(), $event->getTarget());
+ } else {
+ $peerFileId = null;
+
+ if ($event instanceof BeforeNodeRenamedEvent) {
+ $peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getSource()->getId());
+ } elseif ($event instanceof BeforeNodeDeletedEvent) {
+ $peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getNode()->getId());
+ } elseif ($event instanceof CacheEntryRemovedEvent) {
+ $peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getFileId());
+ }
+
+ if ($peerFileId === null) {
+ return; // Not a live photo.
+ }
+
+ // Check the user's folder.
+ $peerFile = $this->userFolder->getFirstNodeById($peerFileId);
+
+ if ($peerFile === null) {
+ return; // Peer file not found.
+ }
+
+ if ($event instanceof BeforeNodeRenamedEvent) {
+ $this->runMoveOrCopyChecks($event->getSource(), $event->getTarget(), $peerFile);
+ $this->handleMove($event->getSource(), $event->getTarget(), $peerFile);
+ } elseif ($event instanceof BeforeNodeDeletedEvent) {
+ $this->handleDeletion($event, $peerFile);
+ } elseif ($event instanceof CacheEntryRemovedEvent) {
+ $peerFile->delete();
+ }
+ }
+ }
+
+ private function runMoveOrCopyChecks(Node $sourceFile, Node $targetFile, Node $peerFile): void {
+ $targetParent = $targetFile->getParent();
+ $sourceExtension = $sourceFile->getExtension();
+ $peerFileExtension = $peerFile->getExtension();
+ $targetName = $targetFile->getName();
+ $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension;
+
+ if (!str_ends_with($targetName, '.' . $sourceExtension)) {
+ throw new AbortedEventException('Cannot change the extension of a Live Photo');
+ }
+
+ try {
+ $targetParent->get($targetName);
+ throw new AbortedEventException('A file already exist at destination path of the Live Photo');
+ } catch (NotFoundException) {
+ }
+
+ if (!($targetParent instanceof NonExistingFolder)) {
+ try {
+ $targetParent->get($peerTargetName);
+ throw new AbortedEventException('A file already exist at destination path of the Live Photo');
+ } catch (NotFoundException) {
+ }
+ }
+ }
+
+ /**
+ * During rename events, which also include move operations,
+ * we rename the peer file using the same name.
+ * The event listener being singleton, we can store the current state
+ * of pending renames inside the 'pendingRenames' property,
+ * to prevent infinite recursive.
+ */
+ private function handleMove(Node $sourceFile, Node $targetFile, Node $peerFile): void {
+ $targetParent = $targetFile->getParent();
+ $sourceExtension = $sourceFile->getExtension();
+ $peerFileExtension = $peerFile->getExtension();
+ $targetName = $targetFile->getName();
+ $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension;
+
+ // in case the rename was initiated from this listener, we stop right now
+ if (in_array($peerFile->getId(), $this->pendingRenames)) {
+ return;
+ }
+
+ $this->pendingRenames[] = $sourceFile->getId();
+ try {
+ $peerFile->move($targetParent->getPath() . '/' . $peerTargetName);
+ } catch (\Throwable $ex) {
+ throw new AbortedEventException($ex->getMessage());
+ }
+
+ $this->pendingRenames = array_diff($this->pendingRenames, [$sourceFile->getId()]);
+ }
+
+
+ /**
+ * handle copy, we already know if it is doable from BeforeNodeCopiedEvent, so we just copy the linked file
+ */
+ private function handleCopy(File $sourceFile, File $targetFile, File $peerFile): void {
+ $sourceExtension = $sourceFile->getExtension();
+ $peerFileExtension = $peerFile->getExtension();
+ $targetParent = $targetFile->getParent();
+ $targetName = $targetFile->getName();
+ $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension;
+
+ if ($targetParent->nodeExists($peerTargetName)) {
+ // If the copy was a folder copy, then the peer file already exists.
+ $targetPeerFile = $targetParent->get($peerTargetName);
+ } else {
+ // If the copy was a file copy, then we need to create the peer file.
+ $targetPeerFile = $peerFile->copy($targetParent->getPath() . '/' . $peerTargetName);
+ }
+
+ /** @var FilesMetadata $targetMetadata */
+ $targetMetadata = $this->filesMetadataManager->getMetadata($targetFile->getId(), true);
+ $targetMetadata->setStorageId($targetFile->getStorage()->getCache()->getNumericStorageId());
+ $targetMetadata->setString('files-live-photo', (string)$targetPeerFile->getId());
+ $this->filesMetadataManager->saveMetadata($targetMetadata);
+ /** @var FilesMetadata $peerMetadata */
+ $peerMetadata = $this->filesMetadataManager->getMetadata($targetPeerFile->getId(), true);
+ $peerMetadata->setStorageId($targetPeerFile->getStorage()->getCache()->getNumericStorageId());
+ $peerMetadata->setString('files-live-photo', (string)$targetFile->getId());
+ $this->filesMetadataManager->saveMetadata($peerMetadata);
+ }
+
+ /**
+ * During deletion event, we trigger another recursive delete on the peer file.
+ * Delete operations on the .mov file directly are currently blocked.
+ * The event listener being singleton, we can store the current state
+ * of pending deletions inside the 'pendingDeletions' property,
+ * to prevent infinite recursivity.
+ */
+ private function handleDeletion(BeforeNodeDeletedEvent $event, Node $peerFile): void {
+ $deletedFile = $event->getNode();
+ if ($deletedFile->getMimetype() === 'video/quicktime') {
+ if (isset($this->pendingDeletion[$peerFile->getId()])) {
+ unset($this->pendingDeletion[$peerFile->getId()]);
+ return;
+ } else {
+ throw new AbortedEventException('Cannot delete the video part of a live photo');
+ }
+ } else {
+ $this->pendingDeletion[$deletedFile->getId()] = true;
+ try {
+ $peerFile->delete();
+ } catch (\Throwable $ex) {
+ throw new AbortedEventException($ex->getMessage());
+ }
+ }
+ return;
+ }
+
+ /*
+ * Recursively get all the peer ids of a live photo.
+ * Needed when coping a folder.
+ *
+ * @param BeforeNodeCopiedEvent|NodeCopiedEvent $event
+ */
+ private function handleCopyRecursive(Event $event, Node $sourceNode, Node $targetNode): void {
+ if ($sourceNode instanceof Folder && $targetNode instanceof Folder) {
+ foreach ($sourceNode->getDirectoryListing() as $sourceChild) {
+ if ($event instanceof BeforeNodeCopiedEvent) {
+ if ($sourceChild instanceof Folder) {
+ $targetChild = new NonExistingFolder($this->rootFolder, $this->view, $targetNode->getPath() . '/' . $sourceChild->getName(), null, $targetNode);
+ } else {
+ $targetChild = new NonExistingFile($this->rootFolder, $this->view, $targetNode->getPath() . '/' . $sourceChild->getName(), null, $targetNode);
+ }
+ } elseif ($event instanceof NodeCopiedEvent) {
+ $targetChild = $targetNode->get($sourceChild->getName());
+ } else {
+ throw new Exception('Event is type is not supported');
+ }
+
+ $this->handleCopyRecursive($event, $sourceChild, $targetChild);
+ }
+ } elseif ($sourceNode instanceof File && $targetNode instanceof File) {
+ // in case the copy was initiated from this listener, we stop right now
+ if (in_array($sourceNode->getId(), $this->pendingCopies)) {
+ return;
+ }
+
+ $peerFileId = $this->livePhotosService->getLivePhotoPeerId($sourceNode->getId());
+ if ($peerFileId === null) {
+ return;
+ }
+ $peerFile = $this->userFolder->getFirstNodeById($peerFileId);
+ if ($peerFile === null) {
+ return;
+ }
+
+ $this->pendingCopies[] = $peerFileId;
+ if ($event instanceof BeforeNodeCopiedEvent) {
+ $this->runMoveOrCopyChecks($sourceNode, $targetNode, $peerFile);
+ } elseif ($event instanceof NodeCopiedEvent) {
+ $this->handleCopy($sourceNode, $targetNode, $peerFile);
+ }
+ $this->pendingCopies = array_diff($this->pendingCopies, [$peerFileId]);
+ } else {
+ throw new Exception('Source and target type are not matching');
+ }
+ }
+}
diff --git a/apps/files/lib/Migration/Version11301Date20191205150729.php b/apps/files/lib/Migration/Version11301Date20191205150729.php
new file mode 100644
index 00000000000..2e3d72c7ece
--- /dev/null
+++ b/apps/files/lib/Migration/Version11301Date20191205150729.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version11301Date20191205150729 extends SimpleMigrationStep {
+
+ /**
+ * @param IOutput $output
+ * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+ * @param array $options
+ * @return null|ISchemaWrapper
+ */
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $table = $schema->createTable('user_transfer_owner');
+ $table->addColumn('id', 'bigint', [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 20,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('source_user', 'string', [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('target_user', 'string', [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('file_id', 'bigint', [
+ 'notnull' => true,
+ 'length' => 20,
+ ]);
+ $table->addColumn('node_name', 'string', [
+ 'notnull' => true,
+ 'length' => 255,
+ ]);
+ $table->setPrimaryKey(['id']);
+
+ // Quite radical, we just assume no one updates cross beta with a pending request.
+ // Do not try this at home
+ if ($schema->hasTable('user_transfer_ownership')) {
+ $schema->dropTable('user_transfer_ownership');
+ }
+
+ return $schema;
+ }
+}
diff --git a/apps/files/lib/Migration/Version12101Date20221011153334.php b/apps/files/lib/Migration/Version12101Date20221011153334.php
new file mode 100644
index 00000000000..ed4d8bef90b
--- /dev/null
+++ b/apps/files/lib/Migration/Version12101Date20221011153334.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version12101Date20221011153334 extends SimpleMigrationStep {
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $table = $schema->createTable('open_local_editor');
+ $table->addColumn('id', Types::BIGINT, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 20,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('user_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('path_hash', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('expiration_time', Types::BIGINT, [
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('token', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 128,
+ ]);
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['user_id', 'path_hash', 'token'], 'openlocal_user_path_token');
+
+ return $schema;
+ }
+}
diff --git a/apps/files/lib/Migration/Version2003Date20241021095629.php b/apps/files/lib/Migration/Version2003Date20241021095629.php
new file mode 100644
index 00000000000..30d05fa12ad
--- /dev/null
+++ b/apps/files/lib/Migration/Version2003Date20241021095629.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Migration;
+
+use Closure;
+use OCA\Files\Service\ChunkedUploadConfig;
+use OCP\DB\ISchemaWrapper;
+use OCP\IConfig;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+use OCP\Server;
+
+class Version2003Date20241021095629 extends SimpleMigrationStep {
+ /**
+ * @param IOutput $output
+ * @param Closure(): ISchemaWrapper $schemaClosure
+ * @param array $options
+ */
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $maxChunkSize = Server::get(IConfig::class)->getAppValue('files', 'max_chunk_size');
+ if ($maxChunkSize === '') {
+ // Skip if no value was configured before
+ return;
+ }
+
+ ChunkedUploadConfig::setMaxChunkSize((int)$maxChunkSize);
+ Server::get(IConfig::class)->deleteAppValue('files', 'max_chunk_size');
+ }
+}
diff --git a/apps/files/lib/Notification/Notifier.php b/apps/files/lib/Notification/Notifier.php
new file mode 100644
index 00000000000..6acc312c126
--- /dev/null
+++ b/apps/files/lib/Notification/Notifier.php
@@ -0,0 +1,290 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Notification;
+
+use OCA\Files\BackgroundJob\TransferOwnership;
+use OCA\Files\Db\TransferOwnershipMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJobList;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\L10N\IFactory;
+use OCP\Notification\IAction;
+use OCP\Notification\IDismissableNotifier;
+use OCP\Notification\IManager;
+use OCP\Notification\INotification;
+use OCP\Notification\INotifier;
+use OCP\Notification\UnknownNotificationException;
+
+class Notifier implements INotifier, IDismissableNotifier {
+ public function __construct(
+ protected IFactory $l10nFactory,
+ protected IURLGenerator $urlGenerator,
+ private TransferOwnershipMapper $mapper,
+ private IManager $notificationManager,
+ private IUserManager $userManager,
+ private IJobList $jobList,
+ private ITimeFactory $timeFactory,
+ ) {
+ }
+
+ public function getID(): string {
+ return 'files';
+ }
+
+ public function getName(): string {
+ return $this->l10nFactory->get('files')->t('Files');
+ }
+
+ /**
+ * @param INotification $notification
+ * @param string $languageCode The code of the language that should be used to prepare the notification
+ * @return INotification
+ * @throws UnknownNotificationException When the notification was not prepared by a notifier
+ */
+ public function prepare(INotification $notification, string $languageCode): INotification {
+ if ($notification->getApp() !== 'files') {
+ throw new UnknownNotificationException('Unhandled app');
+ }
+
+ $imagePath = $this->urlGenerator->imagePath('files', 'folder-move.svg');
+ $iconUrl = $this->urlGenerator->getAbsoluteURL($imagePath);
+ $notification->setIcon($iconUrl);
+
+ return match($notification->getSubject()) {
+ 'transferownershipRequest' => $this->handleTransferownershipRequest($notification, $languageCode),
+ 'transferownershipRequestDenied' => $this->handleTransferOwnershipRequestDenied($notification, $languageCode),
+ 'transferOwnershipFailedSource' => $this->handleTransferOwnershipFailedSource($notification, $languageCode),
+ 'transferOwnershipFailedTarget' => $this->handleTransferOwnershipFailedTarget($notification, $languageCode),
+ 'transferOwnershipDoneSource' => $this->handleTransferOwnershipDoneSource($notification, $languageCode),
+ 'transferOwnershipDoneTarget' => $this->handleTransferOwnershipDoneTarget($notification, $languageCode),
+ default => throw new UnknownNotificationException('Unhandled subject')
+ };
+ }
+
+ public function handleTransferownershipRequest(INotification $notification, string $languageCode): INotification {
+ $l = $this->l10nFactory->get('files', $languageCode);
+ $id = $notification->getObjectId();
+ $param = $notification->getSubjectParameters();
+
+ $approveAction = $notification->createAction()
+ ->setParsedLabel($l->t('Accept'))
+ ->setPrimary(true)
+ ->setLink(
+ $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkTo(
+ '',
+ 'ocs/v2.php/apps/files/api/v1/transferownership/' . $id
+ )
+ ),
+ IAction::TYPE_POST
+ );
+
+ $disapproveAction = $notification->createAction()
+ ->setParsedLabel($l->t('Reject'))
+ ->setPrimary(false)
+ ->setLink(
+ $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkTo(
+ '',
+ 'ocs/v2.php/apps/files/api/v1/transferownership/' . $id
+ )
+ ),
+ IAction::TYPE_DELETE
+ );
+
+ $sourceUser = $this->getUser($param['sourceUser']);
+ $notification->addParsedAction($approveAction)
+ ->addParsedAction($disapproveAction)
+ ->setRichSubject(
+ $l->t('Incoming ownership transfer from {user}'),
+ [
+ 'user' => [
+ 'type' => 'user',
+ 'id' => $sourceUser->getUID(),
+ 'name' => $sourceUser->getDisplayName(),
+ ],
+ ])
+ ->setRichMessage(
+ $l->t("Do you want to accept {path}?\n\nNote: The transfer process after accepting may take up to 1 hour."),
+ [
+ 'path' => [
+ 'type' => 'highlight',
+ 'id' => $param['targetUser'] . '::' . $param['nodeName'],
+ 'name' => $param['nodeName'],
+ ]
+ ]);
+
+ return $notification;
+ }
+
+ public function handleTransferOwnershipRequestDenied(INotification $notification, string $languageCode): INotification {
+ $l = $this->l10nFactory->get('files', $languageCode);
+ $param = $notification->getSubjectParameters();
+
+ $targetUser = $this->getUser($param['targetUser']);
+ $notification->setRichSubject($l->t('Ownership transfer denied'))
+ ->setRichMessage(
+ $l->t('Your ownership transfer of {path} was denied by {user}.'),
+ [
+ 'path' => [
+ 'type' => 'highlight',
+ 'id' => $param['targetUser'] . '::' . $param['nodeName'],
+ 'name' => $param['nodeName'],
+ ],
+ 'user' => [
+ 'type' => 'user',
+ 'id' => $targetUser->getUID(),
+ 'name' => $targetUser->getDisplayName(),
+ ],
+ ]);
+ return $notification;
+ }
+
+ public function handleTransferOwnershipFailedSource(INotification $notification, string $languageCode): INotification {
+ $l = $this->l10nFactory->get('files', $languageCode);
+ $param = $notification->getSubjectParameters();
+
+ $targetUser = $this->getUser($param['targetUser']);
+ $notification->setRichSubject($l->t('Ownership transfer failed'))
+ ->setRichMessage(
+ $l->t('Your ownership transfer of {path} to {user} failed.'),
+ [
+ 'path' => [
+ 'type' => 'highlight',
+ 'id' => $param['targetUser'] . '::' . $param['nodeName'],
+ 'name' => $param['nodeName'],
+ ],
+ 'user' => [
+ 'type' => 'user',
+ 'id' => $targetUser->getUID(),
+ 'name' => $targetUser->getDisplayName(),
+ ],
+ ]);
+ return $notification;
+ }
+
+ public function handleTransferOwnershipFailedTarget(INotification $notification, string $languageCode): INotification {
+ $l = $this->l10nFactory->get('files', $languageCode);
+ $param = $notification->getSubjectParameters();
+
+ $sourceUser = $this->getUser($param['sourceUser']);
+ $notification->setRichSubject($l->t('Ownership transfer failed'))
+ ->setRichMessage(
+ $l->t('The ownership transfer of {path} from {user} failed.'),
+ [
+ 'path' => [
+ 'type' => 'highlight',
+ 'id' => $param['sourceUser'] . '::' . $param['nodeName'],
+ 'name' => $param['nodeName'],
+ ],
+ 'user' => [
+ 'type' => 'user',
+ 'id' => $sourceUser->getUID(),
+ 'name' => $sourceUser->getDisplayName(),
+ ],
+ ]);
+
+ return $notification;
+ }
+
+ public function handleTransferOwnershipDoneSource(INotification $notification, string $languageCode): INotification {
+ $l = $this->l10nFactory->get('files', $languageCode);
+ $param = $notification->getSubjectParameters();
+
+ $targetUser = $this->getUser($param['targetUser']);
+ $notification->setRichSubject($l->t('Ownership transfer done'))
+ ->setRichMessage(
+ $l->t('Your ownership transfer of {path} to {user} has completed.'),
+ [
+ 'path' => [
+ 'type' => 'highlight',
+ 'id' => $param['targetUser'] . '::' . $param['nodeName'],
+ 'name' => $param['nodeName'],
+ ],
+ 'user' => [
+ 'type' => 'user',
+ 'id' => $targetUser->getUID(),
+ 'name' => $targetUser->getDisplayName(),
+ ],
+ ]);
+
+ return $notification;
+ }
+
+ public function handleTransferOwnershipDoneTarget(INotification $notification, string $languageCode): INotification {
+ $l = $this->l10nFactory->get('files', $languageCode);
+ $param = $notification->getSubjectParameters();
+
+ $sourceUser = $this->getUser($param['sourceUser']);
+ $notification->setRichSubject($l->t('Ownership transfer done'))
+ ->setRichMessage(
+ $l->t('The ownership transfer of {path} from {user} has completed.'),
+ [
+ 'path' => [
+ 'type' => 'highlight',
+ 'id' => $param['sourceUser'] . '::' . $param['nodeName'],
+ 'name' => $param['nodeName'],
+ ],
+ 'user' => [
+ 'type' => 'user',
+ 'id' => $sourceUser->getUID(),
+ 'name' => $sourceUser->getDisplayName(),
+ ],
+ ]);
+
+ return $notification;
+ }
+
+ public function dismissNotification(INotification $notification): void {
+ if ($notification->getApp() !== 'files') {
+ throw new UnknownNotificationException('Unhandled app');
+ }
+ if ($notification->getSubject() !== 'transferownershipRequest') {
+ throw new UnknownNotificationException('Unhandled notification type');
+ }
+
+ // TODO: This should all be moved to a service that also the transferownershipController uses.
+ try {
+ $transferOwnership = $this->mapper->getById((int)$notification->getObjectId());
+ } catch (DoesNotExistException $e) {
+ return;
+ }
+
+ if ($this->jobList->has(TransferOwnership::class, [
+ 'id' => $transferOwnership->getId(),
+ ])) {
+ return;
+ }
+
+ $notification = $this->notificationManager->createNotification();
+ $notification->setUser($transferOwnership->getSourceUser())
+ ->setApp('files')
+ ->setDateTime($this->timeFactory->getDateTime())
+ ->setSubject('transferownershipRequestDenied', [
+ 'sourceUser' => $transferOwnership->getSourceUser(),
+ 'targetUser' => $transferOwnership->getTargetUser(),
+ 'nodeName' => $transferOwnership->getNodeName()
+ ])
+ ->setObject('transfer', (string)$transferOwnership->getId());
+ $this->notificationManager->notify($notification);
+
+ $this->mapper->delete($transferOwnership);
+ }
+
+ protected function getUser(string $userId): IUser {
+ $user = $this->userManager->get($userId);
+ if ($user instanceof IUser) {
+ return $user;
+ }
+ throw new \InvalidArgumentException('User not found');
+ }
+}
diff --git a/apps/files/lib/ResponseDefinitions.php b/apps/files/lib/ResponseDefinitions.php
new file mode 100644
index 00000000000..c5d094e7bd8
--- /dev/null
+++ b/apps/files/lib/ResponseDefinitions.php
@@ -0,0 +1,75 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files;
+
+/**
+ * @psalm-type FilesTemplateFile = array{
+ * basename: string,
+ * etag: string,
+ * fileid: int,
+ * filename: ?string,
+ * lastmod: int,
+ * mime: string,
+ * size: int,
+ * type: string,
+ * hasPreview: bool,
+ * }
+ *
+ * @psalm-type FilesTemplateField = array{
+ * index: string,
+ * type: string,
+ * alias: ?string,
+ * tag: ?string,
+ * id: ?int,
+ * content?: string,
+ * checked?: bool,
+ * }
+ *
+ * @psalm-type FilesTemplate = array{
+ * templateType: string,
+ * templateId: string,
+ * basename: string,
+ * etag: string,
+ * fileid: int,
+ * filename: string,
+ * lastmod: int,
+ * mime: string,
+ * size: int|float,
+ * type: string,
+ * hasPreview: bool,
+ * previewUrl: ?string,
+ * fields: list<FilesTemplateField>,
+ * }
+ *
+ * @psalm-type FilesTemplateFileCreator = array{
+ * app: string,
+ * label: string,
+ * extension: string,
+ * iconClass: ?string,
+ * iconSvgInline: ?string,
+ * mimetypes: list<string>,
+ * ratio: ?float,
+ * actionLabel: string,
+ * }
+ *
+ * @psalm-type FilesTemplateFileCreatorWithTemplates = FilesTemplateFileCreator&array{
+ * templates: list<FilesTemplate>,
+ * }
+ *
+ * @psalm-type FilesFolderTree = list<array{
+ * id: int,
+ * basename: string,
+ * displayName?: string,
+ * children: list<array{}>,
+ * }>
+ *
+ */
+class ResponseDefinitions {
+}
diff --git a/apps/files/lib/Search/FilesSearchProvider.php b/apps/files/lib/Search/FilesSearchProvider.php
new file mode 100644
index 00000000000..f71d58c6fae
--- /dev/null
+++ b/apps/files/lib/Search/FilesSearchProvider.php
@@ -0,0 +1,202 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Search;
+
+use InvalidArgumentException;
+use OC\Files\Search\SearchBinaryOperator;
+use OC\Files\Search\SearchComparison;
+use OC\Files\Search\SearchOrder;
+use OC\Files\Search\SearchQuery;
+use OC\Search\Filter\GroupFilter;
+use OC\Search\Filter\UserFilter;
+use OCP\Files\FileInfo;
+use OCP\Files\IMimeTypeDetector;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\Files\Search\ISearchComparison;
+use OCP\Files\Search\ISearchOperator;
+use OCP\Files\Search\ISearchOrder;
+use OCP\IL10N;
+use OCP\IPreview;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\Search\FilterDefinition;
+use OCP\Search\IFilter;
+use OCP\Search\IFilteringProvider;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use OCP\Search\SearchResultEntry;
+use OCP\Share\IShare;
+
+class FilesSearchProvider implements IFilteringProvider {
+ public function __construct(
+ private IL10N $l10n,
+ private IURLGenerator $urlGenerator,
+ private IMimeTypeDetector $mimeTypeDetector,
+ private IRootFolder $rootFolder,
+ private IPreview $previewManager,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getId(): string {
+ return 'files';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getName(): string {
+ return $this->l10n->t('Files');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOrder(string $route, array $routeParameters): int {
+ if ($route === 'files.View.index') {
+ // Before comments
+ return -5;
+ }
+ return 5;
+ }
+
+ public function getSupportedFilters(): array {
+ return [
+ 'term',
+ 'since',
+ 'until',
+ 'person',
+ 'min-size',
+ 'max-size',
+ 'mime',
+ 'type',
+ 'path',
+ 'is-favorite',
+ 'title-only',
+ ];
+ }
+
+ public function getAlternateIds(): array {
+ return [];
+ }
+
+ public function getCustomFilters(): array {
+ return [
+ new FilterDefinition('min-size', FilterDefinition::TYPE_INT),
+ new FilterDefinition('max-size', FilterDefinition::TYPE_INT),
+ new FilterDefinition('mime', FilterDefinition::TYPE_STRING),
+ new FilterDefinition('type', FilterDefinition::TYPE_STRING),
+ new FilterDefinition('path', FilterDefinition::TYPE_STRING),
+ new FilterDefinition('is-favorite', FilterDefinition::TYPE_BOOL),
+ ];
+ }
+
+ public function search(IUser $user, ISearchQuery $query): SearchResult {
+ $userFolder = $this->rootFolder->getUserFolder($user->getUID());
+ $fileQuery = $this->buildSearchQuery($query, $user);
+ return SearchResult::paginated(
+ $this->l10n->t('Files'),
+ array_map(function (Node $result) use ($userFolder) {
+ $thumbnailUrl = $this->previewManager->isMimeSupported($result->getMimetype())
+ ? $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->getId()])
+ : '';
+ $icon = $result->getMimetype() === FileInfo::MIMETYPE_FOLDER
+ ? 'icon-folder'
+ : $this->mimeTypeDetector->mimeTypeIcon($result->getMimetype());
+ $path = $userFolder->getRelativePath($result->getPath());
+
+ // Use shortened link to centralize the various
+ // files/folder url redirection in files.View.showFile
+ $link = $this->urlGenerator->linkToRoute(
+ 'files.View.showFile',
+ ['fileid' => $result->getId()]
+ );
+
+ $searchResultEntry = new SearchResultEntry(
+ $thumbnailUrl,
+ $result->getName(),
+ $this->formatSubline($path),
+ $this->urlGenerator->getAbsoluteURL($link),
+ $icon,
+ );
+ $searchResultEntry->addAttribute('fileId', (string)$result->getId());
+ $searchResultEntry->addAttribute('path', $path);
+ return $searchResultEntry;
+ }, $userFolder->search($fileQuery)),
+ $query->getCursor() + $query->getLimit()
+ );
+ }
+
+ private function buildSearchQuery(ISearchQuery $query, IUser $user): SearchQuery {
+ $comparisons = [];
+ foreach ($query->getFilters() as $name => $filter) {
+ $comparisons[] = match ($name) {
+ 'term' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', '%' . $filter->get() . '%'),
+ 'since' => new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN_EQUAL, 'mtime', $filter->get()->getTimestamp()),
+ 'until' => new SearchComparison(ISearchComparison::COMPARE_LESS_THAN_EQUAL, 'mtime', $filter->get()->getTimestamp()),
+ 'min-size' => new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN_EQUAL, 'size', $filter->get()),
+ 'max-size' => new SearchComparison(ISearchComparison::COMPARE_LESS_THAN_EQUAL, 'size', $filter->get()),
+ 'mime' => new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', $filter->get()),
+ 'type' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', $filter->get() . '/%'),
+ 'path' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'path', 'files/' . ltrim($filter->get(), '/') . '%'),
+ 'person' => $this->buildPersonSearchQuery($filter),
+ default => throw new InvalidArgumentException('Unsupported comparison'),
+ };
+ }
+
+ return new SearchQuery(
+ new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, $comparisons),
+ $query->getLimit(),
+ (int)$query->getCursor(),
+ $query->getSortOrder() === ISearchQuery::SORT_DATE_DESC
+ ? [new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')]
+ : [],
+ $user
+ );
+ }
+
+ private function buildPersonSearchQuery(IFilter $person): ISearchOperator {
+ if ($person instanceof UserFilter) {
+ return new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_OR, [
+ new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, [
+ new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_with', $person->get()->getUID()),
+ new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_type', IShare::TYPE_USER),
+ ]),
+ new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'owner', $person->get()->getUID()),
+ ]);
+ }
+ if ($person instanceof GroupFilter) {
+ return new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, [
+ new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_with', $person->get()->getGID()),
+ new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_type', IShare::TYPE_GROUP),
+ ]);
+ }
+
+ throw new InvalidArgumentException('Unsupported filter type');
+ }
+
+ /**
+ * Format subline for files
+ *
+ * @param string $path
+ * @return string
+ */
+ private function formatSubline(string $path): string {
+ // Do not show the location if the file is in root
+ if (strrpos($path, '/') > 0) {
+ $path = ltrim(dirname($path), '/');
+ return $this->l10n->t('in %s', [$path]);
+ } else {
+ return '';
+ }
+ }
+}
diff --git a/apps/files/lib/Service/ChunkedUploadConfig.php b/apps/files/lib/Service/ChunkedUploadConfig.php
new file mode 100644
index 00000000000..29661750f8b
--- /dev/null
+++ b/apps/files/lib/Service/ChunkedUploadConfig.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Service;
+
+use OCP\IConfig;
+use OCP\Server;
+
+class ChunkedUploadConfig {
+ private const KEY_MAX_SIZE = 'files.chunked_upload.max_size';
+ private const KEY_MAX_PARALLEL_COUNT = 'files.chunked_upload.max_parallel_count';
+
+ public static function getMaxChunkSize(): int {
+ return Server::get(IConfig::class)->getSystemValueInt(self::KEY_MAX_SIZE, 100 * 1024 * 1024);
+ }
+
+ public static function setMaxChunkSize(int $maxChunkSize): void {
+ Server::get(IConfig::class)->setSystemValue(self::KEY_MAX_SIZE, $maxChunkSize);
+ }
+
+ public static function getMaxParallelCount(): int {
+ return Server::get(IConfig::class)->getSystemValueInt(self::KEY_MAX_PARALLEL_COUNT, 5);
+ }
+}
diff --git a/apps/files/lib/Service/DirectEditingService.php b/apps/files/lib/Service/DirectEditingService.php
new file mode 100644
index 00000000000..3d756ee56fa
--- /dev/null
+++ b/apps/files/lib/Service/DirectEditingService.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Service;
+
+use OCP\DirectEditing\ACreateEmpty;
+use OCP\DirectEditing\ACreateFromTemplate;
+use OCP\DirectEditing\IEditor;
+use OCP\DirectEditing\IManager;
+use OCP\DirectEditing\RegisterDirectEditorEvent;
+use OCP\EventDispatcher\IEventDispatcher;
+
+class DirectEditingService {
+
+ public function __construct(
+ private IEventDispatcher $eventDispatcher,
+ private IManager $directEditingManager,
+ ) {
+ }
+
+ public function getDirectEditingETag(): string {
+ return \md5(\json_encode($this->getDirectEditingCapabilitites()));
+ }
+
+ public function getDirectEditingCapabilitites(): array {
+ $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager));
+
+ $capabilities = [
+ 'editors' => [],
+ 'creators' => []
+ ];
+
+ if (!$this->directEditingManager->isEnabled()) {
+ return $capabilities;
+ }
+
+ /**
+ * @var string $id
+ * @var IEditor $editor
+ */
+ foreach ($this->directEditingManager->getEditors() as $id => $editor) {
+ $capabilities['editors'][$id] = [
+ 'id' => $editor->getId(),
+ 'name' => $editor->getName(),
+ 'mimetypes' => $editor->getMimetypes(),
+ 'optionalMimetypes' => $editor->getMimetypesOptional(),
+ 'secure' => $editor->isSecure(),
+ ];
+ /** @var ACreateEmpty|ACreateFromTemplate $creator */
+ foreach ($editor->getCreators() as $creator) {
+ $id = $creator->getId();
+ $capabilities['creators'][$id] = [
+ 'id' => $id,
+ 'editor' => $editor->getId(),
+ 'name' => $creator->getName(),
+ 'extension' => $creator->getExtension(),
+ 'templates' => $creator instanceof ACreateFromTemplate,
+ 'mimetype' => $creator->getMimetype()
+ ];
+ }
+ }
+ return $capabilities;
+ }
+}
diff --git a/apps/files/lib/Service/LivePhotosService.php b/apps/files/lib/Service/LivePhotosService.php
new file mode 100644
index 00000000000..3ac6601d5dc
--- /dev/null
+++ b/apps/files/lib/Service/LivePhotosService.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Service;
+
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\IFilesMetadataManager;
+
+class LivePhotosService {
+ public function __construct(
+ private IFilesMetadataManager $filesMetadataManager,
+ ) {
+ }
+
+ /**
+ * Get the associated live photo for a given file id
+ */
+ public function getLivePhotoPeerId(int $fileId): ?int {
+ try {
+ $metadata = $this->filesMetadataManager->getMetadata($fileId);
+ } catch (FilesMetadataNotFoundException $ex) {
+ return null;
+ }
+
+ if (!$metadata->hasKey('files-live-photo')) {
+ return null;
+ }
+
+ return (int)$metadata->getString('files-live-photo');
+ }
+}
diff --git a/apps/files/lib/Service/OwnershipTransferService.php b/apps/files/lib/Service/OwnershipTransferService.php
new file mode 100644
index 00000000000..afef5d2093d
--- /dev/null
+++ b/apps/files/lib/Service/OwnershipTransferService.php
@@ -0,0 +1,624 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Service;
+
+use Closure;
+use Exception;
+use OC\Files\Filesystem;
+use OC\Files\View;
+use OC\User\NoUserException;
+use OCA\Encryption\Util;
+use OCA\Files\Exception\TransferOwnershipException;
+use OCA\Files_External\Config\ConfigAdapter;
+use OCP\Encryption\IManager as IEncryptionManager;
+use OCP\Files\Config\IHomeMountProvider;
+use OCP\Files\Config\IUserMountCache;
+use OCP\Files\File;
+use OCP\Files\FileInfo;
+use OCP\Files\InvalidPathException;
+use OCP\Files\IRootFolder;
+use OCP\Files\Mount\IMountManager;
+use OCP\Files\NotFoundException;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\L10N\IFactory;
+use OCP\Server;
+use OCP\Share\IManager as IShareManager;
+use OCP\Share\IShare;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Output\NullOutput;
+use Symfony\Component\Console\Output\OutputInterface;
+use function array_merge;
+use function basename;
+use function count;
+use function date;
+use function is_dir;
+use function rtrim;
+
+class OwnershipTransferService {
+
+ public function __construct(
+ private IEncryptionManager $encryptionManager,
+ private IShareManager $shareManager,
+ private IMountManager $mountManager,
+ private IUserMountCache $userMountCache,
+ private IUserManager $userManager,
+ private IFactory $l10nFactory,
+ private IRootFolder $rootFolder,
+ ) {
+ }
+
+ /**
+ * @param IUser $sourceUser
+ * @param IUser $destinationUser
+ * @param string $path
+ *
+ * @param OutputInterface|null $output
+ * @param bool $move
+ * @throws TransferOwnershipException
+ * @throws NoUserException
+ */
+ public function transfer(
+ IUser $sourceUser,
+ IUser $destinationUser,
+ string $path,
+ ?OutputInterface $output = null,
+ bool $move = false,
+ bool $firstLogin = false,
+ bool $includeExternalStorage = false,
+ ): void {
+ $output = $output ?? new NullOutput();
+ $sourceUid = $sourceUser->getUID();
+ $destinationUid = $destinationUser->getUID();
+ $sourcePath = rtrim($sourceUid . '/files/' . $path, '/');
+
+ // If encryption is on we have to ensure the user has logged in before and that all encryption modules are ready
+ if (($this->encryptionManager->isEnabled() && $destinationUser->getLastLogin() === 0)
+ || !$this->encryptionManager->isReadyForUser($destinationUid)) {
+ throw new TransferOwnershipException('The target user is not ready to accept files. The user has at least to have logged in once.', 2);
+ }
+
+ // setup filesystem
+ // Requesting the user folder will set it up if the user hasn't logged in before
+ // We need a setupFS for the full filesystem setup before as otherwise we will just return
+ // a lazy root folder which does not create the destination users folder
+ \OC_Util::setupFS($sourceUser->getUID());
+ \OC_Util::setupFS($destinationUser->getUID());
+ $this->rootFolder->getUserFolder($sourceUser->getUID());
+ $this->rootFolder->getUserFolder($destinationUser->getUID());
+ Filesystem::initMountPoints($sourceUid);
+ Filesystem::initMountPoints($destinationUid);
+
+ $view = new View();
+
+ if ($move) {
+ $finalTarget = "$destinationUid/files/";
+ } else {
+ $l = $this->l10nFactory->get('files', $this->l10nFactory->getUserLanguage($destinationUser));
+ $date = date('Y-m-d H-i-s');
+
+ $cleanUserName = $this->sanitizeFolderName($sourceUser->getDisplayName()) ?: $sourceUid;
+ $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$cleanUserName, $date]));
+ try {
+ $view->verifyPath(dirname($finalTarget), basename($finalTarget));
+ } catch (InvalidPathException $e) {
+ $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$sourceUid, $date]));
+ }
+ }
+
+ if (!($view->is_dir($sourcePath) || $view->is_file($sourcePath))) {
+ throw new TransferOwnershipException("Unknown path provided: $path", 1);
+ }
+
+ if ($move && !$view->is_dir($finalTarget)) {
+ // Initialize storage
+ \OC_Util::setupFS($destinationUser->getUID());
+ }
+
+ if ($move && !$firstLogin && count($view->getDirectoryContent($finalTarget)) > 0) {
+ throw new TransferOwnershipException('Destination path does not exists or is not empty', 1);
+ }
+
+
+ // analyse source folder
+ $this->analyse(
+ $sourceUid,
+ $destinationUid,
+ $sourcePath,
+ $view,
+ $output
+ );
+
+ // collect all the shares
+ $shares = $this->collectUsersShares(
+ $sourceUid,
+ $output,
+ $view,
+ $sourcePath
+ );
+
+ $sourceSize = $view->getFileInfo($sourcePath)->getSize();
+
+ // transfer the files
+ $this->transferFiles(
+ $sourceUid,
+ $sourcePath,
+ $finalTarget,
+ $view,
+ $output,
+ $includeExternalStorage,
+ );
+ $sizeDifference = $sourceSize - $view->getFileInfo($finalTarget)->getSize();
+
+ // transfer the incoming shares
+ $sourceShares = $this->collectIncomingShares(
+ $sourceUid,
+ $output,
+ $sourcePath,
+ );
+ $destinationShares = $this->collectIncomingShares(
+ $destinationUid,
+ $output,
+ null,
+ );
+ $this->transferIncomingShares(
+ $sourceUid,
+ $destinationUid,
+ $sourceShares,
+ $destinationShares,
+ $output,
+ $path,
+ $finalTarget,
+ $move
+ );
+
+ $destinationPath = $finalTarget . '/' . $path;
+ // restore the shares
+ $this->restoreShares(
+ $sourceUid,
+ $destinationUid,
+ $destinationPath,
+ $shares,
+ $output
+ );
+ if ($sizeDifference !== 0) {
+ $output->writeln("Transferred folder have a size difference of: $sizeDifference Bytes which means the transfer may be incomplete. Please check the logs if there was any issue during the transfer operation.");
+ }
+ }
+
+ private function sanitizeFolderName(string $name): string {
+ // Remove some characters which are prone to cause errors
+ $name = str_replace(['\\', '/', ':', '.', '?', '#', '\'', '"'], '-', $name);
+ // Replace multiple dashes with one dash
+ return preg_replace('/-{2,}/s', '-', $name);
+ }
+
+ private function walkFiles(View $view, $path, Closure $callBack) {
+ foreach ($view->getDirectoryContent($path) as $fileInfo) {
+ if (!$callBack($fileInfo)) {
+ return;
+ }
+ if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
+ $this->walkFiles($view, $fileInfo->getPath(), $callBack);
+ }
+ }
+ }
+
+ /**
+ * @param OutputInterface $output
+ *
+ * @throws TransferOwnershipException
+ */
+ protected function analyse(
+ string $sourceUid,
+ string $destinationUid,
+ string $sourcePath,
+ View $view,
+ OutputInterface $output,
+ bool $includeExternalStorage = false,
+ ): void {
+ $output->writeln('Validating quota');
+ $sourceFileInfo = $view->getFileInfo($sourcePath, false);
+ if ($sourceFileInfo === false) {
+ throw new TransferOwnershipException("Unknown path provided: $sourcePath", 1);
+ }
+ $size = $sourceFileInfo->getSize(false);
+ $freeSpace = $view->free_space($destinationUid . '/files/');
+ if ($size > $freeSpace && $freeSpace !== FileInfo::SPACE_UNKNOWN) {
+ throw new TransferOwnershipException('Target user does not have enough free space available.', 1);
+ }
+
+ $output->writeln("Analysing files of $sourceUid ...");
+ $progress = new ProgressBar($output);
+ $progress->start();
+
+ if ($this->encryptionManager->isEnabled()) {
+ $masterKeyEnabled = Server::get(Util::class)->isMasterKeyEnabled();
+ } else {
+ $masterKeyEnabled = false;
+ }
+ $encryptedFiles = [];
+ if ($sourceFileInfo->getType() === FileInfo::TYPE_FOLDER) {
+ if ($sourceFileInfo->isEncrypted()) {
+ /* Encrypted folder means e2ee encrypted */
+ $encryptedFiles[] = $sourceFileInfo;
+ } else {
+ $this->walkFiles($view, $sourcePath,
+ function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFiles, $includeExternalStorage) {
+ if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
+ $mount = $fileInfo->getMountPoint();
+ // only analyze into folders from main storage,
+ if (
+ $mount->getMountProvider() instanceof IHomeMountProvider
+ || ($includeExternalStorage && $mount->getMountProvider() instanceof ConfigAdapter)
+ ) {
+ if ($fileInfo->isEncrypted()) {
+ /* Encrypted folder means e2ee encrypted, we cannot transfer it */
+ $encryptedFiles[] = $fileInfo;
+ }
+ return true;
+ } else {
+ return false;
+ }
+ }
+ $progress->advance();
+ if ($fileInfo->isEncrypted() && !$masterKeyEnabled) {
+ /* Encrypted file means SSE, we can only transfer it if master key is enabled */
+ $encryptedFiles[] = $fileInfo;
+ }
+ return true;
+ });
+ }
+ } elseif ($sourceFileInfo->isEncrypted() && !$masterKeyEnabled) {
+ /* Encrypted file means SSE, we can only transfer it if master key is enabled */
+ $encryptedFiles[] = $sourceFileInfo;
+ }
+ $progress->finish();
+ $output->writeln('');
+
+ // no file is allowed to be encrypted
+ if (!empty($encryptedFiles)) {
+ $output->writeln('<error>Some files are encrypted - please decrypt them first.</error>');
+ foreach ($encryptedFiles as $encryptedFile) {
+ /** @var FileInfo $encryptedFile */
+ $output->writeln(' ' . $encryptedFile->getPath());
+ }
+ throw new TransferOwnershipException('Some files are encrypted - please decrypt them first.', 1);
+ }
+ }
+
+ /**
+ * @return array<array{share: IShare, suffix: string}>
+ */
+ private function collectUsersShares(
+ string $sourceUid,
+ OutputInterface $output,
+ View $view,
+ string $path,
+ ): array {
+ $output->writeln("Collecting all share information for files and folders of $sourceUid ...");
+
+ $shares = [];
+ $progress = new ProgressBar($output);
+
+ $normalizedPath = Filesystem::normalizePath($path);
+
+ $supportedShareTypes = [
+ IShare::TYPE_GROUP,
+ IShare::TYPE_USER,
+ IShare::TYPE_LINK,
+ IShare::TYPE_REMOTE,
+ IShare::TYPE_ROOM,
+ IShare::TYPE_EMAIL,
+ IShare::TYPE_CIRCLE,
+ IShare::TYPE_DECK,
+ IShare::TYPE_SCIENCEMESH,
+ ];
+
+ foreach ($supportedShareTypes as $shareType) {
+ $offset = 0;
+ while (true) {
+ $sharePage = $this->shareManager->getSharesBy($sourceUid, $shareType, null, true, 50, $offset, onlyValid: false);
+ $progress->advance(count($sharePage));
+ if (empty($sharePage)) {
+ break;
+ }
+ if ($path !== "$sourceUid/files") {
+ $sharePage = array_filter($sharePage, function (IShare $share) use ($view, $normalizedPath) {
+ try {
+ $sourceNode = $share->getNode();
+ $relativePath = $view->getRelativePath($sourceNode->getPath());
+
+ return str_starts_with($relativePath . '/', $normalizedPath . '/');
+ } catch (Exception $e) {
+ return false;
+ }
+ });
+ }
+ $shares = array_merge($shares, $sharePage);
+ $offset += 50;
+ }
+ }
+
+ $progress->finish();
+ $output->writeln('');
+
+ return array_values(array_filter(array_map(function (IShare $share) use ($view, $normalizedPath, $output, $sourceUid) {
+ try {
+ $nodePath = $view->getRelativePath($share->getNode()->getPath());
+ } catch (NotFoundException $e) {
+ $output->writeln("<error>Failed to find path for shared file {$share->getNodeId()} for user $sourceUid, skipping</error>");
+ return null;
+ }
+
+ return [
+ 'share' => $share,
+ 'suffix' => substr(Filesystem::normalizePath($nodePath), strlen($normalizedPath)),
+ ];
+ }, $shares)));
+ }
+
+ private function collectIncomingShares(
+ string $sourceUid,
+ OutputInterface $output,
+ ?string $path,
+ ): array {
+ $output->writeln("Collecting all incoming share information for files and folders of $sourceUid ...");
+
+ $shares = [];
+ $progress = new ProgressBar($output);
+ $normalizedPath = Filesystem::normalizePath($path);
+
+ $offset = 0;
+ while (true) {
+ $sharePage = $this->shareManager->getSharedWith($sourceUid, IShare::TYPE_USER, null, 50, $offset);
+ $progress->advance(count($sharePage));
+ if (empty($sharePage)) {
+ break;
+ }
+
+ if ($path !== null && $path !== "$sourceUid/files") {
+ $sharePage = array_filter($sharePage, static function (IShare $share) use ($sourceUid, $normalizedPath) {
+ try {
+ return str_starts_with(Filesystem::normalizePath($sourceUid . '/files' . $share->getTarget() . '/', false), $normalizedPath . '/');
+ } catch (Exception) {
+ return false;
+ }
+ });
+ }
+
+ foreach ($sharePage as $share) {
+ $shares[$share->getNodeId()] = $share;
+ }
+
+ $offset += 50;
+ }
+
+
+ $progress->finish();
+ $output->writeln('');
+ return $shares;
+ }
+
+ /**
+ * @throws TransferOwnershipException
+ */
+ protected function transferFiles(
+ string $sourceUid,
+ string $sourcePath,
+ string $finalTarget,
+ View $view,
+ OutputInterface $output,
+ bool $includeExternalStorage,
+ ): void {
+ $output->writeln("Transferring files to $finalTarget ...");
+
+ // This change will help user to transfer the folder specified using --path option.
+ // Else only the content inside folder is transferred which is not correct.
+ if ($sourcePath !== "$sourceUid/files") {
+ $view->mkdir($finalTarget);
+ $finalTarget = $finalTarget . '/' . basename($sourcePath);
+ }
+ $sourceInfo = $view->getFileInfo($sourcePath);
+
+ /// handle the external storages mounted at the root, or the admin specifying an external storage with --path
+ if ($sourceInfo->getInternalPath() === '' && $includeExternalStorage) {
+ $this->moveMountContents($view, $sourcePath, $finalTarget);
+ } else {
+ if ($view->rename($sourcePath, $finalTarget, ['checkSubMounts' => false]) === false) {
+ throw new TransferOwnershipException('Could not transfer files.', 1);
+ }
+ }
+
+ if ($includeExternalStorage) {
+ $nestedMounts = $this->mountManager->findIn($sourcePath);
+ foreach ($nestedMounts as $mount) {
+ if ($mount->getMountProvider() === ConfigAdapter::class) {
+ $relativePath = substr(trim($mount->getMountPoint(), '/'), strlen($sourcePath));
+ $this->moveMountContents($view, $mount->getMountPoint(), $finalTarget . $relativePath);
+ }
+ }
+ }
+
+ if (!is_dir("$sourceUid/files")) {
+ // because the files folder is moved away we need to recreate it
+ $view->mkdir("$sourceUid/files");
+ }
+ }
+
+ private function moveMountContents(View $rootView, string $source, string $target) {
+ if ($rootView->copy($source, $target)) {
+ // just doing `rmdir` on the mountpoint would cause it to try and unmount the storage
+ // we need to empty the contents instead
+ $content = $rootView->getDirectoryContent($source);
+ foreach ($content as $item) {
+ if ($item->getType() === FileInfo::TYPE_FOLDER) {
+ $rootView->rmdir($item->getPath());
+ } else {
+ $rootView->unlink($item->getPath());
+ }
+ }
+ } else {
+ throw new TransferOwnershipException("Could not transfer $source to $target");
+ }
+ }
+
+ /**
+ * @param string $targetLocation New location of the transfered node
+ * @param array<array{share: IShare, suffix: string}> $shares previously collected share information
+ */
+ private function restoreShares(
+ string $sourceUid,
+ string $destinationUid,
+ string $targetLocation,
+ array $shares,
+ OutputInterface $output,
+ ):void {
+ $output->writeln('Restoring shares ...');
+ $progress = new ProgressBar($output, count($shares));
+
+ foreach ($shares as ['share' => $share, 'suffix' => $suffix]) {
+ try {
+ $output->writeln('Transfering share ' . $share->getId() . ' of type ' . $share->getShareType(), OutputInterface::VERBOSITY_VERBOSE);
+ if ($share->getShareType() === IShare::TYPE_USER
+ && $share->getSharedWith() === $destinationUid) {
+ // Unmount the shares before deleting, so we don't try to get the storage later on.
+ $shareMountPoint = $this->mountManager->find('/' . $destinationUid . '/files' . $share->getTarget());
+ if ($shareMountPoint) {
+ $this->mountManager->removeMount($shareMountPoint->getMountPoint());
+ }
+ $this->shareManager->deleteShare($share);
+ } else {
+ if ($share->getShareOwner() === $sourceUid) {
+ $share->setShareOwner($destinationUid);
+ }
+ if ($share->getSharedBy() === $sourceUid) {
+ $share->setSharedBy($destinationUid);
+ }
+
+ if ($share->getShareType() === IShare::TYPE_USER
+ && !$this->userManager->userExists($share->getSharedWith())) {
+ // stray share with deleted user
+ $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted user "' . $share->getSharedWith() . '", deleting</error>');
+ $this->shareManager->deleteShare($share);
+ continue;
+ } else {
+ // trigger refetching of the node so that the new owner and mountpoint are taken into account
+ // otherwise the checks on the share update will fail due to the original node not being available in the new user scope
+ $this->userMountCache->clear();
+
+ try {
+ // Try to get the "old" id.
+ // Normally the ID is preserved,
+ // but for transferes between different storages the ID might change
+ $newNodeId = $share->getNode()->getId();
+ } catch (NotFoundException) {
+ // ID has changed due to transfer between different storages
+ // Try to get the new ID from the target path and suffix of the share
+ $node = $this->rootFolder->get(Filesystem::normalizePath($targetLocation . '/' . $suffix));
+ $newNodeId = $node->getId();
+ $output->writeln('Had to change node id to ' . $newNodeId, OutputInterface::VERBOSITY_VERY_VERBOSE);
+ }
+ $share->setNodeId($newNodeId);
+
+ $this->shareManager->updateShare($share, onlyValid: false);
+ }
+ }
+ } catch (NotFoundException $e) {
+ $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>');
+ } catch (\Throwable $e) {
+ $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getMessage() . ' : ' . $e->getTraceAsString() . '</error>');
+ }
+ $progress->advance();
+ }
+ $progress->finish();
+ $output->writeln('');
+ }
+
+ private function transferIncomingShares(string $sourceUid,
+ string $destinationUid,
+ array $sourceShares,
+ array $destinationShares,
+ OutputInterface $output,
+ string $path,
+ string $finalTarget,
+ bool $move): void {
+ $output->writeln('Restoring incoming shares ...');
+ $progress = new ProgressBar($output, count($sourceShares));
+ $prefix = "$destinationUid/files";
+ $finalShareTarget = '';
+ if (str_starts_with($finalTarget, $prefix)) {
+ $finalShareTarget = substr($finalTarget, strlen($prefix));
+ }
+ foreach ($sourceShares as $share) {
+ try {
+ // Only restore if share is in given path.
+ $pathToCheck = '/';
+ if (trim($path, '/') !== '') {
+ $pathToCheck = '/' . trim($path) . '/';
+ }
+ if (!str_starts_with($share->getTarget(), $pathToCheck)) {
+ continue;
+ }
+ $shareTarget = $share->getTarget();
+ $shareTarget = $finalShareTarget . $shareTarget;
+ if ($share->getShareType() === IShare::TYPE_USER
+ && $share->getSharedBy() === $destinationUid) {
+ $this->shareManager->deleteShare($share);
+ } elseif (isset($destinationShares[$share->getNodeId()])) {
+ $destinationShare = $destinationShares[$share->getNodeId()];
+ // Keep the share which has the most permissions and discard the other one.
+ if ($destinationShare->getPermissions() < $share->getPermissions()) {
+ $this->shareManager->deleteShare($destinationShare);
+ $share->setSharedWith($destinationUid);
+ // trigger refetching of the node so that the new owner and mountpoint are taken into account
+ // otherwise the checks on the share update will fail due to the original node not being available in the new user scope
+ $this->userMountCache->clear();
+ $share->setNodeId($share->getNode()->getId());
+ $this->shareManager->updateShare($share);
+ // The share is already transferred.
+ $progress->advance();
+ if ($move) {
+ continue;
+ }
+ $share->setTarget($shareTarget);
+ $this->shareManager->moveShare($share, $destinationUid);
+ continue;
+ }
+ $this->shareManager->deleteShare($share);
+ } elseif ($share->getShareOwner() === $destinationUid) {
+ $this->shareManager->deleteShare($share);
+ } else {
+ $share->setSharedWith($destinationUid);
+ $share->setNodeId($share->getNode()->getId());
+ $this->shareManager->updateShare($share);
+ // trigger refetching of the node so that the new owner and mountpoint are taken into account
+ // otherwise the checks on the share update will fail due to the original node not being available in the new user scope
+ $this->userMountCache->clear();
+ // The share is already transferred.
+ $progress->advance();
+ if ($move) {
+ continue;
+ }
+ $share->setTarget($shareTarget);
+ $this->shareManager->moveShare($share, $destinationUid);
+ continue;
+ }
+ } catch (NotFoundException $e) {
+ $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>');
+ } catch (\Throwable $e) {
+ $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getTraceAsString() . '</error>');
+ }
+ $progress->advance();
+ }
+ $progress->finish();
+ $output->writeln('');
+ }
+}
diff --git a/apps/files/lib/Service/SettingsService.php b/apps/files/lib/Service/SettingsService.php
new file mode 100644
index 00000000000..d07e907a5f6
--- /dev/null
+++ b/apps/files/lib/Service/SettingsService.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Service;
+
+use OC\Files\FilenameValidator;
+use OCP\IConfig;
+use Psr\Log\LoggerInterface;
+
+class SettingsService {
+
+ protected const WINDOWS_EXTENSION = [
+ ' ',
+ '.',
+ ];
+
+ protected const WINDOWS_BASENAMES = [
+ 'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3', 'com4', 'com5',
+ 'com6', 'com7', 'com8', 'com9', 'com¹', 'com²', 'com³', 'lpt0', 'lpt1', 'lpt2',
+ 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', 'lpt¹', 'lpt²', 'lpt³',
+ ];
+
+ protected const WINDOWS_CHARACTERS = [
+ '<', '>', ':',
+ '"', '|', '?',
+ '*',
+ ];
+
+ public function __construct(
+ private IConfig $config,
+ private FilenameValidator $filenameValidator,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ public function hasFilesWindowsSupport(): bool {
+ return empty(array_diff(self::WINDOWS_BASENAMES, $this->filenameValidator->getForbiddenBasenames()))
+ && empty(array_diff(self::WINDOWS_CHARACTERS, $this->filenameValidator->getForbiddenCharacters()))
+ && empty(array_diff(self::WINDOWS_EXTENSION, $this->filenameValidator->getForbiddenExtensions()));
+ }
+
+ public function setFilesWindowsSupport(bool $enabled = true): void {
+ if ($enabled) {
+ $basenames = array_unique(array_merge(self::WINDOWS_BASENAMES, $this->filenameValidator->getForbiddenBasenames()));
+ $characters = array_unique(array_merge(self::WINDOWS_CHARACTERS, $this->filenameValidator->getForbiddenCharacters()));
+ $extensions = array_unique(array_merge(self::WINDOWS_EXTENSION, $this->filenameValidator->getForbiddenExtensions()));
+ } else {
+ $basenames = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenBasenames(), self::WINDOWS_BASENAMES)));
+ $characters = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenCharacters(), self::WINDOWS_CHARACTERS)));
+ $extensions = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenExtensions(), self::WINDOWS_EXTENSION)));
+ }
+ $values = [
+ 'forbidden_filename_basenames' => empty($basenames) ? null : $basenames,
+ 'forbidden_filename_characters' => empty($characters) ? null : $characters,
+ 'forbidden_filename_extensions' => empty($extensions) ? null : $extensions,
+ ];
+ $this->config->setSystemValues($values);
+ }
+}
diff --git a/apps/files/lib/Service/TagService.php b/apps/files/lib/Service/TagService.php
new file mode 100644
index 00000000000..63c54d01fd0
--- /dev/null
+++ b/apps/files/lib/Service/TagService.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Files\Service;
+
+use OCP\Activity\IManager;
+use OCP\Files\Folder;
+use OCP\Files\NotFoundException;
+use OCP\ITags;
+use OCP\IUserSession;
+
+/**
+ * Service class to manage tags on files.
+ */
+class TagService {
+
+ public function __construct(
+ private IUserSession $userSession,
+ private IManager $activityManager,
+ private ?ITags $tagger,
+ private ?Folder $homeFolder,
+ ) {
+ }
+
+ /**
+ * Updates the tags of the specified file path.
+ * The passed tags are absolute, which means they will
+ * replace the actual tag selection.
+ *
+ * @param string $path path
+ * @param array $tags array of tags
+ * @return array list of tags
+ * @throws NotFoundException if the file does not exist
+ */
+ public function updateFileTags($path, $tags) {
+ if ($this->tagger === null) {
+ throw new \RuntimeException('No tagger set');
+ }
+ if ($this->homeFolder === null) {
+ throw new \RuntimeException('No homeFolder set');
+ }
+
+ $fileId = $this->homeFolder->get($path)->getId();
+
+ $currentTags = $this->tagger->getTagsForObjects([$fileId]);
+
+ if (!empty($currentTags)) {
+ $currentTags = current($currentTags);
+ }
+
+ $newTags = array_diff($tags, $currentTags);
+ foreach ($newTags as $tag) {
+ $this->tagger->tagAs($fileId, $tag);
+ }
+ $deletedTags = array_diff($currentTags, $tags);
+ foreach ($deletedTags as $tag) {
+ $this->tagger->unTag($fileId, $tag);
+ }
+
+ // TODO: re-read from tagger to make sure the
+ // list is up to date, in case of concurrent changes ?
+ return $tags;
+ }
+}
diff --git a/apps/files/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php
new file mode 100644
index 00000000000..dcf30b7796d
--- /dev/null
+++ b/apps/files/lib/Service/UserConfig.php
@@ -0,0 +1,182 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Service;
+
+use OCA\Files\AppInfo\Application;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserSession;
+
+class UserConfig {
+ public const ALLOWED_CONFIGS = [
+ [
+ // Whether to crop the files previews or not in the files list
+ 'key' => 'crop_image_previews',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
+ [
+ // The view to start the files app in
+ 'key' => 'default_view',
+ 'default' => 'files',
+ 'allowed' => ['files', 'personal'],
+ ],
+ [
+ // Whether to show the folder tree
+ 'key' => 'folder_tree',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to show the files list in grid view or not
+ 'key' => 'grid_view',
+ 'default' => false,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to show the "confirm file deletion" warning
+ 'key' => 'show_dialog_deletion',
+ 'default' => false,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to show the "confirm file extension change" warning
+ 'key' => 'show_dialog_file_extension',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to show the files extensions in the files list or not
+ 'key' => 'show_files_extensions',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to show the hidden files or not in the files list
+ 'key' => 'show_hidden',
+ 'default' => false,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to show the mime column or not
+ 'key' => 'show_mime_column',
+ 'default' => false,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to sort favorites first in the list or not
+ 'key' => 'sort_favorites_first',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
+ [
+ // Whether to sort folders before files in the list or not
+ 'key' => 'sort_folders_first',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
+ ];
+ protected ?IUser $user = null;
+
+ public function __construct(
+ protected IConfig $config,
+ IUserSession $userSession,
+ ) {
+ $this->user = $userSession->getUser();
+ }
+
+ /**
+ * Get the list of all allowed user config keys
+ * @return string[]
+ */
+ public function getAllowedConfigKeys(): array {
+ return array_map(function ($config) {
+ return $config['key'];
+ }, self::ALLOWED_CONFIGS);
+ }
+
+ /**
+ * Get the list of allowed config values for a given key
+ *
+ * @param string $key a valid config key
+ * @return array
+ */
+ private function getAllowedConfigValues(string $key): array {
+ foreach (self::ALLOWED_CONFIGS as $config) {
+ if ($config['key'] === $key) {
+ return $config['allowed'];
+ }
+ }
+ return [];
+ }
+
+ /**
+ * Get the default config value for a given key
+ *
+ * @param string $key a valid config key
+ * @return string|bool
+ */
+ private function getDefaultConfigValue(string $key) {
+ foreach (self::ALLOWED_CONFIGS as $config) {
+ if ($config['key'] === $key) {
+ return $config['default'];
+ }
+ }
+ return '';
+ }
+
+ /**
+ * Set a user config
+ *
+ * @param string $key
+ * @param string|bool $value
+ * @throws \Exception
+ * @throws \InvalidArgumentException
+ */
+ public function setConfig(string $key, $value): void {
+ if ($this->user === null) {
+ throw new \Exception('No user logged in');
+ }
+
+ if (!in_array($key, $this->getAllowedConfigKeys())) {
+ throw new \InvalidArgumentException('Unknown config key');
+ }
+
+ if (!in_array($value, $this->getAllowedConfigValues($key))) {
+ throw new \InvalidArgumentException('Invalid config value');
+ }
+
+ if (is_bool($value)) {
+ $value = $value ? '1' : '0';
+ }
+
+ $this->config->setUserValue($this->user->getUID(), Application::APP_ID, $key, $value);
+ }
+
+ /**
+ * Get the current user configs array
+ *
+ * @return array
+ */
+ public function getConfigs(): array {
+ if ($this->user === null) {
+ throw new \Exception('No user logged in');
+ }
+
+ $userId = $this->user->getUID();
+ $userConfigs = array_map(function (string $key) use ($userId) {
+ $value = $this->config->getUserValue($userId, Application::APP_ID, $key, $this->getDefaultConfigValue($key));
+ // If the default is expected to be a boolean, we need to cast the value
+ if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) {
+ return $value === '1';
+ }
+ return $value;
+ }, $this->getAllowedConfigKeys());
+
+ return array_combine($this->getAllowedConfigKeys(), $userConfigs);
+ }
+}
diff --git a/apps/files/lib/Service/ViewConfig.php b/apps/files/lib/Service/ViewConfig.php
new file mode 100644
index 00000000000..cf8bebd5372
--- /dev/null
+++ b/apps/files/lib/Service/ViewConfig.php
@@ -0,0 +1,168 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Service;
+
+use OCA\Files\AppInfo\Application;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserSession;
+
+class ViewConfig {
+ public const CONFIG_KEY = 'files_views_configs';
+ public const ALLOWED_CONFIGS = [
+ [
+ // The default sorting key for the files list view
+ 'key' => 'sorting_mode',
+ // null by default as views can provide default sorting key
+ // and will fallback to it if user hasn't change it
+ 'default' => null,
+ ],
+ [
+ // The default sorting direction for the files list view
+ 'key' => 'sorting_direction',
+ 'default' => 'asc',
+ 'allowed' => ['asc', 'desc'],
+ ],
+ [
+ // If the navigation entry for this view is expanded or not
+ 'key' => 'expanded',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
+ ];
+ protected ?IUser $user = null;
+
+ public function __construct(
+ protected IConfig $config,
+ IUserSession $userSession,
+ ) {
+ $this->user = $userSession->getUser();
+ }
+
+ /**
+ * Get the list of all allowed user config keys
+ * @return string[]
+ */
+ public function getAllowedConfigKeys(): array {
+ return array_map(function ($config) {
+ return $config['key'];
+ }, self::ALLOWED_CONFIGS);
+ }
+
+ /**
+ * Get the list of allowed config values for a given key
+ *
+ * @param string $key a valid config key
+ * @return array
+ */
+ private function getAllowedConfigValues(string $key): array {
+ foreach (self::ALLOWED_CONFIGS as $config) {
+ if ($config['key'] === $key) {
+ return $config['allowed'] ?? [];
+ }
+ }
+ return [];
+ }
+
+ /**
+ * Get the default config value for a given key
+ *
+ * @param string $key a valid config key
+ * @return string|bool|null
+ */
+ private function getDefaultConfigValue(string $key) {
+ foreach (self::ALLOWED_CONFIGS as $config) {
+ if ($config['key'] === $key) {
+ return $config['default'];
+ }
+ }
+ return '';
+ }
+
+ /**
+ * Set a user config
+ *
+ * @param string $view
+ * @param string $key
+ * @param string|bool $value
+ * @throws \Exception
+ * @throws \InvalidArgumentException
+ */
+ public function setConfig(string $view, string $key, $value): void {
+ if ($this->user === null) {
+ throw new \Exception('No user logged in');
+ }
+
+ if (!$view) {
+ throw new \Exception('Unknown view');
+ }
+
+ if (!in_array($key, $this->getAllowedConfigKeys())) {
+ throw new \InvalidArgumentException('Unknown config key');
+ }
+
+ if (!in_array($value, $this->getAllowedConfigValues($key))
+ && !empty($this->getAllowedConfigValues($key))) {
+ throw new \InvalidArgumentException('Invalid config value');
+ }
+
+ // Cast boolean values
+ if (is_bool($this->getDefaultConfigValue($key))) {
+ $value = $value === '1';
+ }
+
+ $config = $this->getConfigs();
+ $config[$view][$key] = $value;
+
+ $this->config->setUserValue($this->user->getUID(), Application::APP_ID, self::CONFIG_KEY, json_encode($config));
+ }
+
+ /**
+ * Get the current user configs array for a given view
+ *
+ * @return array
+ */
+ public function getConfig(string $view): array {
+ if ($this->user === null) {
+ throw new \Exception('No user logged in');
+ }
+
+ $userId = $this->user->getUID();
+ $configs = json_decode($this->config->getUserValue($userId, Application::APP_ID, self::CONFIG_KEY, '[]'), true);
+
+ if (!isset($configs[$view])) {
+ $configs[$view] = [];
+ }
+
+ // Extend undefined values with defaults
+ return array_reduce(self::ALLOWED_CONFIGS, function ($carry, $config) use ($view, $configs) {
+ $key = $config['key'];
+ $carry[$key] = $configs[$view][$key] ?? $this->getDefaultConfigValue($key);
+ return $carry;
+ }, []);
+ }
+
+ /**
+ * Get the current user configs array
+ *
+ * @return array
+ */
+ public function getConfigs(): array {
+ if ($this->user === null) {
+ throw new \Exception('No user logged in');
+ }
+
+ $userId = $this->user->getUID();
+ $configs = json_decode($this->config->getUserValue($userId, Application::APP_ID, self::CONFIG_KEY, '[]'), true);
+ $views = array_keys($configs);
+
+ return array_reduce($views, function ($carry, $view) use ($configs) {
+ $carry[$view] = $this->getConfig($view);
+ return $carry;
+ }, []);
+ }
+}
diff --git a/apps/files/lib/Settings/DeclarativeAdminSettings.php b/apps/files/lib/Settings/DeclarativeAdminSettings.php
new file mode 100644
index 00000000000..bbf97cc4d32
--- /dev/null
+++ b/apps/files/lib/Settings/DeclarativeAdminSettings.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Settings;
+
+use OCA\Files\Service\SettingsService;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\Settings\DeclarativeSettingsTypes;
+use OCP\Settings\IDeclarativeSettingsFormWithHandlers;
+
+class DeclarativeAdminSettings implements IDeclarativeSettingsFormWithHandlers {
+
+ public function __construct(
+ private IL10N $l,
+ private SettingsService $service,
+ private IURLGenerator $urlGenerator,
+ ) {
+ }
+
+ public function getValue(string $fieldId, IUser $user): mixed {
+ return match($fieldId) {
+ 'windows_support' => $this->service->hasFilesWindowsSupport(),
+ default => throw new \InvalidArgumentException('Unexpected field id ' . $fieldId),
+ };
+ }
+
+ public function setValue(string $fieldId, mixed $value, IUser $user): void {
+ switch ($fieldId) {
+ case 'windows_support':
+ $this->service->setFilesWindowsSupport((bool)$value);
+ break;
+ }
+ }
+
+ public function getSchema(): array {
+ return [
+ 'id' => 'files-filename-support',
+ 'priority' => 10,
+ 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
+ 'section_id' => 'server',
+ 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL,
+ 'title' => $this->l->t('Files compatibility'),
+ 'doc_url' => $this->urlGenerator->linkToDocs('admin-windows-compatible-filenames'),
+ 'description' => (
+ $this->l->t('Allow to restrict filenames to ensure files can be synced with all clients. By default all filenames valid on POSIX (e.g. Linux or macOS) are allowed.')
+ . "\n" . $this->l->t('After enabling the Windows compatible filenames, existing files cannot be modified anymore but can be renamed to valid new names by their owner.')
+ . "\n" . $this->l->t('It is also possible to migrate files automatically after enabling this setting, please refer to the documentation about the occ command.')
+ ),
+
+ 'fields' => [
+ [
+ 'id' => 'windows_support',
+ 'title' => $this->l->t('Enforce Windows compatibility'),
+ 'description' => $this->l->t('This will block filenames not valid on Windows systems, like using reserved names or special characters. But this will not enforce compatibility of case sensitivity.'),
+ 'type' => DeclarativeSettingsTypes::CHECKBOX,
+ 'default' => false,
+ ],
+ ],
+ ];
+ }
+}
diff --git a/apps/files/lib/Settings/PersonalSettings.php b/apps/files/lib/Settings/PersonalSettings.php
new file mode 100644
index 00000000000..fe43265bc13
--- /dev/null
+++ b/apps/files/lib/Settings/PersonalSettings.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Settings;
+
+use OCA\Files\AppInfo\Application;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\Settings\ISettings;
+use OCP\Util;
+
+class PersonalSettings implements ISettings {
+ public function getForm(): TemplateResponse {
+ Util::addScript(Application::APP_ID, 'settings-personal');
+ return new TemplateResponse(Application::APP_ID, 'settings-personal');
+ }
+
+ public function getSection(): string {
+ return 'sharing';
+ }
+
+ public function getPriority(): int {
+ return 90;
+ }
+}
diff --git a/apps/files/lib/activity.php b/apps/files/lib/activity.php
deleted file mode 100644
index 1cbd6c3b973..00000000000
--- a/apps/files/lib/activity.php
+++ /dev/null
@@ -1,424 +0,0 @@
-<?php
-/**
- * @author Joas Schilling <nickvergessen@owncloud.com>
- * @author Morris Jobke <hey@morrisjobke.de>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\Files;
-
-use OCP\IDBConnection;
-use OCP\L10N\IFactory;
-use OCP\Activity\IExtension;
-use OCP\Activity\IManager;
-use OCP\IConfig;
-use OCP\IL10N;
-use OCP\IURLGenerator;
-
-class Activity implements IExtension {
- const APP_FILES = 'files';
- const FILTER_FILES = 'files';
- const FILTER_FAVORITES = 'files_favorites';
-
- const TYPE_SHARE_CREATED = 'file_created';
- const TYPE_SHARE_CHANGED = 'file_changed';
- const TYPE_SHARE_DELETED = 'file_deleted';
- const TYPE_SHARE_RESTORED = 'file_restored';
- const TYPE_FAVORITES = 'files_favorites';
-
- /** @var IL10N */
- protected $l;
-
- /** @var IFactory */
- protected $languageFactory;
-
- /** @var IURLGenerator */
- protected $URLGenerator;
-
- /** @var \OCP\Activity\IManager */
- protected $activityManager;
-
- /** @var \OCP\IDBConnection */
- protected $connection;
-
- /** @var \OCP\IConfig */
- protected $config;
-
- /** @var \OCA\Files\ActivityHelper */
- protected $helper;
-
- /**
- * @param IFactory $languageFactory
- * @param IURLGenerator $URLGenerator
- * @param IManager $activityManager
- * @param ActivityHelper $helper
- * @param IDBConnection $connection
- * @param IConfig $config
- */
- public function __construct(IFactory $languageFactory, IURLGenerator $URLGenerator, IManager $activityManager, ActivityHelper $helper, IDBConnection $connection, IConfig $config) {
- $this->languageFactory = $languageFactory;
- $this->URLGenerator = $URLGenerator;
- $this->l = $this->getL10N();
- $this->activityManager = $activityManager;
- $this->helper = $helper;
- $this->connection = $connection;
- $this->config = $config;
- }
-
- /**
- * @param string|null $languageCode
- * @return IL10N
- */
- protected function getL10N($languageCode = null) {
- return $this->languageFactory->get(self::APP_FILES, $languageCode);
- }
-
- /**
- * The extension can return an array of additional notification types.
- * If no additional types are to be added false is to be returned
- *
- * @param string $languageCode
- * @return array|false Array "stringID of the type" => "translated string description for the setting"
- * or Array "stringID of the type" => [
- * 'desc' => "translated string description for the setting"
- * 'methods' => [self::METHOD_*],
- * ]
- */
- public function getNotificationTypes($languageCode) {
- $l = $this->getL10N($languageCode);
- return [
- self::TYPE_SHARE_CREATED => (string) $l->t('A new file or folder has been <strong>created</strong>'),
- self::TYPE_SHARE_CHANGED => (string) $l->t('A file or folder has been <strong>changed</strong>'),
- self::TYPE_FAVORITES => [
- 'desc' => (string) $l->t('Limit notifications about creation and changes to your <strong>favorite files</strong> <em>(Stream only)</em>'),
- 'methods' => [self::METHOD_STREAM],
- ],
- self::TYPE_SHARE_DELETED => (string) $l->t('A file or folder has been <strong>deleted</strong>'),
- self::TYPE_SHARE_RESTORED => (string) $l->t('A file or folder has been <strong>restored</strong>'),
- ];
- }
-
- /**
- * For a given method additional types to be displayed in the settings can be returned.
- * In case no additional types are to be added false is to be returned.
- *
- * @param string $method
- * @return array|false
- */
- public function getDefaultTypes($method) {
- if ($method === self::METHOD_STREAM) {
- $settings = array();
- $settings[] = self::TYPE_SHARE_CREATED;
- $settings[] = self::TYPE_SHARE_CHANGED;
- $settings[] = self::TYPE_SHARE_DELETED;
- $settings[] = self::TYPE_SHARE_RESTORED;
- return $settings;
- }
-
- return false;
- }
-
- /**
- * The extension can translate a given message to the requested languages.
- * If no translation is available false is to be returned.
- *
- * @param string $app
- * @param string $text
- * @param array $params
- * @param boolean $stripPath
- * @param boolean $highlightParams
- * @param string $languageCode
- * @return string|false
- */
- public function translate($app, $text, $params, $stripPath, $highlightParams, $languageCode) {
- if ($app !== self::APP_FILES) {
- return false;
- }
-
- $l = $this->getL10N($languageCode);
-
- if ($this->activityManager->isFormattingFilteredObject()) {
- $translation = $this->translateShort($text, $l, $params);
- if ($translation !== false) {
- return $translation;
- }
- }
-
- return $this->translateLong($text, $l, $params);
- }
-
- /**
- * @param string $text
- * @param IL10N $l
- * @param array $params
- * @return string|false
- */
- protected function translateLong($text, IL10N $l, array $params) {
- switch ($text) {
- case 'created_self':
- return (string) $l->t('You created %1$s', $params);
- case 'created_by':
- return (string) $l->t('%2$s created %1$s', $params);
- case 'created_public':
- return (string) $l->t('%1$s was created in a public folder', $params);
- case 'changed_self':
- return (string) $l->t('You changed %1$s', $params);
- case 'changed_by':
- return (string) $l->t('%2$s changed %1$s', $params);
- case 'deleted_self':
- return (string) $l->t('You deleted %1$s', $params);
- case 'deleted_by':
- return (string) $l->t('%2$s deleted %1$s', $params);
- case 'restored_self':
- return (string) $l->t('You restored %1$s', $params);
- case 'restored_by':
- return (string) $l->t('%2$s restored %1$s', $params);
-
- default:
- return false;
- }
- }
-
- /**
- * @param string $text
- * @param IL10N $l
- * @param array $params
- * @return string|false
- */
- protected function translateShort($text, IL10N $l, array $params) {
- switch ($text) {
- case 'changed_by':
- return (string) $l->t('Changed by %2$s', $params);
- case 'deleted_by':
- return (string) $l->t('Deleted by %2$s', $params);
- case 'restored_by':
- return (string) $l->t('Restored by %2$s', $params);
-
- default:
- return false;
- }
- }
-
- /**
- * The extension can define the type of parameters for translation
- *
- * Currently known types are:
- * * file => will strip away the path of the file and add a tooltip with it
- * * username => will add the avatar of the user
- *
- * @param string $app
- * @param string $text
- * @return array|false
- */
- function getSpecialParameterList($app, $text) {
- if ($app === self::APP_FILES) {
- switch ($text) {
- case 'created_self':
- case 'created_by':
- case 'created_public':
- case 'changed_self':
- case 'changed_by':
- case 'deleted_self':
- case 'deleted_by':
- case 'restored_self':
- case 'restored_by':
- return [
- 0 => 'file',
- 1 => 'username',
- ];
- }
- }
-
- return false;
- }
-
- /**
- * A string naming the css class for the icon to be used can be returned.
- * If no icon is known for the given type false is to be returned.
- *
- * @param string $type
- * @return string|false
- */
- public function getTypeIcon($type) {
- switch ($type) {
- case self::TYPE_SHARE_CHANGED:
- return 'icon-change';
- case self::TYPE_SHARE_CREATED:
- return 'icon-add-color';
- case self::TYPE_SHARE_DELETED:
- return 'icon-delete-color';
-
- default:
- return false;
- }
- }
-
- /**
- * The extension can define the parameter grouping by returning the index as integer.
- * In case no grouping is required false is to be returned.
- *
- * @param array $activity
- * @return integer|false
- */
- public function getGroupParameter($activity) {
- if ($activity['app'] === self::APP_FILES) {
- switch ($activity['subject']) {
- case 'created_self':
- case 'created_by':
- case 'changed_self':
- case 'changed_by':
- case 'deleted_self':
- case 'deleted_by':
- case 'restored_self':
- case 'restored_by':
- return 0;
- }
- }
-
- return false;
- }
-
- /**
- * The extension can define additional navigation entries. The array returned has to contain two keys 'top'
- * and 'apps' which hold arrays with the relevant entries.
- * If no further entries are to be added false is no be returned.
- *
- * @return array|false
- */
- public function getNavigation() {
- return [
- 'top' => [
- self::FILTER_FAVORITES => [
- 'id' => self::FILTER_FAVORITES,
- 'name' => (string) $this->l->t('Favorites'),
- 'url' => $this->URLGenerator->linkToRoute('activity.Activities.showList', ['filter' => self::FILTER_FAVORITES]),
- ],
- ],
- 'apps' => [
- self::FILTER_FILES => [
- 'id' => self::FILTER_FILES,
- 'name' => (string) $this->l->t('Files'),
- 'url' => $this->URLGenerator->linkToRoute('activity.Activities.showList', ['filter' => self::FILTER_FILES]),
- ],
- ],
- ];
- }
-
- /**
- * The extension can check if a customer filter (given by a query string like filter=abc) is valid or not.
- *
- * @param string $filterValue
- * @return boolean
- */
- public function isFilterValid($filterValue) {
- return $filterValue === self::FILTER_FILES || $filterValue === self::FILTER_FAVORITES;
- }
-
- /**
- * The extension can filter the types based on the filter if required.
- * In case no filter is to be applied false is to be returned unchanged.
- *
- * @param array $types
- * @param string $filter
- * @return array|false
- */
- public function filterNotificationTypes($types, $filter) {
- if ($filter === self::FILTER_FILES || $filter === self::FILTER_FAVORITES) {
- return array_intersect([
- self::TYPE_SHARE_CREATED,
- self::TYPE_SHARE_CHANGED,
- self::TYPE_SHARE_DELETED,
- self::TYPE_SHARE_RESTORED,
- ], $types);
- }
- return false;
- }
-
- /**
- * For a given filter the extension can specify the sql query conditions including parameters for that query.
- * In case the extension does not know the filter false is to be returned.
- * The query condition and the parameters are to be returned as array with two elements.
- * E.g. return array('`app` = ? and `message` like ?', array('mail', 'ownCloud%'));
- *
- * @param string $filter
- * @return array|false
- */
- public function getQueryForFilter($filter) {
- $user = $this->activityManager->getCurrentUserId();
- // Display actions from all files
- if ($filter === self::FILTER_FILES) {
- return ['`app` = ?', [self::APP_FILES]];
- }
-
- if (!$user) {
- // Remaining filters only work with a user/token
- return false;
- }
-
- // Display actions from favorites only
- if ($filter === self::FILTER_FAVORITES || in_array($filter, ['all', 'by', 'self']) && $this->userSettingFavoritesOnly($user)) {
- try {
- $favorites = $this->helper->getFavoriteFilePaths($user);
- } catch (\RuntimeException $e) {
- // Too many favorites, can not put them into one query anymore...
- return ['`app` = ?', [self::APP_FILES]];
- }
-
- /*
- * Display activities only, when they are not `type` create/change
- * or `file` is a favorite or in a favorite folder
- */
- $parameters = $fileQueryList = [];
- $parameters[] = self::APP_FILES;
- $parameters[] = self::APP_FILES;
-
- $fileQueryList[] = '(`type` <> ? AND `type` <> ?)';
- $parameters[] = self::TYPE_SHARE_CREATED;
- $parameters[] = self::TYPE_SHARE_CHANGED;
-
- foreach ($favorites['items'] as $favorite) {
- $fileQueryList[] = '`file` = ?';
- $parameters[] = $favorite;
- }
- foreach ($favorites['folders'] as $favorite) {
- $fileQueryList[] = '`file` LIKE ?';
- $parameters[] = $this->connection->escapeLikeParameter($favorite) . '/%';
- }
-
- return [
- ' CASE '
- . 'WHEN `app` <> ? THEN 1 '
- . 'WHEN `app` = ? AND (' . implode(' OR ', $fileQueryList) . ') THEN 1 '
- . 'ELSE 0 '
- . 'END = 1 ',
- $parameters,
- ];
- }
- return false;
- }
-
- /**
- * Is the file actions favorite limitation enabled?
- *
- * @param string $user
- * @return bool
- */
- protected function userSettingFavoritesOnly($user) {
- return (bool) $this->config->getUserValue($user, 'activity', 'notify_' . self::METHOD_STREAM . '_' . self::TYPE_FAVORITES, false);
- }
-}
diff --git a/apps/files/lib/activityhelper.php b/apps/files/lib/activityhelper.php
deleted file mode 100644
index 046dd59bc76..00000000000
--- a/apps/files/lib/activityhelper.php
+++ /dev/null
@@ -1,84 +0,0 @@
-<?php
-/**
- * @author Joas Schilling <nickvergessen@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\Files;
-
-use OCP\Files\Folder;
-use OCP\ITagManager;
-
-class ActivityHelper {
- /** If a user has a lot of favorites the query might get too slow and long */
- const FAVORITE_LIMIT = 50;
-
- /** @var \OCP\ITagManager */
- protected $tagManager;
-
- /**
- * @param ITagManager $tagManager
- */
- public function __construct(ITagManager $tagManager) {
- $this->tagManager = $tagManager;
- }
-
- /**
- * Returns an array with the favorites
- *
- * @param string $user
- * @return array
- * @throws \RuntimeException when too many or no favorites where found
- */
- public function getFavoriteFilePaths($user) {
- $tags = $this->tagManager->load('files', [], false, $user);
- $favorites = $tags->getFavorites();
-
- if (empty($favorites)) {
- throw new \RuntimeException('No favorites', 1);
- } else if (isset($favorites[self::FAVORITE_LIMIT])) {
- throw new \RuntimeException('Too many favorites', 2);
- }
-
- // Can not DI because the user is not known on instantiation
- $rootFolder = \OC::$server->getUserFolder($user);
- $folders = $items = [];
- foreach ($favorites as $favorite) {
- $nodes = $rootFolder->getById($favorite);
- if (!empty($nodes)) {
- /** @var \OCP\Files\Node $node */
- $node = array_shift($nodes);
- $path = substr($node->getPath(), strlen($user . '/files/'));
-
- $items[] = $path;
- if ($node instanceof Folder) {
- $folders[] = $path;
- }
- }
- }
-
- if (empty($items)) {
- throw new \RuntimeException('No favorites', 1);
- }
-
- return [
- 'items' => $items,
- 'folders' => $folders,
- ];
- }
-}
diff --git a/apps/files/lib/app.php b/apps/files/lib/app.php
deleted file mode 100644
index 981c41ff413..00000000000
--- a/apps/files/lib/app.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-/**
- * @author Christopher Schäpers <kondou@ts.unde.re>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- * @author Vincent Petry <pvince81@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-
-namespace OCA\Files;
-
-class App {
- /**
- * @var \OCP\INavigationManager
- */
- private static $navigationManager;
-
- /**
- * Returns the app's navigation manager
- *
- * @return \OCP\INavigationManager
- */
- public static function getNavigationManager() {
- // TODO: move this into a service in the Application class
- if (self::$navigationManager === null) {
- self::$navigationManager = new \OC\NavigationManager();
- }
- return self::$navigationManager;
- }
-
-}
diff --git a/apps/files/lib/backgroundjob/cleanupfilelocks.php b/apps/files/lib/backgroundjob/cleanupfilelocks.php
deleted file mode 100644
index b5cf8e94551..00000000000
--- a/apps/files/lib/backgroundjob/cleanupfilelocks.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-/**
- * @author Morris Jobke <hey@morrisjobke.de>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\Files\BackgroundJob;
-
-use OC\BackgroundJob\TimedJob;
-use OC\Lock\DBLockingProvider;
-
-/**
- * Clean up all file locks that are expired for the DB file locking provider
- */
-class CleanupFileLocks extends TimedJob {
-
- /**
- * Default interval in minutes
- *
- * @var int $defaultIntervalMin
- **/
- protected $defaultIntervalMin = 5;
-
- /**
- * sets the correct interval for this timed job
- */
- public function __construct() {
- $this->interval = $this->defaultIntervalMin * 60;
- }
-
- /**
- * Makes the background job do its work
- *
- * @param array $argument unused argument
- */
- public function run($argument) {
- $lockingProvider = \OC::$server->getLockingProvider();
- if($lockingProvider instanceof DBLockingProvider) {
- $lockingProvider->cleanExpiredLocks();
- }
- }
-}
diff --git a/apps/files/lib/backgroundjob/deleteorphaneditems.php b/apps/files/lib/backgroundjob/deleteorphaneditems.php
deleted file mode 100644
index 1eef9c24e0c..00000000000
--- a/apps/files/lib/backgroundjob/deleteorphaneditems.php
+++ /dev/null
@@ -1,153 +0,0 @@
-<?php
-/**
- * @author Arthur Schiwon <blizzz@owncloud.com>
- * @author Joas Schilling <nickvergessen@owncloud.com>
- * @author Vincent Petry <pvince81@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\Files\BackgroundJob;
-
-use OC\BackgroundJob\TimedJob;
-use OCP\DB\QueryBuilder\IQueryBuilder;
-
-/**
- * Delete all share entries that have no matching entries in the file cache table.
- */
-class DeleteOrphanedItems extends TimedJob {
-
- const CHUNK_SIZE = 200;
-
- /** @var \OCP\IDBConnection */
- protected $connection;
-
- /** @var \OCP\ILogger */
- protected $logger;
-
- /**
- * Default interval in minutes
- *
- * @var int $defaultIntervalMin
- **/
- protected $defaultIntervalMin = 60;
-
- /**
- * sets the correct interval for this timed job
- */
- public function __construct() {
- $this->interval = $this->defaultIntervalMin * 60;
- $this->connection = \OC::$server->getDatabaseConnection();
- $this->logger = \OC::$server->getLogger();
- }
-
- /**
- * Makes the background job do its work
- *
- * @param array $argument unused argument
- */
- public function run($argument) {
- $this->cleanSystemTags();
- $this->cleanUserTags();
- $this->cleanComments();
- $this->cleanCommentMarkers();
- }
-
- /**
- * Deleting orphaned system tag mappings
- *
- * @param string $table
- * @param string $idCol
- * @param string $typeCol
- * @return int Number of deleted entries
- */
- protected function cleanUp($table, $idCol, $typeCol) {
- $deletedEntries = 0;
-
- $query = $this->connection->getQueryBuilder();
- $query->select('t1.' . $idCol)
- ->from($table, 't1')
- ->where($query->expr()->eq($typeCol, $query->expr()->literal('files')))
- ->andWhere($query->expr()->isNull('t2.fileid'))
- ->leftJoin('t1', 'filecache', 't2', $query->expr()->eq($query->expr()->castColumn('t1.' . $idCol, IQueryBuilder::PARAM_INT), 't2.fileid'))
- ->groupBy('t1.' . $idCol)
- ->setMaxResults(self::CHUNK_SIZE);
-
- $deleteQuery = $this->connection->getQueryBuilder();
- $deleteQuery->delete($table)
- ->where($deleteQuery->expr()->eq($idCol, $deleteQuery->createParameter('objectid')));
-
- $deletedInLastChunk = self::CHUNK_SIZE;
- while ($deletedInLastChunk === self::CHUNK_SIZE) {
- $result = $query->execute();
- $deletedInLastChunk = 0;
- while ($row = $result->fetch()) {
- $deletedInLastChunk++;
- $deletedEntries += $deleteQuery->setParameter('objectid', (int) $row[$idCol])
- ->execute();
- }
- $result->closeCursor();
- }
-
- return $deletedEntries;
- }
-
- /**
- * Deleting orphaned system tag mappings
- *
- * @return int Number of deleted entries
- */
- protected function cleanSystemTags() {
- $deletedEntries = $this->cleanUp('systemtag_object_mapping', 'objectid', 'objecttype');
- $this->logger->debug("$deletedEntries orphaned system tag relations deleted", ['app' => 'DeleteOrphanedItems']);
- return $deletedEntries;
- }
-
- /**
- * Deleting orphaned user tag mappings
- *
- * @return int Number of deleted entries
- */
- protected function cleanUserTags() {
- $deletedEntries = $this->cleanUp('vcategory_to_object', 'objid', 'type');
- $this->logger->debug("$deletedEntries orphaned user tag relations deleted", ['app' => 'DeleteOrphanedItems']);
- return $deletedEntries;
- }
-
- /**
- * Deleting orphaned comments
- *
- * @return int Number of deleted entries
- */
- protected function cleanComments() {
- $deletedEntries = $this->cleanUp('comments', 'object_id', 'object_type');
- $this->logger->debug("$deletedEntries orphaned comments deleted", ['app' => 'DeleteOrphanedItems']);
- return $deletedEntries;
- }
-
- /**
- * Deleting orphaned comment read markers
- *
- * @return int Number of deleted entries
- */
- protected function cleanCommentMarkers() {
- $deletedEntries = $this->cleanUp('comments_read_markers', 'object_id', 'object_type');
- $this->logger->debug("$deletedEntries orphaned comment read marks deleted", ['app' => 'DeleteOrphanedItems']);
- return $deletedEntries;
- }
-
-}
diff --git a/apps/files/lib/backgroundjob/scanfiles.php b/apps/files/lib/backgroundjob/scanfiles.php
deleted file mode 100644
index dcc180bcfbe..00000000000
--- a/apps/files/lib/backgroundjob/scanfiles.php
+++ /dev/null
@@ -1,114 +0,0 @@
-<?php
-/**
- * @author Lukas Reschke <lukas@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\Files\BackgroundJob;
-
-use OC\Files\Utils\Scanner;
-use OCP\IConfig;
-use OCP\IDBConnection;
-use OCP\ILogger;
-use OCP\IUser;
-use OCP\IUserManager;
-
-/**
- * Class ScanFiles is a background job used to run the file scanner over the user
- * accounts to ensure integrity of the file cache.
- *
- * @package OCA\Files\BackgroundJob
- */
-class ScanFiles extends \OC\BackgroundJob\TimedJob {
- /** @var IConfig */
- private $config;
- /** @var IUserManager */
- private $userManager;
- /** @var IDBConnection */
- private $dbConnection;
- /** @var ILogger */
- private $logger;
- /** Amount of users that should get scanned per execution */
- const USERS_PER_SESSION = 500;
-
- /**
- * @param IConfig|null $config
- * @param IUserManager|null $userManager
- * @param IDBConnection|null $dbConnection
- * @param ILogger|null $logger
- */
- public function __construct(IConfig $config = null,
- IUserManager $userManager = null,
- IDBConnection $dbConnection = null,
- ILogger $logger = null) {
- // Run once per 10 minutes
- $this->setInterval(60 * 10);
-
- if (is_null($userManager) || is_null($config)) {
- $this->fixDIForJobs();
- } else {
- $this->config = $config;
- $this->userManager = $userManager;
- $this->logger = $logger;
- }
- }
-
- protected function fixDIForJobs() {
- $this->config = \OC::$server->getConfig();
- $this->userManager = \OC::$server->getUserManager();
- $this->logger = \OC::$server->getLogger();
- }
-
- /**
- * @param IUser $user
- */
- protected function runScanner(IUser $user) {
- try {
- $scanner = new Scanner(
- $user->getUID(),
- $this->dbConnection,
- $this->logger
- );
- $scanner->backgroundScan('');
- } catch (\Exception $e) {
- $this->logger->logException($e, ['app' => 'files']);
- }
- \OC_Util::tearDownFS();
- }
-
- /**
- * @param $argument
- * @throws \Exception
- */
- protected function run($argument) {
- $offset = $this->config->getAppValue('files', 'cronjob_scan_files', 0);
- $users = $this->userManager->search('', self::USERS_PER_SESSION, $offset);
- if (!count($users)) {
- // No users found, reset offset and retry
- $offset = 0;
- $users = $this->userManager->search('', self::USERS_PER_SESSION);
- }
-
- $offset += self::USERS_PER_SESSION;
- $this->config->setAppValue('files', 'cronjob_scan_files', $offset);
-
- foreach ($users as $user) {
- $this->runScanner($user);
- }
- }
-}
diff --git a/apps/files/lib/capabilities.php b/apps/files/lib/capabilities.php
deleted file mode 100644
index 7d50b51bb97..00000000000
--- a/apps/files/lib/capabilities.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-/**
- * @author Christopher Schäpers <kondou@ts.unde.re>
- * @author Roeland Jago Douma <rullzer@owncloud.com>
- * @author Tom Needham <tom@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\Files;
-
-use OCP\Capabilities\ICapability;
-
-/**
- * Class Capabilities
- *
- * @package OCA\Files
- */
-class Capabilities implements ICapability {
-
- /**
- * Return this classes capabilities
- *
- * @return array
- */
- public function getCapabilities() {
- return [
- 'files' => [
- 'bigfilechunking' => true,
- ],
- ];
- }
-}
diff --git a/apps/files/lib/helper.php b/apps/files/lib/helper.php
deleted file mode 100644
index d21a65afcee..00000000000
--- a/apps/files/lib/helper.php
+++ /dev/null
@@ -1,248 +0,0 @@
-<?php
-/**
- * @author Björn Schießle <schiessle@owncloud.com>
- * @author brumsel <brumsel@losecatcher.de>
- * @author Jörn Friedrich Dreyer <jfd@butonic.de>
- * @author Lukas Reschke <lukas@owncloud.com>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Robin Appelman <icewind@owncloud.com>
- * @author Robin McCorkell <robin@mccorkell.me.uk>
- * @author Roeland Jago Douma <rullzer@owncloud.com>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- * @author Vincent Petry <pvince81@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\Files;
-
-use OCP\Files\FileInfo;
-
-/**
- * Helper class for manipulating file information
- */
-class Helper {
- /**
- * @param string $dir
- * @return array
- * @throws \OCP\Files\NotFoundException
- */
- public static function buildFileStorageStatistics($dir) {
- // information about storage capacities
- $storageInfo = \OC_Helper::getStorageInfo($dir);
- $l = new \OC_L10N('files');
- $maxUploadFileSize = \OCP\Util::maxUploadFilesize($dir, $storageInfo['free']);
- $maxHumanFileSize = \OCP\Util::humanFileSize($maxUploadFileSize);
- $maxHumanFileSize = $l->t('Upload (max. %s)', array($maxHumanFileSize));
-
- return [
- 'uploadMaxFilesize' => $maxUploadFileSize,
- 'maxHumanFilesize' => $maxHumanFileSize,
- 'freeSpace' => $storageInfo['free'],
- 'usedSpacePercent' => (int)$storageInfo['relative'],
- 'owner' => $storageInfo['owner'],
- 'ownerDisplayName' => $storageInfo['ownerDisplayName'],
- ];
- }
-
- /**
- * Determine icon for a given file
- *
- * @param \OCP\Files\FileInfo $file file info
- * @return string icon URL
- */
- public static function determineIcon($file) {
- if($file['type'] === 'dir') {
- $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon('dir');
- // TODO: move this part to the client side, using mountType
- if ($file->isShared()) {
- $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon('dir-shared');
- } elseif ($file->isMounted()) {
- $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon('dir-external');
- }
- }else{
- $icon = \OC::$server->getMimeTypeDetector()->mimeTypeIcon($file->getMimetype());
- }
-
- return substr($icon, 0, -3) . 'svg';
- }
-
- /**
- * Comparator function to sort files alphabetically and have
- * the directories appear first
- *
- * @param \OCP\Files\FileInfo $a file
- * @param \OCP\Files\FileInfo $b file
- * @return int -1 if $a must come before $b, 1 otherwise
- */
- public static function compareFileNames(FileInfo $a, FileInfo $b) {
- $aType = $a->getType();
- $bType = $b->getType();
- if ($aType === 'dir' and $bType !== 'dir') {
- return -1;
- } elseif ($aType !== 'dir' and $bType === 'dir') {
- return 1;
- } else {
- return \OCP\Util::naturalSortCompare($a->getName(), $b->getName());
- }
- }
-
- /**
- * Comparator function to sort files by date
- *
- * @param \OCP\Files\FileInfo $a file
- * @param \OCP\Files\FileInfo $b file
- * @return int -1 if $a must come before $b, 1 otherwise
- */
- public static function compareTimestamp(FileInfo $a, FileInfo $b) {
- $aTime = $a->getMTime();
- $bTime = $b->getMTime();
- return ($aTime < $bTime) ? -1 : 1;
- }
-
- /**
- * Comparator function to sort files by size
- *
- * @param \OCP\Files\FileInfo $a file
- * @param \OCP\Files\FileInfo $b file
- * @return int -1 if $a must come before $b, 1 otherwise
- */
- public static function compareSize(FileInfo $a, FileInfo $b) {
- $aSize = $a->getSize();
- $bSize = $b->getSize();
- return ($aSize < $bSize) ? -1 : 1;
- }
-
- /**
- * Formats the file info to be returned as JSON to the client.
- *
- * @param \OCP\Files\FileInfo $i
- * @return array formatted file info
- */
- public static function formatFileInfo(FileInfo $i) {
- $entry = array();
-
- $entry['id'] = $i['fileid'];
- $entry['parentId'] = $i['parent'];
- $entry['mtime'] = $i['mtime'] * 1000;
- // only pick out the needed attributes
- $entry['name'] = $i->getName();
- $entry['permissions'] = $i['permissions'];
- $entry['mimetype'] = $i['mimetype'];
- $entry['size'] = $i['size'];
- $entry['type'] = $i['type'];
- $entry['etag'] = $i['etag'];
- if (isset($i['tags'])) {
- $entry['tags'] = $i['tags'];
- }
- if (isset($i['displayname_owner'])) {
- $entry['shareOwner'] = $i['displayname_owner'];
- }
- if (isset($i['is_share_mount_point'])) {
- $entry['isShareMountPoint'] = $i['is_share_mount_point'];
- }
- $mountType = null;
- if ($i->isShared()) {
- $mountType = 'shared';
- } else if ($i->isMounted()) {
- $mountType = 'external';
- }
- if ($mountType !== null) {
- if ($i->getInternalPath() === '') {
- $mountType .= '-root';
- }
- $entry['mountType'] = $mountType;
- }
- if (isset($i['extraData'])) {
- $entry['extraData'] = $i['extraData'];
- }
- return $entry;
- }
-
- /**
- * Format file info for JSON
- * @param \OCP\Files\FileInfo[] $fileInfos file infos
- * @return array
- */
- public static function formatFileInfos($fileInfos) {
- $files = array();
- foreach ($fileInfos as $i) {
- $files[] = self::formatFileInfo($i);
- }
-
- return $files;
- }
-
- /**
- * Retrieves the contents of the given directory and
- * returns it as a sorted array of FileInfo.
- *
- * @param string $dir path to the directory
- * @param string $sortAttribute attribute to sort on
- * @param bool $sortDescending true for descending sort, false otherwise
- * @param string $mimetypeFilter limit returned content to this mimetype or mimepart
- * @return \OCP\Files\FileInfo[] files
- */
- public static function getFiles($dir, $sortAttribute = 'name', $sortDescending = false, $mimetypeFilter = '') {
- $content = \OC\Files\Filesystem::getDirectoryContent($dir, $mimetypeFilter);
-
- return self::sortFiles($content, $sortAttribute, $sortDescending);
- }
-
- /**
- * Populate the result set with file tags
- *
- * @param array $fileList
- * @return array file list populated with tags
- */
- public static function populateTags(array $fileList) {
- $filesById = array();
- foreach ($fileList as $fileData) {
- $filesById[$fileData['fileid']] = $fileData;
- }
- $tagger = \OC::$server->getTagManager()->load('files');
- $tags = $tagger->getTagsForObjects(array_keys($filesById));
- if ($tags) {
- foreach ($tags as $fileId => $fileTags) {
- $filesById[$fileId]['tags'] = $fileTags;
- }
- }
- return $fileList;
- }
-
- /**
- * Sort the given file info array
- *
- * @param \OCP\Files\FileInfo[] $files files to sort
- * @param string $sortAttribute attribute to sort on
- * @param bool $sortDescending true for descending sort, false otherwise
- * @return \OCP\Files\FileInfo[] sorted files
- */
- public static function sortFiles($files, $sortAttribute = 'name', $sortDescending = false) {
- $sortFunc = 'compareFileNames';
- if ($sortAttribute === 'mtime') {
- $sortFunc = 'compareTimestamp';
- } else if ($sortAttribute === 'size') {
- $sortFunc = 'compareSize';
- }
- usort($files, array('\OCA\Files\Helper', $sortFunc));
- if ($sortDescending) {
- $files = array_reverse($files);
- }
- return $files;
- }
-}