diff options
Diffstat (limited to 'core/Controller')
42 files changed, 3195 insertions, 1828 deletions
diff --git a/core/Controller/AppPasswordController.php b/core/Controller/AppPasswordController.php index 3f254f03370..e5edc165bf5 100644 --- a/core/Controller/AppPasswordController.php +++ b/core/Controller/AppPasswordController.php @@ -3,72 +3,62 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Authentication\Events\AppPasswordCreatedEvent; -use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; +use OC\User\Session; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\Attribute\UseSession; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCSController; use OCP\Authentication\Exceptions\CredentialsUnavailableException; +use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Authentication\Exceptions\PasswordUnavailableException; use OCP\Authentication\LoginCredentials\IStore; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\ISession; +use OCP\IUserManager; +use OCP\Security\Bruteforce\IThrottler; use OCP\Security\ISecureRandom; -class AppPasswordController extends \OCP\AppFramework\OCSController { - private ISession $session; - private ISecureRandom $random; - private IProvider $tokenProvider; - private IStore $credentialStore; - private IEventDispatcher $eventDispatcher; - - public function __construct(string $appName, - IRequest $request, - ISession $session, - ISecureRandom $random, - IProvider $tokenProvider, - IStore $credentialStore, - IEventDispatcher $eventDispatcher) { +class AppPasswordController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private ISession $session, + private ISecureRandom $random, + private IProvider $tokenProvider, + private IStore $credentialStore, + private IEventDispatcher $eventDispatcher, + private Session $userSession, + private IUserManager $userManager, + private IThrottler $throttler, + ) { parent::__construct($appName, $request); - - $this->session = $session; - $this->random = $random; - $this->tokenProvider = $tokenProvider; - $this->credentialStore = $credentialStore; - $this->eventDispatcher = $eventDispatcher; } /** - * @NoAdminRequired + * Create app password + * + * @return DataResponse<Http::STATUS_OK, array{apppassword: string}, array{}> + * @throws OCSForbiddenException Creating app password is not allowed * - * @throws OCSForbiddenException + * 200: App password returned */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + #[ApiRoute(verb: 'GET', url: '/getapppassword', root: '/core')] public function getAppPassword(): DataResponse { // We do not allow the creation of new tokens if this is an app password if ($this->session->exists('app_password')) { @@ -87,9 +77,9 @@ class AppPasswordController extends \OCP\AppFramework\OCSController { $password = null; } - $userAgent = $this->request->getHeader('USER_AGENT'); + $userAgent = $this->request->getHeader('user-agent'); - $token = $this->random->generate(72, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS); + $token = $this->random->generate(72, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); $generatedToken = $this->tokenProvider->generateToken( $token, @@ -111,8 +101,15 @@ class AppPasswordController extends \OCP\AppFramework\OCSController { } /** - * @NoAdminRequired + * Delete app password + * + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSForbiddenException Deleting app password is not allowed + * + * 200: App password deleted successfully */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/apppassword', root: '/core')] public function deleteAppPassword(): DataResponse { if (!$this->session->exists('app_password')) { throw new OCSForbiddenException('no app password in use'); @@ -131,8 +128,15 @@ class AppPasswordController extends \OCP\AppFramework\OCSController { } /** - * @NoAdminRequired + * Rotate app password + * + * @return DataResponse<Http::STATUS_OK, array{apppassword: string}, array{}> + * @throws OCSForbiddenException Rotating app password is not allowed + * + * 200: App password returned */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/apppassword/rotate', root: '/core')] public function rotateAppPassword(): DataResponse { if (!$this->session->exists('app_password')) { throw new OCSForbiddenException('no app password in use'); @@ -146,11 +150,40 @@ class AppPasswordController extends \OCP\AppFramework\OCSController { throw new OCSForbiddenException('could not rotate apptoken'); } - $newToken = $this->random->generate(72, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS); + $newToken = $this->random->generate(72, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); $this->tokenProvider->rotate($token, $appPassword, $newToken); return new DataResponse([ 'apppassword' => $newToken, ]); } + + /** + * Confirm the user password + * + * @param string $password The password of the user + * + * @return DataResponse<Http::STATUS_OK, array{lastLogin: int}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, list<empty>, array{}> + * + * 200: Password confirmation succeeded + * 403: Password confirmation failed + */ + #[NoAdminRequired] + #[BruteForceProtection(action: 'sudo')] + #[UseSession] + #[ApiRoute(verb: 'PUT', url: '/apppassword/confirm', root: '/core')] + public function confirmUserPassword(string $password): DataResponse { + $loginName = $this->userSession->getLoginName(); + $loginResult = $this->userManager->checkPassword($loginName, $password); + if ($loginResult === false) { + $response = new DataResponse([], Http::STATUS_FORBIDDEN); + $response->throttle(['loginName' => $loginName]); + return $response; + } + + $confirmTimestamp = time(); + $this->session->set('last-password-confirm', $confirmTimestamp); + $this->throttler->resetDelay($this->request->getRemoteAddress(), 'sudo', ['loginName' => $loginName]); + return new DataResponse(['lastLogin' => $confirmTimestamp], Http::STATUS_OK); + } } diff --git a/core/Controller/AutoCompleteController.php b/core/Controller/AutoCompleteController.php index f7174456731..692fe1b7297 100644 --- a/core/Controller/AutoCompleteController.php +++ b/core/Controller/AutoCompleteController.php @@ -3,62 +3,55 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; +use OC\Core\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\OCSController as Controller; +use OCP\AppFramework\OCSController; use OCP\Collaboration\AutoComplete\AutoCompleteEvent; +use OCP\Collaboration\AutoComplete\AutoCompleteFilterEvent; use OCP\Collaboration\AutoComplete\IManager; use OCP\Collaboration\Collaborators\ISearch; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\Share\IShare; -class AutoCompleteController extends Controller { - private ISearch $collaboratorSearch; - private IManager $autoCompleteManager; - private IEventDispatcher $dispatcher; - - public function __construct(string $appName, - IRequest $request, - ISearch $collaboratorSearch, - IManager $autoCompleteManager, - IEventDispatcher $dispatcher) { +/** + * @psalm-import-type CoreAutocompleteResult from ResponseDefinitions + */ +class AutoCompleteController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private ISearch $collaboratorSearch, + private IManager $autoCompleteManager, + private IEventDispatcher $dispatcher, + ) { parent::__construct($appName, $request); - - $this->collaboratorSearch = $collaboratorSearch; - $this->autoCompleteManager = $autoCompleteManager; - $this->dispatcher = $dispatcher; } /** - * @NoAdminRequired + * Autocomplete a query + * + * @param string $search Text to search for + * @param string|null $itemType Type of the items to search for + * @param string|null $itemId ID of the items to search for * @param string|null $sorter can be piped, top prio first, e.g.: "commenters|share-recipients" + * @param list<int> $shareTypes Types of shares to search for + * @param int $limit Maximum number of results to return + * + * @return DataResponse<Http::STATUS_OK, list<CoreAutocompleteResult>, array{}> + * + * 200: Autocomplete results returned */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/autocomplete/get', root: '/core')] public function get(string $search, ?string $itemType, ?string $itemId, ?string $sorter = null, array $shareTypes = [IShare::TYPE_USER], int $limit = 10): DataResponse { // if enumeration/user listings are disabled, we'll receive an empty // result from search() – thus nothing else to do here. @@ -76,6 +69,18 @@ class AutoCompleteController extends Controller { $this->dispatcher->dispatch(IManager::class . '::filterResults', $event); $results = $event->getResults(); + $event = new AutoCompleteFilterEvent( + $results, + $search, + $itemType, + $itemId, + $sorter, + $shareTypes, + $limit, + ); + $this->dispatcher->dispatchTyped($event); + $results = $event->getResults(); + $exactMatches = $results['exact']; unset($results['exact']); $results = array_merge_recursive($exactMatches, $results); @@ -94,18 +99,37 @@ class AutoCompleteController extends Controller { return new DataResponse($results); } + /** + * @return list<CoreAutocompleteResult> + */ protected function prepareResultArray(array $results): array { $output = []; + /** @var string $type */ foreach ($results as $type => $subResult) { foreach ($subResult as $result) { + /** @var ?string $icon */ + $icon = array_key_exists('icon', $result) ? $result['icon'] : null; + + /** @var string $label */ + $label = $result['label']; + + /** @var ?string $subline */ + $subline = array_key_exists('subline', $result) ? $result['subline'] : null; + + /** @var ?array{status: string, message: ?string, icon: ?string, clearAt: ?int} $status */ + $status = array_key_exists('status', $result) && is_array($result['status']) && !empty($result['status']) ? $result['status'] : null; + + /** @var ?string $shareWithDisplayNameUnique */ + $shareWithDisplayNameUnique = array_key_exists('shareWithDisplayNameUnique', $result) ? $result['shareWithDisplayNameUnique'] : null; + $output[] = [ - 'id' => (string) $result['value']['shareWith'], - 'label' => $result['label'], - 'icon' => $result['icon'] ?? '', + 'id' => (string)$result['value']['shareWith'], + 'label' => $label, + 'icon' => $icon ?? '', 'source' => $type, - 'status' => $result['status'] ?? '', - 'subline' => $result['subline'] ?? '', - 'shareWithDisplayNameUnique' => $result['shareWithDisplayNameUnique'] ?? '', + 'status' => $status ?? '', + 'subline' => $subline ?? '', + 'shareWithDisplayNameUnique' => $shareWithDisplayNameUnique ?? '', ]; } } diff --git a/core/Controller/AvatarController.php b/core/Controller/AvatarController.php index c6567b33209..b577b2fd460 100644 --- a/core/Controller/AvatarController.php +++ b/core/Controller/AvatarController.php @@ -1,49 +1,34 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julien Veyssier <eneiluj@posteo.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Core\Controller; use OC\AppFramework\Utility\TimeFactory; +use OC\NotSquareException; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +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\DataDisplayResponse; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; use OCP\Files\File; use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; use OCP\IAvatarManager; use OCP\ICache; use OCP\IL10N; +use OCP\Image; use OCP\IRequest; use OCP\IUserManager; -use OCP\IUserSession; use Psr\Log\LoggerInterface; /** @@ -52,47 +37,41 @@ use Psr\Log\LoggerInterface; * @package OC\Core\Controller */ class AvatarController extends Controller { - protected IAvatarManager $avatarManager; - protected ICache $cache; - protected IL10N $l; - protected IUserManager $userManager; - protected IUserSession $userSession; - protected IRootFolder $rootFolder; - protected LoggerInterface $logger; - protected ?string $userId; - protected TimeFactory $timeFactory; - - public function __construct(string $appName, - IRequest $request, - IAvatarManager $avatarManager, - ICache $cache, - IL10N $l10n, - IUserManager $userManager, - IRootFolder $rootFolder, - LoggerInterface $logger, - ?string $userId, - TimeFactory $timeFactory) { + public function __construct( + string $appName, + IRequest $request, + protected IAvatarManager $avatarManager, + protected ICache $cache, + protected IL10N $l10n, + protected IUserManager $userManager, + protected IRootFolder $rootFolder, + protected LoggerInterface $logger, + protected ?string $userId, + protected TimeFactory $timeFactory, + protected GuestAvatarController $guestAvatarController, + ) { parent::__construct($appName, $request); - - $this->avatarManager = $avatarManager; - $this->cache = $cache; - $this->l = $l10n; - $this->userManager = $userManager; - $this->rootFolder = $rootFolder; - $this->logger = $logger; - $this->userId = $userId; - $this->timeFactory = $timeFactory; } /** - * @NoAdminRequired - * @NoCSRFRequired * @NoSameSiteCookieRequired - * @PublicPage * - * @return JSONResponse|FileDisplayResponse + * Get the dark avatar + * + * @param string $userId ID of the user + * @param 64|512 $size Size of the avatar + * @param bool $guestFallback Fallback to guest avatar if not found + * @return FileDisplayResponse<Http::STATUS_OK|Http::STATUS_CREATED, array{Content-Type: string, X-NC-IsCustomAvatar: int}>|JSONResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}>|Response<Http::STATUS_INTERNAL_SERVER_ERROR, array{}> + * + * 200: Avatar returned + * 201: Avatar returned + * 404: Avatar not found */ - public function getAvatarDark(string $userId, int $size) { + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'GET', url: '/avatar/{userId}/{size}/dark')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getAvatarDark(string $userId, int $size, bool $guestFallback = false) { if ($size <= 64) { if ($size !== 64) { $this->logger->debug('Avatar requested in deprecated size ' . $size); @@ -114,6 +93,9 @@ class AvatarController extends Controller { ['Content-Type' => $avatarFile->getMimeType(), 'X-NC-IsCustomAvatar' => (int)$avatar->isCustomAvatar()] ); } catch (\Exception $e) { + if ($guestFallback) { + return $this->guestAvatarController->getAvatarDark($userId, $size); + } return new JSONResponse([], Http::STATUS_NOT_FOUND); } @@ -124,14 +106,24 @@ class AvatarController extends Controller { /** - * @NoAdminRequired - * @NoCSRFRequired * @NoSameSiteCookieRequired - * @PublicPage * - * @return JSONResponse|FileDisplayResponse + * Get the avatar + * + * @param string $userId ID of the user + * @param 64|512 $size Size of the avatar + * @param bool $guestFallback Fallback to guest avatar if not found + * @return FileDisplayResponse<Http::STATUS_OK|Http::STATUS_CREATED, array{Content-Type: string, X-NC-IsCustomAvatar: int}>|JSONResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}>|Response<Http::STATUS_INTERNAL_SERVER_ERROR, array{}> + * + * 200: Avatar returned + * 201: Avatar returned + * 404: Avatar not found */ - public function getAvatar(string $userId, int $size) { + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'GET', url: '/avatar/{userId}/{size}')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getAvatar(string $userId, int $size, bool $guestFallback = false) { if ($size <= 64) { if ($size !== 64) { $this->logger->debug('Avatar requested in deprecated size ' . $size); @@ -153,6 +145,9 @@ class AvatarController extends Controller { ['Content-Type' => $avatarFile->getMimeType(), 'X-NC-IsCustomAvatar' => (int)$avatar->isCustomAvatar()] ); } catch (\Exception $e) { + if ($guestFallback) { + return $this->guestAvatarController->getAvatar($userId, $size); + } return new JSONResponse([], Http::STATUS_NOT_FOUND); } @@ -161,9 +156,8 @@ class AvatarController extends Controller { return $response; } - /** - * @NoAdminRequired - */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'POST', url: '/avatar/')] public function postAvatar(?string $path = null): JSONResponse { $files = $this->request->getUploadedFile('files'); @@ -173,39 +167,38 @@ class AvatarController extends Controller { /** @var File $node */ $node = $userFolder->get($path); if (!($node instanceof File)) { - return new JSONResponse(['data' => ['message' => $this->l->t('Please select a file.')]]); + return new JSONResponse(['data' => ['message' => $this->l10n->t('Please select a file.')]]); } if ($node->getSize() > 20 * 1024 * 1024) { return new JSONResponse( - ['data' => ['message' => $this->l->t('File is too big')]], + ['data' => ['message' => $this->l10n->t('File is too big')]], Http::STATUS_BAD_REQUEST ); } if ($node->getMimeType() !== 'image/jpeg' && $node->getMimeType() !== 'image/png') { return new JSONResponse( - ['data' => ['message' => $this->l->t('The selected file is not an image.')]], + ['data' => ['message' => $this->l10n->t('The selected file is not an image.')]], Http::STATUS_BAD_REQUEST ); } try { $content = $node->getContent(); - } catch (\OCP\Files\NotPermittedException $e) { + } catch (NotPermittedException $e) { return new JSONResponse( - ['data' => ['message' => $this->l->t('The selected file cannot be read.')]], + ['data' => ['message' => $this->l10n->t('The selected file cannot be read.')]], Http::STATUS_BAD_REQUEST ); } } elseif (!is_null($files)) { if ( - $files['error'][0] === 0 && - is_uploaded_file($files['tmp_name'][0]) && - !\OC\Files\Filesystem::isFileBlacklisted($files['tmp_name'][0]) + $files['error'][0] === 0 + && is_uploaded_file($files['tmp_name'][0]) ) { if ($files['size'][0] > 20 * 1024 * 1024) { return new JSONResponse( - ['data' => ['message' => $this->l->t('File is too big')]], + ['data' => ['message' => $this->l10n->t('File is too big')]], Http::STATUS_BAD_REQUEST ); } @@ -214,16 +207,16 @@ class AvatarController extends Controller { unlink($files['tmp_name'][0]); } else { $phpFileUploadErrors = [ - UPLOAD_ERR_OK => $this->l->t('The file was uploaded'), - UPLOAD_ERR_INI_SIZE => $this->l->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), - UPLOAD_ERR_FORM_SIZE => $this->l->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), - UPLOAD_ERR_PARTIAL => $this->l->t('The file was only partially uploaded'), - UPLOAD_ERR_NO_FILE => $this->l->t('No file was uploaded'), - UPLOAD_ERR_NO_TMP_DIR => $this->l->t('Missing a temporary folder'), - UPLOAD_ERR_CANT_WRITE => $this->l->t('Could not write file to disk'), - UPLOAD_ERR_EXTENSION => $this->l->t('A PHP extension stopped the file upload'), + UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), + UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), + UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), + UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), + UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), + UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), + UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), + UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), ]; - $message = $phpFileUploadErrors[$files['error'][0]] ?? $this->l->t('Invalid file provided'); + $message = $phpFileUploadErrors[$files['error'][0]] ?? $this->l10n->t('Invalid file provided'); $this->logger->warning($message, ['app' => 'core']); return new JSONResponse( ['data' => ['message' => $message]], @@ -233,13 +226,13 @@ class AvatarController extends Controller { } else { //Add imgfile return new JSONResponse( - ['data' => ['message' => $this->l->t('No image or file provided')]], + ['data' => ['message' => $this->l10n->t('No image or file provided')]], Http::STATUS_BAD_REQUEST ); } try { - $image = new \OCP\Image(); + $image = new Image(); $image->loadFromData($content); $image->readExif($content); $image->fixOrientation(); @@ -248,7 +241,7 @@ class AvatarController extends Controller { $mimeType = $image->mimeType(); if ($mimeType !== 'image/jpeg' && $mimeType !== 'image/png') { return new JSONResponse( - ['data' => ['message' => $this->l->t('Unknown filetype')]], + ['data' => ['message' => $this->l10n->t('Unknown filetype')]], Http::STATUS_OK ); } @@ -262,7 +255,7 @@ class AvatarController extends Controller { return new JSONResponse(['status' => 'success']); } catch (\Throwable $e) { $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']); - return new JSONResponse(['data' => ['message' => $this->l->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST); + return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST); } } @@ -273,19 +266,18 @@ class AvatarController extends Controller { ); } else { return new JSONResponse( - ['data' => ['message' => $this->l->t('Invalid image')]], + ['data' => ['message' => $this->l10n->t('Invalid image')]], Http::STATUS_OK ); } } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']); - return new JSONResponse(['data' => ['message' => $this->l->t('An error occurred. Please contact your admin.')]], Http::STATUS_OK); + return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_OK); } } - /** - * @NoAdminRequired - */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'DELETE', url: '/avatar/')] public function deleteAvatar(): JSONResponse { try { $avatar = $this->avatarManager->getAvatar($this->userId); @@ -293,25 +285,25 @@ class AvatarController extends Controller { return new JSONResponse(); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']); - return new JSONResponse(['data' => ['message' => $this->l->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST); + return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST); } } /** - * @NoAdminRequired - * * @return JSONResponse|DataDisplayResponse */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'GET', url: '/avatar/tmp')] public function getTmpAvatar() { $tmpAvatar = $this->cache->get('tmpAvatar'); if (is_null($tmpAvatar)) { return new JSONResponse(['data' => [ - 'message' => $this->l->t("No temporary profile picture available, try again") + 'message' => $this->l10n->t('No temporary profile picture available, try again') ]], Http::STATUS_NOT_FOUND); } - $image = new \OCP\Image(); + $image = new Image(); $image->loadFromData($tmpAvatar); $resp = new DataDisplayResponse( @@ -325,29 +317,28 @@ class AvatarController extends Controller { return $resp; } - /** - * @NoAdminRequired - */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'POST', url: '/avatar/cropped')] public function postCroppedAvatar(?array $crop = null): JSONResponse { if (is_null($crop)) { - return new JSONResponse(['data' => ['message' => $this->l->t("No crop data provided")]], + return new JSONResponse(['data' => ['message' => $this->l10n->t('No crop data provided')]], Http::STATUS_BAD_REQUEST); } if (!isset($crop['x'], $crop['y'], $crop['w'], $crop['h'])) { - return new JSONResponse(['data' => ['message' => $this->l->t("No valid crop data provided")]], + return new JSONResponse(['data' => ['message' => $this->l10n->t('No valid crop data provided')]], Http::STATUS_BAD_REQUEST); } $tmpAvatar = $this->cache->get('tmpAvatar'); if (is_null($tmpAvatar)) { return new JSONResponse(['data' => [ - 'message' => $this->l->t("No temporary profile picture available, try again") + 'message' => $this->l10n->t('No temporary profile picture available, try again') ]], Http::STATUS_BAD_REQUEST); } - $image = new \OCP\Image(); + $image = new Image(); $image->loadFromData($tmpAvatar); $image->crop($crop['x'], $crop['y'], (int)round($crop['w']), (int)round($crop['h'])); try { @@ -356,12 +347,12 @@ class AvatarController extends Controller { // Clean up $this->cache->remove('tmpAvatar'); return new JSONResponse(['status' => 'success']); - } catch (\OC\NotSquareException $e) { - return new JSONResponse(['data' => ['message' => $this->l->t('Crop is not square')]], + } catch (NotSquareException $e) { + return new JSONResponse(['data' => ['message' => $this->l10n->t('Crop is not square')]], Http::STATUS_BAD_REQUEST); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']); - return new JSONResponse(['data' => ['message' => $this->l->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST); + return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST); } } } diff --git a/core/Controller/CSRFTokenController.php b/core/Controller/CSRFTokenController.php index 16288a8b318..edf7c26e94c 100644 --- a/core/Controller/CSRFTokenController.php +++ b/core/Controller/CSRFTokenController.php @@ -3,49 +3,44 @@ declare(strict_types=1); /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Security\CSRF\CsrfTokenManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; class CSRFTokenController extends Controller { - private CsrfTokenManager $tokenManager; - - public function __construct(string $appName, IRequest $request, - CsrfTokenManager $tokenManager) { + public function __construct( + string $appName, + IRequest $request, + private CsrfTokenManager $tokenManager, + ) { parent::__construct($appName, $request); - $this->tokenManager = $tokenManager; } /** - * @NoAdminRequired - * @NoCSRFRequired - * @PublicPage + * Returns a new CSRF token. + * + * @return JSONResponse<Http::STATUS_OK, array{token: string}, array{}>|JSONResponse<Http::STATUS_FORBIDDEN, list<empty>, array{}> + * + * 200: CSRF token returned + * 403: Strict cookie check failed + * + * @NoTwoFactorRequired */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/csrftoken')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function index(): JSONResponse { if (!$this->request->passesStrictCookieCheck()) { return new JSONResponse([], Http::STATUS_FORBIDDEN); diff --git a/core/Controller/ClientFlowLoginController.php b/core/Controller/ClientFlowLoginController.php index 85a793bd92b..4464af890c4 100644 --- a/core/Controller/ClientFlowLoginController.php +++ b/core/Controller/ClientFlowLoginController.php @@ -1,51 +1,36 @@ <?php + /** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Mario Danic <mario@lovelyhq.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author RussellAult <RussellAult@users.noreply.github.com> - * @author Sergej Nikolaev <kinolaev@gmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Authentication\Events\AppPasswordCreatedEvent; -use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Exceptions\PasswordlessTokenException; use OC\Authentication\Token\IProvider; -use OC\Authentication\Token\IToken; use OCA\OAuth2\Db\AccessToken; use OCA\OAuth2\Db\AccessTokenMapper; use OCA\OAuth2\Db\ClientMapper; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UseSession; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\StandaloneTemplateResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\Defaults; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; use OCP\ISession; @@ -56,50 +41,32 @@ use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; use OCP\Session\Exceptions\SessionNotAvailableException; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class ClientFlowLoginController extends Controller { - private IUserSession $userSession; - private IL10N $l10n; - private Defaults $defaults; - private ISession $session; - private IProvider $tokenProvider; - private ISecureRandom $random; - private IURLGenerator $urlGenerator; - private ClientMapper $clientMapper; - private AccessTokenMapper $accessTokenMapper; - private ICrypto $crypto; - private IEventDispatcher $eventDispatcher; - public const STATE_NAME = 'client.flow.state.token'; - public function __construct(string $appName, - IRequest $request, - IUserSession $userSession, - IL10N $l10n, - Defaults $defaults, - ISession $session, - IProvider $tokenProvider, - ISecureRandom $random, - IURLGenerator $urlGenerator, - ClientMapper $clientMapper, - AccessTokenMapper $accessTokenMapper, - ICrypto $crypto, - IEventDispatcher $eventDispatcher) { + public function __construct( + string $appName, + IRequest $request, + private IUserSession $userSession, + private IL10N $l10n, + private Defaults $defaults, + private ISession $session, + private IProvider $tokenProvider, + private ISecureRandom $random, + private IURLGenerator $urlGenerator, + private ClientMapper $clientMapper, + private AccessTokenMapper $accessTokenMapper, + private ICrypto $crypto, + private IEventDispatcher $eventDispatcher, + private ITimeFactory $timeFactory, + private IConfig $config, + ) { parent::__construct($appName, $request); - $this->userSession = $userSession; - $this->l10n = $l10n; - $this->defaults = $defaults; - $this->session = $session; - $this->tokenProvider = $tokenProvider; - $this->random = $random; - $this->urlGenerator = $urlGenerator; - $this->clientMapper = $clientMapper; - $this->accessTokenMapper = $accessTokenMapper; - $this->crypto = $crypto; - $this->eventDispatcher = $eventDispatcher; } private function getClientName(): string { - $userAgent = $this->request->getHeader('USER_AGENT'); + $userAgent = $this->request->getHeader('user-agent'); return $userAgent !== '' ? $userAgent : 'unknown'; } @@ -124,12 +91,11 @@ class ClientFlowLoginController extends Controller { return $response; } - /** - * @PublicPage - * @NoCSRFRequired - */ + #[PublicPage] + #[NoCSRFRequired] #[UseSession] - public function showAuthPickerPage(string $clientIdentifier = '', string $user = '', int $direct = 0): StandaloneTemplateResponse { + #[FrontpageRoute(verb: 'GET', url: '/login/flow')] + public function showAuthPickerPage(string $clientIdentifier = '', string $user = '', int $direct = 0, string $providedRedirectUri = ''): StandaloneTemplateResponse { $clientName = $this->getClientName(); $client = null; if ($clientIdentifier !== '') { @@ -144,8 +110,8 @@ class ClientFlowLoginController extends Controller { $this->appName, 'error', [ - 'errors' => - [ + 'errors' + => [ [ 'error' => 'Access Forbidden', 'hint' => 'Invalid request', @@ -158,11 +124,11 @@ class ClientFlowLoginController extends Controller { $stateToken = $this->random->generate( 64, - ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS + ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS ); $this->session->set(self::STATE_NAME, $stateToken); - $csp = new Http\ContentSecurityPolicy(); + $csp = new ContentSecurityPolicy(); if ($client) { $csp->addAllowedFormActionDomain($client->getRedirectUri()); } else { @@ -182,6 +148,7 @@ class ClientFlowLoginController extends Controller { 'oauthState' => $this->session->get('oauth.state'), 'user' => $user, 'direct' => $direct, + 'providedRedirectUri' => $providedRedirectUri, ], 'guest' ); @@ -191,14 +158,18 @@ class ClientFlowLoginController extends Controller { } /** - * @NoAdminRequired - * @NoCSRFRequired * @NoSameSiteCookieRequired */ + #[NoAdminRequired] + #[NoCSRFRequired] #[UseSession] - public function grantPage(string $stateToken = '', - string $clientIdentifier = '', - int $direct = 0): StandaloneTemplateResponse { + #[FrontpageRoute(verb: 'GET', url: '/login/flow/grant')] + public function grantPage( + string $stateToken = '', + string $clientIdentifier = '', + int $direct = 0, + string $providedRedirectUri = '', + ): Response { if (!$this->isValidToken($stateToken)) { return $this->stateTokenForbiddenResponse(); } @@ -210,7 +181,7 @@ class ClientFlowLoginController extends Controller { $clientName = $client->getName(); } - $csp = new Http\ContentSecurityPolicy(); + $csp = new ContentSecurityPolicy(); if ($client) { $csp->addAllowedFormActionDomain($client->getRedirectUri()); } else { @@ -234,6 +205,7 @@ class ClientFlowLoginController extends Controller { 'serverHost' => $this->getServerPath(), 'oauthState' => $this->session->get('oauth.state'), 'direct' => $direct, + 'providedRedirectUri' => $providedRedirectUri, ], 'guest' ); @@ -242,14 +214,15 @@ class ClientFlowLoginController extends Controller { return $response; } - /** - * @NoAdminRequired - * - * @return Http\RedirectResponse|Response - */ + #[NoAdminRequired] #[UseSession] - public function generateAppPassword(string $stateToken, - string $clientIdentifier = '') { + #[PasswordConfirmationRequired(strict: false)] + #[FrontpageRoute(verb: 'POST', url: '/login/flow')] + public function generateAppPassword( + string $stateToken, + string $clientIdentifier = '', + string $providedRedirectUri = '', + ): Response { if (!$this->isValidToken($stateToken)) { $this->session->remove(self::STATE_NAME); return $this->stateTokenForbiddenResponse(); @@ -286,7 +259,7 @@ class ClientFlowLoginController extends Controller { $clientName = $client->getName(); } - $token = $this->random->generate(72, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS); + $token = $this->random->generate(72, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); $uid = $this->userSession->getUser()->getUID(); $generatedToken = $this->tokenProvider->generateToken( $token, @@ -299,15 +272,28 @@ class ClientFlowLoginController extends Controller { ); if ($client) { - $code = $this->random->generate(128, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS); + $code = $this->random->generate(128, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); $accessToken = new AccessToken(); $accessToken->setClientId($client->getId()); $accessToken->setEncryptedToken($this->crypto->encrypt($token, $code)); $accessToken->setHashedCode(hash('sha512', $code)); $accessToken->setTokenId($generatedToken->getId()); + $accessToken->setCodeCreatedAt($this->timeFactory->now()->getTimestamp()); $this->accessTokenMapper->insert($accessToken); + $enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false); + $redirectUri = $client->getRedirectUri(); + if ($enableOcClients && $redirectUri === 'http://localhost:*') { + // Sanity check untrusted redirect URI provided by the client first + if (!preg_match('/^http:\/\/localhost:[0-9]+$/', $providedRedirectUri)) { + $response = new Response(); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + $redirectUri = $providedRedirectUri; + } if (parse_url($redirectUri, PHP_URL_QUERY)) { $redirectUri .= '&'; @@ -332,12 +318,11 @@ class ClientFlowLoginController extends Controller { new AppPasswordCreatedEvent($generatedToken) ); - return new Http\RedirectResponse($redirectUri); + return new RedirectResponse($redirectUri); } - /** - * @PublicPage - */ + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: '/login/flow/apptoken')] public function apptokenRedirect(string $stateToken, string $user, string $password): Response { if (!$this->isValidToken($stateToken)) { return $this->stateTokenForbiddenResponse(); @@ -362,21 +347,21 @@ class ClientFlowLoginController extends Controller { } $redirectUri = 'nc://login/server:' . $this->getServerPath() . '&user:' . urlencode($user) . '&password:' . urlencode($password); - return new Http\RedirectResponse($redirectUri); + return new RedirectResponse($redirectUri); } private function getServerPath(): string { $serverPostfix = ''; - if (strpos($this->request->getRequestUri(), '/index.php') !== false) { + if (str_contains($this->request->getRequestUri(), '/index.php')) { $serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/index.php')); - } elseif (strpos($this->request->getRequestUri(), '/login/flow') !== false) { + } elseif (str_contains($this->request->getRequestUri(), '/login/flow')) { $serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/login/flow')); } $protocol = $this->request->getServerProtocol(); - if ($protocol !== "https") { + if ($protocol !== 'https') { $xForwardedProto = $this->request->getHeader('X-Forwarded-Proto'); $xForwardedSSL = $this->request->getHeader('X-Forwarded-Ssl'); if ($xForwardedProto === 'https' || $xForwardedSSL === 'on') { @@ -384,6 +369,6 @@ class ClientFlowLoginController extends Controller { } } - return $protocol . "://" . $this->request->getServerHost() . $serverPostfix; + return $protocol . '://' . $this->request->getServerHost() . $serverPostfix; } } diff --git a/core/Controller/ClientFlowLoginV2Controller.php b/core/Controller/ClientFlowLoginV2Controller.php index 613829787b4..8c0c1e8179d 100644 --- a/core/Controller/ClientFlowLoginV2Controller.php +++ b/core/Controller/ClientFlowLoginV2Controller.php @@ -3,41 +3,30 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; -use OC\Authentication\Exceptions\InvalidTokenException; use OC\Core\Db\LoginFlowV2; +use OC\Core\Exception\LoginFlowV2ClientForbiddenException; use OC\Core\Exception\LoginFlowV2NotFoundException; +use OC\Core\ResponseDefinitions; use OC\Core\Service\LoginFlowV2Service; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UseSession; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\StandaloneTemplateResponse; +use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Defaults; use OCP\IL10N; use OCP\IRequest; @@ -46,45 +35,46 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserSession; use OCP\Security\ISecureRandom; +use OCP\Server; +/** + * @psalm-import-type CoreLoginFlowV2Credentials from ResponseDefinitions + * @psalm-import-type CoreLoginFlowV2 from ResponseDefinitions + */ class ClientFlowLoginV2Controller extends Controller { public const TOKEN_NAME = 'client.flow.v2.login.token'; public const STATE_NAME = 'client.flow.v2.state.token'; - - private LoginFlowV2Service $loginFlowV2Service; - private IURLGenerator $urlGenerator; - private IUserSession $userSession; - private ISession $session; - private ISecureRandom $random; - private Defaults $defaults; - private ?string $userId; - private IL10N $l10n; - - public function __construct(string $appName, - IRequest $request, - LoginFlowV2Service $loginFlowV2Service, - IURLGenerator $urlGenerator, - ISession $session, - IUserSession $userSession, - ISecureRandom $random, - Defaults $defaults, - ?string $userId, - IL10N $l10n) { + // Denotes that the session was created for the login flow and should therefore be ephemeral. + public const EPHEMERAL_NAME = 'client.flow.v2.state.ephemeral'; + + public function __construct( + string $appName, + IRequest $request, + private LoginFlowV2Service $loginFlowV2Service, + private IURLGenerator $urlGenerator, + private ISession $session, + private IUserSession $userSession, + private ISecureRandom $random, + private Defaults $defaults, + private ?string $userId, + private IL10N $l10n, + ) { parent::__construct($appName, $request); - $this->loginFlowV2Service = $loginFlowV2Service; - $this->urlGenerator = $urlGenerator; - $this->session = $session; - $this->userSession = $userSession; - $this->random = $random; - $this->defaults = $defaults; - $this->userId = $userId; - $this->l10n = $l10n; } /** - * @NoCSRFRequired - * @PublicPage + * Poll the login flow credentials + * + * @param string $token Token of the flow + * @return JSONResponse<Http::STATUS_OK, CoreLoginFlowV2Credentials, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Login flow credentials returned + * 404: Login flow not found or completed */ + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: '/login/v2/poll')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function poll(string $token): JSONResponse { try { $creds = $this->loginFlowV2Service->poll($token); @@ -92,15 +82,15 @@ class ClientFlowLoginV2Controller extends Controller { return new JSONResponse([], Http::STATUS_NOT_FOUND); } - return new JSONResponse($creds); + return new JSONResponse($creds->jsonSerialize()); } - /** - * @NoCSRFRequired - * @PublicPage - */ + #[NoCSRFRequired] + #[PublicPage] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] #[UseSession] - public function landing(string $token, $user = ''): Response { + #[FrontpageRoute(verb: 'GET', url: '/login/v2/flow/{token}')] + public function landing(string $token, $user = '', int $direct = 0): Response { if (!$this->loginFlowV2Service->startLoginFlow($token)) { return $this->loginTokenForbiddenResponse(); } @@ -108,25 +98,27 @@ class ClientFlowLoginV2Controller extends Controller { $this->session->set(self::TOKEN_NAME, $token); return new RedirectResponse( - $this->urlGenerator->linkToRouteAbsolute('core.ClientFlowLoginV2.showAuthPickerPage', ['user' => $user]) + $this->urlGenerator->linkToRouteAbsolute('core.ClientFlowLoginV2.showAuthPickerPage', ['user' => $user, 'direct' => $direct]) ); } - /** - * @NoCSRFRequired - * @PublicPage - */ + #[NoCSRFRequired] + #[PublicPage] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] #[UseSession] - public function showAuthPickerPage($user = ''): StandaloneTemplateResponse { + #[FrontpageRoute(verb: 'GET', url: '/login/v2/flow')] + public function showAuthPickerPage(string $user = '', int $direct = 0): StandaloneTemplateResponse { try { $flow = $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } $stateToken = $this->random->generate( 64, - ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS + ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS ); $this->session->set(self::STATE_NAME, $stateToken); @@ -139,18 +131,21 @@ class ClientFlowLoginV2Controller extends Controller { 'urlGenerator' => $this->urlGenerator, 'stateToken' => $stateToken, 'user' => $user, + 'direct' => $direct, ], 'guest' ); } /** - * @NoAdminRequired - * @NoCSRFRequired * @NoSameSiteCookieRequired */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] #[UseSession] - public function grantPage(?string $stateToken): StandaloneTemplateResponse { + #[FrontpageRoute(verb: 'GET', url: '/login/v2/grant')] + public function grantPage(?string $stateToken, int $direct = 0): StandaloneTemplateResponse { if ($stateToken === null) { return $this->stateTokenMissingResponse(); } @@ -162,6 +157,8 @@ class ClientFlowLoginV2Controller extends Controller { $flow = $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } /** @var IUser $user */ @@ -177,17 +174,17 @@ class ClientFlowLoginV2Controller extends Controller { 'instanceName' => $this->defaults->getName(), 'urlGenerator' => $this->urlGenerator, 'stateToken' => $stateToken, + 'direct' => $direct, ], 'guest' ); } - /** - * @PublicPage - */ + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: '/login/v2/apptoken')] public function apptokenRedirect(?string $stateToken, string $user, string $password) { if ($stateToken === null) { - return $this->loginTokenForbiddenResponse(); + return $this->stateTokenMissingResponse(); } if (!$this->isValidStateToken($stateToken)) { @@ -198,6 +195,8 @@ class ClientFlowLoginV2Controller extends Controller { $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } $loginToken = $this->session->get(self::TOKEN_NAME); @@ -207,7 +206,7 @@ class ClientFlowLoginV2Controller extends Controller { $this->session->remove(self::STATE_NAME); try { - $token = \OC::$server->get(\OC\Authentication\Token\IProvider::class)->getToken($password); + $token = Server::get(\OC\Authentication\Token\IProvider::class)->getToken($password); if ($token->getLoginName() !== $user) { throw new InvalidTokenException('login name does not match'); } @@ -228,10 +227,10 @@ class ClientFlowLoginV2Controller extends Controller { return $this->handleFlowDone($result); } - /** - * @NoAdminRequired - */ + #[NoAdminRequired] #[UseSession] + #[PasswordConfirmationRequired(strict: false)] + #[FrontpageRoute(verb: 'POST', url: '/login/v2/grant')] public function generateAppPassword(?string $stateToken): Response { if ($stateToken === null) { return $this->stateTokenMissingResponse(); @@ -244,6 +243,8 @@ class ClientFlowLoginV2Controller extends Controller { $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } $loginToken = $this->session->get(self::TOKEN_NAME); @@ -280,12 +281,19 @@ class ClientFlowLoginV2Controller extends Controller { } /** - * @NoCSRFRequired - * @PublicPage + * Init a login flow + * + * @return JSONResponse<Http::STATUS_OK, CoreLoginFlowV2, array{}> + * + * 200: Login flow init returned */ + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: '/login/v2')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function init(): JSONResponse { // Get client user agent - $userAgent = $this->request->getHeader('USER_AGENT'); + $userAgent = $this->request->getHeader('user-agent'); $tokens = $this->loginFlowV2Service->createTokens($userAgent); @@ -337,6 +345,7 @@ class ClientFlowLoginV2Controller extends Controller { /** * @return LoginFlowV2 * @throws LoginFlowV2NotFoundException + * @throws LoginFlowV2ClientForbiddenException */ private function getFlowByLoginToken(): LoginFlowV2 { $currentToken = $this->session->get(self::TOKEN_NAME); @@ -360,12 +369,25 @@ class ClientFlowLoginV2Controller extends Controller { return $response; } + private function loginTokenForbiddenClientResponse(): StandaloneTemplateResponse { + $response = new StandaloneTemplateResponse( + $this->appName, + '403', + [ + 'message' => $this->l10n->t('Please use original client'), + ], + 'guest' + ); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + private function getServerPath(): string { $serverPostfix = ''; - if (strpos($this->request->getRequestUri(), '/index.php') !== false) { + if (str_contains($this->request->getRequestUri(), '/index.php')) { $serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/index.php')); - } elseif (strpos($this->request->getRequestUri(), '/login/v2') !== false) { + } elseif (str_contains($this->request->getRequestUri(), '/login/v2')) { $serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/login/v2')); } diff --git a/core/Controller/CollaborationResourcesController.php b/core/Controller/CollaborationResourcesController.php index 659ff32baee..e160d733176 100644 --- a/core/Controller/CollaborationResourcesController.php +++ b/core/Controller/CollaborationResourcesController.php @@ -3,32 +3,17 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OC\Core\Controller; use Exception; +use OC\Core\ResponseDefinitions; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\Collaboration\Resources\CollectionException; @@ -40,23 +25,19 @@ use OCP\IRequest; use OCP\IUserSession; use Psr\Log\LoggerInterface; +/** + * @psalm-import-type CoreResource from ResponseDefinitions + * @psalm-import-type CoreCollection from ResponseDefinitions + */ class CollaborationResourcesController extends OCSController { - private IManager $manager; - private IUserSession $userSession; - private LoggerInterface $logger; - public function __construct( string $appName, IRequest $request, - IManager $manager, - IUserSession $userSession, - LoggerInterface $logger + private IManager $manager, + private IUserSession $userSession, + private LoggerInterface $logger, ) { parent::__construct($appName, $request); - - $this->manager = $manager; - $this->userSession = $userSession; - $this->logger = $logger; } /** @@ -75,11 +56,16 @@ class CollaborationResourcesController extends OCSController { } /** - * @NoAdminRequired + * Get a collection * - * @param int $collectionId - * @return DataResponse + * @param int $collectionId ID of the collection + * @return DataResponse<Http::STATUS_OK, CoreCollection, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}> + * + * 200: Collection returned + * 404: Collection not found */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/resources/collections/{collectionId}', root: '/collaboration')] public function listCollection(int $collectionId): DataResponse { try { $collection = $this->getCollection($collectionId); @@ -91,11 +77,16 @@ class CollaborationResourcesController extends OCSController { } /** - * @NoAdminRequired + * Search for collections * - * @param string $filter - * @return DataResponse + * @param string $filter Filter collections + * @return DataResponse<Http::STATUS_OK, list<CoreCollection>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Collections returned + * 404: Collection not found */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/resources/collections/search/{filter}', root: '/collaboration')] public function searchCollections(string $filter): DataResponse { try { $collections = $this->manager->searchCollections($this->userSession->getUser(), $filter); @@ -107,13 +98,18 @@ class CollaborationResourcesController extends OCSController { } /** - * @NoAdminRequired + * Add a resource to a collection * - * @param int $collectionId - * @param string $resourceType - * @param string $resourceId - * @return DataResponse + * @param int $collectionId ID of the collection + * @param string $resourceType Name of the resource + * @param string $resourceId ID of the resource + * @return DataResponse<Http::STATUS_OK, CoreCollection, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}> + * + * 200: Collection returned + * 404: Collection not found or resource inaccessible */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/resources/collections/{collectionId}', root: '/collaboration')] public function addResource(int $collectionId, string $resourceType, string $resourceId): DataResponse { try { $collection = $this->getCollection($collectionId); @@ -136,13 +132,18 @@ class CollaborationResourcesController extends OCSController { } /** - * @NoAdminRequired + * Remove a resource from a collection * - * @param int $collectionId - * @param string $resourceType - * @param string $resourceId - * @return DataResponse + * @param int $collectionId ID of the collection + * @param string $resourceType Name of the resource + * @param string $resourceId ID of the resource + * @return DataResponse<Http::STATUS_OK, CoreCollection, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}> + * + * 200: Collection returned + * 404: Collection or resource not found */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/resources/collections/{collectionId}', root: '/collaboration')] public function removeResource(int $collectionId, string $resourceType, string $resourceId): DataResponse { try { $collection = $this->getCollection($collectionId); @@ -162,12 +163,17 @@ class CollaborationResourcesController extends OCSController { } /** - * @NoAdminRequired + * Get collections by resource + * + * @param string $resourceType Type of the resource + * @param string $resourceId ID of the resource + * @return DataResponse<Http::STATUS_OK, list<CoreCollection>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> * - * @param string $resourceType - * @param string $resourceId - * @return DataResponse + * 200: Collections returned + * 404: Resource not accessible */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/resources/{resourceType}/{resourceId}', root: '/collaboration')] public function getCollectionsByResource(string $resourceType, string $resourceId): DataResponse { try { $resource = $this->manager->getResourceForUser($resourceType, $resourceId, $this->userSession->getUser()); @@ -183,13 +189,19 @@ class CollaborationResourcesController extends OCSController { } /** - * @NoAdminRequired + * Create a collection for a resource * - * @param string $baseResourceType - * @param string $baseResourceId - * @param string $name - * @return DataResponse + * @param string $baseResourceType Type of the base resource + * @param string $baseResourceId ID of the base resource + * @param string $name Name of the collection + * @return DataResponse<Http::STATUS_OK, CoreCollection, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}> + * + * 200: Collection returned + * 400: Creating collection is not possible + * 404: Resource inaccessible */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/resources/{baseResourceType}/{baseResourceId}', root: '/collaboration')] public function createCollectionOnResource(string $baseResourceType, string $baseResourceId, string $name): DataResponse { if (!isset($name[0]) || isset($name[64])) { return new DataResponse([], Http::STATUS_BAD_REQUEST); @@ -212,12 +224,17 @@ class CollaborationResourcesController extends OCSController { } /** - * @NoAdminRequired + * Rename a collection * - * @param int $collectionId - * @param string $collectionName - * @return DataResponse + * @param int $collectionId ID of the collection + * @param string $collectionName New name + * @return DataResponse<Http::STATUS_OK, CoreCollection, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}> + * + * 200: Collection returned + * 404: Collection not found */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/resources/collections/{collectionId}', root: '/collaboration')] public function renameCollection(int $collectionId, string $collectionName): DataResponse { try { $collection = $this->getCollection($collectionId); @@ -230,6 +247,9 @@ class CollaborationResourcesController extends OCSController { return $this->respondCollection($collection); } + /** + * @return DataResponse<Http::STATUS_OK, CoreCollection, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, list<empty>, array{}> + */ protected function respondCollection(ICollection $collection): DataResponse { try { return new DataResponse($this->prepareCollection($collection)); @@ -241,6 +261,9 @@ class CollaborationResourcesController extends OCSController { } } + /** + * @return list<CoreCollection> + */ protected function prepareCollections(array $collections): array { $result = []; @@ -256,6 +279,9 @@ class CollaborationResourcesController extends OCSController { return $result; } + /** + * @return CoreCollection + */ protected function prepareCollection(ICollection $collection): array { if (!$collection->canAccess($this->userSession->getUser())) { throw new CollectionException('Can not access collection'); @@ -268,7 +294,10 @@ class CollaborationResourcesController extends OCSController { ]; } - protected function prepareResources(array $resources): ?array { + /** + * @return list<CoreResource> + */ + protected function prepareResources(array $resources): array { $result = []; foreach ($resources as $resource) { @@ -283,6 +312,9 @@ class CollaborationResourcesController extends OCSController { return $result; } + /** + * @return CoreResource + */ protected function prepareResource(IResource $resource): array { if (!$resource->canAccess($this->userSession->getUser())) { throw new ResourceException('Can not access resource'); diff --git a/core/Controller/ContactsMenuController.php b/core/Controller/ContactsMenuController.php index 87ed02362aa..d90ee8a1c61 100644 --- a/core/Controller/ContactsMenuController.php +++ b/core/Controller/ContactsMenuController.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; @@ -28,36 +10,37 @@ use Exception; use OC\Contacts\ContactsMenu\Manager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; use OCP\IUserSession; class ContactsMenuController extends Controller { - private Manager $manager; - private IUserSession $userSession; - - public function __construct(IRequest $request, IUserSession $userSession, Manager $manager) { + public function __construct( + IRequest $request, + private IUserSession $userSession, + private Manager $manager, + ) { parent::__construct('core', $request); - $this->userSession = $userSession; - $this->manager = $manager; } /** - * @NoAdminRequired - * * @return \JsonSerializable[] * @throws Exception */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'POST', url: '/contactsmenu/contacts')] public function index(?string $filter = null): array { return $this->manager->getEntries($this->userSession->getUser(), $filter); } /** - * @NoAdminRequired - * * @return JSONResponse|\JsonSerializable * @throws Exception */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'POST', url: '/contactsmenu/findOne')] public function findOne(int $shareType, string $shareWith) { $contact = $this->manager->findOne($this->userSession->getUser(), $shareType, $shareWith); diff --git a/core/Controller/CssController.php b/core/Controller/CssController.php index 4cd2996835e..37e7edc530f 100644 --- a/core/Controller/CssController.php +++ b/core/Controller/CssController.php @@ -3,36 +3,18 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, John Molakvoæ (skjnldsv@protonmail.com) - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Files\AppData\Factory; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\Response; @@ -43,29 +25,31 @@ use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; use OCP\IRequest; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class CssController extends Controller { protected IAppData $appData; - protected ITimeFactory $timeFactory; - public function __construct(string $appName, - IRequest $request, - Factory $appDataFactory, - ITimeFactory $timeFactory) { + public function __construct( + string $appName, + IRequest $request, + Factory $appDataFactory, + protected ITimeFactory $timeFactory, + ) { parent::__construct($appName, $request); $this->appData = $appDataFactory->get('css'); - $this->timeFactory = $timeFactory; } /** - * @PublicPage - * @NoCSRFRequired * @NoSameSiteCookieRequired * * @param string $fileName css filename with extension * @param string $appName css folder name * @return FileDisplayResponse|NotFoundResponse */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/css/{appName}/{fileName}')] public function getCss(string $fileName, string $appName): Response { try { $folder = $this->appData->getFolder($appName); @@ -81,13 +65,12 @@ class CssController extends Controller { } $ttl = 31536000; - $response->addHeader('Cache-Control', 'max-age='.$ttl.', immutable'); + $response->addHeader('Cache-Control', 'max-age=' . $ttl . ', immutable'); $expires = new \DateTime(); $expires->setTimestamp($this->timeFactory->getTime()); - $expires->add(new \DateInterval('PT'.$ttl.'S')); + $expires->add(new \DateInterval('PT' . $ttl . 'S')); $response->addHeader('Expires', $expires->format(\DateTime::RFC1123)); - $response->addHeader('Pragma', 'cache'); return $response; } @@ -101,7 +84,7 @@ class CssController extends Controller { private function getFile(ISimpleFolder $folder, string $fileName, bool &$gzip): ISimpleFile { $encoding = $this->request->getHeader('Accept-Encoding'); - if (strpos($encoding, 'gzip') !== false) { + if (str_contains($encoding, 'gzip')) { try { $gzip = true; return $folder->getFile($fileName . '.gzip'); # Safari doesn't like .gz diff --git a/core/Controller/ErrorController.php b/core/Controller/ErrorController.php index 550b320a989..d80dc3f76eb 100644 --- a/core/Controller/ErrorController.php +++ b/core/Controller/ErrorController.php @@ -3,37 +3,25 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; +use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\TemplateResponse; -class ErrorController extends \OCP\AppFramework\Controller { - /** - * @PublicPage - * @NoCSRFRequired - */ +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class ErrorController extends Controller { + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: 'error/403')] public function error403(): TemplateResponse { $response = new TemplateResponse( 'core', @@ -45,10 +33,9 @@ class ErrorController extends \OCP\AppFramework\Controller { return $response; } - /** - * @PublicPage - * @NoCSRFRequired - */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: 'error/404')] public function error404(): TemplateResponse { $response = new TemplateResponse( 'core', diff --git a/core/Controller/GuestAvatarController.php b/core/Controller/GuestAvatarController.php index dc4f81bd643..711158e0708 100644 --- a/core/Controller/GuestAvatarController.php +++ b/core/Controller/GuestAvatarController.php @@ -1,30 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2019, Michael Weimann <mail@michael-weimann.eu> - * - * @author Michael Weimann <mail@michael-weimann.eu> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\Response; use OCP\IAvatarManager; use OCP\IRequest; use Psr\Log\LoggerInterface; @@ -33,35 +22,34 @@ use Psr\Log\LoggerInterface; * This controller handles guest avatar requests. */ class GuestAvatarController extends Controller { - private LoggerInterface $logger; - private IAvatarManager $avatarManager; - /** * GuestAvatarController constructor. */ public function __construct( string $appName, IRequest $request, - IAvatarManager $avatarManager, - LoggerInterface $logger + private IAvatarManager $avatarManager, + private LoggerInterface $logger, ) { parent::__construct($appName, $request); - $this->avatarManager = $avatarManager; - $this->logger = $logger; } /** - * Returns a guest avatar image response. - * - * @PublicPage - * @NoCSRFRequired + * Returns a guest avatar image response * * @param string $guestName The guest name, e.g. "Albert" - * @param string $size The desired avatar size, e.g. 64 for 64x64px - * @return FileDisplayResponse|Http\Response + * @param 64|512 $size The desired avatar size, e.g. 64 for 64x64px + * @param bool|null $darkTheme Return dark avatar + * @return FileDisplayResponse<Http::STATUS_OK|Http::STATUS_CREATED, array{Content-Type: string, X-NC-IsCustomAvatar: int}>|Response<Http::STATUS_INTERNAL_SERVER_ERROR, array{}> + * + * 200: Custom avatar returned + * 201: Avatar returned */ - public function getAvatar(string $guestName, string $size, ?bool $darkTheme = false) { - $size = (int) $size; + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/avatar/guest/{guestName}/{size}')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getAvatar(string $guestName, int $size, ?bool $darkTheme = false) { $darkTheme = $darkTheme ?? false; if ($size <= 64) { @@ -83,13 +71,13 @@ class GuestAvatarController extends Controller { $resp = new FileDisplayResponse( $avatarFile, $avatar->isCustomAvatar() ? Http::STATUS_OK : Http::STATUS_CREATED, - ['Content-Type' => $avatarFile->getMimeType()] + ['Content-Type' => $avatarFile->getMimeType(), 'X-NC-IsCustomAvatar' => (int)$avatar->isCustomAvatar()] ); } catch (\Exception $e) { $this->logger->error('error while creating guest avatar', [ 'err' => $e, ]); - $resp = new Http\Response(); + $resp = new Response(); $resp->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); return $resp; } @@ -100,10 +88,20 @@ class GuestAvatarController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired + * Returns a dark guest avatar image response + * + * @param string $guestName The guest name, e.g. "Albert" + * @param 64|512 $size The desired avatar size, e.g. 64 for 64x64px + * @return FileDisplayResponse<Http::STATUS_OK|Http::STATUS_CREATED, array{Content-Type: string, X-NC-IsCustomAvatar: int}>|Response<Http::STATUS_INTERNAL_SERVER_ERROR, array{}> + * + * 200: Custom avatar returned + * 201: Avatar returned */ - public function getAvatarDark(string $guestName, string $size) { + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/avatar/guest/{guestName}/{size}/dark')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getAvatarDark(string $guestName, int $size) { return $this->getAvatar($guestName, $size, true); } } diff --git a/core/Controller/HoverCardController.php b/core/Controller/HoverCardController.php index 632cdd0d02f..236a81760ac 100644 --- a/core/Controller/HoverCardController.php +++ b/core/Controller/HoverCardController.php @@ -2,49 +2,45 @@ declare(strict_types=1); /** - * @copyright 2021 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Contacts\ContactsMenu\Manager; +use OC\Core\ResponseDefinitions; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; -use OCP\Contacts\ContactsMenu\IEntry; +use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\IUserSession; use OCP\Share\IShare; -class HoverCardController extends \OCP\AppFramework\OCSController { - private Manager $manager; - private IUserSession $userSession; - - public function __construct(IRequest $request, IUserSession $userSession, Manager $manager) { +/** + * @psalm-import-type CoreContactsAction from ResponseDefinitions + */ +class HoverCardController extends OCSController { + public function __construct( + IRequest $request, + private IUserSession $userSession, + private Manager $manager, + ) { parent::__construct('core', $request); - $this->userSession = $userSession; - $this->manager = $manager; } /** - * @NoAdminRequired + * Get the account details for a hovercard + * + * @param string $userId ID of the user + * @return DataResponse<Http::STATUS_OK, array{userId: string, displayName: string, actions: list<CoreContactsAction>}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Account details returned + * 404: Account not found */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/v1/{userId}', root: '/hovercard')] public function getUser(string $userId): DataResponse { $contact = $this->manager->findOne($this->userSession->getUser(), IShare::TYPE_USER, $userId); @@ -52,21 +48,18 @@ class HoverCardController extends \OCP\AppFramework\OCSController { return new DataResponse([], Http::STATUS_NOT_FOUND); } - $data = $this->entryToArray($contact); + $data = $contact->jsonSerialize(); $actions = $data['actions']; if ($data['topAction']) { array_unshift($actions, $data['topAction']); } + /** @var list<CoreContactsAction> $actions */ return new DataResponse([ 'userId' => $userId, 'displayName' => $contact->getFullName(), 'actions' => $actions, ]); } - - protected function entryToArray(IEntry $entry): array { - return json_decode(json_encode($entry), true); - } } diff --git a/core/Controller/JsController.php b/core/Controller/JsController.php index 885de5491e7..5754c554e50 100644 --- a/core/Controller/JsController.php +++ b/core/Controller/JsController.php @@ -3,36 +3,18 @@ declare(strict_types=1); /** - * @copyright 2017, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Files\AppData\Factory; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\Response; @@ -43,26 +25,31 @@ use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; use OCP\IRequest; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class JsController extends Controller { protected IAppData $appData; - protected ITimeFactory $timeFactory; - public function __construct($appName, IRequest $request, Factory $appDataFactory, ITimeFactory $timeFactory) { + public function __construct( + string $appName, + IRequest $request, + Factory $appDataFactory, + protected ITimeFactory $timeFactory, + ) { parent::__construct($appName, $request); $this->appData = $appDataFactory->get('js'); - $this->timeFactory = $timeFactory; } /** - * @PublicPage - * @NoCSRFRequired * @NoSameSiteCookieRequired * * @param string $fileName js filename with extension * @param string $appName js folder name * @return FileDisplayResponse|NotFoundResponse */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/js/{appName}/{fileName}')] public function getJs(string $fileName, string $appName): Response { try { $folder = $this->appData->getFolder($appName); @@ -78,13 +65,12 @@ class JsController extends Controller { } $ttl = 31536000; - $response->addHeader('Cache-Control', 'max-age='.$ttl.', immutable'); + $response->addHeader('Cache-Control', 'max-age=' . $ttl . ', immutable'); $expires = new \DateTime(); $expires->setTimestamp($this->timeFactory->getTime()); - $expires->add(new \DateInterval('PT'.$ttl.'S')); + $expires->add(new \DateInterval('PT' . $ttl . 'S')); $response->addHeader('Expires', $expires->format(\DateTime::RFC1123)); - $response->addHeader('Pragma', 'cache'); return $response; } @@ -99,7 +85,7 @@ class JsController extends Controller { private function getFile(ISimpleFolder $folder, string $fileName, bool &$gzip): ISimpleFile { $encoding = $this->request->getHeader('Accept-Encoding'); - if (strpos($encoding, 'gzip') !== false) { + if (str_contains($encoding, 'gzip')) { try { $gzip = true; return $folder->getFile($fileName . '.gzip'); # Safari doesn't like .gz diff --git a/core/Controller/LoginController.php b/core/Controller/LoginController.php index b93c6002ed5..5a21d27898f 100644 --- a/core/Controller/LoginController.php +++ b/core/Controller/LoginController.php @@ -3,34 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2017, Sandro Lutz <sandro.lutz@temparus.ch> - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Weimann <mail@michael-weimann.eu> - * @author Rayn0r <andrew@ilpss8.myfirewall.org> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Core\Controller; @@ -38,78 +13,70 @@ use OC\AppFramework\Http\Request; use OC\Authentication\Login\Chain; use OC\Authentication\Login\LoginData; use OC\Authentication\WebAuthn\Manager as WebAuthnManager; -use OC\Security\Bruteforce\Throttler; use OC\User\Session; use OC_App; +use OCA\User_LDAP\Configuration; +use OCA\User_LDAP\Helper; +use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +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\UseSession; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; use OCP\Defaults; use OCP\IConfig; -use OCP\IInitialStateService; use OCP\IL10N; use OCP\IRequest; use OCP\ISession; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; -use OCP\IUserSession; use OCP\Notification\IManager; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\ITrustedDomainHelper; +use OCP\Server; use OCP\Util; class LoginController extends Controller { public const LOGIN_MSG_INVALIDPASSWORD = 'invalidpassword'; public const LOGIN_MSG_USERDISABLED = 'userdisabled'; - - private IUserManager $userManager; - private IConfig $config; - private ISession $session; - /** @var IUserSession|Session */ - private $userSession; - private IURLGenerator $urlGenerator; - private Defaults $defaults; - private Throttler $throttler; - private IInitialStateService $initialStateService; - private WebAuthnManager $webAuthnManager; - private IManager $manager; - private IL10N $l10n; - - public function __construct(?string $appName, - IRequest $request, - IUserManager $userManager, - IConfig $config, - ISession $session, - IUserSession $userSession, - IURLGenerator $urlGenerator, - Defaults $defaults, - Throttler $throttler, - IInitialStateService $initialStateService, - WebAuthnManager $webAuthnManager, - IManager $manager, - IL10N $l10n) { + public const LOGIN_MSG_CSRFCHECKFAILED = 'csrfCheckFailed'; + public const LOGIN_MSG_INVALID_ORIGIN = 'invalidOrigin'; + + public function __construct( + ?string $appName, + IRequest $request, + private IUserManager $userManager, + private IConfig $config, + private ISession $session, + private Session $userSession, + private IURLGenerator $urlGenerator, + private Defaults $defaults, + private IThrottler $throttler, + private IInitialState $initialState, + private WebAuthnManager $webAuthnManager, + private IManager $manager, + private IL10N $l10n, + private IAppManager $appManager, + ) { parent::__construct($appName, $request); - $this->userManager = $userManager; - $this->config = $config; - $this->session = $session; - $this->userSession = $userSession; - $this->urlGenerator = $urlGenerator; - $this->defaults = $defaults; - $this->throttler = $throttler; - $this->initialStateService = $initialStateService; - $this->webAuthnManager = $webAuthnManager; - $this->manager = $manager; - $this->l10n = $l10n; } /** - * @NoAdminRequired - * * @return RedirectResponse */ + #[NoAdminRequired] #[UseSession] + #[FrontpageRoute(verb: 'GET', url: '/logout')] public function logout() { $loginToken = $this->request->getCookie('nc_token'); if (!is_null($loginToken)) { @@ -125,7 +92,10 @@ class LoginController extends Controller { $this->session->set('clearingExecutionContexts', '1'); $this->session->close(); - if (!$this->request->isUserAgent([Request::USER_AGENT_CHROME, Request::USER_AGENT_ANDROID_MOBILE_CHROME])) { + if ( + $this->request->getServerProtocol() === 'https' + && !$this->request->isUserAgent([Request::USER_AGENT_CHROME, Request::USER_AGENT_ANDROID_MOBILE_CHROME]) + ) { $response->addHeader('Clear-Site-Data', '"cache", "storage"'); } @@ -133,16 +103,17 @@ class LoginController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired - * * @param string $user * @param string $redirect_url * * @return TemplateResponse|RedirectResponse */ + #[NoCSRFRequired] + #[PublicPage] #[UseSession] - public function showLoginForm(string $user = null, string $redirect_url = null): Http\Response { + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + #[FrontpageRoute(verb: 'GET', url: '/login')] + public function showLoginForm(?string $user = null, ?string $redirect_url = null): Response { if ($this->userSession->isLoggedIn()) { return new RedirectResponse($this->urlGenerator->linkToDefaultPageUrl()); } @@ -156,19 +127,18 @@ class LoginController extends Controller { } if (is_array($loginMessages)) { [$errors, $messages] = $loginMessages; - $this->initialStateService->provideInitialState('core', 'loginMessages', $messages); - $this->initialStateService->provideInitialState('core', 'loginErrors', $errors); + $this->initialState->provideInitialState('loginMessages', $messages); + $this->initialState->provideInitialState('loginErrors', $errors); } $this->session->remove('loginMessages'); if ($user !== null && $user !== '') { - $this->initialStateService->provideInitialState('core', 'loginUsername', $user); + $this->initialState->provideInitialState('loginUsername', $user); } else { - $this->initialStateService->provideInitialState('core', 'loginUsername', ''); + $this->initialState->provideInitialState('loginUsername', ''); } - $this->initialStateService->provideInitialState( - 'core', + $this->initialState->provideInitialState( 'loginAutocomplete', $this->config->getSystemValue('login_form_autocomplete', true) === true ); @@ -176,21 +146,22 @@ class LoginController extends Controller { if (!empty($redirect_url)) { [$url, ] = explode('?', $redirect_url); if ($url !== $this->urlGenerator->linkToRoute('core.login.logout')) { - $this->initialStateService->provideInitialState('core', 'loginRedirectUrl', $redirect_url); + $this->initialState->provideInitialState('loginRedirectUrl', $redirect_url); } } - $this->initialStateService->provideInitialState( - 'core', + $this->initialState->provideInitialState( 'loginThrottleDelay', $this->throttler->getDelay($this->request->getRemoteAddress()) ); $this->setPasswordResetInitialState($user); - $this->initialStateService->provideInitialState('core', 'webauthn-available', $this->webAuthnManager->isWebAuthnAvailable()); + $this->setEmailStates(); + + $this->initialState->provideInitialState('webauthn-available', $this->webAuthnManager->isWebAuthnAvailable()); - $this->initialStateService->provideInitialState('core', 'hideLoginForm', $this->config->getSystemValueBool('hide_login_form', false)); + $this->initialState->provideInitialState('hideLoginForm', $this->config->getSystemValueBool('hide_login_form', false)); // OpenGraph Support: http://ogp.me/ Util::addHeader('meta', ['property' => 'og:title', 'content' => Util::sanitizeHTML($this->defaults->getName())]); @@ -200,13 +171,17 @@ class LoginController extends Controller { Util::addHeader('meta', ['property' => 'og:type', 'content' => 'website']); Util::addHeader('meta', ['property' => 'og:image', 'content' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'favicon-touch.png'))]); + // Add same-origin referrer policy so we can check for valid requests + Util::addHeader('meta', ['name' => 'referrer', 'content' => 'same-origin']); + $parameters = [ 'alt_login' => OC_App::getAlternativeLogIns(), 'pageTitle' => $this->l10n->t('Login'), ]; - $this->initialStateService->provideInitialState('core', 'countAlternativeLogins', count($parameters['alt_login'])); - $this->initialStateService->provideInitialState('core', 'alternativeLogins', $parameters['alt_login']); + $this->initialState->provideInitialState('countAlternativeLogins', count($parameters['alt_login'])); + $this->initialState->provideInitialState('alternativeLogins', $parameters['alt_login']); + $this->initialState->provideInitialState('loginTimeout', $this->config->getSystemValueInt('login_form_timeout', 5 * 60)); return new TemplateResponse( $this->appName, @@ -230,20 +205,39 @@ class LoginController extends Controller { $passwordLink = $this->config->getSystemValueString('lost_password_link', ''); - $this->initialStateService->provideInitialState( - 'core', + $this->initialState->provideInitialState( 'loginResetPasswordLink', $passwordLink ); - $this->initialStateService->provideInitialState( - 'core', + $this->initialState->provideInitialState( 'loginCanResetPassword', $this->canResetPassword($passwordLink, $user) ); } /** + * Sets the initial state of whether or not a user is allowed to login with their email + * initial state is passed in the array of 1 for email allowed and 0 for not allowed + */ + private function setEmailStates(): void { + $emailStates = []; // true: can login with email, false otherwise - default to true + + // check if user_ldap is enabled, and the required classes exist + if ($this->appManager->isAppLoaded('user_ldap') + && class_exists(Helper::class)) { + $helper = Server::get(Helper::class); + $allPrefixes = $helper->getServerConfigurationPrefixes(); + // check each LDAP server the user is connected too + foreach ($allPrefixes as $prefix) { + $emailConfig = new Configuration($prefix); + array_push($emailStates, $emailConfig->__get('ldapLoginFilterEmail')); + } + } + $this->initialState->provideInitialState('emailStates', $emailStates); + } + + /** * @param string|null $passwordLink * @param IUser|null $user * @@ -275,35 +269,49 @@ class LoginController extends Controller { $location = $this->urlGenerator->getAbsoluteURL($redirectUrl); // Deny the redirect if the URL contains a @ // This prevents unvalidated redirects like ?redirect_url=:user@domain.com - if (strpos($location, '@') === false) { + if (!str_contains($location, '@')) { return new RedirectResponse($location); } } return new RedirectResponse($this->urlGenerator->linkToDefaultPageUrl()); } - /** - * @PublicPage - * @NoCSRFRequired - * @BruteForceProtection(action=login) - * - * @return RedirectResponse - */ + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'login')] #[UseSession] - public function tryLogin(Chain $loginChain, - string $user, - string $password, - string $redirect_url = null, - string $timezone = '', - string $timezone_offset = ''): RedirectResponse { - if (!$this->request->passesCSRFCheck()) { + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + #[FrontpageRoute(verb: 'POST', url: '/login')] + public function tryLogin( + Chain $loginChain, + ITrustedDomainHelper $trustedDomainHelper, + string $user = '', + string $password = '', + ?string $redirect_url = null, + string $timezone = '', + string $timezone_offset = '', + ): RedirectResponse { + $error = ''; + + $origin = $this->request->getHeader('Origin'); + $throttle = true; + if ($origin === '' || !$trustedDomainHelper->isTrustedUrl($origin)) { + // Login attempt not from the same origin, + // We only allow this on the login flow but not on the UI login page. + // This could have come from someone malicious who tries to block a user by triggering the bruteforce protection. + $error = self::LOGIN_MSG_INVALID_ORIGIN; + $throttle = false; + } elseif (!$this->request->passesCSRFCheck()) { if ($this->userSession->isLoggedIn()) { // If the user is already logged in and the CSRF check does not pass then // simply redirect the user to the correct page as required. This is the // case when a user has already logged-in, in another tab. return $this->generateRedirect($redirect_url); } + $error = self::LOGIN_MSG_CSRFCHECKFAILED; + } + if ($error !== '') { // Clear any auth remnants like cookies to ensure a clean login // For the next attempt $this->userSession->logout(); @@ -311,13 +319,25 @@ class LoginController extends Controller { $user, $user, $redirect_url, - $this->l10n->t('Please try again') + $error, + $throttle, + ); + } + + $user = trim($user); + + if (strlen($user) > 255) { + return $this->createLoginFailedResponse( + $user, + $user, + $redirect_url, + $this->l10n->t('Unsupported email length (>255)') ); } $data = new LoginData( $this->request, - trim($user), + $user, $password, $redirect_url, $timezone, @@ -350,7 +370,12 @@ class LoginController extends Controller { * @return RedirectResponse */ private function createLoginFailedResponse( - $user, $originalUser, $redirect_url, string $loginMessage) { + $user, + $originalUser, + $redirect_url, + string $loginMessage, + bool $throttle = true, + ) { // Read current user and append if possible we need to // return the unmodified user otherwise we will leak the login name $args = $user !== null ? ['user' => $originalUser, 'direct' => 1] : []; @@ -360,32 +385,46 @@ class LoginController extends Controller { $response = new RedirectResponse( $this->urlGenerator->linkToRoute('core.login.showLoginForm', $args) ); - $response->throttle(['user' => substr($user, 0, 64)]); + if ($throttle) { + $response->throttle(['user' => substr($user, 0, 64)]); + } $this->session->set('loginMessages', [ [$loginMessage], [] ]); + return $response; } /** - * @NoAdminRequired - * @BruteForceProtection(action=sudo) + * Confirm the user password * * @license GNU AGPL version 3 or any later version * + * @param string $password The password of the user + * + * @return DataResponse<Http::STATUS_OK, array{lastLogin: int}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, list<empty>, array{}> + * + * 200: Password confirmation succeeded + * 403: Password confirmation failed */ + #[NoAdminRequired] + #[BruteForceProtection(action: 'sudo')] #[UseSession] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'POST', url: '/login/confirm')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function confirmPassword(string $password): DataResponse { $loginName = $this->userSession->getLoginName(); $loginResult = $this->userManager->checkPassword($loginName, $password); if ($loginResult === false) { $response = new DataResponse([], Http::STATUS_FORBIDDEN); - $response->throttle(); + $response->throttle(['loginName' => $loginName]); return $response; } $confirmTimestamp = time(); $this->session->set('last-password-confirm', $confirmTimestamp); + $this->throttler->resetDelay($this->request->getRemoteAddress(), 'sudo', ['loginName' => $loginName]); return new DataResponse(['lastLogin' => $confirmTimestamp], Http::STATUS_OK); } } diff --git a/core/Controller/LostController.php b/core/Controller/LostController.php index 6176e3cd5e5..d956f3427f2 100644 --- a/core/Controller/LostController.php +++ b/core/Controller/LostController.php @@ -1,42 +1,27 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bernhard Posselt <dev@bernhard-posselt.com> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Haertl <jus@bitgrid.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Rémy Jacquin <remy@remyj.fr> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Victor Dubiniuk <dubiniuk@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 OC\Core\Controller; use Exception; +use OC\Authentication\TwoFactorAuth\Manager; +use OC\Core\Events\BeforePasswordResetEvent; +use OC\Core\Events\PasswordResetEvent; +use OC\Core\Exception\ResetPasswordException; +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OC\User\Session; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; @@ -52,14 +37,11 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\Mail\IMailer; -use OCP\Security\VerificationToken\IVerificationToken; +use OCP\PreConditionNotMetException; use OCP\Security\VerificationToken\InvalidTokenException; -use OC\Authentication\TwoFactorAuth\Manager; -use OC\Core\Events\BeforePasswordResetEvent; -use OC\Core\Events\PasswordResetEvent; -use OC\Core\Exception\ResetPasswordException; -use OC\Security\RateLimiting\Exception\RateLimitExceededException; -use OC\Security\RateLimiting\Limiter; +use OCP\Security\VerificationToken\IVerificationToken; +use OCP\Server; +use OCP\Util; use Psr\Log\LoggerInterface; use function array_filter; use function count; @@ -72,63 +54,37 @@ use function reset; * * @package OC\Core\Controller */ +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class LostController extends Controller { - protected IURLGenerator $urlGenerator; - protected IUserManager $userManager; - protected Defaults $defaults; - protected IL10N $l10n; - protected string $from; - protected IManager $encryptionManager; - protected IConfig $config; - protected IMailer $mailer; - private LoggerInterface $logger; - private Manager $twoFactorManager; - private IInitialState $initialState; - private IVerificationToken $verificationToken; - private IEventDispatcher $eventDispatcher; - private Limiter $limiter; - public function __construct( string $appName, IRequest $request, - IURLGenerator $urlGenerator, - IUserManager $userManager, - Defaults $defaults, - IL10N $l10n, - IConfig $config, - string $defaultMailAddress, - IManager $encryptionManager, - IMailer $mailer, - LoggerInterface $logger, - Manager $twoFactorManager, - IInitialState $initialState, - IVerificationToken $verificationToken, - IEventDispatcher $eventDispatcher, - Limiter $limiter + private IURLGenerator $urlGenerator, + private IUserManager $userManager, + private Defaults $defaults, + private IL10N $l10n, + private IConfig $config, + protected string $defaultMailAddress, + private IManager $encryptionManager, + private IMailer $mailer, + private LoggerInterface $logger, + private Manager $twoFactorManager, + private IInitialState $initialState, + private IVerificationToken $verificationToken, + private IEventDispatcher $eventDispatcher, + private Limiter $limiter, ) { parent::__construct($appName, $request); - $this->urlGenerator = $urlGenerator; - $this->userManager = $userManager; - $this->defaults = $defaults; - $this->l10n = $l10n; - $this->from = $defaultMailAddress; - $this->encryptionManager = $encryptionManager; - $this->config = $config; - $this->mailer = $mailer; - $this->logger = $logger; - $this->twoFactorManager = $twoFactorManager; - $this->initialState = $initialState; - $this->verificationToken = $verificationToken; - $this->eventDispatcher = $eventDispatcher; - $this->limiter = $limiter; } /** * Someone wants to reset their password: - * - * @PublicPage - * @NoCSRFRequired */ + #[PublicPage] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'passwordResetEmail')] + #[AnonRateLimit(limit: 10, period: 300)] + #[FrontpageRoute(verb: 'GET', url: '/lostpassword/reset/form/{token}/{userId}')] public function resetform(string $token, string $userId): TemplateResponse { try { $this->checkPasswordResetToken($token, $userId); @@ -137,12 +93,14 @@ class LostController extends Controller { || ($e instanceof InvalidTokenException && !in_array($e->getCode(), [InvalidTokenException::TOKEN_NOT_FOUND, InvalidTokenException::USER_UNKNOWN])) ) { - return new TemplateResponse( + $response = new TemplateResponse( 'core', 'error', [ - "errors" => [["error" => $e->getMessage()]] + 'errors' => [['error' => $e->getMessage()]] ], TemplateResponse::RENDER_AS_GUEST ); + $response->throttle(); + return $response; } return new TemplateResponse('core', 'error', [ 'errors' => [['error' => $this->l10n->t('Password reset is disabled')]] @@ -186,17 +144,22 @@ class LostController extends Controller { return array_merge($data, ['status' => 'success']); } - /** - * @PublicPage - * @BruteForceProtection(action=passwordResetEmail) - * @AnonRateThrottle(limit=10, period=300) - */ + #[PublicPage] + #[BruteForceProtection(action: 'passwordResetEmail')] + #[AnonRateLimit(limit: 10, period: 300)] + #[FrontpageRoute(verb: 'POST', url: '/lostpassword/email')] public function email(string $user): JSONResponse { if ($this->config->getSystemValue('lost_password_link', '') !== '') { return new JSONResponse($this->error($this->l10n->t('Password reset is disabled'))); } - \OCP\Util::emitHook( + $user = trim($user); + + if (strlen($user) > 255) { + return new JSONResponse($this->error($this->l10n->t('Unsupported email length (>255)'))); + } + + Util::emitHook( '\OCA\Files_Sharing\API\Server2Server', 'preLoginNameUsedAsUserName', ['uid' => &$user] @@ -217,10 +180,11 @@ class LostController extends Controller { return $response; } - /** - * @PublicPage - */ - public function setPassword(string $token, string $userId, string $password, bool $proceed): array { + #[PublicPage] + #[BruteForceProtection(action: 'passwordResetEmail')] + #[AnonRateLimit(limit: 10, period: 300)] + #[FrontpageRoute(verb: 'POST', url: '/lostpassword/set/{token}/{userId}')] + public function setPassword(string $token, string $userId, string $password, bool $proceed): JSONResponse { if ($this->encryptionManager->isEnabled() && !$proceed) { $encryptionModules = $this->encryptionManager->getEncryptionModules(); foreach ($encryptionModules as $module) { @@ -228,7 +192,7 @@ class LostController extends Controller { $instance = call_user_func($module['callback']); // this way we can find out whether per-user keys are used or a system wide encryption key if ($instance->needDetailedAccessList()) { - return $this->error('', ['encryption' => true]); + return new JSONResponse($this->error('', ['encryption' => true])); } } } @@ -254,19 +218,23 @@ class LostController extends Controller { $this->twoFactorManager->clearTwoFactorPending($userId); $this->config->deleteUserValue($userId, 'core', 'lostpassword'); - @\OC::$server->getUserSession()->unsetMagicInCookie(); + @Server::get(Session::class)->unsetMagicInCookie(); } catch (HintException $e) { - return $this->error($e->getHint()); + $response = new JSONResponse($this->error($e->getHint())); + $response->throttle(); + return $response; } catch (Exception $e) { - return $this->error($e->getMessage()); + $response = new JSONResponse($this->error($e->getMessage())); + $response->throttle(); + return $response; } - return $this->success(['user' => $userId]); + return new JSONResponse($this->success(['user' => $userId])); } /** * @throws ResetPasswordException - * @throws \OCP\PreConditionNotMetException + * @throws PreConditionNotMetException */ protected function sendEmail(string $input): void { $user = $this->findUserByIdOrMail($input); @@ -313,7 +281,7 @@ class LostController extends Controller { try { $message = $this->mailer->createMessage(); $message->setTo([$email => $user->getDisplayName()]); - $message->setFrom([$this->from => $this->defaults->getName()]); + $message->setFrom([$this->defaultMailAddress => $this->defaults->getName()]); $message->useTemplate($emailTemplate); $this->mailer->send($message); } catch (Exception $e) { @@ -329,7 +297,7 @@ class LostController extends Controller { $user = $this->userManager->get($input); if ($user instanceof IUser) { if (!$user->isEnabled()) { - throw new ResetPasswordException('User ' . $user->getUID() . ' is disabled'); + throw new ResetPasswordException('Account ' . $user->getUID() . ' is disabled'); } return $user; diff --git a/core/Controller/NavigationController.php b/core/Controller/NavigationController.php index 6b994ca33f1..017061ef979 100644 --- a/core/Controller/NavigationController.php +++ b/core/Controller/NavigationController.php @@ -1,79 +1,78 @@ <?php + /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; +use OC\Core\ResponseDefinitions; 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\DataResponse; use OCP\AppFramework\OCSController; use OCP\INavigationManager; use OCP\IRequest; use OCP\IURLGenerator; +/** + * @psalm-import-type CoreNavigationEntry from ResponseDefinitions + */ class NavigationController extends OCSController { - private INavigationManager $navigationManager; - private IURLGenerator $urlGenerator; - - public function __construct(string $appName, IRequest $request, INavigationManager $navigationManager, IURLGenerator $urlGenerator) { + public function __construct( + string $appName, + IRequest $request, + private INavigationManager $navigationManager, + private IURLGenerator $urlGenerator, + ) { parent::__construct($appName, $request); - $this->navigationManager = $navigationManager; - $this->urlGenerator = $urlGenerator; } /** - * @NoAdminRequired - * @NoCSRFRequired + * Get the apps navigation + * + * @param bool $absolute Rewrite URLs to absolute ones + * @return DataResponse<Http::STATUS_OK, list<CoreNavigationEntry>, array{}>|DataResponse<Http::STATUS_NOT_MODIFIED, list<empty>, array{}> + * + * 200: Apps navigation returned + * 304: No apps navigation changed */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/navigation/apps', root: '/core')] public function getAppsNavigation(bool $absolute = false): DataResponse { $navigation = $this->navigationManager->getAll(); if ($absolute) { $navigation = $this->rewriteToAbsoluteUrls($navigation); } $navigation = array_values($navigation); - $etag = $this->generateETag($navigation); - if ($this->request->getHeader('If-None-Match') === $etag) { - return new DataResponse([], Http::STATUS_NOT_MODIFIED); - } $response = new DataResponse($navigation); - $response->setETag($etag); + $response->setETag($this->generateETag($navigation)); return $response; } /** - * @NoAdminRequired - * @NoCSRFRequired + * Get the settings navigation + * + * @param bool $absolute Rewrite URLs to absolute ones + * @return DataResponse<Http::STATUS_OK, list<CoreNavigationEntry>, array{}>|DataResponse<Http::STATUS_NOT_MODIFIED, list<empty>, array{}> + * + * 200: Apps navigation returned + * 304: No apps navigation changed */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/navigation/settings', root: '/core')] public function getSettingsNavigation(bool $absolute = false): DataResponse { $navigation = $this->navigationManager->getAll('settings'); if ($absolute) { $navigation = $this->rewriteToAbsoluteUrls($navigation); } $navigation = array_values($navigation); - $etag = $this->generateETag($navigation); - if ($this->request->getHeader('If-None-Match') === $etag) { - return new DataResponse([], Http::STATUS_NOT_MODIFIED); - } $response = new DataResponse($navigation); - $response->setETag($etag); + $response->setETag($this->generateETag($navigation)); return $response; } @@ -94,10 +93,11 @@ class NavigationController extends OCSController { */ private function rewriteToAbsoluteUrls(array $navigation): array { foreach ($navigation as &$entry) { - if (0 !== strpos($entry['href'], $this->urlGenerator->getBaseUrl())) { + /* If parse_url finds no host it means the URL is not absolute */ + if (!isset(\parse_url($entry['href'])['host'])) { $entry['href'] = $this->urlGenerator->getAbsoluteURL($entry['href']); } - if (0 !== strpos($entry['icon'], $this->urlGenerator->getBaseUrl())) { + if (!str_starts_with($entry['icon'], $this->urlGenerator->getBaseUrl())) { $entry['icon'] = $this->urlGenerator->getAbsoluteURL($entry['icon']); } } diff --git a/core/Controller/OCJSController.php b/core/Controller/OCJSController.php index fa13f21607c..083ad4b209f 100644 --- a/core/Controller/OCJSController.php +++ b/core/Controller/OCJSController.php @@ -1,40 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use bantu\IniGetWrapper\IniGetWrapper; +use OC\Authentication\Token\IProvider; use OC\CapabilitiesManager; +use OC\Files\FilenameValidator; use OC\Template\JSConfigHelper; use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\Defaults; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroupManager; use OCP\IInitialStateService; @@ -43,45 +29,58 @@ use OCP\ISession; use OCP\IURLGenerator; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\ServerVersion; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class OCJSController extends Controller { private JSConfigHelper $helper; - public function __construct(string $appName, - IRequest $request, - IFactory $l10nFactory, - Defaults $defaults, - IAppManager $appManager, - ISession $session, - IUserSession $userSession, - IConfig $config, - IGroupManager $groupManager, - IniGetWrapper $iniWrapper, - IURLGenerator $urlGenerator, - CapabilitiesManager $capabilitiesManager, - IInitialStateService $initialStateService) { + public function __construct( + string $appName, + IRequest $request, + IFactory $l10nFactory, + Defaults $defaults, + IAppManager $appManager, + ISession $session, + IUserSession $userSession, + IConfig $config, + IAppConfig $appConfig, + IGroupManager $groupManager, + IniGetWrapper $iniWrapper, + IURLGenerator $urlGenerator, + CapabilitiesManager $capabilitiesManager, + IInitialStateService $initialStateService, + IProvider $tokenProvider, + FilenameValidator $filenameValidator, + ServerVersion $serverVersion, + ) { parent::__construct($appName, $request); $this->helper = new JSConfigHelper( + $serverVersion, $l10nFactory->get('lib'), $defaults, $appManager, $session, $userSession->getUser(), $config, + $appConfig, $groupManager, $iniWrapper, $urlGenerator, $capabilitiesManager, - $initialStateService + $initialStateService, + $tokenProvider, + $filenameValidator, ); } /** - * @NoCSRFRequired * @NoTwoFactorRequired - * @PublicPage */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/core/js/oc.js')] public function getConfig(): DataDisplayResponse { $data = $this->helper->getConfig(); diff --git a/core/Controller/OCMController.php b/core/Controller/OCMController.php new file mode 100644 index 00000000000..2d3b99f431d --- /dev/null +++ b/core/Controller/OCMController.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Controller; + +use Exception; +use OCA\CloudFederationAPI\Capabilities; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataResponse; +use OCP\Capabilities\ICapability; +use OCP\IAppConfig; +use OCP\IRequest; +use OCP\Server; +use Psr\Container\ContainerExceptionInterface; +use Psr\Log\LoggerInterface; + +/** + * Controller about the endpoint /ocm-provider/ + * + * @since 28.0.0 + */ +class OCMController extends Controller { + public function __construct( + IRequest $request, + private readonly IAppConfig $appConfig, + private LoggerInterface $logger, + ) { + parent::__construct('core', $request); + } + + /** + * generate a OCMProvider with local data and send it as DataResponse. + * This replaces the old PHP file ocm-provider/index.php + * + * @psalm-suppress MoreSpecificReturnType + * @psalm-suppress LessSpecificReturnStatement + * @return DataResponse<Http::STATUS_OK, array{enabled: bool, apiVersion: string, endPoint: string, resourceTypes: list<array{name: string, shareTypes: list<string>, protocols: array{webdav: string}}>}, array{X-NEXTCLOUD-OCM-PROVIDERS: true, Content-Type: 'application/json'}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: OCM Provider details returned + * 500: OCM not supported + */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/ocm-provider/')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function discovery(): DataResponse { + try { + $cap = Server::get( + $this->appConfig->getValueString( + 'core', 'ocm_providers', + Capabilities::class, + lazy: true + ) + ); + + if (!($cap instanceof ICapability)) { + throw new Exception('loaded class does not implements OCP\Capabilities\ICapability'); + } + + return new DataResponse( + $cap->getCapabilities()['ocm'] ?? ['enabled' => false], + Http::STATUS_OK, + [ + 'X-NEXTCLOUD-OCM-PROVIDERS' => true, + 'Content-Type' => 'application/json' + ] + ); + } catch (ContainerExceptionInterface|Exception $e) { + $this->logger->error('issue during OCM discovery request', ['exception' => $e]); + + return new DataResponse( + ['message' => '/ocm-provider/ not supported'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/core/Controller/OCSController.php b/core/Controller/OCSController.php index b40776fcfb8..fb0280479c4 100644 --- a/core/Controller/OCSController.php +++ b/core/Controller/OCSController.php @@ -1,61 +1,41 @@ <?php + /** - * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\CapabilitiesManager; use OC\Security\IdentityProof\Manager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; +use OCP\ServerVersion; +use OCP\Util; class OCSController extends \OCP\AppFramework\OCSController { - private CapabilitiesManager $capabilitiesManager; - private IUserSession $userSession; - private IUserManager $userManager; - private Manager $keyManager; - - public function __construct(string $appName, - IRequest $request, - CapabilitiesManager $capabilitiesManager, - IUserSession $userSession, - IUserManager $userManager, - Manager $keyManager) { + public function __construct( + string $appName, + IRequest $request, + private CapabilitiesManager $capabilitiesManager, + private IUserSession $userSession, + private IUserManager $userManager, + private Manager $keyManager, + private ServerVersion $serverVersion, + ) { parent::__construct($appName, $request); - $this->capabilitiesManager = $capabilitiesManager; - $this->userSession = $userSession; - $this->userManager = $userManager; - $this->keyManager = $keyManager; } - /** - * @PublicPage - */ + #[PublicPage] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + #[ApiRoute(verb: 'GET', url: '/config', root: '')] public function getConfig(): DataResponse { $data = [ 'version' => '1.7', @@ -69,18 +49,23 @@ class OCSController extends \OCP\AppFramework\OCSController { } /** - * @PublicPage + * Get the capabilities + * + * @return DataResponse<Http::STATUS_OK, array{version: array{major: int, minor: int, micro: int, string: string, edition: '', extendedSupport: bool}, capabilities: array<string, mixed>}, array{}> + * + * 200: Capabilities returned */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/capabilities', root: '/cloud')] public function getCapabilities(): DataResponse { $result = []; - [$major, $minor, $micro] = \OCP\Util::getVersion(); $result['version'] = [ - 'major' => $major, - 'minor' => $minor, - 'micro' => $micro, - 'string' => \OC_Util::getVersionString(), + 'major' => $this->serverVersion->getMajorVersion(), + 'minor' => $this->serverVersion->getMinorVersion(), + 'micro' => $this->serverVersion->getPatchVersion(), + 'string' => $this->serverVersion->getVersionString(), 'edition' => '', - 'extendedSupport' => \OCP\Util::hasExtendedSupport() + 'extendedSupport' => Util::hasExtendedSupport() ]; if ($this->userSession->isLoggedIn()) { @@ -94,10 +79,10 @@ class OCSController extends \OCP\AppFramework\OCSController { return $response; } - /** - * @PublicPage - * @BruteForceProtection(action=login) - */ + #[PublicPage] + #[BruteForceProtection(action: 'login')] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + #[ApiRoute(verb: 'POST', url: '/check', root: '/person')] public function personCheck(string $login = '', string $password = ''): DataResponse { if ($login !== '' && $password !== '') { if ($this->userManager->checkPassword($login, $password)) { @@ -115,9 +100,9 @@ class OCSController extends \OCP\AppFramework\OCSController { return new DataResponse([], 101); } - /** - * @PublicPage - */ + #[PublicPage] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + #[ApiRoute(verb: 'GET', url: '/key/{cloudId}', root: '/identityproof')] public function getIdentityProof(string $cloudId): DataResponse { $userObject = $this->userManager->get($cloudId); @@ -129,6 +114,6 @@ class OCSController extends \OCP\AppFramework\OCSController { return new DataResponse($data); } - return new DataResponse(['User not found'], 404); + return new DataResponse(['Account not found'], 404); } } diff --git a/core/Controller/PreviewController.php b/core/Controller/PreviewController.php index 118f1b47752..aac49c06d57 100644 --- a/core/Controller/PreviewController.php +++ b/core/Controller/PreviewController.php @@ -3,73 +3,73 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; -use OCA\Files_Sharing\SharedStorage; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +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\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; use OCP\Files\File; use OCP\Files\IRootFolder; use OCP\Files\Node; use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; use OCP\IPreview; use OCP\IRequest; +use OCP\Preview\IMimeIconProvider; class PreviewController extends Controller { - private ?string $userId; - private IRootFolder $root; - private IPreview $preview; - - public function __construct(string $appName, - IRequest $request, - IPreview $preview, - IRootFolder $root, - ?string $userId + public function __construct( + string $appName, + IRequest $request, + private IPreview $preview, + private IRootFolder $root, + private ?string $userId, + private IMimeIconProvider $mimeIconProvider, ) { parent::__construct($appName, $request); - - $this->preview = $preview; - $this->root = $root; - $this->userId = $userId; } /** - * @NoAdminRequired - * @NoCSRFRequired + * Get a preview by file path + * + * @param string $file Path of the file + * @param int $x Width of the preview. A width of -1 will use the original image width. + * @param int $y Height of the preview. A height of -1 will use the original image height. + * @param bool $a Preserve the aspect ratio + * @param bool $forceIcon Force returning an icon + * @param 'fill'|'cover' $mode How to crop the image + * @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}> * - * @return DataResponse|FileDisplayResponse + * 200: Preview returned + * 303: Redirect to the mime icon url if mimeFallback is true + * 400: Getting preview is not possible + * 403: Getting preview is not allowed + * 404: Preview not found */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/core/preview.png')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getPreview( string $file = '', int $x = 32, int $y = 32, bool $a = false, bool $forceIcon = true, - string $mode = 'fill'): Http\Response { + string $mode = 'fill', + bool $mimeFallback = false): Response { if ($file === '' || $x === 0 || $y === 0) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } @@ -81,40 +81,55 @@ class PreviewController extends Controller { return new DataResponse([], Http::STATUS_NOT_FOUND); } - return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode); + return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode, $mimeFallback); } /** - * @NoAdminRequired - * @NoCSRFRequired + * Get a preview by file ID + * + * @param int $fileId ID of the file + * @param int $x Width of the preview. A width of -1 will use the original image width. + * @param int $y Height of the preview. A height of -1 will use the original image height. + * @param bool $a Preserve the aspect ratio + * @param bool $forceIcon Force returning an icon + * @param 'fill'|'cover' $mode How to crop the image + * @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}> * - * @return DataResponse|FileDisplayResponse + * 200: Preview returned + * 303: Redirect to the mime icon url if mimeFallback is true + * 400: Getting preview is not possible + * 403: Getting preview is not allowed + * 404: Preview not found */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/core/preview')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getPreviewByFileId( int $fileId = -1, int $x = 32, int $y = 32, bool $a = false, bool $forceIcon = true, - string $mode = 'fill') { + string $mode = 'fill', + bool $mimeFallback = false) { if ($fileId === -1 || $x === 0 || $y === 0) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } $userFolder = $this->root->getUserFolder($this->userId); - $nodes = $userFolder->getById($fileId); + $node = $userFolder->getFirstNodeById($fileId); - if (\count($nodes) === 0) { + if (!$node) { return new DataResponse([], Http::STATUS_NOT_FOUND); } - $node = array_pop($nodes); - - return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode); + return $this->fetchPreview($node, $x, $y, $a, $forceIcon, $mode, $mimeFallback); } /** - * @return DataResponse|FileDisplayResponse + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}> */ private function fetchPreview( Node $node, @@ -122,7 +137,8 @@ class PreviewController extends Controller { int $y, bool $a, bool $forceIcon, - string $mode) : Http\Response { + string $mode, + bool $mimeFallback = false) : Response { if (!($node instanceof File) || (!$forceIcon && !$this->preview->isAvailable($node))) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -130,12 +146,18 @@ class PreviewController extends Controller { return new DataResponse([], Http::STATUS_FORBIDDEN); } + if ($node->getId() <= 0) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + // Is this header is set it means our UI is doing a preview for no-download shares + // we check a header so we at least prevent people from using the link directly (obfuscation) + $isNextcloudPreview = $this->request->getHeader('x-nc-preview') === 'true'; $storage = $node->getStorage(); - if ($storage->instanceOfStorage(SharedStorage::class)) { - /** @var SharedStorage $storage */ + if ($isNextcloudPreview === false && $storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ $share = $storage->getShare(); - $attributes = $share->getAttributes(); - if ($attributes !== null && $attributes->getAttribute('permissions', 'download') === false) { + if (!$share->canSeeContent()) { return new DataResponse([], Http::STATUS_FORBIDDEN); } } @@ -148,9 +170,37 @@ class PreviewController extends Controller { $response->cacheFor(3600 * 24, false, true); return $response; } catch (NotFoundException $e) { + // If we have no preview enabled, we can redirect to the mime icon if any + if ($mimeFallback) { + if ($url = $this->mimeIconProvider->getMimeIconUrl($node->getMimeType())) { + return new RedirectResponse($url); + } + } + return new DataResponse([], Http::STATUS_NOT_FOUND); } catch (\InvalidArgumentException $e) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } } + + /** + * Get a preview by mime + * + * @param string $mime Mime type + * @return RedirectResponse<Http::STATUS_SEE_OTHER, array{}> + * + * 303: The mime icon url + */ + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'GET', url: '/core/mimeicon')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getMimeIconUrl(string $mime = 'application/octet-stream') { + $url = $this->mimeIconProvider->getMimeIconUrl($mime); + if ($url === null) { + $url = $this->mimeIconProvider->getMimeIconUrl('application/octet-stream'); + } + + return new RedirectResponse($url); + } } diff --git a/core/Controller/ProfileApiController.php b/core/Controller/ProfileApiController.php index a25ebaf846d..02979cb1649 100644 --- a/core/Controller/ProfileApiController.php +++ b/core/Controller/ProfileApiController.php @@ -3,75 +3,79 @@ declare(strict_types=1); /** - * @copyright 2021 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Core\Db\ProfileConfigMapper; +use OC\Core\ResponseDefinitions; +use OC\Profile\ProfileManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; use OCP\IRequest; +use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; -use OC\Profile\ProfileManager; +use OCP\Share\IManager; +/** + * @psalm-import-type CoreProfileData from ResponseDefinitions + */ class ProfileApiController extends OCSController { - private ProfileConfigMapper $configMapper; - private ProfileManager $profileManager; - private IUserManager $userManager; - private IUserSession $userSession; - public function __construct( IRequest $request, - ProfileConfigMapper $configMapper, - ProfileManager $profileManager, - IUserManager $userManager, - IUserSession $userSession + private IConfig $config, + private ITimeFactory $timeFactory, + private ProfileConfigMapper $configMapper, + private ProfileManager $profileManager, + private IUserManager $userManager, + private IUserSession $userSession, + private IManager $shareManager, ) { parent::__construct('core', $request); - $this->configMapper = $configMapper; - $this->profileManager = $profileManager; - $this->userManager = $userManager; - $this->userSession = $userSession; } /** - * @NoAdminRequired * @NoSubAdminRequired - * @PasswordConfirmationRequired + * + * Update the visibility of a parameter + * + * @param string $targetUserId ID of the user + * @param string $paramId ID of the parameter + * @param string $visibility New visibility + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSBadRequestException Updating visibility is not possible + * @throws OCSForbiddenException Not allowed to edit other users visibility + * @throws OCSNotFoundException Account not found + * + * 200: Visibility updated successfully */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + #[UserRateLimit(limit: 40, period: 600)] + #[ApiRoute(verb: 'PUT', url: '/{targetUserId}', root: '/profile')] public function setVisibility(string $targetUserId, string $paramId, string $visibility): DataResponse { $requestingUser = $this->userSession->getUser(); - $targetUser = $this->userManager->get($targetUserId); - - if (!$this->userManager->userExists($targetUserId)) { - throw new OCSNotFoundException('User does not exist'); + if ($requestingUser->getUID() !== $targetUserId) { + throw new OCSForbiddenException('People can only edit their own visibility settings'); } - if ($requestingUser !== $targetUser) { - throw new OCSForbiddenException('Users can only edit their own visibility settings'); + $targetUser = $this->userManager->get($targetUserId); + if (!$targetUser instanceof IUser) { + throw new OCSNotFoundException('Account does not exist'); } // Ensure that a profile config is created in the database @@ -79,7 +83,7 @@ class ProfileApiController extends OCSController { $config = $this->configMapper->get($targetUserId); if (!in_array($paramId, array_keys($config->getVisibilityMap()), true)) { - throw new OCSBadRequestException('User does not have a profile parameter with ID: ' . $paramId); + throw new OCSBadRequestException('Account does not have a profile parameter with ID: ' . $paramId); } $config->setVisibility($paramId, $visibility); @@ -87,4 +91,55 @@ class ProfileApiController extends OCSController { return new DataResponse(); } + + /** + * Get profile fields for another user + * + * @param string $targetUserId ID of the user + * @return DataResponse<Http::STATUS_OK, CoreProfileData, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}> + * + * 200: Profile data returned successfully + * 400: Profile is disabled + * 404: Account not found or disabled + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/{targetUserId}', root: '/profile')] + #[BruteForceProtection(action: 'user')] + #[UserRateLimit(limit: 30, period: 120)] + public function getProfileFields(string $targetUserId): DataResponse { + $targetUser = $this->userManager->get($targetUserId); + if (!$targetUser instanceof IUser) { + $response = new DataResponse(null, Http::STATUS_NOT_FOUND); + $response->throttle(); + return $response; + } + if (!$targetUser->isEnabled()) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + + if (!$this->profileManager->isProfileEnabled($targetUser)) { + return new DataResponse(null, Http::STATUS_BAD_REQUEST); + } + + $requestingUser = $this->userSession->getUser(); + if ($targetUser !== $requestingUser) { + if (!$this->shareManager->currentUserCanEnumerateTargetUser($requestingUser, $targetUser)) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + } + + $profileFields = $this->profileManager->getProfileFields($targetUser, $requestingUser); + + // Extend the profile information with timezone of the user + $timezoneStringTarget = $this->config->getUserValue($targetUser->getUID(), 'core', 'timezone') ?: $this->config->getSystemValueString('default_timezone', 'UTC'); + try { + $timezoneTarget = new \DateTimeZone($timezoneStringTarget); + } catch (\Throwable) { + $timezoneTarget = new \DateTimeZone('UTC'); + } + $profileFields['timezone'] = $timezoneTarget->getName(); // E.g. Europe/Berlin + $profileFields['timezoneOffset'] = $timezoneTarget->getOffset($this->timeFactory->now()); // In seconds E.g. 7200 + + return new DataResponse($profileFields); + } } diff --git a/core/Controller/ProfilePageController.php b/core/Controller/ProfilePageController.php deleted file mode 100644 index 4b710911482..00000000000 --- a/core/Controller/ProfilePageController.php +++ /dev/null @@ -1,130 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright 2021 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -namespace OC\Core\Controller; - -use OC\Profile\ProfileManager; -use OCP\Profile\BeforeTemplateRenderedEvent; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\TemplateResponse; -use OCP\AppFramework\Services\IInitialState; -use OCP\IRequest; -use OCP\IUser; -use OCP\IUserManager; -use OCP\IUserSession; -use OCP\Share\IManager as IShareManager; -use OCP\UserStatus\IManager as IUserStatusManager; -use OCP\EventDispatcher\IEventDispatcher; - -class ProfilePageController extends Controller { - private IInitialState $initialStateService; - private ProfileManager $profileManager; - private IShareManager $shareManager; - private IUserManager $userManager; - private IUserSession $userSession; - private IUserStatusManager $userStatusManager; - private IEventDispatcher $eventDispatcher; - - public function __construct( - $appName, - IRequest $request, - IInitialState $initialStateService, - ProfileManager $profileManager, - IShareManager $shareManager, - IUserManager $userManager, - IUserSession $userSession, - IUserStatusManager $userStatusManager, - IEventDispatcher $eventDispatcher - ) { - parent::__construct($appName, $request); - $this->initialStateService = $initialStateService; - $this->profileManager = $profileManager; - $this->shareManager = $shareManager; - $this->userManager = $userManager; - $this->userSession = $userSession; - $this->userStatusManager = $userStatusManager; - $this->eventDispatcher = $eventDispatcher; - } - - /** - * @PublicPage - * @NoCSRFRequired - * @NoAdminRequired - * @NoSubAdminRequired - */ - public function index(string $targetUserId): TemplateResponse { - $profileNotFoundTemplate = new TemplateResponse( - 'core', - '404-profile', - [], - TemplateResponse::RENDER_AS_GUEST, - ); - - $targetUser = $this->userManager->get($targetUserId); - if (!($targetUser instanceof IUser) || !$targetUser->isEnabled()) { - return $profileNotFoundTemplate; - } - $visitingUser = $this->userSession->getUser(); - - if (!$this->profileManager->isProfileEnabled($targetUser)) { - return $profileNotFoundTemplate; - } - - // Run user enumeration checks only if viewing another user's profile - if ($targetUser !== $visitingUser) { - if (!$this->shareManager->currentUserCanEnumerateTargetUser($visitingUser, $targetUser)) { - return $profileNotFoundTemplate; - } - } - - if ($visitingUser !== null) { - $userStatuses = $this->userStatusManager->getUserStatuses([$targetUserId]); - $status = $userStatuses[$targetUserId] ?? null; - if ($status !== null) { - $this->initialStateService->provideInitialState('status', [ - 'icon' => $status->getIcon(), - 'message' => $status->getMessage(), - ]); - } - } - - $this->initialStateService->provideInitialState( - 'profileParameters', - $this->profileManager->getProfileParams($targetUser, $visitingUser), - ); - - $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($targetUserId)); - - \OCP\Util::addScript('core', 'profile'); - - return new TemplateResponse( - 'core', - 'profile', - [], - $this->userSession->isLoggedIn() ? TemplateResponse::RENDER_AS_USER : TemplateResponse::RENDER_AS_PUBLIC, - ); - } -} diff --git a/core/Controller/RecommendedAppsController.php b/core/Controller/RecommendedAppsController.php index 5c73f3c5f5e..ba35bc8705e 100644 --- a/core/Controller/RecommendedAppsController.php +++ b/core/Controller/RecommendedAppsController.php @@ -3,51 +3,36 @@ declare(strict_types=1); /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\StandaloneTemplateResponse; use OCP\IInitialStateService; use OCP\IRequest; use OCP\IURLGenerator; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class RecommendedAppsController extends Controller { - public IURLGenerator $urlGenerator; - private IInitialStateService $initialStateService; - - public function __construct(IRequest $request, - IURLGenerator $urlGenerator, - IInitialStateService $initialStateService) { + public function __construct( + IRequest $request, + public IURLGenerator $urlGenerator, + private IInitialStateService $initialStateService, + ) { parent::__construct('core', $request); - $this->urlGenerator = $urlGenerator; - $this->initialStateService = $initialStateService; } /** - * @NoCSRFRequired * @return Response */ + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/core/apps/recommended')] public function index(): Response { $defaultPageUrl = $this->urlGenerator->linkToDefaultPageUrl(); $this->initialStateService->provideInitialState('core', 'defaultPageUrl', $defaultPageUrl); diff --git a/core/Controller/ReferenceApiController.php b/core/Controller/ReferenceApiController.php index 6aba56d7e77..d4fb753f404 100644 --- a/core/Controller/ReferenceApiController.php +++ b/core/Controller/ReferenceApiController.php @@ -2,49 +2,53 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; +use OC\Core\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; use OCP\Collaboration\Reference\IDiscoverableReferenceProvider; use OCP\Collaboration\Reference\IReferenceManager; +use OCP\Collaboration\Reference\Reference; use OCP\IRequest; -class ReferenceApiController extends \OCP\AppFramework\OCSController { - private IReferenceManager $referenceManager; - private ?string $userId; - - public function __construct(string $appName, - IRequest $request, - IReferenceManager $referenceManager, - ?string $userId) { +/** + * @psalm-import-type CoreReference from ResponseDefinitions + * @psalm-import-type CoreReferenceProvider from ResponseDefinitions + */ +class ReferenceApiController extends OCSController { + private const LIMIT_MAX = 15; + + public function __construct( + string $appName, + IRequest $request, + private IReferenceManager $referenceManager, + private ?string $userId, + ) { parent::__construct($appName, $request); - $this->referenceManager = $referenceManager; - $this->userId = $userId; } /** - * @NoAdminRequired + * Extract references from a text + * + * @param string $text Text to extract from + * @param bool $resolve Resolve the references + * @param int $limit Maximum amount of references to extract + * @return DataResponse<Http::STATUS_OK, array{references: array<string, CoreReference|null>}, array{}> + * + * 200: References returned */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/extract', root: '/references')] public function extract(string $text, bool $resolve = false, int $limit = 1): DataResponse { $references = $this->referenceManager->extractReferences($text); @@ -55,7 +59,7 @@ class ReferenceApiController extends \OCP\AppFramework\OCSController { break; } - $result[$reference] = $resolve ? $this->referenceManager->resolveReference($reference) : null; + $result[$reference] = $resolve ? $this->referenceManager->resolveReference($reference)->jsonSerialize() : null; } return new DataResponse([ @@ -64,21 +68,88 @@ class ReferenceApiController extends \OCP\AppFramework\OCSController { } /** - * @NoAdminRequired + * Extract references from a text + * + * @param string $text Text to extract from + * @param string $sharingToken Token of the public share + * @param bool $resolve Resolve the references + * @param int $limit Maximum amount of references to extract, limited to 15 + * @return DataResponse<Http::STATUS_OK, array{references: array<string, CoreReference|null>}, array{}> + * + * 200: References returned */ + #[ApiRoute(verb: 'POST', url: '/extractPublic', root: '/references')] + #[PublicPage] + #[AnonRateLimit(limit: 10, period: 120)] + public function extractPublic(string $text, string $sharingToken, bool $resolve = false, int $limit = 1): DataResponse { + $references = $this->referenceManager->extractReferences($text); + + $result = []; + $index = 0; + foreach ($references as $reference) { + if ($index++ >= min($limit, self::LIMIT_MAX)) { + break; + } + + $result[$reference] = $resolve ? $this->referenceManager->resolveReference($reference, true, $sharingToken)?->jsonSerialize() : null; + } + + return new DataResponse([ + 'references' => $result + ]); + } + + /** + * Resolve a reference + * + * @param string $reference Reference to resolve + * @return DataResponse<Http::STATUS_OK, array{references: array<string, ?CoreReference>}, array{}> + * + * 200: Reference returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/resolve', root: '/references')] public function resolveOne(string $reference): DataResponse { - $resolvedReference = $this->referenceManager->resolveReference(trim($reference)); + /** @var ?CoreReference $resolvedReference */ + $resolvedReference = $this->referenceManager->resolveReference(trim($reference))?->jsonSerialize(); - $response = new DataResponse(['references' => [ $reference => $resolvedReference ]]); + $response = new DataResponse(['references' => [$reference => $resolvedReference]]); $response->cacheFor(3600, false, true); return $response; } /** - * @NoAdminRequired + * Resolve from a public page * - * @param string[] $references + * @param string $reference Reference to resolve + * @param string $sharingToken Token of the public share + * @return DataResponse<Http::STATUS_OK, array{references: array<string, ?CoreReference>}, array{}> + * + * 200: Reference returned */ + #[ApiRoute(verb: 'GET', url: '/resolvePublic', root: '/references')] + #[PublicPage] + #[AnonRateLimit(limit: 25, period: 120)] + public function resolveOnePublic(string $reference, string $sharingToken): DataResponse { + /** @var ?CoreReference $resolvedReference */ + $resolvedReference = $this->referenceManager->resolveReference(trim($reference), true, trim($sharingToken))?->jsonSerialize(); + + $response = new DataResponse(['references' => [$reference => $resolvedReference]]); + $response->cacheFor(3600, false, true); + return $response; + } + + /** + * Resolve multiple references + * + * @param list<string> $references References to resolve + * @param int $limit Maximum amount of references to resolve + * @return DataResponse<Http::STATUS_OK, array{references: array<string, CoreReference|null>}, array{}> + * + * 200: References returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/resolve', root: '/references')] public function resolve(array $references, int $limit = 1): DataResponse { $result = []; $index = 0; @@ -87,28 +158,71 @@ class ReferenceApiController extends \OCP\AppFramework\OCSController { break; } - $result[$reference] = $this->referenceManager->resolveReference($reference); + $result[$reference] = $this->referenceManager->resolveReference($reference)?->jsonSerialize(); + } + + return new DataResponse([ + 'references' => $result + ]); + } + + /** + * Resolve multiple references from a public page + * + * @param list<string> $references References to resolve + * @param string $sharingToken Token of the public share + * @param int $limit Maximum amount of references to resolve, limited to 15 + * @return DataResponse<Http::STATUS_OK, array{references: array<string, CoreReference|null>}, array{}> + * + * 200: References returned + */ + #[ApiRoute(verb: 'POST', url: '/resolvePublic', root: '/references')] + #[PublicPage] + #[AnonRateLimit(limit: 10, period: 120)] + public function resolvePublic(array $references, string $sharingToken, int $limit = 1): DataResponse { + $result = []; + $index = 0; + foreach ($references as $reference) { + if ($index++ >= min($limit, self::LIMIT_MAX)) { + break; + } + + $result[$reference] = $this->referenceManager->resolveReference($reference, true, $sharingToken)?->jsonSerialize(); } return new DataResponse([ - 'references' => array_filter($result) + 'references' => $result ]); } /** - * @NoAdminRequired + * Get the providers + * + * @return DataResponse<Http::STATUS_OK, list<CoreReferenceProvider>, array{}> + * + * 200: Providers returned */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/providers', root: '/references')] public function getProvidersInfo(): DataResponse { $providers = $this->referenceManager->getDiscoverableProviders(); - $jsonProviders = array_map(static function (IDiscoverableReferenceProvider $provider) { + $jsonProviders = array_values(array_map(static function (IDiscoverableReferenceProvider $provider) { return $provider->jsonSerialize(); - }, $providers); + }, $providers)); return new DataResponse($jsonProviders); } /** - * @NoAdminRequired + * Touch a provider + * + * @param string $providerId ID of the provider + * @param int|null $timestamp Timestamp of the last usage + * @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}> + * + * 200: Provider touched */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/provider/{providerId}', root: '/references')] public function touchProvider(string $providerId, ?int $timestamp = null): DataResponse { if ($this->userId !== null) { $success = $this->referenceManager->touchProvider($this->userId, $providerId, $timestamp); diff --git a/core/Controller/ReferenceController.php b/core/Controller/ReferenceController.php index a892de03e79..6ed15e2d2f1 100644 --- a/core/Controller/ReferenceController.php +++ b/core/Controller/ReferenceController.php @@ -2,66 +2,63 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; -use OCP\AppFramework\Http\Response; -use OCP\Collaboration\Reference\IReferenceManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; +use OCP\Collaboration\Reference\IReferenceManager; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IRequest; class ReferenceController extends Controller { - private IReferenceManager $referenceManager; - private IAppDataFactory $appDataFactory; - - public function __construct(string $appName, IRequest $request, IReferenceManager $referenceManager, IAppDataFactory $appDataFactory) { + public function __construct( + string $appName, + IRequest $request, + private IReferenceManager $referenceManager, + private IAppDataFactory $appDataFactory, + ) { parent::__construct($appName, $request); - $this->referenceManager = $referenceManager; - $this->appDataFactory = $appDataFactory; } /** - * @PublicPage - * @NoCSRFRequired + * Get a preview for a reference + * * @param string $referenceId the reference cache key - * @return Response + * @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_NOT_FOUND, '', array{}> + * + * 200: Preview returned + * 404: Reference not found */ - public function preview(string $referenceId): Response { + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/core/references/preview/{referenceId}')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function preview(string $referenceId): DataDownloadResponse|DataResponse { $reference = $this->referenceManager->getReferenceByCacheKey($referenceId); try { $appData = $this->appDataFactory->get('core'); $folder = $appData->getFolder('opengraph'); $file = $folder->getFile($referenceId); + $contentType = $reference === null || $reference->getImageContentType() === null + ? $file->getMimeType() + : $reference->getImageContentType(); $response = new DataDownloadResponse( $file->getContent(), $referenceId, - $reference === null ? $file->getMimeType() : $reference->getImageContentType() + $contentType ); } catch (NotFoundException|NotPermittedException $e) { $response = new DataResponse('', Http::STATUS_NOT_FOUND); diff --git a/core/Controller/SearchController.php b/core/Controller/SearchController.php deleted file mode 100644 index 5881ec2db86..00000000000 --- a/core/Controller/SearchController.php +++ /dev/null @@ -1,69 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -namespace OC\Core\Controller; - -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; -use OCP\IRequest; -use OCP\ISearch; -use OCP\Search\Result; -use Psr\Log\LoggerInterface; - -class SearchController extends Controller { - private ISearch $searcher; - private LoggerInterface $logger; - - public function __construct( - string $appName, - IRequest $request, - ISearch $search, - LoggerInterface $logger - ) { - parent::__construct($appName, $request); - - $this->searcher = $search; - $this->logger = $logger; - } - - /** - * @NoAdminRequired - */ - public function search(string $query, array $inApps = [], int $page = 1, int $size = 30): JSONResponse { - $results = $this->searcher->searchPaged($query, $inApps, $page, $size); - - $results = array_filter($results, function (Result $result) { - if (json_encode($result, JSON_HEX_TAG) === false) { - $this->logger->warning("Skipping search result due to invalid encoding: {type: " . $result->type . ", id: " . $result->id . "}"); - return false; - } else { - return true; - } - }); - - return new JSONResponse($results); - } -} diff --git a/core/Controller/SetupController.php b/core/Controller/SetupController.php index cdab39edf84..f89506680ad 100644 --- a/core/Controller/SetupController.php +++ b/core/Controller/SetupController.php @@ -1,49 +1,32 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Damjan Georgievski <gdamjan@gmail.com> - * @author ideaship <ideaship@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @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 OC\Core\Controller; +use OC\IntegrityCheck\Checker; use OC\Setup; -use OCP\ILogger; +use OCP\IInitialStateService; +use OCP\IURLGenerator; +use OCP\Server; +use OCP\Template\ITemplateManager; +use OCP\Util; +use Psr\Log\LoggerInterface; class SetupController { - protected Setup $setupHelper; private string $autoConfigFile; - /** - * @param Setup $setupHelper - */ - public function __construct(Setup $setupHelper) { - $this->autoConfigFile = \OC::$configDir.'autoconfig.php'; - $this->setupHelper = $setupHelper; + public function __construct( + protected Setup $setupHelper, + protected LoggerInterface $logger, + protected ITemplateManager $templateManager, + protected IInitialStateService $initialStateService, + protected IURLGenerator $urlGenerator, + ) { + $this->autoConfigFile = \OC::$configDir . 'autoconfig.php'; } public function run(array $post): void { @@ -81,11 +64,11 @@ class SetupController { } } - private function displaySetupForbidden() { - \OC_Template::printGuestPage('', 'installation_forbidden'); + private function displaySetupForbidden(): void { + $this->templateManager->printGuestPage('', 'installation_forbidden'); } - public function display($post): void { + public function display(array $post): void { $defaults = [ 'adminlogin' => '', 'adminpass' => '', @@ -95,31 +78,53 @@ class SetupController { 'dbtablespace' => '', 'dbhost' => 'localhost', 'dbtype' => '', + 'hasAutoconfig' => false, + 'serverRoot' => \OC::$SERVERROOT, ]; $parameters = array_merge($defaults, $post); - \OC_Template::printGuestPage('', 'installation', $parameters); + Util::addStyle('server', null); + + // include common nextcloud webpack bundle + Util::addScript('core', 'common'); + Util::addScript('core', 'main'); + Util::addScript('core', 'install'); + Util::addTranslations('core'); + + $this->initialStateService->provideInitialState('core', 'config', $parameters); + $this->initialStateService->provideInitialState('core', 'data', false); + $this->initialStateService->provideInitialState('core', 'links', [ + 'adminInstall' => $this->urlGenerator->linkToDocs('admin-install'), + 'adminSourceInstall' => $this->urlGenerator->linkToDocs('admin-source_install'), + 'adminDBConfiguration' => $this->urlGenerator->linkToDocs('admin-db-configuration'), + ]); + + $this->templateManager->printGuestPage('', 'installation'); } - private function finishSetup() { + private function finishSetup(): void { if (file_exists($this->autoConfigFile)) { unlink($this->autoConfigFile); } - \OC::$server->getIntegrityCodeChecker()->runInstanceVerification(); + Server::get(Checker::class)->runInstanceVerification(); if ($this->setupHelper->shouldRemoveCanInstallFile()) { - \OC_Template::printGuestPage('', 'installation_incomplete'); + $this->templateManager->printGuestPage('', 'installation_incomplete'); } - header('Location: ' . \OC::$server->getURLGenerator()->getAbsoluteURL('index.php/core/apps/recommended')); + header('Location: ' . Server::get(IURLGenerator::class)->getAbsoluteURL('index.php/core/apps/recommended')); exit(); } + /** + * @psalm-taint-escape file we trust file path given in POST for setup + */ public function loadAutoConfig(array $post): array { if (file_exists($this->autoConfigFile)) { - \OCP\Util::writeLog('core', 'Autoconfig file found, setting up Nextcloud…', ILogger::INFO); + $this->logger->info('Autoconfig file found, setting up Nextcloud…'); $AUTOCONFIG = []; include $this->autoConfigFile; + $post['hasAutoconfig'] = count($AUTOCONFIG) > 0; $post = array_merge($post, $AUTOCONFIG); } @@ -130,8 +135,6 @@ class SetupController { if ($dbIsSet and $directoryIsSet and $adminAccountIsSet) { $post['install'] = 'true'; } - $post['dbIsSet'] = $dbIsSet; - $post['directoryIsSet'] = $directoryIsSet; return $post; } diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php new file mode 100644 index 00000000000..90a0e9ba14a --- /dev/null +++ b/core/Controller/TaskProcessingApiController.php @@ -0,0 +1,657 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + + +namespace OC\Core\Controller; + +use OC\Core\ResponseDefinitions; +use OC\Files\SimpleFS\SimpleFile; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\ExAppRequired; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\StreamResponse; +use OCP\AppFramework\OCSController; +use OCP\Files\File; +use OCP\Files\IAppData; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\Lock\LockedException; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\Exception\Exception; +use OCP\TaskProcessing\Exception\NotFoundException; +use OCP\TaskProcessing\Exception\PreConditionNotMetException; +use OCP\TaskProcessing\Exception\UnauthorizedException; +use OCP\TaskProcessing\Exception\ValidationException; +use OCP\TaskProcessing\IManager; +use OCP\TaskProcessing\ShapeEnumValue; +use OCP\TaskProcessing\Task; +use RuntimeException; +use stdClass; + +/** + * @psalm-import-type CoreTaskProcessingTask from ResponseDefinitions + * @psalm-import-type CoreTaskProcessingTaskType from ResponseDefinitions + */ +class TaskProcessingApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private IManager $taskProcessingManager, + private IL10N $l, + private ?string $userId, + private IRootFolder $rootFolder, + private IAppData $appData, + private IMimeTypeDetector $mimeTypeDetector, + ) { + parent::__construct($appName, $request); + } + + /** + * Returns all available TaskProcessing task types + * + * @return DataResponse<Http::STATUS_OK, array{types: array<string, CoreTaskProcessingTaskType>}, array{}> + * + * 200: Task types returned + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/tasktypes', root: '/taskprocessing')] + public function taskTypes(): DataResponse { + /** @var array<string, CoreTaskProcessingTaskType> $taskTypes */ + $taskTypes = array_map(function (array $tt) { + $tt['inputShape'] = array_map(function ($descriptor) { + return $descriptor->jsonSerialize(); + }, $tt['inputShape']); + if (empty($tt['inputShape'])) { + $tt['inputShape'] = new stdClass; + } + + $tt['outputShape'] = array_map(function ($descriptor) { + return $descriptor->jsonSerialize(); + }, $tt['outputShape']); + if (empty($tt['outputShape'])) { + $tt['outputShape'] = new stdClass; + } + + $tt['optionalInputShape'] = array_map(function ($descriptor) { + return $descriptor->jsonSerialize(); + }, $tt['optionalInputShape']); + if (empty($tt['optionalInputShape'])) { + $tt['optionalInputShape'] = new stdClass; + } + + $tt['optionalOutputShape'] = array_map(function ($descriptor) { + return $descriptor->jsonSerialize(); + }, $tt['optionalOutputShape']); + if (empty($tt['optionalOutputShape'])) { + $tt['optionalOutputShape'] = new stdClass; + } + + $tt['inputShapeEnumValues'] = array_map(function (array $enumValues) { + return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); + }, $tt['inputShapeEnumValues']); + if (empty($tt['inputShapeEnumValues'])) { + $tt['inputShapeEnumValues'] = new stdClass; + } + + $tt['optionalInputShapeEnumValues'] = array_map(function (array $enumValues) { + return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); + }, $tt['optionalInputShapeEnumValues']); + if (empty($tt['optionalInputShapeEnumValues'])) { + $tt['optionalInputShapeEnumValues'] = new stdClass; + } + + $tt['outputShapeEnumValues'] = array_map(function (array $enumValues) { + return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); + }, $tt['outputShapeEnumValues']); + if (empty($tt['outputShapeEnumValues'])) { + $tt['outputShapeEnumValues'] = new stdClass; + } + + $tt['optionalOutputShapeEnumValues'] = array_map(function (array $enumValues) { + return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); + }, $tt['optionalOutputShapeEnumValues']); + if (empty($tt['optionalOutputShapeEnumValues'])) { + $tt['optionalOutputShapeEnumValues'] = new stdClass; + } + + if (empty($tt['inputShapeDefaults'])) { + $tt['inputShapeDefaults'] = new stdClass; + } + if (empty($tt['optionalInputShapeDefaults'])) { + $tt['optionalInputShapeDefaults'] = new stdClass; + } + return $tt; + }, $this->taskProcessingManager->getAvailableTaskTypes()); + return new DataResponse([ + 'types' => $taskTypes, + ]); + } + + /** + * Schedules a task + * + * @param array<string, mixed> $input Task's input parameters + * @param string $type Type of the task + * @param string $appId ID of the app that will execute the task + * @param string $customId An arbitrary identifier for the task + * @param string|null $webhookUri URI to be requested when the task finishes + * @param string|null $webhookMethod Method used for the webhook request (HTTP:GET, HTTP:POST, HTTP:PUT, HTTP:DELETE or AppAPI:APP_ID:GET, AppAPI:APP_ID:POST...) + * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_BAD_REQUEST|Http::STATUS_PRECONDITION_FAILED|Http::STATUS_UNAUTHORIZED, array{message: string}, array{}> + * + * 200: Task scheduled successfully + * 400: Scheduling task is not possible + * 412: Scheduling task is not possible + * 401: Cannot schedule task because it references files in its input that the user doesn't have access to + */ + #[PublicPage] + #[UserRateLimit(limit: 20, period: 120)] + #[AnonRateLimit(limit: 5, period: 120)] + #[ApiRoute(verb: 'POST', url: '/schedule', root: '/taskprocessing')] + public function schedule( + array $input, string $type, string $appId, string $customId = '', + ?string $webhookUri = null, ?string $webhookMethod = null, + ): DataResponse { + $task = new Task($type, $input, $appId, $this->userId, $customId); + $task->setWebhookUri($webhookUri); + $task->setWebhookMethod($webhookMethod); + try { + $this->taskProcessingManager->scheduleTask($task); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (PreConditionNotMetException) { + return new DataResponse(['message' => $this->l->t('The given provider is not available')], Http::STATUS_PRECONDITION_FAILED); + } catch (ValidationException $e) { + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (UnauthorizedException) { + return new DataResponse(['message' => 'User does not have access to the files mentioned in the task input'], Http::STATUS_UNAUTHORIZED); + } catch (Exception) { + return new DataResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Gets a task including status and result + * + * Tasks are removed 1 week after receiving their last update + * + * @param int $id The id of the task + * + * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task returned + * 404: Task not found + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/task/{id}', root: '/taskprocessing')] + public function getTask(int $id): DataResponse { + try { + $task = $this->taskProcessingManager->getUserTask($id, $this->userId); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + } catch (RuntimeException) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Deletes a task + * + * @param int $id The id of the task + * + * @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task deleted + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/task/{id}', root: '/taskprocessing')] + public function deleteTask(int $id): DataResponse { + try { + $task = $this->taskProcessingManager->getUserTask($id, $this->userId); + + $this->taskProcessingManager->deleteTask($task); + + return new DataResponse(null); + } catch (NotFoundException) { + return new DataResponse(null); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + + /** + * Returns tasks for the current user filtered by the appId and optional customId + * + * @param string $appId ID of the app + * @param string|null $customId An arbitrary identifier for the task + * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTaskProcessingTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Tasks returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/tasks/app/{appId}', root: '/taskprocessing')] + public function listTasksByApp(string $appId, ?string $customId = null): DataResponse { + try { + $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, $appId, $customId); + $json = array_map(static function (Task $task) { + return $task->jsonSerialize(); + }, $tasks); + + return new DataResponse([ + 'tasks' => $json, + ]); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Returns tasks for the current user filtered by the optional taskType and optional customId + * + * @param string|null $taskType The task type to filter by + * @param string|null $customId An arbitrary identifier for the task + * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTaskProcessingTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Tasks returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/tasks', root: '/taskprocessing')] + public function listTasks(?string $taskType, ?string $customId = null): DataResponse { + try { + $tasks = $this->taskProcessingManager->getUserTasks($this->userId, $taskType, $customId); + $json = array_map(static function (Task $task) { + return $task->jsonSerialize(); + }, $tasks); + + return new DataResponse([ + 'tasks' => $json, + ]); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Returns the contents of a file referenced in a task + * + * @param int $taskId The id of the task + * @param int $fileId The file id of the file to retrieve + * @return StreamResponse<Http::STATUS_OK, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 200: File content returned + * 404: Task or file not found + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/tasks/{taskId}/file/{fileId}', root: '/taskprocessing')] + public function getFileContents(int $taskId, int $fileId): StreamResponse|DataResponse { + try { + $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); + return $this->getFileContentsInternal($task, $fileId); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (LockedException) { + return new DataResponse(['message' => $this->l->t('Node is locked')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Returns the contents of a file referenced in a task(ExApp route version) + * + * @param int $taskId The id of the task + * @param int $fileId The file id of the file to retrieve + * @return StreamResponse<Http::STATUS_OK, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 200: File content returned + * 404: Task or file not found + */ + #[ExAppRequired] + #[ApiRoute(verb: 'GET', url: '/tasks_provider/{taskId}/file/{fileId}', root: '/taskprocessing')] + public function getFileContentsExApp(int $taskId, int $fileId): StreamResponse|DataResponse { + try { + $task = $this->taskProcessingManager->getTask($taskId); + return $this->getFileContentsInternal($task, $fileId); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (LockedException) { + return new DataResponse(['message' => $this->l->t('Node is locked')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Upload a file so it can be referenced in a task result (ExApp route version) + * + * Use field 'file' for the file upload + * + * @param int $taskId The id of the task + * @return DataResponse<Http::STATUS_CREATED, array{fileId: int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 201: File created + * 400: File upload failed or no file was uploaded + * 404: Task not found + */ + #[ExAppRequired] + #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/file', root: '/taskprocessing')] + public function setFileContentsExApp(int $taskId): DataResponse { + try { + $task = $this->taskProcessingManager->getTask($taskId); + $file = $this->request->getUploadedFile('file'); + if (!isset($file['tmp_name'])) { + return new DataResponse(['message' => $this->l->t('Bad request')], Http::STATUS_BAD_REQUEST); + } + $handle = fopen($file['tmp_name'], 'r'); + if (!$handle) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $fileId = $this->setFileContentsInternal($handle); + return new DataResponse(['fileId' => $fileId], Http::STATUS_CREATED); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @throws NotPermittedException + * @throws NotFoundException + * @throws LockedException + * + * @return StreamResponse<Http::STATUS_OK, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + */ + private function getFileContentsInternal(Task $task, int $fileId): StreamResponse|DataResponse { + $ids = $this->extractFileIdsFromTask($task); + if (!in_array($fileId, $ids)) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } + if ($task->getUserId() !== null) { + \OC_Util::setupFS($task->getUserId()); + } + $node = $this->rootFolder->getFirstNodeById($fileId); + if ($node === null) { + $node = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + if (!$node instanceof File) { + throw new NotFoundException('Node is not a file'); + } + } elseif (!$node instanceof File) { + throw new NotFoundException('Node is not a file'); + } + + $contentType = $node->getMimeType(); + if (function_exists('mime_content_type')) { + $mimeType = mime_content_type($node->fopen('rb')); + if ($mimeType !== false) { + $mimeType = $this->mimeTypeDetector->getSecureMimeType($mimeType); + if ($mimeType !== 'application/octet-stream') { + $contentType = $mimeType; + } + } + } + + $response = new StreamResponse($node->fopen('rb')); + $response->addHeader( + 'Content-Disposition', + 'attachment; filename="' . rawurldecode($node->getName()) . '"' + ); + $response->addHeader('Content-Type', $contentType); + return $response; + } + + /** + * @param Task $task + * @return list<int> + * @throws NotFoundException + */ + private function extractFileIdsFromTask(Task $task): array { + $ids = []; + $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); + if (!isset($taskTypes[$task->getTaskTypeId()])) { + throw new NotFoundException('Could not find task type'); + } + $taskType = $taskTypes[$task->getTaskTypeId()]; + foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { + if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + /** @var int|list<int> $inputSlot */ + $inputSlot = $task->getInput()[$key]; + if (is_array($inputSlot)) { + $ids = array_merge($inputSlot, $ids); + } else { + $ids[] = $inputSlot; + } + } + } + if ($task->getOutput() !== null) { + foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { + if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + /** @var int|list<int> $outputSlot */ + $outputSlot = $task->getOutput()[$key]; + if (is_array($outputSlot)) { + $ids = array_merge($outputSlot, $ids); + } else { + $ids[] = $outputSlot; + } + } + } + } + return $ids; + } + + /** + * Sets the task progress + * + * @param int $taskId The id of the task + * @param float $progress The progress + * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 200: Progress updated successfully + * 404: Task not found + */ + #[ExAppRequired] + #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/progress', root: '/taskprocessing')] + public function setProgress(int $taskId, float $progress): DataResponse { + try { + $this->taskProcessingManager->setTaskProgress($taskId, $progress); + $task = $this->taskProcessingManager->getTask($taskId); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Sets the task result + * + * @param int $taskId The id of the task + * @param array<string,mixed>|null $output The resulting task output, files are represented by their IDs + * @param string|null $errorMessage An error message if the task failed + * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 200: Result updated successfully + * 404: Task not found + */ + #[ExAppRequired] + #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/result', root: '/taskprocessing')] + public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null): DataResponse { + try { + // set result + $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output, true); + $task = $this->taskProcessingManager->getTask($taskId); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Cancels a task + * + * @param int $taskId The id of the task + * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 200: Task canceled successfully + * 404: Task not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/tasks/{taskId}/cancel', root: '/taskprocessing')] + public function cancelTask(int $taskId): DataResponse { + try { + // Check if the current user can access the task + $this->taskProcessingManager->getUserTask($taskId, $this->userId); + // set result + $this->taskProcessingManager->cancelTask($taskId); + $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Returns the next scheduled task for the taskTypeId + * + * @param list<string> $providerIds The ids of the providers + * @param list<string> $taskTypeIds The ids of the task types + * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask, provider: array{name: string}}, array{}>|DataResponse<Http::STATUS_NO_CONTENT, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task returned + * 204: No task found + */ + #[ExAppRequired] + #[ApiRoute(verb: 'GET', url: '/tasks_provider/next', root: '/taskprocessing')] + public function getNextScheduledTask(array $providerIds, array $taskTypeIds): DataResponse { + try { + $providerIdsBasedOnTaskTypesWithNull = array_unique(array_map(function ($taskTypeId) { + try { + return $this->taskProcessingManager->getPreferredProvider($taskTypeId)->getId(); + } catch (Exception) { + return null; + } + }, $taskTypeIds)); + + $providerIdsBasedOnTaskTypes = array_filter($providerIdsBasedOnTaskTypesWithNull, fn ($providerId) => $providerId !== null); + + // restrict $providerIds to providers that are configured as preferred for the passed task types + $possibleProviderIds = array_values(array_intersect($providerIdsBasedOnTaskTypes, $providerIds)); + + // restrict $taskTypeIds to task types that can actually be run by one of the now restricted providers + $possibleTaskTypeIds = array_values(array_filter($taskTypeIds, function ($taskTypeId) use ($possibleProviderIds) { + try { + $providerForTaskType = $this->taskProcessingManager->getPreferredProvider($taskTypeId)->getId(); + } catch (Exception) { + // no provider found for task type + return false; + } + return in_array($providerForTaskType, $possibleProviderIds, true); + })); + + if (count($possibleProviderIds) === 0 || count($possibleTaskTypeIds) === 0) { + throw new NotFoundException(); + } + + $taskIdsToIgnore = []; + while (true) { + // Until we find a task whose task type is set to be provided by the providers requested with this request + // Or no scheduled task is found anymore (given the taskIds to ignore) + $task = $this->taskProcessingManager->getNextScheduledTask($possibleTaskTypeIds, $taskIdsToIgnore); + try { + $provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId()); + if (in_array($provider->getId(), $possibleProviderIds, true)) { + if ($this->taskProcessingManager->lockTask($task)) { + break; + } + } + } catch (Exception) { + // There is no provider set for the task type of this task + // proceed to ignore this task + } + + $taskIdsToIgnore[] = (int)$task->getId(); + } + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + 'provider' => [ + 'name' => $provider->getId(), + ], + ]); + } catch (NotFoundException) { + return new DataResponse(null, Http::STATUS_NO_CONTENT); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @param resource $data + * @return int + * @throws NotPermittedException + */ + private function setFileContentsInternal($data): int { + try { + $folder = $this->appData->getFolder('TaskProcessing'); + } catch (\OCP\Files\NotFoundException) { + $folder = $this->appData->newFolder('TaskProcessing'); + } + /** @var SimpleFile $file */ + $file = $folder->newFile(time() . '-' . rand(1, 100000), $data); + return $file->getId(); + } +} diff --git a/core/Controller/TeamsApiController.php b/core/Controller/TeamsApiController.php new file mode 100644 index 00000000000..2eb33a0c254 --- /dev/null +++ b/core/Controller/TeamsApiController.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Controller; + +use OC\Core\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\Teams\ITeamManager; +use OCP\Teams\Team; + +/** + * @psalm-import-type CoreTeamResource from ResponseDefinitions + * @psalm-import-type CoreTeam from ResponseDefinitions + * @property $userId string + */ +class TeamsApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private ITeamManager $teamManager, + private ?string $userId, + ) { + parent::__construct($appName, $request); + } + + /** + * Get all resources of a team + * + * @param string $teamId Unique id of the team + * @return DataResponse<Http::STATUS_OK, array{resources: list<CoreTeamResource>}, array{}> + * + * 200: Resources returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/{teamId}/resources', root: '/teams')] + public function resolveOne(string $teamId): DataResponse { + /** + * @var list<CoreTeamResource> $resolvedResources + * @psalm-suppress PossiblyNullArgument The route is limited to logged-in users + */ + $resolvedResources = $this->teamManager->getSharedWith($teamId, $this->userId); + + return new DataResponse(['resources' => $resolvedResources]); + } + + /** + * Get all teams of a resource + * + * @param string $providerId Identifier of the provider (e.g. deck, talk, collectives) + * @param string $resourceId Unique id of the resource to list teams for (e.g. deck board id) + * @return DataResponse<Http::STATUS_OK, array{teams: list<CoreTeam>}, array{}> + * + * 200: Teams returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/resources/{providerId}/{resourceId}', root: '/teams')] + public function listTeams(string $providerId, string $resourceId): DataResponse { + /** @psalm-suppress PossiblyNullArgument The route is limited to logged-in users */ + $teams = $this->teamManager->getTeamsForResource($providerId, $resourceId, $this->userId); + /** @var list<CoreTeam> $teams */ + $teams = array_values(array_map(function (Team $team) { + $response = $team->jsonSerialize(); + /** @psalm-suppress PossiblyNullArgument The route is limited to logged in users */ + $response['resources'] = $this->teamManager->getSharedWith($team->getId(), $this->userId); + return $response; + }, $teams)); + + return new DataResponse([ + 'teams' => $teams, + ]); + } +} diff --git a/core/Controller/TextProcessingApiController.php b/core/Controller/TextProcessingApiController.php new file mode 100644 index 00000000000..d3e6967f169 --- /dev/null +++ b/core/Controller/TextProcessingApiController.php @@ -0,0 +1,215 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + + +namespace OC\Core\Controller; + +use InvalidArgumentException; +use OC\Core\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\Common\Exception\NotFoundException; +use OCP\DB\Exception; +use OCP\IL10N; +use OCP\IRequest; +use OCP\PreConditionNotMetException; +use OCP\TextProcessing\Exception\TaskFailureException; +use OCP\TextProcessing\IManager; +use OCP\TextProcessing\ITaskType; +use OCP\TextProcessing\Task; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; + +/** + * @psalm-import-type CoreTextProcessingTask from ResponseDefinitions + */ +class TextProcessingApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private IManager $textProcessingManager, + private IL10N $l, + private ?string $userId, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * This endpoint returns all available LanguageModel task types + * + * @return DataResponse<Http::STATUS_OK, array{types: list<array{id: string, name: string, description: string}>}, array{}> + * + * 200: Task types returned + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/tasktypes', root: '/textprocessing')] + public function taskTypes(): DataResponse { + $typeClasses = $this->textProcessingManager->getAvailableTaskTypes(); + $types = []; + /** @var string $typeClass */ + foreach ($typeClasses as $typeClass) { + try { + /** @var ITaskType $object */ + $object = $this->container->get($typeClass); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { + $this->logger->warning('Could not find ' . $typeClass, ['exception' => $e]); + continue; + } + $types[] = [ + 'id' => $typeClass, + 'name' => $object->getName(), + 'description' => $object->getDescription(), + ]; + } + + return new DataResponse([ + 'types' => $types, + ]); + } + + /** + * This endpoint allows scheduling a language model task + * + * @param string $input Input text + * @param string $type Type of the task + * @param string $appId ID of the app that will execute the task + * @param string $identifier An arbitrary identifier for the task + * + * @return DataResponse<Http::STATUS_OK, array{task: CoreTextProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_BAD_REQUEST|Http::STATUS_PRECONDITION_FAILED, array{message: string}, array{}> + * + * 200: Task scheduled successfully + * 400: Scheduling task is not possible + * 412: Scheduling task is not possible + */ + #[PublicPage] + #[UserRateLimit(limit: 20, period: 120)] + #[AnonRateLimit(limit: 5, period: 120)] + #[ApiRoute(verb: 'POST', url: '/schedule', root: '/textprocessing')] + public function schedule(string $input, string $type, string $appId, string $identifier = ''): DataResponse { + try { + $task = new Task($type, $input, $appId, $this->userId, $identifier); + } catch (InvalidArgumentException) { + return new DataResponse(['message' => $this->l->t('Requested task type does not exist')], Http::STATUS_BAD_REQUEST); + } + try { + try { + $this->textProcessingManager->runOrScheduleTask($task); + } catch (TaskFailureException) { + // noop, because the task object has the failure status set already, we just return the task json + } + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (PreConditionNotMetException) { + return new DataResponse(['message' => $this->l->t('Necessary language model provider is not available')], Http::STATUS_PRECONDITION_FAILED); + } catch (Exception) { + return new DataResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint allows checking the status and results of a task. + * Tasks are removed 1 week after receiving their last update. + * + * @param int $id The id of the task + * + * @return DataResponse<Http::STATUS_OK, array{task: CoreTextProcessingTask}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task returned + * 404: Task not found + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/task/{id}', root: '/textprocessing')] + public function getTask(int $id): DataResponse { + try { + $task = $this->textProcessingManager->getUserTask($id, $this->userId); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + } catch (\RuntimeException $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint allows to delete a scheduled task for a user + * + * @param int $id The id of the task + * + * @return DataResponse<Http::STATUS_OK, array{task: CoreTextProcessingTask}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task returned + * 404: Task not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/task/{id}', root: '/textprocessing')] + public function deleteTask(int $id): DataResponse { + try { + $task = $this->textProcessingManager->getUserTask($id, $this->userId); + + $this->textProcessingManager->deleteTask($task); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + } catch (\RuntimeException $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + + /** + * This endpoint returns a list of tasks of a user that are related + * with a specific appId and optionally with an identifier + * + * @param string $appId ID of the app + * @param string|null $identifier An arbitrary identifier for the task + * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTextProcessingTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task list returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/tasks/app/{appId}', root: '/textprocessing')] + public function listTasksByApp(string $appId, ?string $identifier = null): DataResponse { + try { + $tasks = $this->textProcessingManager->getUserTasksByApp($this->userId, $appId, $identifier); + $json = array_values(array_map(static function (Task $task) { + return $task->jsonSerialize(); + }, $tasks)); + + return new DataResponse([ + 'tasks' => $json, + ]); + } catch (\RuntimeException $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/core/Controller/TextToImageApiController.php b/core/Controller/TextToImageApiController.php new file mode 100644 index 00000000000..d2c3e1ec288 --- /dev/null +++ b/core/Controller/TextToImageApiController.php @@ -0,0 +1,237 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + + +namespace OC\Core\Controller; + +use OC\Core\ResponseDefinitions; +use OC\Files\AppData\AppData; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\OCSController; +use OCP\DB\Exception; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\PreConditionNotMetException; +use OCP\TextToImage\Exception\TaskFailureException; +use OCP\TextToImage\Exception\TaskNotFoundException; +use OCP\TextToImage\IManager; +use OCP\TextToImage\Task; + +/** + * @psalm-import-type CoreTextToImageTask from ResponseDefinitions + */ +class TextToImageApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private IManager $textToImageManager, + private IL10N $l, + private ?string $userId, + private AppData $appData, + ) { + parent::__construct($appName, $request); + } + + /** + * Check whether this feature is available + * + * @return DataResponse<Http::STATUS_OK, array{isAvailable: bool}, array{}> + * + * 200: Returns availability status + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/is_available', root: '/text2image')] + public function isAvailable(): DataResponse { + return new DataResponse([ + 'isAvailable' => $this->textToImageManager->hasProviders(), + ]); + } + + /** + * This endpoint allows scheduling a text to image task + * + * @param string $input Input text + * @param string $appId ID of the app that will execute the task + * @param string $identifier An arbitrary identifier for the task + * @param int $numberOfImages The number of images to generate + * + * @return DataResponse<Http::STATUS_OK, array{task: CoreTextToImageTask}, array{}>|DataResponse<Http::STATUS_PRECONDITION_FAILED|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task scheduled successfully + * 412: Scheduling task is not possible + */ + #[PublicPage] + #[UserRateLimit(limit: 20, period: 120)] + #[AnonRateLimit(limit: 5, period: 120)] + #[ApiRoute(verb: 'POST', url: '/schedule', root: '/text2image')] + public function schedule(string $input, string $appId, string $identifier = '', int $numberOfImages = 8): DataResponse { + $task = new Task($input, $appId, $numberOfImages, $this->userId, $identifier); + try { + try { + $this->textToImageManager->runOrScheduleTask($task); + } catch (TaskFailureException) { + // Task status was already updated by the manager, nothing to do here + } + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (PreConditionNotMetException) { + return new DataResponse(['message' => $this->l->t('No text to image provider is available')], Http::STATUS_PRECONDITION_FAILED); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint allows checking the status and results of a task. + * Tasks are removed 1 week after receiving their last update. + * + * @param int $id The id of the task + * + * @return DataResponse<Http::STATUS_OK, array{task: CoreTextToImageTask}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task returned + * 404: Task not found + */ + #[PublicPage] + #[BruteForceProtection(action: 'text2image')] + #[ApiRoute(verb: 'GET', url: '/task/{id}', root: '/text2image')] + public function getTask(int $id): DataResponse { + try { + $task = $this->textToImageManager->getUserTask($id, $this->userId); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (TaskNotFoundException) { + $res = new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + $res->throttle(['action' => 'text2image']); + return $res; + } catch (\RuntimeException) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint allows downloading the resulting image of a task + * + * @param int $id The id of the task + * @param int $index The index of the image to retrieve + * + * @return FileDisplayResponse<Http::STATUS_OK, array{'Content-Type': string}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Image returned + * 404: Task or image not found + */ + #[PublicPage] + #[BruteForceProtection(action: 'text2image')] + #[ApiRoute(verb: 'GET', url: '/task/{id}/image/{index}', root: '/text2image')] + public function getImage(int $id, int $index): DataResponse|FileDisplayResponse { + try { + $task = $this->textToImageManager->getUserTask($id, $this->userId); + try { + $folder = $this->appData->getFolder('text2image'); + } catch (NotFoundException) { + $res = new DataResponse(['message' => $this->l->t('Image not found')], Http::STATUS_NOT_FOUND); + $res->throttle(['action' => 'text2image']); + return $res; + } + $file = $folder->getFolder((string)$task->getId())->getFile((string)$index); + $info = getimagesizefromstring($file->getContent()); + + return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => image_type_to_mime_type($info[2])]); + } catch (TaskNotFoundException) { + $res = new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + $res->throttle(['action' => 'text2image']); + return $res; + } catch (\RuntimeException) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (NotFoundException) { + $res = new DataResponse(['message' => $this->l->t('Image not found')], Http::STATUS_NOT_FOUND); + $res->throttle(['action' => 'text2image']); + return $res; + } + } + + /** + * This endpoint allows to delete a scheduled task for a user + * + * @param int $id The id of the task + * + * @return DataResponse<Http::STATUS_OK, array{task: CoreTextToImageTask}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task returned + * 404: Task not found + */ + #[NoAdminRequired] + #[BruteForceProtection(action: 'text2image')] + #[ApiRoute(verb: 'DELETE', url: '/task/{id}', root: '/text2image')] + public function deleteTask(int $id): DataResponse { + try { + $task = $this->textToImageManager->getUserTask($id, $this->userId); + + $this->textToImageManager->deleteTask($task); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (TaskNotFoundException) { + $res = new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + $res->throttle(['action' => 'text2image']); + return $res; + } catch (\RuntimeException) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + + /** + * This endpoint returns a list of tasks of a user that are related + * with a specific appId and optionally with an identifier + * + * @param string $appId ID of the app + * @param string|null $identifier An arbitrary identifier for the task + * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTextToImageTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> + * + * 200: Task list returned + */ + #[NoAdminRequired] + #[AnonRateLimit(limit: 5, period: 120)] + #[ApiRoute(verb: 'GET', url: '/tasks/app/{appId}', root: '/text2image')] + public function listTasksByApp(string $appId, ?string $identifier = null): DataResponse { + try { + $tasks = $this->textToImageManager->getUserTasksByApp($this->userId, $appId, $identifier); + $json = array_values(array_map(static function (Task $task) { + return $task->jsonSerialize(); + }, $tasks)); + + return new DataResponse([ + 'tasks' => $json, + ]); + } catch (\RuntimeException) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/core/Controller/TranslationApiController.php b/core/Controller/TranslationApiController.php new file mode 100644 index 00000000000..73dd0657230 --- /dev/null +++ b/core/Controller/TranslationApiController.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + + +namespace OC\Core\Controller; + +use InvalidArgumentException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IL10N; +use OCP\IRequest; +use OCP\PreConditionNotMetException; +use OCP\Translation\CouldNotTranslateException; +use OCP\Translation\ITranslationManager; + +class TranslationApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private ITranslationManager $translationManager, + private IL10N $l10n, + ) { + parent::__construct($appName, $request); + } + + /** + * Get the list of supported languages + * + * @return DataResponse<Http::STATUS_OK, array{languages: list<array{from: string, fromLabel: string, to: string, toLabel: string}>, languageDetection: bool}, array{}> + * + * 200: Supported languages returned + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/languages', root: '/translation')] + public function languages(): DataResponse { + return new DataResponse([ + 'languages' => array_values(array_map(fn ($lang) => $lang->jsonSerialize(), $this->translationManager->getLanguages())), + 'languageDetection' => $this->translationManager->canDetectLanguage(), + ]); + } + + /** + * Translate a text + * + * @param string $text Text to be translated + * @param string|null $fromLanguage Language to translate from + * @param string $toLanguage Language to translate to + * @return DataResponse<Http::STATUS_OK, array{text: string, from: ?string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_PRECONDITION_FAILED|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string, from?: ?string}, array{}> + * + * 200: Translated text returned + * 400: Language not detected or unable to translate + * 412: Translating is not possible + */ + #[PublicPage] + #[UserRateLimit(limit: 25, period: 120)] + #[AnonRateLimit(limit: 10, period: 120)] + #[ApiRoute(verb: 'POST', url: '/translate', root: '/translation')] + public function translate(string $text, ?string $fromLanguage, string $toLanguage): DataResponse { + try { + $translation = $this->translationManager->translate($text, $fromLanguage, $toLanguage); + + return new DataResponse([ + 'text' => $translation, + 'from' => $fromLanguage, + + ]); + } catch (PreConditionNotMetException) { + return new DataResponse(['message' => $this->l10n->t('No translation provider available')], Http::STATUS_PRECONDITION_FAILED); + } catch (InvalidArgumentException) { + return new DataResponse(['message' => $this->l10n->t('Could not detect language')], Http::STATUS_BAD_REQUEST); + } catch (CouldNotTranslateException $e) { + return new DataResponse(['message' => $this->l10n->t('Unable to translate'), 'from' => $e->getFrom()], Http::STATUS_BAD_REQUEST); + } + } +} diff --git a/core/Controller/TwoFactorApiController.php b/core/Controller/TwoFactorApiController.php new file mode 100644 index 00000000000..8d89963e6ad --- /dev/null +++ b/core/Controller/TwoFactorApiController.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Controller; + +use OC\Authentication\TwoFactorAuth\ProviderManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\Authentication\TwoFactorAuth\IRegistry; +use OCP\IRequest; +use OCP\IUserManager; + +class TwoFactorApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private ProviderManager $tfManager, + private IRegistry $tfRegistry, + private IUserManager $userManager, + ) { + parent::__construct($appName, $request); + } + + /** + * Get two factor authentication provider states + * + * @param string $user system user id + * + * @return DataResponse<Http::STATUS_OK, array<string, bool>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}> + * + * 200: provider states + * 404: user not found + */ + #[ApiRoute(verb: 'GET', url: '/state', root: '/twofactor')] + public function state(string $user): DataResponse { + $userObject = $this->userManager->get($user); + if ($userObject !== null) { + $state = $this->tfRegistry->getProviderStates($userObject); + return new DataResponse($state); + } + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + + /** + * Enable two factor authentication providers for specific user + * + * @param string $user system user identifier + * @param list<string> $providers collection of TFA provider ids + * + * @return DataResponse<Http::STATUS_OK, array<string, bool>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}> + * + * 200: provider states + * 404: user not found + */ + #[ApiRoute(verb: 'POST', url: '/enable', root: '/twofactor')] + public function enable(string $user, array $providers = []): DataResponse { + $userObject = $this->userManager->get($user); + if ($userObject !== null) { + foreach ($providers as $providerId) { + $this->tfManager->tryEnableProviderFor($providerId, $userObject); + } + $state = $this->tfRegistry->getProviderStates($userObject); + return new DataResponse($state); + } + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + + /** + * Disable two factor authentication providers for specific user + * + * @param string $user system user identifier + * @param list<string> $providers collection of TFA provider ids + * + * @return DataResponse<Http::STATUS_OK, array<string, bool>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}> + * + * 200: provider states + * 404: user not found + */ + #[ApiRoute(verb: 'POST', url: '/disable', root: '/twofactor')] + public function disable(string $user, array $providers = []): DataResponse { + $userObject = $this->userManager->get($user); + if ($userObject !== null) { + foreach ($providers as $providerId) { + $this->tfManager->tryDisableProviderFor($providerId, $userObject); + } + $state = $this->tfRegistry->getProviderStates($userObject); + return new DataResponse($state); + } + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + +} diff --git a/core/Controller/TwoFactorChallengeController.php b/core/Controller/TwoFactorChallengeController.php index 7a57d5eeb1a..4791139bb12 100644 --- a/core/Controller/TwoFactorChallengeController.php +++ b/core/Controller/TwoFactorChallengeController.php @@ -1,33 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Cornelius Kölbel <cornelius.koelbel@netknights.it> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Core\Controller; use OC\Authentication\TwoFactorAuth\Manager; use OC_User; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\UseSession; use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\StandaloneTemplateResponse; @@ -39,23 +25,21 @@ use OCP\IRequest; use OCP\ISession; use OCP\IURLGenerator; use OCP\IUserSession; +use OCP\Util; use Psr\Log\LoggerInterface; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class TwoFactorChallengeController extends Controller { - private Manager $twoFactorManager; - private IUserSession $userSession; - private ISession $session; - private LoggerInterface $logger; - private IURLGenerator $urlGenerator; - - public function __construct($appName, IRequest $request, Manager $twoFactorManager, IUserSession $userSession, - ISession $session, IURLGenerator $urlGenerator, LoggerInterface $logger) { + public function __construct( + string $appName, + IRequest $request, + private Manager $twoFactorManager, + private IUserSession $userSession, + private ISession $session, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + ) { parent::__construct($appName, $request); - $this->twoFactorManager = $twoFactorManager; - $this->userSession = $userSession; - $this->session = $session; - $this->urlGenerator = $urlGenerator; - $this->logger = $logger; } /** @@ -83,13 +67,14 @@ class TwoFactorChallengeController extends Controller { } /** - * @NoAdminRequired - * @NoCSRFRequired * @TwoFactorSetUpDoneRequired * * @param string $redirect_url * @return StandaloneTemplateResponse */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/login/selectchallenge')] public function selectChallenge($redirect_url) { $user = $this->userSession->getUser(); $providerSet = $this->twoFactorManager->getProviderSet($user); @@ -105,19 +90,21 @@ class TwoFactorChallengeController extends Controller { 'logout_url' => $this->getLogoutUrl(), 'hasSetupProviders' => !empty($setupProviders), ]; + Util::addScript('core', 'twofactor-request-token'); return new StandaloneTemplateResponse($this->appName, 'twofactorselectchallenge', $data, 'guest'); } /** - * @NoAdminRequired - * @NoCSRFRequired * @TwoFactorSetUpDoneRequired * * @param string $challengeProviderId * @param string $redirect_url * @return StandaloneTemplateResponse|RedirectResponse */ + #[NoAdminRequired] + #[NoCSRFRequired] #[UseSession] + #[FrontpageRoute(verb: 'GET', url: '/login/challenge/{challengeProviderId}')] public function showChallenge($challengeProviderId, $redirect_url) { $user = $this->userSession->getUser(); $providerSet = $this->twoFactorManager->getProviderSet($user); @@ -138,7 +125,7 @@ class TwoFactorChallengeController extends Controller { if ($this->session->exists('two_factor_auth_error')) { $this->session->remove('two_factor_auth_error'); $error = true; - $errorMessage = $this->session->get("two_factor_auth_error_message"); + $errorMessage = $this->session->get('two_factor_auth_error_message'); $this->session->remove('two_factor_auth_error_message'); } $tmpl = $provider->getTemplate($user); @@ -156,12 +143,11 @@ class TwoFactorChallengeController extends Controller { if ($provider instanceof IProvidesCustomCSP) { $response->setContentSecurityPolicy($provider->getCSP()); } + Util::addScript('core', 'twofactor-request-token'); return $response; } /** - * @NoAdminRequired - * @NoCSRFRequired * @TwoFactorSetUpDoneRequired * * @UserRateThrottle(limit=5, period=100) @@ -171,7 +157,10 @@ class TwoFactorChallengeController extends Controller { * @param string $redirect_url * @return RedirectResponse */ + #[NoAdminRequired] + #[NoCSRFRequired] #[UseSession] + #[FrontpageRoute(verb: 'POST', url: '/login/challenge/{challengeProviderId}')] public function solveChallenge($challengeProviderId, $challenge, $redirect_url = null) { $user = $this->userSession->getUser(); $provider = $this->twoFactorManager->getProvider($user, $challengeProviderId); @@ -205,27 +194,27 @@ class TwoFactorChallengeController extends Controller { ])); } - /** - * @NoAdminRequired - * @NoCSRFRequired - */ - public function setupProviders(): StandaloneTemplateResponse { + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: 'login/setupchallenge')] + public function setupProviders(?string $redirect_url = null): StandaloneTemplateResponse { $user = $this->userSession->getUser(); $setupProviders = $this->twoFactorManager->getLoginSetupProviders($user); $data = [ 'providers' => $setupProviders, 'logout_url' => $this->getLogoutUrl(), + 'redirect_url' => $redirect_url, ]; + Util::addScript('core', 'twofactor-request-token'); return new StandaloneTemplateResponse($this->appName, 'twofactorsetupselection', $data, 'guest'); } - /** - * @NoAdminRequired - * @NoCSRFRequired - */ - public function setupProvider(string $providerId) { + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: 'login/setupchallenge/{providerId}')] + public function setupProvider(string $providerId, ?string $redirect_url = null) { $user = $this->userSession->getUser(); $providers = $this->twoFactorManager->getLoginSetupProviders($user); @@ -246,23 +235,26 @@ class TwoFactorChallengeController extends Controller { $data = [ 'provider' => $provider, 'logout_url' => $this->getLogoutUrl(), + 'redirect_url' => $redirect_url, 'template' => $tmpl->fetchPage(), ]; $response = new StandaloneTemplateResponse($this->appName, 'twofactorsetupchallenge', $data, 'guest'); + Util::addScript('core', 'twofactor-request-token'); return $response; } /** - * @NoAdminRequired - * @NoCSRFRequired - * * @todo handle the extreme edge case of an invalid provider ID and redirect to the provider selection page */ - public function confirmProviderSetup(string $providerId) { + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'POST', url: 'login/setupchallenge/{providerId}')] + public function confirmProviderSetup(string $providerId, ?string $redirect_url = null) { return new RedirectResponse($this->urlGenerator->linkToRoute( 'core.TwoFactorChallenge.showChallenge', [ 'challengeProviderId' => $providerId, + 'redirect_url' => $redirect_url, ] )); } diff --git a/core/Controller/UnifiedSearchController.php b/core/Controller/UnifiedSearchController.php index 3290307dc23..c770c6240df 100644 --- a/core/Controller/UnifiedSearchController.php +++ b/core/Controller/UnifiedSearchController.php @@ -3,35 +3,22 @@ declare(strict_types=1); /** - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; +use InvalidArgumentException; +use OC\Core\ResponseDefinitions; use OC\Search\SearchComposer; use OC\Search\SearchQuery; -use OCP\AppFramework\OCSController; +use OC\Search\UnsupportedFilter; 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\DataResponse; +use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; @@ -39,31 +26,32 @@ use OCP\Route\IRouter; use OCP\Search\ISearchQuery; use Symfony\Component\Routing\Exception\ResourceNotFoundException; +/** + * @psalm-import-type CoreUnifiedSearchProvider from ResponseDefinitions + * @psalm-import-type CoreUnifiedSearchResult from ResponseDefinitions + */ class UnifiedSearchController extends OCSController { - private SearchComposer $composer; - private IUserSession $userSession; - private IRouter $router; - private IURLGenerator $urlGenerator; - - public function __construct(IRequest $request, - IUserSession $userSession, - SearchComposer $composer, - IRouter $router, - IURLGenerator $urlGenerator) { + public function __construct( + IRequest $request, + private IUserSession $userSession, + private SearchComposer $composer, + private IRouter $router, + private IURLGenerator $urlGenerator, + ) { parent::__construct('core', $request); - - $this->composer = $composer; - $this->userSession = $userSession; - $this->router = $router; - $this->urlGenerator = $urlGenerator; } /** - * @NoAdminRequired - * @NoCSRFRequired + * Get the providers for unified search * * @param string $from the url the user is currently at + * @return DataResponse<Http::STATUS_OK, list<CoreUnifiedSearchProvider>, array{}> + * + * 200: Providers returned */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/providers', root: '/search')] public function getProviders(string $from = ''): DataResponse { [$route, $parameters] = $this->getRouteInformation($from); @@ -74,42 +62,58 @@ class UnifiedSearchController extends OCSController { } /** - * @NoAdminRequired - * @NoCSRFRequired + * Launch a search for a specific search provider. * - * @param string $providerId - * @param string $term - * @param int|null $sortOrder - * @param int|null $limit - * @param int|string|null $cursor - * @param string $from + * Additional filters are available for each provider. + * Send a request to /providers endpoint to list providers with their available filters. * - * @return DataResponse + * @param string $providerId ID of the provider + * @param string $term Term to search + * @param int|null $sortOrder Order of entries + * @param int|null $limit Maximum amount of entries, limited to 25 + * @param int|string|null $cursor Offset for searching + * @param string $from The current user URL + * + * @return DataResponse<Http::STATUS_OK, CoreUnifiedSearchResult, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}> + * + * 200: Search entries returned + * 400: Searching is not possible */ - public function search(string $providerId, - string $term = '', - ?int $sortOrder = null, - ?int $limit = null, - $cursor = null, - string $from = ''): DataResponse { - if (empty(trim($term))) { - return new DataResponse(null, Http::STATUS_BAD_REQUEST); - } + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/providers/{providerId}/search', root: '/search')] + public function search( + string $providerId, + // Unused parameter for OpenAPI spec generator + string $term = '', + ?int $sortOrder = null, + ?int $limit = null, + $cursor = null, + string $from = '', + ): DataResponse { [$route, $routeParameters] = $this->getRouteInformation($from); + $limit ??= SearchQuery::LIMIT_DEFAULT; + $limit = max(1, min($limit, 25)); + + try { + $filters = $this->composer->buildFilterList($providerId, $this->request->getParams()); + } catch (UnsupportedFilter|InvalidArgumentException $e) { + return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } return new DataResponse( $this->composer->search( $this->userSession->getUser(), $providerId, new SearchQuery( - $term, + $filters, $sortOrder ?? ISearchQuery::SORT_DATE_DESC, - $limit ?? SearchQuery::LIMIT_DEFAULT, + $limit, $cursor, $route, $routeParameters ) - ) + )->jsonSerialize() ); } diff --git a/core/Controller/UnsupportedBrowserController.php b/core/Controller/UnsupportedBrowserController.php index 8cdc190deea..2877e2e9047 100644 --- a/core/Controller/UnsupportedBrowserController.php +++ b/core/Controller/UnsupportedBrowserController.php @@ -3,49 +3,39 @@ declare(strict_types=1); /** - * @copyright 2021 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\TemplateResponse; use OCP\IRequest; use OCP\Util; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class UnsupportedBrowserController extends Controller { public function __construct(IRequest $request) { parent::__construct('core', $request); } /** - * @PublicPage - * @NoCSRFRequired - * * @return Response */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: 'unsupported')] public function index(): Response { Util::addScript('core', 'unsupported-browser'); Util::addStyle('core', 'icons'); - return new TemplateResponse('core', 'unsupportedbrowser', [], TemplateResponse::RENDER_AS_ERROR); + + // not using RENDER_AS_ERROR as we need the JSConfigHelper for url generation + return new TemplateResponse('core', 'unsupportedbrowser', [], TemplateResponse::RENDER_AS_GUEST); } } diff --git a/core/Controller/UserController.php b/core/Controller/UserController.php index b9946dfaec6..b6e464d9a95 100644 --- a/core/Controller/UserController.php +++ b/core/Controller/UserController.php @@ -1,54 +1,37 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Core\Controller; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; use OCP\IUserManager; class UserController extends Controller { - protected IUserManager $userManager; - - public function __construct($appName, - IRequest $request, - IUserManager $userManager + public function __construct( + string $appName, + IRequest $request, + protected IUserManager $userManager, ) { parent::__construct($appName, $request); - $this->userManager = $userManager; } /** * Lookup user display names * - * @NoAdminRequired - * * @param array $users * * @return JSONResponse */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'POST', url: '/displaynames')] public function getDisplayNames($users) { $result = []; diff --git a/core/Controller/WalledGardenController.php b/core/Controller/WalledGardenController.php index 0079cc5a69a..d0bc0665534 100644 --- a/core/Controller/WalledGardenController.php +++ b/core/Controller/WalledGardenController.php @@ -1,37 +1,24 @@ <?php + /** - * @copyright 2017, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Response; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class WalledGardenController extends Controller { - /** - * @PublicPage - * @NoCSRFRequired - */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/204')] public function get(): Response { $resp = new Response(); $resp->setStatus(Http::STATUS_NO_CONTENT); diff --git a/core/Controller/WebAuthnController.php b/core/Controller/WebAuthnController.php index bd0726d2aa2..d7255831e88 100644 --- a/core/Controller/WebAuthnController.php +++ b/core/Controller/WebAuthnController.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Richard Steinmetz <richard@steinmetz.cloud> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; @@ -33,6 +14,8 @@ use OC\Authentication\WebAuthn\Manager; use OC\URLGenerator; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UseSession; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; @@ -45,27 +28,21 @@ class WebAuthnController extends Controller { private const WEBAUTHN_LOGIN = 'webauthn_login'; private const WEBAUTHN_LOGIN_UID = 'webauthn_login_uid'; - private Manager $webAuthnManger; - private ISession $session; - private LoggerInterface $logger; - private WebAuthnChain $webAuthnChain; - private UrlGenerator $urlGenerator; - - public function __construct($appName, IRequest $request, Manager $webAuthnManger, ISession $session, LoggerInterface $logger, WebAuthnChain $webAuthnChain, URLGenerator $urlGenerator) { + public function __construct( + string $appName, + IRequest $request, + private Manager $webAuthnManger, + private ISession $session, + private LoggerInterface $logger, + private WebAuthnChain $webAuthnChain, + private URLGenerator $urlGenerator, + ) { parent::__construct($appName, $request); - - $this->webAuthnManger = $webAuthnManger; - $this->session = $session; - $this->logger = $logger; - $this->webAuthnChain = $webAuthnChain; - $this->urlGenerator = $urlGenerator; } - /** - * @NoAdminRequired - * @PublicPage - */ + #[PublicPage] #[UseSession] + #[FrontpageRoute(verb: 'POST', url: 'login/webauthn/start')] public function startAuthentication(string $loginName): JSONResponse { $this->logger->debug('Starting WebAuthn login'); @@ -85,11 +62,9 @@ class WebAuthnController extends Controller { return new JSONResponse($publicKeyCredentialRequestOptions); } - /** - * @NoAdminRequired - * @PublicPage - */ + #[PublicPage] #[UseSession] + #[FrontpageRoute(verb: 'POST', url: 'login/webauthn/finish')] public function finishAuthentication(string $data): JSONResponse { $this->logger->debug('Validating WebAuthn login'); diff --git a/core/Controller/WellKnownController.php b/core/Controller/WellKnownController.php index 01ae5e4fae8..9ce83686355 100644 --- a/core/Controller/WellKnownController.php +++ b/core/Controller/WellKnownController.php @@ -3,51 +3,37 @@ declare(strict_types=1); /** - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Http\WellKnown\RequestManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\IRequest; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class WellKnownController extends Controller { - /** @var RequestManager */ - private $requestManager; - - public function __construct(IRequest $request, - RequestManager $wellKnownManager) { + public function __construct( + IRequest $request, + private RequestManager $requestManager, + ) { parent::__construct('core', $request); - $this->requestManager = $wellKnownManager; } /** - * @PublicPage - * @NoCSRFRequired - * * @return Response */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '.well-known/{service}')] public function handle(string $service): Response { $response = $this->requestManager->process( $service, @@ -55,7 +41,7 @@ class WellKnownController extends Controller { ); if ($response === null) { - $httpResponse = new JSONResponse(["message" => "$service not supported"], Http::STATUS_NOT_FOUND); + $httpResponse = new JSONResponse(['message' => "$service not supported"], Http::STATUS_NOT_FOUND); } else { $httpResponse = $response->toHttpResponse(); } diff --git a/core/Controller/WhatsNewController.php b/core/Controller/WhatsNewController.php index 0dae0f97322..af8c3d4853b 100644 --- a/core/Controller/WhatsNewController.php +++ b/core/Controller/WhatsNewController.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; @@ -28,6 +11,8 @@ use OC\Security\IdentityProof\Manager; use OC\Updater\ChangesCheck; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\Defaults; use OCP\IConfig; @@ -35,46 +20,40 @@ use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\PreConditionNotMetException; +use OCP\ServerVersion; class WhatsNewController extends OCSController { - /** @var IConfig */ - protected $config; - /** @var IUserSession */ - private $userSession; - /** @var ChangesCheck */ - private $whatsNewService; - /** @var IFactory */ - private $langFactory; - /** @var Defaults */ - private $defaults; - public function __construct( string $appName, IRequest $request, CapabilitiesManager $capabilitiesManager, - IUserSession $userSession, + private IUserSession $userSession, IUserManager $userManager, Manager $keyManager, - IConfig $config, - ChangesCheck $whatsNewService, - IFactory $langFactory, - Defaults $defaults + ServerVersion $serverVersion, + private IConfig $config, + private ChangesCheck $whatsNewService, + private IFactory $langFactory, + private Defaults $defaults, ) { - parent::__construct($appName, $request, $capabilitiesManager, $userSession, $userManager, $keyManager); - $this->config = $config; - $this->userSession = $userSession; - $this->whatsNewService = $whatsNewService; - $this->langFactory = $langFactory; - $this->defaults = $defaults; + parent::__construct($appName, $request, $capabilitiesManager, $userSession, $userManager, $keyManager, $serverVersion); } /** - * @NoAdminRequired + * Get the changes + * + * @return DataResponse<Http::STATUS_OK, array{changelogURL: string, product: string, version: string, whatsNew?: array{regular: list<string>, admin: list<string>}}, array{}>|DataResponse<Http::STATUS_NO_CONTENT, list<empty>, array{}> + * + * 200: Changes returned + * 204: No changes */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/whatsnew', root: '/core')] public function get():DataResponse { $user = $this->userSession->getUser(); if ($user === null) { - throw new \RuntimeException("Acting user cannot be resolved"); + throw new \RuntimeException('Acting user cannot be resolved'); } $lastRead = $this->config->getUserValue($user->getUID(), 'core', 'whatsNewLastRead', 0); $currentVersion = $this->whatsNewService->normalizeVersion($this->config->getSystemValue('version')); @@ -106,15 +85,22 @@ class WhatsNewController extends OCSController { } /** - * @NoAdminRequired + * Dismiss the changes + * + * @param string $version Version to dismiss the changes for * - * @throws \OCP\PreConditionNotMetException + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws PreConditionNotMetException * @throws DoesNotExistException + * + * 200: Changes dismissed */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/whatsnew', root: '/core')] public function dismiss(string $version):DataResponse { $user = $this->userSession->getUser(); if ($user === null) { - throw new \RuntimeException("Acting user cannot be resolved"); + throw new \RuntimeException('Acting user cannot be resolved'); } $version = $this->whatsNewService->normalizeVersion($version); // checks whether it's a valid version, throws an Exception otherwise diff --git a/core/Controller/WipeController.php b/core/Controller/WipeController.php index 44ec5fc598a..1b57be71aa0 100644 --- a/core/Controller/WipeController.php +++ b/core/Controller/WipeController.php @@ -3,58 +3,47 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; -use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\RemoteWipe; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; +use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\IRequest; +#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] class WipeController extends Controller { - /** @var RemoteWipe */ - private $remoteWipe; - - public function __construct(string $appName, - IRequest $request, - RemoteWipe $remoteWipe) { + public function __construct( + string $appName, + IRequest $request, + private RemoteWipe $remoteWipe, + ) { parent::__construct($appName, $request); - - $this->remoteWipe = $remoteWipe; } /** - * @NoAdminRequired - * @NoCSRFRequired - * @PublicPage + * Check if the device should be wiped * - * @AnonRateThrottle(limit=10, period=300) + * @param string $token App password * - * @param string $token + * @return JSONResponse<Http::STATUS_OK, array{wipe: bool}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> * - * @return JSONResponse + * 200: Device should be wiped + * 404: Device should not be wiped */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 10, period: 300)] + #[FrontpageRoute(verb: 'POST', url: '/core/wipe/check')] public function checkWipe(string $token): JSONResponse { try { if ($this->remoteWipe->start($token)) { @@ -71,16 +60,19 @@ class WipeController extends Controller { /** - * @NoAdminRequired - * @NoCSRFRequired - * @PublicPage + * Finish the wipe * - * @AnonRateThrottle(limit=10, period=300) + * @param string $token App password * - * @param string $token + * @return JSONResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, list<empty>, array{}> * - * @return JSONResponse + * 200: Wipe finished successfully + * 404: Device should not be wiped */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 10, period: 300)] + #[FrontpageRoute(verb: 'POST', url: '/core/wipe/success')] public function wipeDone(string $token): JSONResponse { try { if ($this->remoteWipe->finish($token)) { |