aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/lib/Controller
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/lib/Controller')
-rw-r--r--apps/files/lib/Controller/ApiController.php470
-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/SettingsController.php51
-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.php402
9 files changed, 1289 insertions, 371 deletions
diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php
index a66b1b4d565..8bb024fb698 100644
--- a/apps/files/lib/Controller/ApiController.php
+++ b/apps/files/lib/Controller/ApiController.php
@@ -1,127 +1,124 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Bjoern Schiessle <bjoern@schiessle.org>
- * @author Christoph Wurst <christoph@owncloud.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Tobias Kaminsky <tobias@kaminsky.me>
- * @author Vincent Petry <pvince81@owncloud.com>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
-
namespace OCA\Files\Controller;
-use OCP\AppFramework\Http;
+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\IRequest;
-use OCP\AppFramework\Http\DataResponse;
-use OCP\AppFramework\Http\FileDisplayResponse;
-use OCP\AppFramework\Http\Response;
-use OCA\Files\Service\TagService;
+use OCP\IL10N;
use OCP\IPreview;
-use OCP\Share\IManager;
-use OC\Files\Node\Node;
+use OCP\IRequest;
+use OCP\IUser;
use OCP\IUserSession;
+use OCP\PreConditionNotMetException;
+use OCP\Share\IManager;
+use OCP\Share\IShare;
+use Psr\Log\LoggerInterface;
+use Throwable;
/**
- * Class ApiController
+ * @psalm-import-type FilesFolderTree from ResponseDefinitions
*
* @package OCA\Files\Controller
*/
class ApiController extends Controller {
- /** @var TagService */
- private $tagService;
- /** @var IManager **/
- private $shareManager;
- /** @var IPreview */
- private $previewManager;
- /** IUserSession */
- private $userSession;
- /** IConfig */
- private $config;
- /** @var Folder */
- private $userFolder;
-
- /**
- * @param string $appName
- * @param IRequest $request
- * @param IUserSession $userSession
- * @param TagService $tagService
- * @param IPreview $previewManager
- * @param IManager $shareManager
- * @param IConfig $config
- * @param Folder $userFolder
- */
- public function __construct($appName,
- IRequest $request,
- IUserSession $userSession,
- TagService $tagService,
- IPreview $previewManager,
- IManager $shareManager,
- IConfig $config,
- Folder $userFolder) {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IUserSession $userSession,
+ private TagService $tagService,
+ private IPreview $previewManager,
+ private IManager $shareManager,
+ private IConfig $config,
+ private ?Folder $userFolder,
+ private UserConfig $userConfig,
+ private ViewConfig $viewConfig,
+ private IL10N $l10n,
+ private IRootFolder $rootFolder,
+ private LoggerInterface $logger,
+ ) {
parent::__construct($appName, $request);
- $this->userSession = $userSession;
- $this->tagService = $tagService;
- $this->previewManager = $previewManager;
- $this->shareManager = $shareManager;
- $this->config = $config;
- $this->userFolder = $userFolder;
}
/**
* Gets a thumbnail of the specified file
*
* @since API version 1.0
+ * @deprecated 32.0.0 Use the preview endpoint provided by core instead
*
- * @NoAdminRequired
- * @NoCSRFRequired
- * @StrictCookieRequired
- *
- * @param int $x
- * @param int $y
+ * @param int $x Width of the thumbnail
+ * @param int $y Height of the thumbnail
* @param string $file URL-encoded filename
- * @return DataResponse|FileDisplayResponse
+ * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message?: string}, array{}>
+ *
+ * 200: Thumbnail returned
+ * 400: Getting thumbnail is not possible
+ * 404: File not found
*/
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[StrictCookiesRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getThumbnail($x, $y, $file) {
- if($x < 1 || $y < 1) {
+ if ($x < 1 || $y < 1) {
return new DataResponse(['message' => 'Requested size must be numeric and a positive value.'], Http::STATUS_BAD_REQUEST);
}
try {
- $file = $this->userFolder->get($file);
- if ($file instanceof Folder) {
+ $file = $this->userFolder?->get($file);
+ if ($file === null
+ || !($file instanceof File)
+ || ($file->getId() <= 0)
+ ) {
throw new NotFoundException();
}
- /** @var File $file */
+ // Validate the user is allowed to download the file (preview is some kind of download)
+ /** @var ISharedStorage $storage */
+ $storage = $file->getStorage();
+ if ($storage->instanceOfStorage(ISharedStorage::class)) {
+ /** @var IShare $share */
+ $share = $storage->getShare();
+ if (!$share->canSeeContent()) {
+ throw new NotFoundException();
+ }
+ }
+
$preview = $this->previewManager->getPreview($file, $x, $y, true);
return new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]);
- } catch (NotFoundException $e) {
+ } catch (NotFoundException|NotPermittedException|InvalidPathException) {
return new DataResponse(['message' => 'File not found.'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
@@ -133,23 +130,22 @@ class ApiController extends Controller {
* The passed tags are absolute, which means they will
* replace the actual tag selection.
*
- * @NoAdminRequired
- *
* @param string $path path
* @param array|string $tags array of tags
* @return DataResponse
*/
+ #[NoAdminRequired]
public function updateFileTags($path, $tags = null) {
$result = [];
// if tags specified or empty array, update tags
if (!is_null($tags)) {
try {
$this->tagService->updateFileTags($path, $tags);
- } catch (\OCP\Files\NotFoundException $e) {
+ } catch (NotFoundException $e) {
return new DataResponse([
'message' => $e->getMessage()
], Http::STATUS_NOT_FOUND);
- } catch (\OCP\Files\StorageNotAvailableException $e) {
+ } catch (StorageNotAvailableException $e) {
return new DataResponse([
'message' => $e->getMessage()
], Http::STATUS_SERVICE_UNAVAILABLE);
@@ -168,10 +164,11 @@ class ApiController extends Controller {
* @return array
*/
private function formatNodes(array $nodes) {
- return array_values(array_map(function (Node $node) {
- /** @var \OC\Files\Node\Node $shareTypes */
- $shareTypes = $this->getShareTypes($node);
- $file = \OCA\Files\Helper::formatFileInfo($node->getFileInfo());
+ $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];
@@ -186,12 +183,63 @@ class ApiController extends Controller {
}
/**
- * Returns a list of recently modifed files.
+ * Get the share types for each node
*
- * @NoAdminRequired
+ * @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);
@@ -199,70 +247,216 @@ class ApiController extends Controller {
}
/**
- * Return a list of share types for outgoing shares
+ * @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 Node $node file node
+ * @param string $path The path relative to the user folder
+ * @param int $depth The depth of the tree
*
- * @return int[] array of share types
+ * @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
*/
- private function getShareTypes(Node $node) {
- $userId = $this->userSession->getUser()->getUID();
- $shareTypes = [];
- $requestedShareTypes = [
- \OCP\Share::SHARE_TYPE_USER,
- \OCP\Share::SHARE_TYPE_GROUP,
- \OCP\Share::SHARE_TYPE_LINK,
- \OCP\Share::SHARE_TYPE_REMOTE,
- \OCP\Share::SHARE_TYPE_EMAIL
- ];
- foreach ($requestedShareTypes as $requestedShareType) {
- // one of each type is enough to find out about the types
- $shares = $this->shareManager->getSharesBy(
- $userId,
- $requestedShareType,
- $node,
- false,
- 1
- );
- if (!empty($shares)) {
- $shareTypes[] = $requestedShareType;
+ #[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 $shareTypes;
+ return new JSONResponse($tree);
}
/**
- * Change the default sort mode
+ * Returns the current logged-in user's storage stats.
*
- * @NoAdminRequired
+ * @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 $mode
- * @param string $direction
- * @return Response
+ * @param string $view
+ * @param string $key
+ * @param string|bool $value
+ * @return JSONResponse
*/
- public function updateFileSorting($mode, $direction) {
- $allowedMode = ['name', 'size', 'mtime'];
- $allowedDirection = ['asc', 'desc'];
- if (!in_array($mode, $allowedMode) || !in_array($direction, $allowedDirection)) {
- $response = new Response();
- $response->setStatus(Http::STATUS_UNPROCESSABLE_ENTITY);
- return $response;
+ #[NoAdminRequired]
+ public function setViewConfig(string $view, string $key, $value): JSONResponse {
+ try {
+ $this->viewConfig->setConfig($view, $key, (string)$value);
+ } catch (\InvalidArgumentException $e) {
+ return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
- $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting', $mode);
- $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting_direction', $direction);
- return new Response();
+
+ return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfig($view)]);
+ }
+
+
+ /**
+ * 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
*
- * @NoAdminRequired
+ * @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
*/
- public function showHiddenFiles($show) {
- $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'show_hidden', (int) $show);
+ #[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/SettingsController.php b/apps/files/lib/Controller/SettingsController.php
deleted file mode 100644
index 0b2dc9c2dd1..00000000000
--- a/apps/files/lib/Controller/SettingsController.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php
-/**
- *
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-namespace OCA\Files\Controller;
-
-use OCP\AppFramework\Controller;
-use OCP\AppFramework\Http;
-use OCP\AppFramework\Http\JSONResponse;
-use OCP\IRequest;
-use OCP\Util;
-
-class SettingsController extends Controller {
- public function __construct($appName, IRequest $request) {
- parent::__construct($appName, $request);
- }
-
- /**
- * @param string $maxUploadSize
- * @return JSONResponse
- */
- public function setMaxUploadSize($maxUploadSize) {
- $setMaxSize = \OC_Files::setUploadLimit(Util::computerFileSize($maxUploadSize));
-
- if ($setMaxSize === false) {
- return new JSONResponse([], Http::STATUS_BAD_REQUEST);
- } else {
- return new JSONResponse([
- 'maxUploadSize' => Util::humanFileSize($setMaxSize)
- ]);
- }
- }
-}
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
index fa8243822a8..ecf21cef313 100644
--- a/apps/files/lib/Controller/ViewController.php
+++ b/apps/files/lib/Controller/ViewController.php
@@ -1,268 +1,306 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Christoph Wurst <christoph@owncloud.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- * @author Vincent Petry <pvince81@owncloud.com>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
-
namespace OCA\Files\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 Symfony\Component\EventDispatcher\EventDispatcherInterface;
-use OCP\Files\Folder;
-use OCP\App\IAppManager;
-use Symfony\Component\EventDispatcher\GenericEvent;
+use OCP\Util;
/**
- * Class ViewController
- *
* @package OCA\Files\Controller
*/
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class ViewController extends Controller {
- /** @var string */
- protected $appName;
- /** @var IRequest */
- protected $request;
- /** @var IURLGenerator */
- protected $urlGenerator;
- /** @var IL10N */
- protected $l10n;
- /** @var IConfig */
- protected $config;
- /** @var EventDispatcherInterface */
- protected $eventDispatcher;
- /** @var IUserSession */
- protected $userSession;
- /** @var IAppManager */
- protected $appManager;
- /** @var IRootFolder */
- protected $rootFolder;
- /**
- * @param string $appName
- * @param IRequest $request
- * @param IURLGenerator $urlGenerator
- * @param IL10N $l10n
- * @param IConfig $config
- * @param EventDispatcherInterface $eventDispatcherInterface
- * @param IUserSession $userSession
- * @param IAppManager $appManager
- * @param IRootFolder $rootFolder
- */
- public function __construct($appName,
- IRequest $request,
- IURLGenerator $urlGenerator,
- IL10N $l10n,
- IConfig $config,
- EventDispatcherInterface $eventDispatcherInterface,
- IUserSession $userSession,
- IAppManager $appManager,
- IRootFolder $rootFolder
+ 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);
- $this->appName = $appName;
- $this->request = $request;
- $this->urlGenerator = $urlGenerator;
- $this->l10n = $l10n;
- $this->config = $config;
- $this->eventDispatcher = $eventDispatcherInterface;
- $this->userSession = $userSession;
- $this->appManager = $appManager;
- $this->rootFolder = $rootFolder;
}
/**
- * @param string $appName
- * @param string $scriptName
- * @return string
+ * 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
*/
- protected function renderScript($appName, $scriptName) {
- $content = '';
- $appPath = \OC_App::getAppPath($appName);
- $scriptPath = $appPath . '/' . $scriptName;
- if (file_exists($scriptPath)) {
- // TODO: sanitize path / script name ?
- ob_start();
- include $scriptPath;
- $content = ob_get_contents();
- @ob_end_clean();
+ #[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']));
}
- return $content;
}
+
/**
- * FIXME: Replace with non static code
- *
- * @return array
- * @throws \OCP\Files\NotFoundException
+ * @param string $dir
+ * @param string $view
+ * @param string $fileid
+ * @return TemplateResponse|RedirectResponse
*/
- protected function getStorageInfo() {
- $dirInfo = \OC\Files\Filesystem::getFileInfo('/', false);
- return \OC_Helper::getStorageInfo('/', $dirInfo);
+ #[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);
}
/**
- * @NoCSRFRequired
- * @NoAdminRequired
- *
* @param string $dir
* @param string $view
* @param string $fileid
* @return TemplateResponse|RedirectResponse
*/
- public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false) {
- if ($fileid !== null) {
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function index($dir = '', $view = '', $fileid = null) {
+ if ($fileid !== null && $view !== 'trashbin') {
try {
- return $this->showFile($fileid);
+ return $this->redirectToFileIfInTrashbin((int)$fileid);
} catch (NotFoundException $e) {
- return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true]));
}
}
- $nav = new \OCP\Template('files', 'appnavigation', '');
-
// Load the files we need
- \OCP\Util::addStyle('files', 'merged');
- \OCP\Util::addScript('files', 'merged-index');
-
- // mostly for the home storage's free space
- // FIXME: Make non static
- $storageInfo = $this->getStorageInfo();
-
- \OCA\Files\App::getNavigationManager()->add(
- [
- 'id' => 'favorites',
- 'appname' => 'files',
- 'script' => 'simplelist.php',
- 'order' => 5,
- 'name' => $this->l10n->t('Favorites')
- ]
- );
+ Util::addInitScript('files', 'init');
+ Util::addScript('files', 'main');
- $navItems = \OCA\Files\App::getNavigationManager()->getAll();
- usort($navItems, function($item1, $item2) {
- return $item1['order'] - $item2['order'];
- });
- $nav->assign('navigationItems', $navItems);
+ $user = $this->userSession->getUser();
+ $userId = $user->getUID();
+ // If the file doesn't exists in the folder and
+ // exists in only one occurrence, redirect to that file
+ // in the correct folder
+ if ($fileid && $dir !== '') {
+ $baseFolder = $this->rootFolder->getUserFolder($userId);
+ $nodes = $baseFolder->getById((int)$fileid);
+ if (!empty($nodes)) {
+ $nodePath = $baseFolder->getRelativePath($nodes[0]->getPath());
+ $relativePath = $nodePath ? dirname($nodePath) : '';
+ // If the requested path does not contain the file id
+ // or if the requested path is not the file id itself
+ if (count($nodes) === 1 && $relativePath !== $dir && $nodePath !== $dir) {
+ return $this->redirectToFile((int)$fileid);
+ }
+ }
+ }
- $nav->assign('usage', \OC_Helper::humanFileSize($storageInfo['used']));
- if ($storageInfo['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED) {
- $totalSpace = $this->l10n->t('Unlimited');
- } else {
- $totalSpace = \OC_Helper::humanFileSize($storageInfo['total']);
+ 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();
}
- $nav->assign('total_space', $totalSpace);
- $nav->assign('quota', $storageInfo['quota']);
- $nav->assign('usage_relative', $storageInfo['relative']);
- $contentItems = [];
+ $this->initialState->provideInitialState('storageStats', $storageInfo);
+ $this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
+ $this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs());
- // render the container content for every navigation item
- foreach ($navItems as $item) {
- $content = '';
- if (isset($item['script'])) {
- $content = $this->renderScript($item['appname'], $item['script']);
- }
- $contentItem = [];
- $contentItem['id'] = $item['id'];
- $contentItem['content'] = $content;
- $contentItems[] = $contentItem;
+ // 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());
}
- $event = new GenericEvent(null, ['hiddenFields' => []]);
- $this->eventDispatcher->dispatch('OCA\Files::loadAdditionalScripts', $event);
+ $this->initialState->provideInitialState('templates_enabled', ($this->config->getSystemValueString('skeletondirectory', \OC::$SERVERROOT . '/core/skeleton') !== '') || ($this->config->getSystemValueString('templatedirectory', \OC::$SERVERROOT . '/core/skeleton/Templates') !== ''));
+ $this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false);
+ $this->initialState->provideInitialState('templates', $this->templateManager->listCreators());
- $params = [];
- $params['usedSpacePercent'] = (int)$storageInfo['relative'];
- $params['owner'] = $storageInfo['owner'];
- $params['ownerDisplayName'] = $storageInfo['ownerDisplayName'];
- $params['isPublic'] = false;
- $params['allowShareWithLink'] = $this->config->getAppValue('core', 'shareapi_allow_links', 'yes');
- $user = $this->userSession->getUser()->getUID();
- $params['defaultFileSorting'] = $this->config->getUserValue($user, 'files', 'file_sorting', 'name');
- $params['defaultFileSortingDirection'] = $this->config->getUserValue($user, 'files', 'file_sorting_direction', 'asc');
- $showHidden = (bool) $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'show_hidden', false);
- $params['showHiddenFiles'] = $showHidden ? 1 : 0;
- $params['fileNotFound'] = $fileNotFound ? 1 : 0;
- $params['appNavigation'] = $nav;
- $params['appContents'] = $contentItems;
- $params['hiddenFields'] = $event->getArgument('hiddenFields');
+ $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(
- $this->appName,
+ Application::APP_ID,
'index',
- $params
);
$policy = new ContentSecurityPolicy();
$policy->addAllowedFrameDomain('\'self\'');
+ // Allow preview service worker
+ $policy->addAllowedWorkerSrcDomain('\'self\'');
$response->setContentSecurityPolicy($policy);
return $response;
}
/**
- * Redirects to the file list and highlight the given file id
+ * Redirects to the trashbin file list and highlight the given file id
*
- * @param string $fileId file id to show
+ * @param int $fileId file id to show
* @return RedirectResponse redirect response or not found response
- * @throws \OCP\Files\NotFoundException
+ * @throws NotFoundException
*/
- private function showFile($fileId) {
+ private function redirectToFileIfInTrashbin($fileId): RedirectResponse {
$uid = $this->userSession->getUser()->getUID();
$baseFolder = $this->rootFolder->getUserFolder($uid);
- $files = $baseFolder->getById($fileId);
+ $node = $baseFolder->getFirstNodeById($fileId);
$params = [];
- if (empty($files) && $this->appManager->isEnabledForUser('files_trashbin')) {
+ if (!$node && $this->appManager->isEnabledForUser('files_trashbin')) {
+ /** @var Folder */
$baseFolder = $this->rootFolder->get($uid . '/files_trashbin/files/');
- $files = $baseFolder->getById($fileId);
+ $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'];
- if (!empty($files)) {
- $file = current($files);
- if ($file instanceof Folder) {
+ 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($file->getPath());
+ $params['dir'] = $baseFolder->getRelativePath($node->getPath());
} else {
// set parent path as dir
- $params['dir'] = $baseFolder->getRelativePath($file->getParent()->getPath());
- // and scroll to the entry
- $params['scrollto'] = $file->getName();
+ $params['dir'] = $baseFolder->getRelativePath($node->getParent()->getPath());
+ // open the file by default (opening the viewer)
+ $params['openfile'] = 'true';
+ }
+
+ // Forward open parameters if any.
+ // - openfile is true by default
+ // - opendetails is undefined by default
+ // - both will be evaluated as truthy
+ if ($openDetails !== null) {
+ $params['opendetails'] = $openDetails !== 'false' ? 'true' : 'false';
}
- return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', $params));
+
+ if ($openFile !== null) {
+ $params['openfile'] = $openFile !== 'false' ? 'true' : 'false';
+ }
+
+ return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params));
}
- throw new \OCP\Files\NotFoundException();
+
+ throw new NotFoundException();
}
}