aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/lib/Controller
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/lib/Controller')
-rw-r--r--apps/settings/lib/Controller/AISettingsController.php46
-rw-r--r--apps/settings/lib/Controller/AdminSettingsController.php61
-rw-r--r--apps/settings/lib/Controller/AppSettingsController.php689
-rw-r--r--apps/settings/lib/Controller/AuthSettingsController.php285
-rw-r--r--apps/settings/lib/Controller/AuthorizedGroupController.php63
-rw-r--r--apps/settings/lib/Controller/ChangePasswordController.php224
-rw-r--r--apps/settings/lib/Controller/CheckSetupController.php138
-rw-r--r--apps/settings/lib/Controller/CommonSettingsTrait.php191
-rw-r--r--apps/settings/lib/Controller/DeclarativeSettingsController.php131
-rw-r--r--apps/settings/lib/Controller/HelpController.php91
-rw-r--r--apps/settings/lib/Controller/LogSettingsController.php50
-rw-r--r--apps/settings/lib/Controller/MailSettingsController.php155
-rw-r--r--apps/settings/lib/Controller/PersonalSettingsController.php59
-rw-r--r--apps/settings/lib/Controller/ReasonsController.php33
-rw-r--r--apps/settings/lib/Controller/TwoFactorSettingsController.php41
-rw-r--r--apps/settings/lib/Controller/UsersController.php584
-rw-r--r--apps/settings/lib/Controller/WebAuthnController.php93
17 files changed, 2934 insertions, 0 deletions
diff --git a/apps/settings/lib/Controller/AISettingsController.php b/apps/settings/lib/Controller/AISettingsController.php
new file mode 100644
index 00000000000..114cbf61514
--- /dev/null
+++ b/apps/settings/lib/Controller/AISettingsController.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Settings\Controller;
+
+use OCA\Settings\Settings\Admin\ArtificialIntelligence;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\IAppConfig;
+use OCP\IRequest;
+
+class AISettingsController extends Controller {
+
+ public function __construct(
+ $appName,
+ IRequest $request,
+ private IAppConfig $appConfig,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Sets the email settings
+ *
+ * @param array $settings
+ * @return DataResponse
+ */
+ #[AuthorizedAdminSetting(settings: ArtificialIntelligence::class)]
+ public function update($settings) {
+ $keys = ['ai.stt_provider', 'ai.textprocessing_provider_preferences', 'ai.taskprocessing_provider_preferences','ai.taskprocessing_type_preferences', 'ai.translation_provider_preferences', 'ai.text2image_provider', 'ai.taskprocessing_guests'];
+ foreach ($keys as $key) {
+ if (!isset($settings[$key])) {
+ continue;
+ }
+ $this->appConfig->setValueString('core', $key, json_encode($settings[$key]), lazy: in_array($key, \OC\TaskProcessing\Manager::LAZY_CONFIG_KEYS, true));
+ }
+
+ return new DataResponse();
+ }
+}
diff --git a/apps/settings/lib/Controller/AdminSettingsController.php b/apps/settings/lib/Controller/AdminSettingsController.php
new file mode 100644
index 00000000000..15e2c392148
--- /dev/null
+++ b/apps/settings/lib/Controller/AdminSettingsController.php
@@ -0,0 +1,61 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Settings\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\Group\ISubAdmin;
+use OCP\IGroupManager;
+use OCP\INavigationManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
+use OCP\Settings\IManager as ISettingsManager;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class AdminSettingsController extends Controller {
+ use CommonSettingsTrait;
+
+ public function __construct(
+ $appName,
+ IRequest $request,
+ INavigationManager $navigationManager,
+ ISettingsManager $settingsManager,
+ IUserSession $userSession,
+ IGroupManager $groupManager,
+ ISubAdmin $subAdmin,
+ IDeclarativeManager $declarativeSettingsManager,
+ IInitialState $initialState,
+ ) {
+ parent::__construct($appName, $request);
+ $this->navigationManager = $navigationManager;
+ $this->settingsManager = $settingsManager;
+ $this->userSession = $userSession;
+ $this->groupManager = $groupManager;
+ $this->subAdmin = $subAdmin;
+ $this->declarativeSettingsManager = $declarativeSettingsManager;
+ $this->initialState = $initialState;
+ }
+
+ /**
+ * @NoSubAdminRequired
+ * We are checking the permissions in the getSettings method. If there is no allowed
+ * settings for the given section. The user will be greeted by an error message.
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function index(string $section): TemplateResponse {
+ return $this->getIndexResponse(
+ 'admin',
+ $section,
+ );
+ }
+}
diff --git a/apps/settings/lib/Controller/AppSettingsController.php b/apps/settings/lib/Controller/AppSettingsController.php
new file mode 100644
index 00000000000..a85ee8cc20a
--- /dev/null
+++ b/apps/settings/lib/Controller/AppSettingsController.php
@@ -0,0 +1,689 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Settings\Controller;
+
+use OC\App\AppManager;
+use OC\App\AppStore\Bundles\BundleFetcher;
+use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
+use OC\App\AppStore\Fetcher\AppFetcher;
+use OC\App\AppStore\Fetcher\CategoryFetcher;
+use OC\App\AppStore\Version\VersionParser;
+use OC\App\DependencyAnalyzer;
+use OC\App\Platform;
+use OC\Installer;
+use OCA\AppAPI\Service\ExAppsPageService;
+use OCP\App\AppPathNotFoundException;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
+use OCP\AppFramework\Http\FileDisplayResponse;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\NotFoundResponse;
+use OCP\AppFramework\Http\Response;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\Files\AppData\IAppDataFactory;
+use OCP\Files\IAppData;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\Files\SimpleFS\ISimpleFile;
+use OCP\Files\SimpleFS\ISimpleFolder;
+use OCP\Http\Client\IClientService;
+use OCP\IConfig;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IL10N;
+use OCP\INavigationManager;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use OCP\Security\RateLimiting\ILimiter;
+use OCP\Server;
+use OCP\Util;
+use Psr\Log\LoggerInterface;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class AppSettingsController extends Controller {
+
+ /** @var array */
+ private $allApps = [];
+
+ private IAppData $appData;
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ IAppDataFactory $appDataFactory,
+ private IL10N $l10n,
+ private IConfig $config,
+ private INavigationManager $navigationManager,
+ private AppManager $appManager,
+ private CategoryFetcher $categoryFetcher,
+ private AppFetcher $appFetcher,
+ private IFactory $l10nFactory,
+ private BundleFetcher $bundleFetcher,
+ private Installer $installer,
+ private IURLGenerator $urlGenerator,
+ private LoggerInterface $logger,
+ private IInitialState $initialState,
+ private AppDiscoverFetcher $discoverFetcher,
+ private IClientService $clientService,
+ ) {
+ parent::__construct($appName, $request);
+ $this->appData = $appDataFactory->get('appstore');
+ }
+
+ /**
+ * @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
+ *
+ * @return TemplateResponse
+ */
+ #[NoCSRFRequired]
+ public function viewApps(): TemplateResponse {
+ $this->navigationManager->setActiveEntry('core_apps');
+
+ $this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
+ $this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
+ $this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual'));
+ $this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
+
+ if ($this->appManager->isEnabledForAnyone('app_api')) {
+ try {
+ Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
+ } catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
+ }
+ }
+
+ $policy = new ContentSecurityPolicy();
+ $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
+
+ $templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
+ $templateResponse->setContentSecurityPolicy($policy);
+
+ Util::addStyle('settings', 'settings');
+ Util::addScript('settings', 'vue-settings-apps-users-management');
+
+ return $templateResponse;
+ }
+
+ /**
+ * Get all active entries for the app discover section
+ */
+ #[NoCSRFRequired]
+ public function getAppDiscoverJSON(): JSONResponse {
+ $data = $this->discoverFetcher->get(true);
+ return new JSONResponse(array_values($data));
+ }
+
+ /**
+ * Get a image for the app discover section - this is proxied for privacy and CSP reasons
+ *
+ * @param string $image
+ * @throws \Exception
+ */
+ #[NoCSRFRequired]
+ public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
+ $getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
+ $etag = trim($getEtag, '"');
+
+ $folder = null;
+ try {
+ $folder = $this->appData->getFolder('app-discover-cache');
+ $this->cleanUpImageCache($folder, $etag);
+ } catch (\Throwable $e) {
+ $folder = $this->appData->newFolder('app-discover-cache');
+ }
+
+ // Get the current cache folder
+ try {
+ $folder = $folder->getFolder($etag);
+ } catch (NotFoundException $e) {
+ $folder = $folder->newFolder($etag);
+ }
+
+ $info = pathinfo($fileName);
+ $hashName = md5($fileName);
+ $allFiles = $folder->getDirectoryListing();
+ // Try to find the file
+ $file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
+ return str_starts_with($file->getName(), $hashName);
+ });
+ // Get the first entry
+ $file = reset($file);
+ // If not found request from Web
+ if ($file === false) {
+ $user = $session->getUser();
+ // this route is not public thus we can assume a user is logged-in
+ assert($user !== null);
+ // Register a user request to throttle fetching external data
+ // this will prevent using the server for DoS of other systems.
+ $limiter->registerUserRequest(
+ 'settings-discover-media',
+ // allow up to 24 media requests per hour
+ // this should be a sane default when a completely new section is loaded
+ // keep in mind browsers request all files from a source-set
+ 24,
+ 60 * 60,
+ $user,
+ );
+
+ if (!$this->checkCanDownloadMedia($fileName)) {
+ $this->logger->warning('Tried to load media files for app discover section from untrusted source');
+ return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $client = $this->clientService->newClient();
+ $fileResponse = $client->get($fileName);
+ $contentType = $fileResponse->getHeader('Content-Type');
+ $extension = $info['extension'] ?? '';
+ $file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
+ } catch (\Throwable $e) {
+ $this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
+ return new NotFoundResponse();
+ }
+ } else {
+ // File was found so we can get the content type from the file name
+ $contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
+ }
+
+ $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
+ // cache for 7 days
+ $response->cacheFor(604800, false, true);
+ return $response;
+ }
+
+ private function checkCanDownloadMedia(string $filename): bool {
+ $urlInfo = parse_url($filename);
+ if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
+ return false;
+ }
+
+ // Always allowed hosts
+ if ($urlInfo['host'] === 'nextcloud.com') {
+ return true;
+ }
+
+ // Hosts that need further verification
+ // Github is only allowed if from our organization
+ $ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
+ if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
+ return false;
+ }
+
+ if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Remove orphaned folders from the image cache that do not match the current etag
+ * @param ISimpleFolder $folder The folder to clear
+ * @param string $etag The etag (directory name) to keep
+ */
+ private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
+ // Cleanup old cache folders
+ $allFiles = $folder->getDirectoryListing();
+ foreach ($allFiles as $dir) {
+ try {
+ if ($dir->getName() !== $etag) {
+ $dir->delete();
+ }
+ } catch (NotPermittedException $e) {
+ // ignore folder for now
+ }
+ }
+ }
+
+ private function getAppsWithUpdates() {
+ $appClass = new \OC_App();
+ $apps = $appClass->listAllApps();
+ foreach ($apps as $key => $app) {
+ $newVersion = $this->installer->isUpdateAvailable($app['id']);
+ if ($newVersion === false) {
+ unset($apps[$key]);
+ }
+ }
+ return $apps;
+ }
+
+ private function getBundles() {
+ $result = [];
+ $bundles = $this->bundleFetcher->getBundles();
+ foreach ($bundles as $bundle) {
+ $result[] = [
+ 'name' => $bundle->getName(),
+ 'id' => $bundle->getIdentifier(),
+ 'appIdentifiers' => $bundle->getAppIdentifiers()
+ ];
+ }
+ return $result;
+ }
+
+ /**
+ * Get all available categories
+ *
+ * @return JSONResponse
+ */
+ public function listCategories(): JSONResponse {
+ return new JSONResponse($this->getAllCategories());
+ }
+
+ private function getAllCategories() {
+ $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
+
+ $categories = $this->categoryFetcher->get();
+ return array_map(fn ($category) => [
+ 'id' => $category['id'],
+ 'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
+ ], $categories);
+ }
+
+ /**
+ * Convert URL to proxied URL so CSP is no problem
+ */
+ private function createProxyPreviewUrl(string $url): string {
+ if ($url === '') {
+ return '';
+ }
+ return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
+ }
+
+ private function fetchApps() {
+ $appClass = new \OC_App();
+ $apps = $appClass->listAllApps();
+ foreach ($apps as $app) {
+ $app['installed'] = true;
+
+ if (isset($app['screenshot'][0])) {
+ $appScreenshot = $app['screenshot'][0] ?? null;
+ if (is_array($appScreenshot)) {
+ // Screenshot with thumbnail
+ $appScreenshot = $appScreenshot['@value'];
+ }
+
+ $app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
+ }
+ $this->allApps[$app['id']] = $app;
+ }
+
+ $apps = $this->getAppsForCategory('');
+ $supportedApps = $appClass->getSupportedApps();
+ foreach ($apps as $app) {
+ $app['appstore'] = true;
+ if (!array_key_exists($app['id'], $this->allApps)) {
+ $this->allApps[$app['id']] = $app;
+ } else {
+ $this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
+ }
+
+ if (in_array($app['id'], $supportedApps)) {
+ $this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
+ }
+ }
+
+ // add bundle information
+ $bundles = $this->bundleFetcher->getBundles();
+ foreach ($bundles as $bundle) {
+ foreach ($bundle->getAppIdentifiers() as $identifier) {
+ foreach ($this->allApps as &$app) {
+ if ($app['id'] === $identifier) {
+ $app['bundleIds'][] = $bundle->getIdentifier();
+ continue;
+ }
+ }
+ }
+ }
+ }
+
+ private function getAllApps() {
+ return $this->allApps;
+ }
+
+ /**
+ * Get all available apps in a category
+ *
+ * @return JSONResponse
+ * @throws \Exception
+ */
+ public function listApps(): JSONResponse {
+ $this->fetchApps();
+ $apps = $this->getAllApps();
+
+ $dependencyAnalyzer = new DependencyAnalyzer(new Platform($this->config), $this->l10n);
+
+ $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
+ if (!is_array($ignoreMaxApps)) {
+ $this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
+ $ignoreMaxApps = [];
+ }
+
+ // Extend existing app details
+ $apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) {
+ if (isset($appData['appstoreData'])) {
+ $appstoreData = $appData['appstoreData'];
+ $appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
+ $appData['category'] = $appstoreData['categories'];
+ $appData['releases'] = $appstoreData['releases'];
+ }
+
+ $newVersion = $this->installer->isUpdateAvailable($appData['id']);
+ if ($newVersion) {
+ $appData['update'] = $newVersion;
+ }
+
+ // fix groups to be an array
+ $groups = [];
+ if (is_string($appData['groups'])) {
+ $groups = json_decode($appData['groups']);
+ // ensure 'groups' is an array
+ if (!is_array($groups)) {
+ $groups = [$groups];
+ }
+ }
+ $appData['groups'] = $groups;
+ $appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
+
+ // fix licence vs license
+ if (isset($appData['license']) && !isset($appData['licence'])) {
+ $appData['licence'] = $appData['license'];
+ }
+
+ $ignoreMax = in_array($appData['id'], $ignoreMaxApps);
+
+ // analyse dependencies
+ $missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
+ $appData['canInstall'] = empty($missing);
+ $appData['missingDependencies'] = $missing;
+
+ $appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
+ $appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
+ $appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
+
+ return $appData;
+ }, $apps);
+
+ usort($apps, [$this, 'sortApps']);
+
+ return new JSONResponse(['apps' => $apps, 'status' => 'success']);
+ }
+
+ /**
+ * Get all apps for a category from the app store
+ *
+ * @param string $requestedCategory
+ * @return array
+ * @throws \Exception
+ */
+ private function getAppsForCategory($requestedCategory = ''): array {
+ $versionParser = new VersionParser();
+ $formattedApps = [];
+ $apps = $this->appFetcher->get();
+ foreach ($apps as $app) {
+ // Skip all apps not in the requested category
+ if ($requestedCategory !== '') {
+ $isInCategory = false;
+ foreach ($app['categories'] as $category) {
+ if ($category === $requestedCategory) {
+ $isInCategory = true;
+ }
+ }
+ if (!$isInCategory) {
+ continue;
+ }
+ }
+
+ if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
+ continue;
+ }
+ $nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
+ $nextCloudVersionDependencies = [];
+ if ($nextCloudVersion->getMinimumVersion() !== '') {
+ $nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion();
+ }
+ if ($nextCloudVersion->getMaximumVersion() !== '') {
+ $nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
+ }
+ $phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
+
+ try {
+ $this->appManager->getAppPath($app['id']);
+ $existsLocally = true;
+ } catch (AppPathNotFoundException) {
+ $existsLocally = false;
+ }
+
+ $phpDependencies = [];
+ if ($phpVersion->getMinimumVersion() !== '') {
+ $phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
+ }
+ if ($phpVersion->getMaximumVersion() !== '') {
+ $phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
+ }
+ if (isset($app['releases'][0]['minIntSize'])) {
+ $phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
+ }
+ $authors = '';
+ foreach ($app['authors'] as $key => $author) {
+ $authors .= $author['name'];
+ if ($key !== count($app['authors']) - 1) {
+ $authors .= ', ';
+ }
+ }
+
+ $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
+ $enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
+ $groups = null;
+ if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
+ $groups = $enabledValue;
+ }
+
+ $currentVersion = '';
+ if ($this->appManager->isEnabledForAnyone($app['id'])) {
+ $currentVersion = $this->appManager->getAppVersion($app['id']);
+ } else {
+ $currentVersion = $app['releases'][0]['version'];
+ }
+
+ $formattedApps[] = [
+ 'id' => $app['id'],
+ 'app_api' => false,
+ 'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
+ 'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
+ 'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
+ 'license' => $app['releases'][0]['licenses'],
+ 'author' => $authors,
+ 'shipped' => $this->appManager->isShipped($app['id']),
+ 'version' => $currentVersion,
+ 'default_enable' => '',
+ 'types' => [],
+ 'documentation' => [
+ 'admin' => $app['adminDocs'],
+ 'user' => $app['userDocs'],
+ 'developer' => $app['developerDocs']
+ ],
+ 'website' => $app['website'],
+ 'bugs' => $app['issueTracker'],
+ 'detailpage' => $app['website'],
+ 'dependencies' => array_merge(
+ $nextCloudVersionDependencies,
+ $phpDependencies
+ ),
+ 'level' => ($app['isFeatured'] === true) ? 200 : 100,
+ 'missingMaxOwnCloudVersion' => false,
+ 'missingMinOwnCloudVersion' => false,
+ 'canInstall' => true,
+ 'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
+ 'score' => $app['ratingOverall'],
+ 'ratingNumOverall' => $app['ratingNumOverall'],
+ 'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
+ 'removable' => $existsLocally,
+ 'active' => $this->appManager->isEnabledForUser($app['id']),
+ 'needsDownload' => !$existsLocally,
+ 'groups' => $groups,
+ 'fromAppStore' => true,
+ 'appstoreData' => $app,
+ ];
+ }
+
+ return $formattedApps;
+ }
+
+ /**
+ * @param string $appId
+ * @param array $groups
+ * @return JSONResponse
+ */
+ #[PasswordConfirmationRequired]
+ public function enableApp(string $appId, array $groups = []): JSONResponse {
+ return $this->enableApps([$appId], $groups);
+ }
+
+ /**
+ * Enable one or more apps
+ *
+ * apps will be enabled for specific groups only if $groups is defined
+ *
+ * @param array $appIds
+ * @param array $groups
+ * @return JSONResponse
+ */
+ #[PasswordConfirmationRequired]
+ public function enableApps(array $appIds, array $groups = []): JSONResponse {
+ try {
+ $updateRequired = false;
+
+ foreach ($appIds as $appId) {
+ $appId = $this->appManager->cleanAppId($appId);
+
+ // Check if app is already downloaded
+ /** @var Installer $installer */
+ $installer = Server::get(Installer::class);
+ $isDownloaded = $installer->isDownloaded($appId);
+
+ if (!$isDownloaded) {
+ $installer->downloadApp($appId);
+ }
+
+ $installer->installApp($appId);
+
+ if (count($groups) > 0) {
+ $this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
+ } else {
+ $this->appManager->enableApp($appId);
+ }
+ if (\OC_App::shouldUpgrade($appId)) {
+ $updateRequired = true;
+ }
+ }
+ return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
+ } catch (\Throwable $e) {
+ $this->logger->error('could not enable apps', ['exception' => $e]);
+ return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ private function getGroupList(array $groups) {
+ $groupManager = Server::get(IGroupManager::class);
+ $groupsList = [];
+ foreach ($groups as $group) {
+ $groupItem = $groupManager->get($group);
+ if ($groupItem instanceof IGroup) {
+ $groupsList[] = $groupManager->get($group);
+ }
+ }
+ return $groupsList;
+ }
+
+ /**
+ * @param string $appId
+ * @return JSONResponse
+ */
+ #[PasswordConfirmationRequired]
+ public function disableApp(string $appId): JSONResponse {
+ return $this->disableApps([$appId]);
+ }
+
+ /**
+ * @param array $appIds
+ * @return JSONResponse
+ */
+ #[PasswordConfirmationRequired]
+ public function disableApps(array $appIds): JSONResponse {
+ try {
+ foreach ($appIds as $appId) {
+ $appId = $this->appManager->cleanAppId($appId);
+ $this->appManager->disableApp($appId);
+ }
+ return new JSONResponse([]);
+ } catch (\Exception $e) {
+ $this->logger->error('could not disable app', ['exception' => $e]);
+ return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * @param string $appId
+ * @return JSONResponse
+ */
+ #[PasswordConfirmationRequired]
+ public function uninstallApp(string $appId): JSONResponse {
+ $appId = $this->appManager->cleanAppId($appId);
+ $result = $this->installer->removeApp($appId);
+ if ($result !== false) {
+ // If this app was force enabled, remove the force-enabled-state
+ $this->appManager->removeOverwriteNextcloudRequirement($appId);
+ $this->appManager->clearAppsCache();
+ return new JSONResponse(['data' => ['appid' => $appId]]);
+ }
+ return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ /**
+ * @param string $appId
+ * @return JSONResponse
+ */
+ public function updateApp(string $appId): JSONResponse {
+ $appId = $this->appManager->cleanAppId($appId);
+
+ $this->config->setSystemValue('maintenance', true);
+ try {
+ $result = $this->installer->updateAppstoreApp($appId);
+ $this->config->setSystemValue('maintenance', false);
+ } catch (\Exception $ex) {
+ $this->config->setSystemValue('maintenance', false);
+ return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ if ($result !== false) {
+ return new JSONResponse(['data' => ['appid' => $appId]]);
+ }
+ return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ private function sortApps($a, $b) {
+ $a = (string)$a['name'];
+ $b = (string)$b['name'];
+ if ($a === $b) {
+ return 0;
+ }
+ return ($a < $b) ? -1 : 1;
+ }
+
+ public function force(string $appId): JSONResponse {
+ $appId = $this->appManager->cleanAppId($appId);
+ $this->appManager->overwriteNextcloudRequirement($appId);
+ return new JSONResponse();
+ }
+}
diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php
new file mode 100644
index 00000000000..8652a49fb1d
--- /dev/null
+++ b/apps/settings/lib/Controller/AuthSettingsController.php
@@ -0,0 +1,285 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Settings\Controller;
+
+use BadMethodCallException;
+use OC\Authentication\Exceptions\InvalidTokenException as OcInvalidTokenException;
+use OC\Authentication\Exceptions\PasswordlessTokenException;
+use OC\Authentication\Token\INamedToken;
+use OC\Authentication\Token\IProvider;
+use OC\Authentication\Token\RemoteWipe;
+use OCA\Settings\Activity\Provider;
+use OCP\Activity\IManager;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\Authentication\Exceptions\ExpiredTokenException;
+use OCP\Authentication\Exceptions\InvalidTokenException;
+use OCP\Authentication\Exceptions\WipeTokenException;
+use OCP\Authentication\Token\IToken;
+use OCP\IRequest;
+use OCP\ISession;
+use OCP\IUserSession;
+use OCP\Security\ISecureRandom;
+use OCP\Session\Exceptions\SessionNotAvailableException;
+use Psr\Log\LoggerInterface;
+
+class AuthSettingsController extends Controller {
+ /** @var IProvider */
+ private $tokenProvider;
+
+ /** @var RemoteWipe */
+ private $remoteWipe;
+
+ /**
+ * @param string $appName
+ * @param IRequest $request
+ * @param IProvider $tokenProvider
+ * @param ISession $session
+ * @param ISecureRandom $random
+ * @param string|null $userId
+ * @param IUserSession $userSession
+ * @param IManager $activityManager
+ * @param RemoteWipe $remoteWipe
+ * @param LoggerInterface $logger
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ IProvider $tokenProvider,
+ private ISession $session,
+ private ISecureRandom $random,
+ private ?string $userId,
+ private IUserSession $userSession,
+ private IManager $activityManager,
+ RemoteWipe $remoteWipe,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($appName, $request);
+ $this->tokenProvider = $tokenProvider;
+ $this->remoteWipe = $remoteWipe;
+ }
+
+ /**
+ * @NoSubAdminRequired
+ *
+ * @param string $name
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ public function create($name) {
+ if ($this->checkAppToken()) {
+ return $this->getServiceNotAvailableResponse();
+ }
+
+ try {
+ $sessionId = $this->session->getId();
+ } catch (SessionNotAvailableException $ex) {
+ return $this->getServiceNotAvailableResponse();
+ }
+ if ($this->userSession->getImpersonatingUserID() !== null) {
+ return $this->getServiceNotAvailableResponse();
+ }
+
+ try {
+ $sessionToken = $this->tokenProvider->getToken($sessionId);
+ $loginName = $sessionToken->getLoginName();
+ try {
+ $password = $this->tokenProvider->getPassword($sessionToken, $sessionId);
+ } catch (PasswordlessTokenException $ex) {
+ $password = null;
+ }
+ } catch (InvalidTokenException $ex) {
+ return $this->getServiceNotAvailableResponse();
+ }
+
+ if (mb_strlen($name) > 128) {
+ $name = mb_substr($name, 0, 120) . '…';
+ }
+
+ $token = $this->generateRandomDeviceToken();
+ $deviceToken = $this->tokenProvider->generateToken($token, $this->userId, $loginName, $password, $name, IToken::PERMANENT_TOKEN);
+ $tokenData = $deviceToken->jsonSerialize();
+ $tokenData['canDelete'] = true;
+ $tokenData['canRename'] = true;
+
+ $this->publishActivity(Provider::APP_TOKEN_CREATED, $deviceToken->getId(), ['name' => $deviceToken->getName()]);
+
+ return new JSONResponse([
+ 'token' => $token,
+ 'loginName' => $loginName,
+ 'deviceToken' => $tokenData,
+ ]);
+ }
+
+ /**
+ * @return JSONResponse
+ */
+ private function getServiceNotAvailableResponse() {
+ $resp = new JSONResponse();
+ $resp->setStatus(Http::STATUS_SERVICE_UNAVAILABLE);
+ return $resp;
+ }
+
+ /**
+ * Return a 25 digit device password
+ *
+ * Example: AbCdE-fGhJk-MnPqR-sTwXy-23456
+ *
+ * @return string
+ */
+ private function generateRandomDeviceToken() {
+ $groups = [];
+ for ($i = 0; $i < 5; $i++) {
+ $groups[] = $this->random->generate(5, ISecureRandom::CHAR_HUMAN_READABLE);
+ }
+ return implode('-', $groups);
+ }
+
+ private function checkAppToken(): bool {
+ return $this->session->exists('app_password');
+ }
+
+ /**
+ * @NoSubAdminRequired
+ *
+ * @param int $id
+ * @return array|JSONResponse
+ */
+ #[NoAdminRequired]
+ public function destroy($id) {
+ if ($this->checkAppToken()) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $token = $this->findTokenByIdAndUser($id);
+ } catch (WipeTokenException $e) {
+ //continue as we can destroy tokens in wipe
+ $token = $e->getToken();
+ } catch (InvalidTokenException $e) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ $this->tokenProvider->invalidateTokenById($this->userId, $token->getId());
+ $this->publishActivity(Provider::APP_TOKEN_DELETED, $token->getId(), ['name' => $token->getName()]);
+ return [];
+ }
+
+ /**
+ * @NoSubAdminRequired
+ *
+ * @param int $id
+ * @param array $scope
+ * @param string $name
+ * @return array|JSONResponse
+ */
+ #[NoAdminRequired]
+ public function update($id, array $scope, string $name) {
+ if ($this->checkAppToken()) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $token = $this->findTokenByIdAndUser($id);
+ } catch (InvalidTokenException $e) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ $currentName = $token->getName();
+
+ if ($scope !== $token->getScopeAsArray()) {
+ $token->setScope([IToken::SCOPE_FILESYSTEM => $scope[IToken::SCOPE_FILESYSTEM]]);
+ $this->publishActivity($scope[IToken::SCOPE_FILESYSTEM] ? Provider::APP_TOKEN_FILESYSTEM_GRANTED : Provider::APP_TOKEN_FILESYSTEM_REVOKED, $token->getId(), ['name' => $currentName]);
+ }
+
+ if (mb_strlen($name) > 128) {
+ $name = mb_substr($name, 0, 120) . '…';
+ }
+
+ if ($token instanceof INamedToken && $name !== $currentName) {
+ $token->setName($name);
+ $this->publishActivity(Provider::APP_TOKEN_RENAMED, $token->getId(), ['name' => $currentName, 'newName' => $name]);
+ }
+
+ $this->tokenProvider->updateToken($token);
+ return [];
+ }
+
+ /**
+ * @param string $subject
+ * @param int $id
+ * @param array $parameters
+ */
+ private function publishActivity(string $subject, int $id, array $parameters = []): void {
+ $event = $this->activityManager->generateEvent();
+ $event->setApp('settings')
+ ->setType('security')
+ ->setAffectedUser($this->userId)
+ ->setAuthor($this->userId)
+ ->setSubject($subject, $parameters)
+ ->setObject('app_token', $id, 'App Password');
+
+ try {
+ $this->activityManager->publish($event);
+ } catch (BadMethodCallException $e) {
+ $this->logger->warning('could not publish activity', ['exception' => $e]);
+ }
+ }
+
+ /**
+ * Find a token by given id and check if uid for current session belongs to this token
+ *
+ * @param int $id
+ * @return IToken
+ * @throws InvalidTokenException
+ */
+ private function findTokenByIdAndUser(int $id): IToken {
+ try {
+ $token = $this->tokenProvider->getTokenById($id);
+ } catch (ExpiredTokenException $e) {
+ $token = $e->getToken();
+ }
+ if ($token->getUID() !== $this->userId) {
+ /** @psalm-suppress DeprecatedClass We have to throw the OC version so both OC and OCP catches catch it */
+ throw new OcInvalidTokenException('This token does not belong to you!');
+ }
+ return $token;
+ }
+
+ /**
+ * @NoSubAdminRequired
+ *
+ * @param int $id
+ * @return JSONResponse
+ * @throws InvalidTokenException
+ * @throws ExpiredTokenException
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ public function wipe(int $id): JSONResponse {
+ if ($this->checkAppToken()) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $token = $this->findTokenByIdAndUser($id);
+ } catch (InvalidTokenException $e) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ if (!$this->remoteWipe->markTokenForWipe($token)) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ return new JSONResponse([]);
+ }
+}
diff --git a/apps/settings/lib/Controller/AuthorizedGroupController.php b/apps/settings/lib/Controller/AuthorizedGroupController.php
new file mode 100644
index 00000000000..82a1ca4703e
--- /dev/null
+++ b/apps/settings/lib/Controller/AuthorizedGroupController.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Settings\Controller;
+
+use OC\Settings\AuthorizedGroup;
+use OCA\Settings\Service\AuthorizedGroupService;
+use OCA\Settings\Service\NotFoundException;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\DB\Exception;
+use OCP\IRequest;
+
+class AuthorizedGroupController extends Controller {
+ public function __construct(
+ string $AppName,
+ IRequest $request,
+ private AuthorizedGroupService $authorizedGroupService,
+ ) {
+ parent::__construct($AppName, $request);
+ }
+
+ /**
+ * @throws NotFoundException
+ * @throws Exception
+ */
+ public function saveSettings(array $newGroups, string $class): DataResponse {
+ $currentGroups = $this->authorizedGroupService->findExistingGroupsForClass($class);
+
+ foreach ($currentGroups as $group) {
+ /** @var AuthorizedGroup $group */
+ $removed = true;
+ foreach ($newGroups as $groupData) {
+ if ($groupData['gid'] === $group->getGroupId()) {
+ $removed = false;
+ break;
+ }
+ }
+ if ($removed) {
+ $this->authorizedGroupService->delete($group->getId());
+ }
+ }
+
+ foreach ($newGroups as $groupData) {
+ $added = true;
+ foreach ($currentGroups as $group) {
+ /** @var AuthorizedGroup $group */
+ if ($groupData['gid'] === $group->getGroupId()) {
+ $added = false;
+ break;
+ }
+ }
+ if ($added) {
+ $this->authorizedGroupService->create($groupData['gid'], $class);
+ }
+ }
+
+ return new DataResponse(['valid' => true]);
+ }
+}
diff --git a/apps/settings/lib/Controller/ChangePasswordController.php b/apps/settings/lib/Controller/ChangePasswordController.php
new file mode 100644
index 00000000000..a874a47c16a
--- /dev/null
+++ b/apps/settings/lib/Controller/ChangePasswordController.php
@@ -0,0 +1,224 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+// FIXME: disabled for now to be able to inject IGroupManager and also use
+// getSubAdmin()
+//declare(strict_types=1);
+
+namespace OCA\Settings\Controller;
+
+use OC\Group\Manager as GroupManager;
+use OC\User\Session;
+use OCA\Encryption\KeyManager;
+use OCA\Encryption\Recovery;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\BruteForceProtection;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\HintException;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\Server;
+
+class ChangePasswordController extends Controller {
+ private Session $userSession;
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private ?string $userId,
+ private IUserManager $userManager,
+ IUserSession $userSession,
+ private GroupManager $groupManager,
+ private IAppManager $appManager,
+ private IL10N $l,
+ ) {
+ parent::__construct($appName, $request);
+ $this->userSession = $userSession;
+ }
+
+ /**
+ * @NoSubAdminRequired
+ */
+ #[NoAdminRequired]
+ #[BruteForceProtection(action: 'changePersonalPassword')]
+ public function changePersonalPassword(string $oldpassword = '', ?string $newpassword = null): JSONResponse {
+ $loginName = $this->userSession->getLoginName();
+ /** @var IUser $user */
+ $user = $this->userManager->checkPassword($loginName, $oldpassword);
+ if ($user === false) {
+ $response = new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Wrong password'),
+ ],
+ ]);
+ $response->throttle();
+ return $response;
+ }
+
+ try {
+ if ($newpassword === null || strlen($newpassword) > IUserManager::MAX_PASSWORD_LENGTH || $user->setPassword($newpassword) === false) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Unable to change personal password'),
+ ],
+ ]);
+ }
+ // password policy app throws exception
+ } catch (HintException $e) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $e->getHint(),
+ ],
+ ]);
+ }
+
+ $this->userSession->updateSessionTokenPassword($newpassword);
+
+ return new JSONResponse([
+ 'status' => 'success',
+ 'data' => [
+ 'message' => $this->l->t('Saved'),
+ ],
+ ]);
+ }
+
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ public function changeUserPassword(?string $username = null, ?string $password = null, ?string $recoveryPassword = null): JSONResponse {
+ if ($username === null) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('No Login supplied'),
+ ],
+ ]);
+ }
+
+ if ($password === null) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Unable to change password'),
+ ],
+ ]);
+ }
+
+ if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Unable to change password. Password too long.'),
+ ],
+ ]);
+ }
+
+ $currentUser = $this->userSession->getUser();
+ $targetUser = $this->userManager->get($username);
+ if ($currentUser === null || $targetUser === null
+ || !($this->groupManager->isAdmin($this->userId)
+ || $this->groupManager->getSubAdmin()->isUserAccessible($currentUser, $targetUser))
+ ) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Authentication error'),
+ ],
+ ]);
+ }
+
+ if ($this->appManager->isEnabledForUser('encryption')) {
+ //handle the recovery case
+ $keyManager = Server::get(KeyManager::class);
+ $recovery = Server::get(Recovery::class);
+ $recoveryAdminEnabled = $recovery->isRecoveryKeyEnabled();
+
+ $validRecoveryPassword = false;
+ $recoveryEnabledForUser = false;
+ if ($recoveryAdminEnabled) {
+ $validRecoveryPassword = $keyManager->checkRecoveryPassword($recoveryPassword);
+ $recoveryEnabledForUser = $recovery->isRecoveryEnabledForUser($username);
+ }
+
+ if ($recoveryEnabledForUser && $recoveryPassword === '') {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Please provide an admin recovery password; otherwise, all account data will be lost.'),
+ ]
+ ]);
+ } elseif ($recoveryEnabledForUser && ! $validRecoveryPassword) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Wrong admin recovery password. Please check the password and try again.'),
+ ]
+ ]);
+ } else { // now we know that everything is fine regarding the recovery password, let's try to change the password
+ try {
+ $result = $targetUser->setPassword($password, $recoveryPassword);
+ // password policy app throws exception
+ } catch (HintException $e) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $e->getHint(),
+ ],
+ ]);
+ }
+ if (!$result && $recoveryEnabledForUser) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Backend does not support password change, but the encryption of the account key was updated.'),
+ ]
+ ]);
+ } elseif (!$result && !$recoveryEnabledForUser) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Unable to change password'),
+ ]
+ ]);
+ }
+ }
+ } else {
+ try {
+ if ($targetUser->setPassword($password) === false) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l->t('Unable to change password'),
+ ],
+ ]);
+ }
+ // password policy app throws exception
+ } catch (HintException $e) {
+ return new JSONResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $e->getHint(),
+ ],
+ ]);
+ }
+ }
+
+ return new JSONResponse([
+ 'status' => 'success',
+ 'data' => [
+ 'username' => $username,
+ ],
+ ]);
+ }
+}
diff --git a/apps/settings/lib/Controller/CheckSetupController.php b/apps/settings/lib/Controller/CheckSetupController.php
new file mode 100644
index 00000000000..2a189a37ce6
--- /dev/null
+++ b/apps/settings/lib/Controller/CheckSetupController.php
@@ -0,0 +1,138 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Settings\Controller;
+
+use OC\AppFramework\Http;
+use OC\IntegrityCheck\Checker;
+use OCA\Settings\Settings\Admin\Overview;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\DataDisplayResponse;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\RedirectResponse;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use OCP\SetupCheck\ISetupCheckManager;
+use Psr\Log\LoggerInterface;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class CheckSetupController extends Controller {
+ /** @var Checker */
+ private $checker;
+
+ public function __construct(
+ $AppName,
+ IRequest $request,
+ private IConfig $config,
+ private IURLGenerator $urlGenerator,
+ private IL10N $l10n,
+ Checker $checker,
+ private LoggerInterface $logger,
+ private ISetupCheckManager $setupCheckManager,
+ ) {
+ parent::__construct($AppName, $request);
+ $this->checker = $checker;
+ }
+
+ /**
+ * @return DataResponse
+ */
+ #[NoCSRFRequired]
+ #[NoAdminRequired]
+ public function setupCheckManager(): DataResponse {
+ return new DataResponse($this->setupCheckManager->runAll());
+ }
+
+ /**
+ * @return RedirectResponse
+ */
+ #[NoCSRFRequired]
+ #[AuthorizedAdminSetting(settings: Overview::class)]
+ public function rescanFailedIntegrityCheck(): RedirectResponse {
+ $this->checker->runInstanceVerification();
+ return new RedirectResponse(
+ $this->urlGenerator->linkToRoute('settings.AdminSettings.index', ['section' => 'overview'])
+ );
+ }
+
+ #[NoCSRFRequired]
+ #[AuthorizedAdminSetting(settings: Overview::class)]
+ public function getFailedIntegrityCheckFiles(): DataDisplayResponse {
+ if (!$this->checker->isCodeCheckEnforced()) {
+ return new DataDisplayResponse('Integrity checker has been disabled. Integrity cannot be verified.');
+ }
+
+ $completeResults = $this->checker->getResults();
+
+ if ($completeResults === null) {
+ return new DataDisplayResponse('Integrity checker has not been run. Integrity information not available.');
+ }
+
+ if (!empty($completeResults)) {
+ $formattedTextResponse = 'Technical information
+=====================
+The following list covers which files have failed the integrity check. Please read
+the previous linked documentation to learn more about the errors and how to fix
+them.
+
+Results
+=======
+';
+ foreach ($completeResults as $context => $contextResult) {
+ $formattedTextResponse .= "- $context\n";
+
+ foreach ($contextResult as $category => $result) {
+ $formattedTextResponse .= "\t- $category\n";
+ if ($category !== 'EXCEPTION') {
+ foreach ($result as $key => $results) {
+ $formattedTextResponse .= "\t\t- $key\n";
+ }
+ } else {
+ foreach ($result as $key => $results) {
+ $formattedTextResponse .= "\t\t- $results\n";
+ }
+ }
+ }
+ }
+
+ $formattedTextResponse .= '
+Raw output
+==========
+';
+ $formattedTextResponse .= print_r($completeResults, true);
+ } else {
+ $formattedTextResponse = 'No errors have been found.';
+ }
+
+
+ return new DataDisplayResponse(
+ $formattedTextResponse,
+ Http::STATUS_OK,
+ [
+ 'Content-Type' => 'text/plain',
+ ]
+ );
+ }
+
+ /**
+ * @return DataResponse
+ */
+ #[AuthorizedAdminSetting(settings: Overview::class)]
+ public function check() {
+ return new DataResponse(
+ [
+ 'generic' => $this->setupCheckManager->runAll(),
+ ]
+ );
+ }
+}
diff --git a/apps/settings/lib/Controller/CommonSettingsTrait.php b/apps/settings/lib/Controller/CommonSettingsTrait.php
new file mode 100644
index 00000000000..75d2b1f2f9e
--- /dev/null
+++ b/apps/settings/lib/Controller/CommonSettingsTrait.php
@@ -0,0 +1,191 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Settings\Controller;
+
+use InvalidArgumentException;
+use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
+use OCA\Settings\AppInfo\Application;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\Group\ISubAdmin;
+use OCP\IGroupManager;
+use OCP\INavigationManager;
+use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
+use OCP\Settings\IDeclarativeSettingsForm;
+use OCP\Settings\IIconSection;
+use OCP\Settings\IManager as ISettingsManager;
+use OCP\Settings\ISettings;
+use OCP\Util;
+
+/**
+ * @psalm-import-type DeclarativeSettingsFormSchemaWithValues from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsFormSchemaWithoutValues from IDeclarativeSettingsForm
+ */
+trait CommonSettingsTrait {
+
+ /** @var ISettingsManager */
+ private $settingsManager;
+
+ /** @var INavigationManager */
+ private $navigationManager;
+
+ /** @var IUserSession */
+ private $userSession;
+
+ /** @var IGroupManager */
+ private $groupManager;
+
+ /** @var ISubAdmin */
+ private $subAdmin;
+
+ private IDeclarativeManager $declarativeSettingsManager;
+
+ /** @var IInitialState */
+ private $initialState;
+
+ /**
+ * @return array{forms: array{personal: array, admin: array}}
+ */
+ private function getNavigationParameters(string $currentType, string $currentSection): array {
+ return [
+ 'forms' => [
+ 'personal' => $this->formatPersonalSections($currentType, $currentSection),
+ 'admin' => $this->formatAdminSections($currentType, $currentSection),
+ ],
+ ];
+ }
+
+ /**
+ * @param IIconSection[][] $sections
+ * @psalm-param 'admin'|'personal' $type
+ * @return list<array{anchor: string, section-name: string, active: bool, icon: string}>
+ */
+ protected function formatSections(array $sections, string $currentSection, string $type, string $currentType): array {
+ $templateParameters = [];
+ foreach ($sections as $prioritizedSections) {
+ foreach ($prioritizedSections as $section) {
+ if ($type === 'admin') {
+ $settings = $this->settingsManager->getAllowedAdminSettings($section->getID(), $this->userSession->getUser());
+ } elseif ($type === 'personal') {
+ $settings = $this->settingsManager->getPersonalSettings($section->getID());
+ }
+
+ /** @psalm-suppress PossiblyNullArgument */
+ $declarativeFormIDs = $this->declarativeSettingsManager->getFormIDs($this->userSession->getUser(), $type, $section->getID());
+
+ if (empty($settings) && empty($declarativeFormIDs)) {
+ continue;
+ }
+
+ $icon = $section->getIcon();
+
+ $active = $section->getID() === $currentSection
+ && $type === $currentType;
+
+ $templateParameters[] = [
+ 'anchor' => $section->getID(),
+ 'section-name' => $section->getName(),
+ 'active' => $active,
+ 'icon' => $icon,
+ ];
+ }
+ }
+ return $templateParameters;
+ }
+
+ protected function formatPersonalSections(string $currentType, string $currentSection): array {
+ $sections = $this->settingsManager->getPersonalSections();
+ return $this->formatSections($sections, $currentSection, 'personal', $currentType);
+ }
+
+ protected function formatAdminSections(string $currentType, string $currentSection): array {
+ $sections = $this->settingsManager->getAdminSections();
+ return $this->formatSections($sections, $currentSection, 'admin', $currentType);
+ }
+
+ /**
+ * @param list<ISettings> $settings
+ * @param list<DeclarativeSettingsFormSchemaWithValues> $declarativeSettings
+ * @return array{content: string}
+ */
+ private function formatSettings(array $settings, array $declarativeSettings): array {
+ $settings = array_merge($settings, $declarativeSettings);
+
+ usort($settings, function ($first, $second) {
+ $priorityOne = $first instanceof ISettings ? $first->getPriority() : $first['priority'];
+ $priorityTwo = $second instanceof ISettings ? $second->getPriority() : $second['priority'];
+ return $priorityOne - $priorityTwo;
+ });
+
+ $html = '';
+ foreach ($settings as $setting) {
+ if ($setting instanceof ISettings) {
+ $form = $setting->getForm();
+ $html .= $form->renderAs('')->render();
+ } else {
+ $html .= '<div id="' . $setting['app'] . '_' . $setting['id'] . '"></div>';
+ }
+ }
+ return ['content' => $html];
+ }
+
+ /**
+ * @psalm-param 'admin'|'personal' $type
+ */
+ private function getIndexResponse(string $type, string $section): TemplateResponse {
+ $user = $this->userSession->getUser();
+ assert($user !== null, 'No user logged in for settings');
+
+ $this->declarativeSettingsManager->loadSchemas();
+ $declarativeSettings = $this->declarativeSettingsManager->getFormsWithValues($user, $type, $section);
+
+ foreach ($declarativeSettings as &$form) {
+ foreach ($form['fields'] as &$field) {
+ if (isset($field['sensitive']) && $field['sensitive'] === true && !empty($field['value'])) {
+ $field['value'] = 'dummySecret';
+ }
+ }
+ }
+
+ if ($type === 'personal') {
+ $settings = array_values($this->settingsManager->getPersonalSettings($section));
+ if ($section === 'theming') {
+ $this->navigationManager->setActiveEntry('accessibility_settings');
+ } else {
+ $this->navigationManager->setActiveEntry('settings');
+ }
+ } elseif ($type === 'admin') {
+ $settings = array_values($this->settingsManager->getAllowedAdminSettings($section, $user));
+ if (empty($settings) && empty($declarativeSettings)) {
+ throw new NotAdminException('Logged in user does not have permission to access these settings.');
+ }
+ $this->navigationManager->setActiveEntry('admin_settings');
+ } else {
+ throw new InvalidArgumentException('$type must be either "admin" or "personal"');
+ }
+
+ if (!empty($declarativeSettings)) {
+ Util::addScript(Application::APP_ID, 'declarative-settings-forms');
+ $this->initialState->provideInitialState('declarative-settings-forms', $declarativeSettings);
+ }
+
+ $settings = array_merge(...$settings);
+ $templateParams = $this->formatSettings($settings, $declarativeSettings);
+ $templateParams = array_merge($templateParams, $this->getNavigationParameters($type, $section));
+
+ $activeSection = $this->settingsManager->getSection($type, $section);
+ if ($activeSection) {
+ $templateParams['pageTitle'] = $activeSection->getName();
+ $templateParams['activeSectionId'] = $activeSection->getID();
+ $templateParams['activeSectionType'] = $type;
+ }
+
+ return new TemplateResponse('settings', 'settings/frame', $templateParams);
+ }
+}
diff --git a/apps/settings/lib/Controller/DeclarativeSettingsController.php b/apps/settings/lib/Controller/DeclarativeSettingsController.php
new file mode 100644
index 00000000000..4e4bee4043c
--- /dev/null
+++ b/apps/settings/lib/Controller/DeclarativeSettingsController.php
@@ -0,0 +1,131 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Settings\Controller;
+
+use Exception;
+use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
+use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException;
+use OCA\Settings\ResponseDefinitions;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @psalm-import-type SettingsDeclarativeForm from ResponseDefinitions
+ */
+class DeclarativeSettingsController extends OCSController {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IUserSession $userSession,
+ private IDeclarativeManager $declarativeManager,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Sets a declarative settings value
+ *
+ * @param string $app ID of the app
+ * @param string $formId ID of the form
+ * @param string $fieldId ID of the field
+ * @param mixed $value Value to be saved
+ * @return DataResponse<Http::STATUS_OK, null, array{}>
+ * @throws NotLoggedInException Not logged in or not an admin user
+ * @throws NotAdminException Not logged in or not an admin user
+ * @throws OCSBadRequestException Invalid arguments to save value
+ *
+ * 200: Value set successfully
+ */
+ #[NoAdminRequired]
+ public function setValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
+ return $this->saveValue($app, $formId, $fieldId, $value);
+ }
+
+ /**
+ * Sets a declarative settings value.
+ * Password confirmation is required for sensitive values.
+ *
+ * @param string $app ID of the app
+ * @param string $formId ID of the form
+ * @param string $fieldId ID of the field
+ * @param mixed $value Value to be saved
+ * @return DataResponse<Http::STATUS_OK, null, array{}>
+ * @throws NotLoggedInException Not logged in or not an admin user
+ * @throws NotAdminException Not logged in or not an admin user
+ * @throws OCSBadRequestException Invalid arguments to save value
+ *
+ * 200: Value set successfully
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ public function setSensitiveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
+ return $this->saveValue($app, $formId, $fieldId, $value);
+ }
+
+ /**
+ * Sets a declarative settings value.
+ *
+ * @param string $app ID of the app
+ * @param string $formId ID of the form
+ * @param string $fieldId ID of the field
+ * @param mixed $value Value to be saved
+ * @return DataResponse<Http::STATUS_OK, null, array{}>
+ * @throws NotLoggedInException Not logged in or not an admin user
+ * @throws NotAdminException Not logged in or not an admin user
+ * @throws OCSBadRequestException Invalid arguments to save value
+ *
+ * 200: Value set successfully
+ */
+ private function saveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ throw new NotLoggedInException();
+ }
+
+ try {
+ $this->declarativeManager->loadSchemas();
+ $this->declarativeManager->setValue($user, $app, $formId, $fieldId, $value);
+ return new DataResponse(null);
+ } catch (NotAdminException $e) {
+ throw $e;
+ } catch (Exception $e) {
+ $this->logger->error('Failed to set declarative settings value: ' . $e->getMessage());
+ throw new OCSBadRequestException();
+ }
+ }
+
+ /**
+ * Gets all declarative forms with the values prefilled.
+ *
+ * @return DataResponse<Http::STATUS_OK, list<SettingsDeclarativeForm>, array{}>
+ * @throws NotLoggedInException
+ * @NoSubAdminRequired
+ *
+ * 200: Forms returned
+ */
+ #[NoAdminRequired]
+ public function getForms(): DataResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ throw new NotLoggedInException();
+ }
+ $this->declarativeManager->loadSchemas();
+ return new DataResponse($this->declarativeManager->getFormsWithValues($user, null, null));
+ }
+}
diff --git a/apps/settings/lib/Controller/HelpController.php b/apps/settings/lib/Controller/HelpController.php
new file mode 100644
index 00000000000..05bff158ee6
--- /dev/null
+++ b/apps/settings/lib/Controller/HelpController.php
@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Settings\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\IL10N;
+use OCP\INavigationManager;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class HelpController extends Controller {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private INavigationManager $navigationManager,
+ private IURLGenerator $urlGenerator,
+ /** @var string */
+ private ?string $userId,
+ private IGroupManager $groupManager,
+ private IL10N $l10n,
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * @return TemplateResponse
+ *
+ * @NoSubAdminRequired
+ */
+ #[NoCSRFRequired]
+ #[NoAdminRequired]
+ public function help(string $mode = 'user'): TemplateResponse {
+ $this->navigationManager->setActiveEntry('help');
+ $pageTitle = $this->l10n->t('Administrator documentation');
+ if ($mode !== 'admin') {
+ $pageTitle = $this->l10n->t('User documentation');
+ $mode = 'user';
+ }
+
+ $documentationUrl = $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkTo('', 'core/doc/' . $mode . '/index.html')
+ );
+
+ $urlUserDocs = $this->urlGenerator->linkToRoute('settings.Help.help', ['mode' => 'user']);
+ $urlAdminDocs = $this->urlGenerator->linkToRoute('settings.Help.help', ['mode' => 'admin']);
+
+ $knowledgebaseEmbedded = $this->config->getSystemValueBool('knowledgebase.embedded', false);
+ if (!$knowledgebaseEmbedded) {
+ $pageTitle = $this->l10n->t('Nextcloud help overview');
+ $urlUserDocs = $this->urlGenerator->linkToDocs('user');
+ $urlAdminDocs = $this->urlGenerator->linkToDocs('admin');
+ }
+
+ $legalNoticeUrl = $this->appConfig->getValueString('theming', 'imprintUrl');
+ $privacyUrl = $this->appConfig->getValueString('theming', 'privacyUrl');
+
+ $response = new TemplateResponse('settings', 'help', [
+ 'admin' => $this->groupManager->isAdmin($this->userId),
+ 'url' => $documentationUrl,
+ 'urlUserDocs' => $urlUserDocs,
+ 'urlAdminDocs' => $urlAdminDocs,
+ 'mode' => $mode,
+ 'pageTitle' => $pageTitle,
+ 'knowledgebaseEmbedded' => $knowledgebaseEmbedded,
+ 'legalNoticeUrl' => $legalNoticeUrl,
+ 'privacyUrl' => $privacyUrl,
+ ]);
+ $policy = new ContentSecurityPolicy();
+ $policy->addAllowedFrameDomain('\'self\'');
+ $response->setContentSecurityPolicy($policy);
+ return $response;
+ }
+}
diff --git a/apps/settings/lib/Controller/LogSettingsController.php b/apps/settings/lib/Controller/LogSettingsController.php
new file mode 100644
index 00000000000..90cf4549d2f
--- /dev/null
+++ b/apps/settings/lib/Controller/LogSettingsController.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Settings\Controller;
+
+use OC\Log;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\StreamResponse;
+use OCP\IRequest;
+
+class LogSettingsController extends Controller {
+
+ /** @var Log */
+ private $log;
+
+ public function __construct(string $appName, IRequest $request, Log $logger) {
+ parent::__construct($appName, $request);
+ $this->log = $logger;
+ }
+
+ /**
+ * download logfile
+ *
+ * @return StreamResponse<Http::STATUS_OK, array{Content-Type: 'application/octet-stream', 'Content-Disposition': 'attachment; filename="nextcloud.log"'}>
+ *
+ * 200: Logfile returned
+ */
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)]
+ public function download() {
+ if (!$this->log instanceof Log) {
+ throw new \UnexpectedValueException('Log file not available');
+ }
+ return new StreamResponse(
+ $this->log->getLogPath(),
+ Http::STATUS_OK,
+ [
+ 'Content-Type' => 'application/octet-stream',
+ 'Content-Disposition' => 'attachment; filename="nextcloud.log"',
+ ],
+ );
+ }
+}
diff --git a/apps/settings/lib/Controller/MailSettingsController.php b/apps/settings/lib/Controller/MailSettingsController.php
new file mode 100644
index 00000000000..f1e3b8032dc
--- /dev/null
+++ b/apps/settings/lib/Controller/MailSettingsController.php
@@ -0,0 +1,155 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\Settings\Controller;
+
+use OCA\Settings\Settings\Admin\Overview;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use OCP\IUserSession;
+use OCP\Mail\IMailer;
+
+class MailSettingsController extends Controller {
+
+ /**
+ * @param string $appName
+ * @param IRequest $request
+ * @param IL10N $l10n
+ * @param IConfig $config
+ * @param IUserSession $userSession
+ * @param IURLGenerator $urlGenerator,
+ * @param IMailer $mailer
+ */
+ public function __construct(
+ $appName,
+ IRequest $request,
+ private IL10N $l10n,
+ private IConfig $config,
+ private IUserSession $userSession,
+ private IURLGenerator $urlGenerator,
+ private IMailer $mailer,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Sets the email settings
+ */
+ #[AuthorizedAdminSetting(settings: Overview::class)]
+ #[PasswordConfirmationRequired]
+ public function setMailSettings(
+ string $mail_domain,
+ string $mail_from_address,
+ string $mail_smtpmode,
+ string $mail_smtpsecure,
+ string $mail_smtphost,
+ ?string $mail_smtpauth,
+ string $mail_smtpport,
+ string $mail_sendmailmode,
+ ): DataResponse {
+ $mail_smtpauth = $mail_smtpauth == '1';
+
+ $configs = [
+ 'mail_domain' => $mail_domain,
+ 'mail_from_address' => $mail_from_address,
+ 'mail_smtpmode' => $mail_smtpmode,
+ 'mail_smtpsecure' => $mail_smtpsecure,
+ 'mail_smtphost' => $mail_smtphost,
+ 'mail_smtpauth' => $mail_smtpauth,
+ 'mail_smtpport' => $mail_smtpport,
+ 'mail_sendmailmode' => $mail_sendmailmode,
+ ];
+ foreach ($configs as $key => $value) {
+ $configs[$key] = empty($value) ? null : $value;
+ }
+
+ // Delete passwords from config in case no auth is specified
+ if (!$mail_smtpauth) {
+ $configs['mail_smtpname'] = null;
+ $configs['mail_smtppassword'] = null;
+ }
+
+ $this->config->setSystemValues($configs);
+
+ $this->config->setAppValue('core', 'emailTestSuccessful', '0');
+
+ return new DataResponse();
+ }
+
+ /**
+ * Store the credentials used for SMTP in the config
+ *
+ * @param string $mail_smtpname
+ * @param string $mail_smtppassword
+ * @return DataResponse
+ */
+ #[AuthorizedAdminSetting(settings: Overview::class)]
+ #[PasswordConfirmationRequired]
+ public function storeCredentials($mail_smtpname, $mail_smtppassword) {
+ if ($mail_smtppassword === '********') {
+ return new DataResponse($this->l10n->t('Invalid SMTP password.'), Http::STATUS_BAD_REQUEST);
+ }
+
+ $this->config->setSystemValues([
+ 'mail_smtpname' => $mail_smtpname,
+ 'mail_smtppassword' => $mail_smtppassword,
+ ]);
+
+ $this->config->setAppValue('core', 'emailTestSuccessful', '0');
+
+ return new DataResponse();
+ }
+
+ /**
+ * Send a mail to test the settings
+ * @return DataResponse
+ */
+ #[AuthorizedAdminSetting(settings: Overview::class)]
+ public function sendTestMail() {
+ $email = $this->config->getUserValue($this->userSession->getUser()->getUID(), $this->appName, 'email', '');
+ if (!empty($email)) {
+ try {
+ $displayName = $this->userSession->getUser()->getDisplayName();
+
+ $template = $this->mailer->createEMailTemplate('settings.TestEmail', [
+ 'displayname' => $displayName,
+ ]);
+
+ $template->setSubject($this->l10n->t('Email setting test'));
+ $template->addHeader();
+ $template->addHeading($this->l10n->t('Well done, %s!', [$displayName]));
+ $template->addBodyText($this->l10n->t('If you received this email, the email configuration seems to be correct.'));
+ $template->addFooter();
+
+ $message = $this->mailer->createMessage();
+ $message->setTo([$email => $displayName]);
+ $message->useTemplate($template);
+ $errors = $this->mailer->send($message);
+ if (!empty($errors)) {
+ $this->config->setAppValue('core', 'emailTestSuccessful', '0');
+ throw new \RuntimeException($this->l10n->t('Email could not be sent. Check your mail server log'));
+ }
+ // Store the successful config in the app config
+ $this->config->setAppValue('core', 'emailTestSuccessful', '1');
+ return new DataResponse();
+ } catch (\Exception $e) {
+ $this->config->setAppValue('core', 'emailTestSuccessful', '0');
+ return new DataResponse($this->l10n->t('A problem occurred while sending the email. Please revise your settings. (Error: %s)', [$e->getMessage()]), Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ $this->config->setAppValue('core', 'emailTestSuccessful', '0');
+ return new DataResponse($this->l10n->t('You need to set your account email before being able to send test emails. Go to %s for that.', [$this->urlGenerator->linkToRouteAbsolute('settings.PersonalSettings.index')]), Http::STATUS_BAD_REQUEST);
+ }
+}
diff --git a/apps/settings/lib/Controller/PersonalSettingsController.php b/apps/settings/lib/Controller/PersonalSettingsController.php
new file mode 100644
index 00000000000..340ca3f93eb
--- /dev/null
+++ b/apps/settings/lib/Controller/PersonalSettingsController.php
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Settings\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\Group\ISubAdmin;
+use OCP\IGroupManager;
+use OCP\INavigationManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
+use OCP\Settings\IManager as ISettingsManager;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class PersonalSettingsController extends Controller {
+ use CommonSettingsTrait;
+
+ public function __construct(
+ $appName,
+ IRequest $request,
+ INavigationManager $navigationManager,
+ ISettingsManager $settingsManager,
+ IUserSession $userSession,
+ IGroupManager $groupManager,
+ ISubAdmin $subAdmin,
+ IDeclarativeManager $declarativeSettingsManager,
+ IInitialState $initialState,
+ ) {
+ parent::__construct($appName, $request);
+ $this->navigationManager = $navigationManager;
+ $this->settingsManager = $settingsManager;
+ $this->userSession = $userSession;
+ $this->subAdmin = $subAdmin;
+ $this->groupManager = $groupManager;
+ $this->declarativeSettingsManager = $declarativeSettingsManager;
+ $this->initialState = $initialState;
+ }
+
+ /**
+ * @NoSubAdminRequired
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function index(string $section): TemplateResponse {
+ return $this->getIndexResponse(
+ 'personal',
+ $section,
+ );
+ }
+}
diff --git a/apps/settings/lib/Controller/ReasonsController.php b/apps/settings/lib/Controller/ReasonsController.php
new file mode 100644
index 00000000000..91d0a8640d1
--- /dev/null
+++ b/apps/settings/lib/Controller/ReasonsController.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Settings\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\DataDisplayResponse;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class ReasonsController extends Controller {
+
+ /**
+ * @NoSubAdminRequired
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function getPdf() {
+ $data = file_get_contents(__DIR__ . '/../../data/Reasons to use Nextcloud.pdf');
+
+ $resp = new DataDisplayResponse($data);
+ $resp->addHeader('Content-Type', 'application/pdf');
+
+ return $resp;
+ }
+}
diff --git a/apps/settings/lib/Controller/TwoFactorSettingsController.php b/apps/settings/lib/Controller/TwoFactorSettingsController.php
new file mode 100644
index 00000000000..e08fca8ec6c
--- /dev/null
+++ b/apps/settings/lib/Controller/TwoFactorSettingsController.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Settings\Controller;
+
+use OC\Authentication\TwoFactorAuth\EnforcementState;
+use OC\Authentication\TwoFactorAuth\MandatoryTwoFactor;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+
+class TwoFactorSettingsController extends Controller {
+
+ /** @var MandatoryTwoFactor */
+ private $mandatoryTwoFactor;
+
+ public function __construct(string $appName,
+ IRequest $request,
+ MandatoryTwoFactor $mandatoryTwoFactor) {
+ parent::__construct($appName, $request);
+
+ $this->mandatoryTwoFactor = $mandatoryTwoFactor;
+ }
+
+ public function index(): JSONResponse {
+ return new JSONResponse($this->mandatoryTwoFactor->getState());
+ }
+
+ public function update(bool $enforced, array $enforcedGroups = [], array $excludedGroups = []): JSONResponse {
+ $this->mandatoryTwoFactor->setState(
+ new EnforcementState($enforced, $enforcedGroups, $excludedGroups)
+ );
+
+ return new JSONResponse($this->mandatoryTwoFactor->getState());
+ }
+}
diff --git a/apps/settings/lib/Controller/UsersController.php b/apps/settings/lib/Controller/UsersController.php
new file mode 100644
index 00000000000..8efd3eeb8ca
--- /dev/null
+++ b/apps/settings/lib/Controller/UsersController.php
@@ -0,0 +1,584 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+namespace OCA\Settings\Controller;
+
+use InvalidArgumentException;
+use OC\AppFramework\Http;
+use OC\Encryption\Exceptions\ModuleDoesNotExistsException;
+use OC\ForbiddenException;
+use OC\Group\MetaData;
+use OC\KnownUser\KnownUserService;
+use OC\Security\IdentityProof\Manager;
+use OC\User\Manager as UserManager;
+use OCA\Settings\BackgroundJobs\VerifyUserData;
+use OCA\Settings\Events\BeforeTemplateRenderedEvent;
+use OCA\Settings\Settings\Admin\Users;
+use OCA\User_LDAP\User_Proxy;
+use OCP\Accounts\IAccount;
+use OCP\Accounts\IAccountManager;
+use OCP\Accounts\PropertyDoesNotExistException;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+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\UserRateLimit;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\BackgroundJob\IJobList;
+use OCP\Encryption\IManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Group\ISubAdmin;
+use OCP\IConfig;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IL10N;
+use OCP\INavigationManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use OCP\Mail\IMailer;
+use OCP\Util;
+use function in_array;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class UsersController extends Controller {
+ /** Limit for counting users for subadmins, to avoid spending too much time */
+ private const COUNT_LIMIT_FOR_SUBADMINS = 999;
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private UserManager $userManager,
+ private IGroupManager $groupManager,
+ private IUserSession $userSession,
+ private IConfig $config,
+ private IL10N $l10n,
+ private IMailer $mailer,
+ private IFactory $l10nFactory,
+ private IAppManager $appManager,
+ private IAccountManager $accountManager,
+ private Manager $keyManager,
+ private IJobList $jobList,
+ private IManager $encryptionManager,
+ private KnownUserService $knownUserService,
+ private IEventDispatcher $dispatcher,
+ private IInitialState $initialState,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+
+ /**
+ * Display users list template
+ *
+ * @return TemplateResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function usersListByGroup(INavigationManager $navigationManager, ISubAdmin $subAdmin): TemplateResponse {
+ return $this->usersList($navigationManager, $subAdmin);
+ }
+
+ /**
+ * Display users list template
+ *
+ * @return TemplateResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function usersList(INavigationManager $navigationManager, ISubAdmin $subAdmin): TemplateResponse {
+ $user = $this->userSession->getUser();
+ $uid = $user->getUID();
+ $isAdmin = $this->groupManager->isAdmin($uid);
+ $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($uid);
+
+ $navigationManager->setActiveEntry('core_users');
+
+ /* SORT OPTION: SORT_USERCOUNT or SORT_GROUPNAME */
+ $sortGroupsBy = MetaData::SORT_USERCOUNT;
+ $isLDAPUsed = false;
+ if ($this->config->getSystemValueBool('sort_groups_by_name', false)) {
+ $sortGroupsBy = MetaData::SORT_GROUPNAME;
+ } else {
+ if ($this->appManager->isEnabledForUser('user_ldap')) {
+ $isLDAPUsed
+ = $this->groupManager->isBackendUsed('\OCA\User_LDAP\Group_Proxy');
+ if ($isLDAPUsed) {
+ // LDAP user count can be slow, so we sort by group name here
+ $sortGroupsBy = MetaData::SORT_GROUPNAME;
+ }
+ }
+ }
+
+ $canChangePassword = $this->canAdminChangeUserPasswords();
+
+ /* GROUPS */
+ $groupsInfo = new MetaData(
+ $uid,
+ $isAdmin,
+ $isDelegatedAdmin,
+ $this->groupManager,
+ $this->userSession
+ );
+
+ $adminGroup = $this->groupManager->get('admin');
+ $adminGroupData = [
+ 'id' => $adminGroup->getGID(),
+ 'name' => $adminGroup->getDisplayName(),
+ 'usercount' => $sortGroupsBy === MetaData::SORT_USERCOUNT ? $adminGroup->count() : 0,
+ 'disabled' => $adminGroup->countDisabled(),
+ 'canAdd' => $adminGroup->canAddUser(),
+ 'canRemove' => $adminGroup->canRemoveUser(),
+ ];
+
+ if (!$isLDAPUsed && $this->appManager->isEnabledForUser('user_ldap')) {
+ $isLDAPUsed = (bool)array_reduce($this->userManager->getBackends(), function ($ldapFound, $backend) {
+ return $ldapFound || $backend instanceof User_Proxy;
+ });
+ }
+
+ $disabledUsers = -1;
+ $userCount = 0;
+
+ if (!$isLDAPUsed) {
+ if ($isAdmin || $isDelegatedAdmin) {
+ $disabledUsers = $this->userManager->countDisabledUsers();
+ $userCount = array_reduce($this->userManager->countUsers(), function ($v, $w) {
+ return $v + (int)$w;
+ }, 0);
+ } else {
+ // User is subadmin !
+ [$userCount,$disabledUsers] = $this->userManager->countUsersAndDisabledUsersOfGroups($groupsInfo->getGroups(), self::COUNT_LIMIT_FOR_SUBADMINS);
+ }
+
+ if ($disabledUsers > 0) {
+ $userCount -= $disabledUsers;
+ }
+ }
+
+ $recentUsersGroup = [
+ 'id' => '__nc_internal_recent',
+ 'name' => $this->l10n->t('Recently active'),
+ 'usercount' => $this->userManager->countSeenUsers(),
+ ];
+
+ $disabledUsersGroup = [
+ 'id' => 'disabled',
+ 'name' => $this->l10n->t('Disabled accounts'),
+ 'usercount' => $disabledUsers
+ ];
+
+ if (!$isAdmin && !$isDelegatedAdmin) {
+ $subAdminGroups = array_map(
+ fn (IGroup $group) => ['id' => $group->getGID(), 'name' => $group->getDisplayName()],
+ $subAdmin->getSubAdminsGroups($user),
+ );
+ $subAdminGroups = array_values($subAdminGroups);
+ }
+
+ /* QUOTAS PRESETS */
+ $quotaPreset = $this->parseQuotaPreset($this->config->getAppValue('files', 'quota_preset', '1 GB, 5 GB, 10 GB'));
+ $allowUnlimitedQuota = $this->config->getAppValue('files', 'allow_unlimited_quota', '1') === '1';
+ if (!$allowUnlimitedQuota && count($quotaPreset) > 0) {
+ $defaultQuota = $this->config->getAppValue('files', 'default_quota', $quotaPreset[0]);
+ } else {
+ $defaultQuota = $this->config->getAppValue('files', 'default_quota', 'none');
+ }
+
+ $event = new BeforeTemplateRenderedEvent();
+ $this->dispatcher->dispatch('OC\Settings\Users::loadAdditionalScripts', $event);
+ $this->dispatcher->dispatchTyped($event);
+
+ /* LANGUAGES */
+ $languages = $this->l10nFactory->getLanguages();
+
+ /** Using LDAP or admins (system config) can enfore sorting by group name, in this case the frontend setting is overwritten */
+ $forceSortGroupByName = $sortGroupsBy === MetaData::SORT_GROUPNAME;
+
+ /* FINAL DATA */
+ $serverData = [];
+ // groups
+ $serverData['systemGroups'] = [$adminGroupData, $recentUsersGroup, $disabledUsersGroup];
+ $serverData['subAdminGroups'] = $subAdminGroups ?? [];
+ // Various data
+ $serverData['isAdmin'] = $isAdmin;
+ $serverData['isDelegatedAdmin'] = $isDelegatedAdmin;
+ $serverData['sortGroups'] = $forceSortGroupByName
+ ? MetaData::SORT_GROUPNAME
+ : (int)$this->config->getAppValue('core', 'group.sortBy', (string)MetaData::SORT_USERCOUNT);
+ $serverData['forceSortGroupByName'] = $forceSortGroupByName;
+ $serverData['quotaPreset'] = $quotaPreset;
+ $serverData['allowUnlimitedQuota'] = $allowUnlimitedQuota;
+ $serverData['userCount'] = $userCount;
+ $serverData['languages'] = $languages;
+ $serverData['defaultLanguage'] = $this->config->getSystemValue('default_language', 'en');
+ $serverData['forceLanguage'] = $this->config->getSystemValue('force_language', false);
+ // Settings
+ $serverData['defaultQuota'] = $defaultQuota;
+ $serverData['canChangePassword'] = $canChangePassword;
+ $serverData['newUserGenerateUserID'] = $this->config->getAppValue('core', 'newUser.generateUserID', 'no') === 'yes';
+ $serverData['newUserRequireEmail'] = $this->config->getAppValue('core', 'newUser.requireEmail', 'no') === 'yes';
+ $serverData['newUserSendEmail'] = $this->config->getAppValue('core', 'newUser.sendEmail', 'yes') === 'yes';
+
+ $this->initialState->provideInitialState('usersSettings', $serverData);
+
+ Util::addStyle('settings', 'settings');
+ Util::addScript('settings', 'vue-settings-apps-users-management');
+
+ return new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
+ }
+
+ /**
+ * @param string $key
+ * @param string $value
+ *
+ * @return JSONResponse
+ */
+ #[AuthorizedAdminSetting(settings:Users::class)]
+ public function setPreference(string $key, string $value): JSONResponse {
+ $allowed = ['newUser.sendEmail', 'group.sortBy'];
+ if (!in_array($key, $allowed, true)) {
+ return new JSONResponse([], Http::STATUS_FORBIDDEN);
+ }
+
+ $this->config->setAppValue('core', $key, $value);
+
+ return new JSONResponse([]);
+ }
+
+ /**
+ * Parse the app value for quota_present
+ *
+ * @param string $quotaPreset
+ * @return array
+ */
+ protected function parseQuotaPreset(string $quotaPreset): array {
+ // 1 GB, 5 GB, 10 GB => [1 GB, 5 GB, 10 GB]
+ $presets = array_filter(array_map('trim', explode(',', $quotaPreset)));
+ // Drop default and none, Make array indexes numerically
+ return array_values(array_diff($presets, ['default', 'none']));
+ }
+
+ /**
+ * check if the admin can change the users password
+ *
+ * The admin can change the passwords if:
+ *
+ * - no encryption module is loaded and encryption is disabled
+ * - encryption module is loaded but it doesn't require per user keys
+ *
+ * The admin can not change the passwords if:
+ *
+ * - an encryption module is loaded and it uses per-user keys
+ * - encryption is enabled but no encryption modules are loaded
+ *
+ * @return bool
+ */
+ protected function canAdminChangeUserPasswords(): bool {
+ $isEncryptionEnabled = $this->encryptionManager->isEnabled();
+ try {
+ $noUserSpecificEncryptionKeys = !$this->encryptionManager->getEncryptionModule()->needDetailedAccessList();
+ $isEncryptionModuleLoaded = true;
+ } catch (ModuleDoesNotExistsException $e) {
+ $noUserSpecificEncryptionKeys = true;
+ $isEncryptionModuleLoaded = false;
+ }
+ $canChangePassword = ($isEncryptionModuleLoaded && $noUserSpecificEncryptionKeys)
+ || (!$isEncryptionModuleLoaded && !$isEncryptionEnabled);
+
+ return $canChangePassword;
+ }
+
+ /**
+ * @NoSubAdminRequired
+ *
+ * @param string|null $avatarScope
+ * @param string|null $displayname
+ * @param string|null $displaynameScope
+ * @param string|null $phone
+ * @param string|null $phoneScope
+ * @param string|null $email
+ * @param string|null $emailScope
+ * @param string|null $website
+ * @param string|null $websiteScope
+ * @param string|null $address
+ * @param string|null $addressScope
+ * @param string|null $twitter
+ * @param string|null $twitterScope
+ * @param string|null $bluesky
+ * @param string|null $blueskyScope
+ * @param string|null $fediverse
+ * @param string|null $fediverseScope
+ * @param string|null $birthdate
+ * @param string|null $birthdateScope
+ *
+ * @return DataResponse
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ #[UserRateLimit(limit: 5, period: 60)]
+ public function setUserSettings(?string $avatarScope = null,
+ ?string $displayname = null,
+ ?string $displaynameScope = null,
+ ?string $phone = null,
+ ?string $phoneScope = null,
+ ?string $email = null,
+ ?string $emailScope = null,
+ ?string $website = null,
+ ?string $websiteScope = null,
+ ?string $address = null,
+ ?string $addressScope = null,
+ ?string $twitter = null,
+ ?string $twitterScope = null,
+ ?string $bluesky = null,
+ ?string $blueskyScope = null,
+ ?string $fediverse = null,
+ ?string $fediverseScope = null,
+ ?string $birthdate = null,
+ ?string $birthdateScope = null,
+ ?string $pronouns = null,
+ ?string $pronounsScope = null,
+ ) {
+ $user = $this->userSession->getUser();
+ if (!$user instanceof IUser) {
+ return new DataResponse(
+ [
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l10n->t('Invalid account')
+ ]
+ ],
+ Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $email = !is_null($email) ? strtolower($email) : $email;
+ if (!empty($email) && !$this->mailer->validateMailAddress($email)) {
+ return new DataResponse(
+ [
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $this->l10n->t('Invalid mail address')
+ ]
+ ],
+ Http::STATUS_UNPROCESSABLE_ENTITY
+ );
+ }
+
+ $userAccount = $this->accountManager->getAccount($user);
+ $oldPhoneValue = $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue();
+
+ $updatable = [
+ IAccountManager::PROPERTY_AVATAR => ['value' => null, 'scope' => $avatarScope],
+ IAccountManager::PROPERTY_DISPLAYNAME => ['value' => $displayname, 'scope' => $displaynameScope],
+ IAccountManager::PROPERTY_EMAIL => ['value' => $email, 'scope' => $emailScope],
+ IAccountManager::PROPERTY_WEBSITE => ['value' => $website, 'scope' => $websiteScope],
+ IAccountManager::PROPERTY_ADDRESS => ['value' => $address, 'scope' => $addressScope],
+ IAccountManager::PROPERTY_PHONE => ['value' => $phone, 'scope' => $phoneScope],
+ IAccountManager::PROPERTY_TWITTER => ['value' => $twitter, 'scope' => $twitterScope],
+ IAccountManager::PROPERTY_BLUESKY => ['value' => $bluesky, 'scope' => $blueskyScope],
+ IAccountManager::PROPERTY_FEDIVERSE => ['value' => $fediverse, 'scope' => $fediverseScope],
+ IAccountManager::PROPERTY_BIRTHDATE => ['value' => $birthdate, 'scope' => $birthdateScope],
+ IAccountManager::PROPERTY_PRONOUNS => ['value' => $pronouns, 'scope' => $pronounsScope],
+ ];
+ $allowUserToChangeDisplayName = $this->config->getSystemValueBool('allow_user_to_change_display_name', true);
+ foreach ($updatable as $property => $data) {
+ if ($allowUserToChangeDisplayName === false
+ && in_array($property, [IAccountManager::PROPERTY_DISPLAYNAME, IAccountManager::PROPERTY_EMAIL], true)) {
+ continue;
+ }
+ $property = $userAccount->getProperty($property);
+ if ($data['value'] !== null) {
+ $property->setValue($data['value']);
+ }
+ if ($data['scope'] !== null) {
+ $property->setScope($data['scope']);
+ }
+ }
+
+ try {
+ $this->saveUserSettings($userAccount);
+ if ($oldPhoneValue !== $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue()) {
+ $this->knownUserService->deleteByContactUserId($user->getUID());
+ }
+ return new DataResponse(
+ [
+ 'status' => 'success',
+ 'data' => [
+ 'userId' => $user->getUID(),
+ 'avatarScope' => $userAccount->getProperty(IAccountManager::PROPERTY_AVATAR)->getScope(),
+ 'displayname' => $userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getValue(),
+ 'displaynameScope' => $userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getScope(),
+ 'phone' => $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue(),
+ 'phoneScope' => $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getScope(),
+ 'email' => $userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue(),
+ 'emailScope' => $userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getScope(),
+ 'website' => $userAccount->getProperty(IAccountManager::PROPERTY_WEBSITE)->getValue(),
+ 'websiteScope' => $userAccount->getProperty(IAccountManager::PROPERTY_WEBSITE)->getScope(),
+ 'address' => $userAccount->getProperty(IAccountManager::PROPERTY_ADDRESS)->getValue(),
+ 'addressScope' => $userAccount->getProperty(IAccountManager::PROPERTY_ADDRESS)->getScope(),
+ 'twitter' => $userAccount->getProperty(IAccountManager::PROPERTY_TWITTER)->getValue(),
+ 'twitterScope' => $userAccount->getProperty(IAccountManager::PROPERTY_TWITTER)->getScope(),
+ 'bluesky' => $userAccount->getProperty(IAccountManager::PROPERTY_BLUESKY)->getValue(),
+ 'blueskyScope' => $userAccount->getProperty(IAccountManager::PROPERTY_BLUESKY)->getScope(),
+ 'fediverse' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getValue(),
+ 'fediverseScope' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getScope(),
+ 'birthdate' => $userAccount->getProperty(IAccountManager::PROPERTY_BIRTHDATE)->getValue(),
+ 'birthdateScope' => $userAccount->getProperty(IAccountManager::PROPERTY_BIRTHDATE)->getScope(),
+ 'pronouns' => $userAccount->getProperty(IAccountManager::PROPERTY_PRONOUNS)->getValue(),
+ 'pronounsScope' => $userAccount->getProperty(IAccountManager::PROPERTY_PRONOUNS)->getScope(),
+ 'message' => $this->l10n->t('Settings saved'),
+ ],
+ ],
+ Http::STATUS_OK
+ );
+ } catch (ForbiddenException|InvalidArgumentException|PropertyDoesNotExistException $e) {
+ return new DataResponse([
+ 'status' => 'error',
+ 'data' => [
+ 'message' => $e->getMessage()
+ ],
+ ]);
+ }
+ }
+ /**
+ * update account manager with new user data
+ *
+ * @throws ForbiddenException
+ * @throws InvalidArgumentException
+ */
+ protected function saveUserSettings(IAccount $userAccount): void {
+ // keep the user back-end up-to-date with the latest display name and email
+ // address
+ $oldDisplayName = $userAccount->getUser()->getDisplayName();
+ if ($oldDisplayName !== $userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getValue()) {
+ $result = $userAccount->getUser()->setDisplayName($userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getValue());
+ if ($result === false) {
+ throw new ForbiddenException($this->l10n->t('Unable to change full name'));
+ }
+ }
+
+ $oldEmailAddress = $userAccount->getUser()->getSystemEMailAddress();
+ $oldEmailAddress = strtolower((string)$oldEmailAddress);
+ if ($oldEmailAddress !== strtolower($userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue())) {
+ // this is the only permission a backend provides and is also used
+ // for the permission of setting a email address
+ if (!$userAccount->getUser()->canChangeDisplayName()) {
+ throw new ForbiddenException($this->l10n->t('Unable to change email address'));
+ }
+ $userAccount->getUser()->setSystemEMailAddress($userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue());
+ }
+
+ try {
+ $this->accountManager->updateAccount($userAccount);
+ } catch (InvalidArgumentException $e) {
+ if ($e->getMessage() === IAccountManager::PROPERTY_PHONE) {
+ throw new InvalidArgumentException($this->l10n->t('Unable to set invalid phone number'));
+ }
+ if ($e->getMessage() === IAccountManager::PROPERTY_WEBSITE) {
+ throw new InvalidArgumentException($this->l10n->t('Unable to set invalid website'));
+ }
+ throw new InvalidArgumentException($this->l10n->t('Some account data was invalid'));
+ }
+ }
+
+ /**
+ * Set the mail address of a user
+ *
+ * @NoSubAdminRequired
+ *
+ * @param string $account
+ * @param bool $onlyVerificationCode only return verification code without updating the data
+ * @return DataResponse
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ public function getVerificationCode(string $account, bool $onlyVerificationCode): DataResponse {
+ $user = $this->userSession->getUser();
+
+ if ($user === null) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ $userAccount = $this->accountManager->getAccount($user);
+ $cloudId = $user->getCloudId();
+ $message = 'Use my Federated Cloud ID to share with me: ' . $cloudId;
+ $signature = $this->signMessage($user, $message);
+
+ $code = $message . ' ' . $signature;
+ $codeMd5 = $message . ' ' . md5($signature);
+
+ switch ($account) {
+ case 'verify-twitter':
+ $msg = $this->l10n->t('In order to verify your Twitter account, post the following tweet on Twitter (please make sure to post it without any line breaks):');
+ $code = $codeMd5;
+ $type = IAccountManager::PROPERTY_TWITTER;
+ break;
+ case 'verify-website':
+ $msg = $this->l10n->t('In order to verify your Website, store the following content in your web-root at \'.well-known/CloudIdVerificationCode.txt\' (please make sure that the complete text is in one line):');
+ $type = IAccountManager::PROPERTY_WEBSITE;
+ break;
+ default:
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ $userProperty = $userAccount->getProperty($type);
+ $userProperty
+ ->setVerified(IAccountManager::VERIFICATION_IN_PROGRESS)
+ ->setVerificationData($signature);
+
+ if ($onlyVerificationCode === false) {
+ $this->accountManager->updateAccount($userAccount);
+
+ $this->jobList->add(VerifyUserData::class,
+ [
+ 'verificationCode' => $code,
+ 'data' => $userProperty->getValue(),
+ 'type' => $type,
+ 'uid' => $user->getUID(),
+ 'try' => 0,
+ 'lastRun' => $this->getCurrentTime()
+ ]
+ );
+ }
+
+ return new DataResponse(['msg' => $msg, 'code' => $code]);
+ }
+
+ /**
+ * get current timestamp
+ *
+ * @return int
+ */
+ protected function getCurrentTime(): int {
+ return time();
+ }
+
+ /**
+ * sign message with users private key
+ *
+ * @param IUser $user
+ * @param string $message
+ *
+ * @return string base64 encoded signature
+ */
+ protected function signMessage(IUser $user, string $message): string {
+ $privateKey = $this->keyManager->getKey($user)->getPrivate();
+ openssl_sign(json_encode($message), $signature, $privateKey, OPENSSL_ALGO_SHA512);
+ return base64_encode($signature);
+ }
+}
diff --git a/apps/settings/lib/Controller/WebAuthnController.php b/apps/settings/lib/Controller/WebAuthnController.php
new file mode 100644
index 00000000000..495b58e6a4b
--- /dev/null
+++ b/apps/settings/lib/Controller/WebAuthnController.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Settings\Controller;
+
+use OC\Authentication\WebAuthn\Manager;
+use OCA\Settings\AppInfo\Application;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+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\UseSession;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use OCP\ISession;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Webauthn\PublicKeyCredentialCreationOptions;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class WebAuthnController extends Controller {
+ private const WEBAUTHN_REGISTRATION = 'webauthn_registration';
+
+ public function __construct(
+ IRequest $request,
+ private LoggerInterface $logger,
+ private Manager $manager,
+ private IUserSession $userSession,
+ private ISession $session,
+ ) {
+ parent::__construct(Application::APP_ID, $request);
+ }
+
+ /**
+ * @NoSubAdminRequired
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ #[UseSession]
+ #[NoCSRFRequired]
+ public function startRegistration(): JSONResponse {
+ $this->logger->debug('Starting WebAuthn registration');
+
+ $credentialOptions = $this->manager->startRegistration($this->userSession->getUser(), $this->request->getServerHost());
+
+ // Set this in the session since we need it on finish
+ $this->session->set(self::WEBAUTHN_REGISTRATION, $credentialOptions);
+
+ return new JSONResponse($credentialOptions);
+ }
+
+ /**
+ * @NoSubAdminRequired
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ #[UseSession]
+ public function finishRegistration(string $name, string $data): JSONResponse {
+ $this->logger->debug('Finishing WebAuthn registration');
+
+ if (!$this->session->exists(self::WEBAUTHN_REGISTRATION)) {
+ $this->logger->debug('Trying to finish WebAuthn registration without session data');
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ // Obtain the publicKeyCredentialOptions from when we started the registration
+ $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromArray($this->session->get(self::WEBAUTHN_REGISTRATION));
+
+ $this->session->remove(self::WEBAUTHN_REGISTRATION);
+
+ return new JSONResponse($this->manager->finishRegister($publicKeyCredentialCreationOptions, $name, $data));
+ }
+
+ /**
+ * @NoSubAdminRequired
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ public function deleteRegistration(int $id): JSONResponse {
+ $this->logger->debug('Finishing WebAuthn registration');
+
+ $this->manager->deleteRegistration($this->userSession->getUser(), $id);
+
+ return new JSONResponse([]);
+ }
+}