diff options
Diffstat (limited to 'apps/settings/lib')
131 files changed, 11294 insertions, 0 deletions
diff --git a/apps/settings/lib/Activity/GroupProvider.php b/apps/settings/lib/Activity/GroupProvider.php new file mode 100644 index 00000000000..2d492265cf4 --- /dev/null +++ b/apps/settings/lib/Activity/GroupProvider.php @@ -0,0 +1,132 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Activity; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\Activity\IProvider; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory as L10nFactory; + +class GroupProvider implements IProvider { + public const ADDED_TO_GROUP = 'group_added'; + public const REMOVED_FROM_GROUP = 'group_removed'; + + /** @var string[] */ + protected $groupDisplayNames = []; + + + public function __construct( + private L10nFactory $l10n, + private IURLGenerator $urlGenerator, + private IManager $activityManager, + protected IUserManager $userManager, + protected IGroupManager $groupManager, + ) { + } + + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { + if ($event->getType() !== 'group_settings') { + throw new UnknownActivityException(); + } + + $l = $this->l10n->get('settings', $language); + + $params = $event->getSubjectParameters(); + $parsedParameters = [ + 'user' => $this->generateUserParameter($params['user']), + 'group' => $this->generateGroupParameter($params['group']), + ]; + + if (isset($params['actor'])) { + $parsedParameters['actor'] = $this->generateUserParameter($params['actor']); + } + + switch ($event->getSubject()) { + case self::ADDED_TO_GROUP: + if (isset($parsedParameters['actor'])) { + if ($this->activityManager->getCurrentUserId() === $params['user']) { + $subject = $l->t('{actor} added you to group {group}'); + } elseif (isset($params['actor']) && $this->activityManager->getCurrentUserId() === $params['actor']) { + $subject = $l->t('You added {user} to group {group}'); + } else { + $subject = $l->t('{actor} added {user} to group {group}'); + } + } elseif ($this->activityManager->getCurrentUserId() === $params['user']) { + $subject = $l->t('An administrator added you to group {group}'); + } else { + $subject = $l->t('An administrator added {user} to group {group}'); + } + break; + case self::REMOVED_FROM_GROUP: + if (isset($parsedParameters['actor'])) { + if ($this->activityManager->getCurrentUserId() === $params['user']) { + $subject = $l->t('{actor} removed you from group {group}'); + } elseif (isset($params['actor']) && $this->activityManager->getCurrentUserId() === $params['actor']) { + $subject = $l->t('You removed {user} from group {group}'); + } else { + $subject = $l->t('{actor} removed {user} from group {group}'); + } + } elseif ($this->activityManager->getCurrentUserId() === $params['user']) { + $subject = $l->t('An administrator removed you from group {group}'); + } else { + $subject = $l->t('An administrator removed {user} from group {group}'); + } + break; + default: + throw new UnknownActivityException(); + } + + $this->setSubjects($event, $subject, $parsedParameters); + + return $event; + } + + protected function setSubjects(IEvent $event, string $subject, array $parameters): void { + $event->setRichSubject($subject, $parameters); + } + + /** + * @param string $gid + * @return array + */ + protected function generateGroupParameter(string $gid): array { + if (!isset($this->groupDisplayNames[$gid])) { + $this->groupDisplayNames[$gid] = $this->getGroupDisplayName($gid); + } + + return [ + 'type' => 'user-group', + 'id' => $gid, + 'name' => $this->groupDisplayNames[$gid], + ]; + } + + /** + * @param string $gid + * @return string + */ + protected function getGroupDisplayName(string $gid): string { + $group = $this->groupManager->get($gid); + if ($group instanceof IGroup) { + return $group->getDisplayName(); + } + return $gid; + } + + protected function generateUserParameter(string $uid): array { + return [ + 'type' => 'user', + 'id' => $uid, + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, + ]; + } +} diff --git a/apps/settings/lib/Activity/GroupSetting.php b/apps/settings/lib/Activity/GroupSetting.php new file mode 100644 index 00000000000..917f4a7ef26 --- /dev/null +++ b/apps/settings/lib/Activity/GroupSetting.php @@ -0,0 +1,79 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Activity; + +use OCP\Activity\ISetting; +use OCP\IL10N; + +class GroupSetting implements ISetting { + + /** + * @param IL10N $l + */ + public function __construct( + protected IL10N $l, + ) { + } + + /** + * @return string Lowercase a-z and underscore only identifier + * @since 11.0.0 + */ + public function getIdentifier(): string { + return 'group_settings'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName(): string { + return $this->l->t('Your <strong>group memberships</strong> were modified'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 11.0.0 + */ + public function getPriority(): int { + return 0; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function canChangeStream(): bool { + return false; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledStream(): bool { + return true; + } + + /** + * @return bool True when the option can be changed for the mail + * @since 11.0.0 + */ + public function canChangeMail(): bool { + return true; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledMail(): bool { + return true; + } +} diff --git a/apps/settings/lib/Activity/Provider.php b/apps/settings/lib/Activity/Provider.php new file mode 100644 index 00000000000..c31a900abd5 --- /dev/null +++ b/apps/settings/lib/Activity/Provider.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Activity; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\Activity\IProvider; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; + +class Provider implements IProvider { + public const PASSWORD_CHANGED_BY = 'password_changed_by'; + public const PASSWORD_CHANGED_SELF = 'password_changed_self'; + public const PASSWORD_RESET = 'password_changed'; + public const PASSWORD_RESET_SELF = 'password_reset_self'; + public const EMAIL_CHANGED_BY = 'email_changed_by'; + public const EMAIL_CHANGED_SELF = 'email_changed_self'; + public const EMAIL_CHANGED = 'email_changed'; + public const APP_TOKEN_CREATED = 'app_token_created'; + public const APP_TOKEN_DELETED = 'app_token_deleted'; + public const APP_TOKEN_RENAMED = 'app_token_renamed'; + public const APP_TOKEN_FILESYSTEM_GRANTED = 'app_token_filesystem_granted'; + public const APP_TOKEN_FILESYSTEM_REVOKED = 'app_token_filesystem_revoked'; + + /** @var IL10N */ + protected $l; + + public function __construct( + protected IFactory $languageFactory, + protected IURLGenerator $url, + protected IUserManager $userManager, + private IManager $activityManager, + ) { + } + + /** + * @param string $language + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + * @since 11.0.0 + */ + public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent { + if ($event->getApp() !== 'settings') { + throw new UnknownActivityException('Unknown app'); + } + + $this->l = $this->languageFactory->get('settings', $language); + + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('settings', 'personal.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('settings', 'personal.svg'))); + } + + if ($event->getSubject() === self::PASSWORD_CHANGED_BY) { + $subject = $this->l->t('{actor} changed your password'); + } elseif ($event->getSubject() === self::PASSWORD_CHANGED_SELF) { + $subject = $this->l->t('You changed your password'); + } elseif ($event->getSubject() === self::PASSWORD_RESET) { + $subject = $this->l->t('Your password was reset by an administrator'); + } elseif ($event->getSubject() === self::PASSWORD_RESET_SELF) { + $subject = $this->l->t('Your password was reset'); + } elseif ($event->getSubject() === self::EMAIL_CHANGED_BY) { + $subject = $this->l->t('{actor} changed your email address'); + } elseif ($event->getSubject() === self::EMAIL_CHANGED_SELF) { + $subject = $this->l->t('You changed your email address'); + } elseif ($event->getSubject() === self::EMAIL_CHANGED) { + $subject = $this->l->t('Your email address was changed by an administrator'); + } elseif ($event->getSubject() === self::APP_TOKEN_CREATED) { + if ($event->getAffectedUser() === $event->getAuthor()) { + $subject = $this->l->t('You created an app password for a session named "{token}"'); + } else { + $subject = $this->l->t('An administrator created an app password for a session named "{token}"'); + } + } elseif ($event->getSubject() === self::APP_TOKEN_DELETED) { + $subject = $this->l->t('You deleted app password "{token}"'); + } elseif ($event->getSubject() === self::APP_TOKEN_RENAMED) { + $subject = $this->l->t('You renamed app password "{token}" to "{newToken}"'); + } elseif ($event->getSubject() === self::APP_TOKEN_FILESYSTEM_GRANTED) { + $subject = $this->l->t('You granted filesystem access to app password "{token}"'); + } elseif ($event->getSubject() === self::APP_TOKEN_FILESYSTEM_REVOKED) { + $subject = $this->l->t('You revoked filesystem access from app password "{token}"'); + } else { + throw new UnknownActivityException('Unknown subject'); + } + + $parsedParameters = $this->getParameters($event); + $this->setSubjects($event, $subject, $parsedParameters); + + return $event; + } + + /** + * @param IEvent $event + * @return array + * @throws UnknownActivityException + */ + protected function getParameters(IEvent $event): array { + $subject = $event->getSubject(); + $parameters = $event->getSubjectParameters(); + + switch ($subject) { + case self::PASSWORD_CHANGED_SELF: + case self::PASSWORD_RESET: + case self::PASSWORD_RESET_SELF: + case self::EMAIL_CHANGED_SELF: + case self::EMAIL_CHANGED: + return []; + case self::PASSWORD_CHANGED_BY: + case self::EMAIL_CHANGED_BY: + return [ + 'actor' => $this->generateUserParameter($parameters[0]), + ]; + case self::APP_TOKEN_CREATED: + case self::APP_TOKEN_DELETED: + case self::APP_TOKEN_FILESYSTEM_GRANTED: + case self::APP_TOKEN_FILESYSTEM_REVOKED: + return [ + 'token' => [ + 'type' => 'highlight', + 'id' => (string)$event->getObjectId(), + 'name' => $parameters['name'], + ] + ]; + case self::APP_TOKEN_RENAMED: + return [ + 'token' => [ + 'type' => 'highlight', + 'id' => (string)$event->getObjectId(), + 'name' => $parameters['name'], + ], + 'newToken' => [ + 'type' => 'highlight', + 'id' => (string)$event->getObjectId(), + 'name' => $parameters['newName'], + ] + ]; + } + + throw new UnknownActivityException('Unknown subject'); + } + + protected function setSubjects(IEvent $event, string $subject, array $parameters): void { + $event->setRichSubject($subject, $parameters); + } + + protected function generateUserParameter(string $uid): array { + return [ + 'type' => 'user', + 'id' => $uid, + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, + ]; + } +} diff --git a/apps/settings/lib/Activity/SecurityFilter.php b/apps/settings/lib/Activity/SecurityFilter.php new file mode 100644 index 00000000000..9a32e82a984 --- /dev/null +++ b/apps/settings/lib/Activity/SecurityFilter.php @@ -0,0 +1,44 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Activity; + +use OCP\Activity\IFilter; +use OCP\IL10N; +use OCP\IURLGenerator; + +class SecurityFilter implements IFilter { + + public function __construct( + private IURLGenerator $urlGenerator, + private IL10N $l10n, + ) { + } + + public function allowedApps() { + return []; + } + + public function filterTypes(array $types) { + return array_intersect(['security'], $types); + } + + public function getIcon() { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/password.svg')); + } + + public function getIdentifier() { + return 'security'; + } + + public function getName() { + return $this->l10n->t('Security'); + } + + public function getPriority() { + return 30; + } +} diff --git a/apps/settings/lib/Activity/SecurityProvider.php b/apps/settings/lib/Activity/SecurityProvider.php new file mode 100644 index 00000000000..658e2e7b949 --- /dev/null +++ b/apps/settings/lib/Activity/SecurityProvider.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Activity; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\Activity\IProvider; +use OCP\IURLGenerator; +use OCP\L10N\IFactory as L10nFactory; + +class SecurityProvider implements IProvider { + + public function __construct( + private L10nFactory $l10n, + private IURLGenerator $urlGenerator, + private IManager $activityManager, + ) { + } + + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { + if ($event->getType() !== 'security') { + throw new UnknownActivityException(); + } + + $l = $this->l10n->get('settings', $language); + + switch ($event->getSubject()) { + case 'twofactor_success': + $params = $event->getSubjectParameters(); + $event->setParsedSubject($l->t('You successfully logged in using two-factor authentication (%1$s)', [ + $params['provider'], + ])); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/password.png'))); + } else { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/password.svg'))); + } + break; + case 'twofactor_failed': + $params = $event->getSubjectParameters(); + $event->setParsedSubject($l->t('A login attempt using two-factor authentication failed (%1$s)', [ + $params['provider'], + ])); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/password.png'))); + } else { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/password.svg'))); + } + break; + case 'remote_wipe_start': + $params = $event->getSubjectParameters(); + $event->setParsedSubject($l->t('Remote wipe was started on %1$s', [ + $params['name'], + ])); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/delete.png'))); + } else { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/delete.svg'))); + } + break; + case 'remote_wipe_finish': + $params = $event->getSubjectParameters(); + $event->setParsedSubject($l->t('Remote wipe has finished on %1$s', [ + $params['name'], + ])); + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/delete.png'))); + } else { + $event->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/delete.svg'))); + } + break; + default: + throw new UnknownActivityException(); + } + return $event; + } +} diff --git a/apps/settings/lib/Activity/SecuritySetting.php b/apps/settings/lib/Activity/SecuritySetting.php new file mode 100644 index 00000000000..9226b5aea5b --- /dev/null +++ b/apps/settings/lib/Activity/SecuritySetting.php @@ -0,0 +1,46 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Activity; + +use OCP\Activity\ISetting; +use OCP\IL10N; + +class SecuritySetting implements ISetting { + + public function __construct( + private IL10N $l10n, + ) { + } + + public function canChangeMail() { + return false; + } + + public function canChangeStream() { + return false; + } + + public function getIdentifier() { + return 'security'; + } + + public function getName() { + return $this->l10n->t('Security'); + } + + public function getPriority() { + return 30; + } + + public function isDefaultEnabledMail() { + return true; + } + + public function isDefaultEnabledStream() { + return true; + } +} diff --git a/apps/settings/lib/Activity/Setting.php b/apps/settings/lib/Activity/Setting.php new file mode 100644 index 00000000000..f9c659594d6 --- /dev/null +++ b/apps/settings/lib/Activity/Setting.php @@ -0,0 +1,79 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Activity; + +use OCP\Activity\ISetting; +use OCP\IL10N; + +class Setting implements ISetting { + + /** + * @param IL10N $l + */ + public function __construct( + protected IL10N $l, + ) { + } + + /** + * @return string Lowercase a-z and underscore only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'personal_settings'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return $this->l->t('Your <strong>password</strong> or <strong>email</strong> was modified'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 11.0.0 + */ + public function getPriority() { + return 0; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function canChangeStream() { + return false; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledStream() { + return true; + } + + /** + * @return bool True when the option can be changed for the mail + * @since 11.0.0 + */ + public function canChangeMail() { + return false; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledMail() { + return false; + } +} diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php new file mode 100644 index 00000000000..6e59e56fe82 --- /dev/null +++ b/apps/settings/lib/AppInfo/Application.php @@ -0,0 +1,254 @@ +<?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\AppInfo; + +use OC\AppFramework\Utility\TimeFactory; +use OC\Authentication\Events\AppPasswordCreatedEvent; +use OC\Authentication\Token\IProvider; +use OC\Server; +use OCA\Settings\Hooks; +use OCA\Settings\Listener\AppPasswordCreatedActivityListener; +use OCA\Settings\Listener\GroupRemovedListener; +use OCA\Settings\Listener\MailProviderListener; +use OCA\Settings\Listener\UserAddedToGroupActivityListener; +use OCA\Settings\Listener\UserRemovedFromGroupActivityListener; +use OCA\Settings\Mailer\NewUserMailHelper; +use OCA\Settings\Middleware\SubadminMiddleware; +use OCA\Settings\Search\AppSearch; +use OCA\Settings\Search\SectionSearch; +use OCA\Settings\Search\UserSearch; +use OCA\Settings\Settings\Admin\MailProvider; +use OCA\Settings\SetupChecks\AllowedAdminRanges; +use OCA\Settings\SetupChecks\AppDirsWithDifferentOwner; +use OCA\Settings\SetupChecks\BruteForceThrottler; +use OCA\Settings\SetupChecks\CheckUserCertificates; +use OCA\Settings\SetupChecks\CodeIntegrity; +use OCA\Settings\SetupChecks\CronErrors; +use OCA\Settings\SetupChecks\CronInfo; +use OCA\Settings\SetupChecks\DatabaseHasMissingColumns; +use OCA\Settings\SetupChecks\DatabaseHasMissingIndices; +use OCA\Settings\SetupChecks\DatabaseHasMissingPrimaryKeys; +use OCA\Settings\SetupChecks\DatabasePendingBigIntConversions; +use OCA\Settings\SetupChecks\DataDirectoryProtected; +use OCA\Settings\SetupChecks\DebugMode; +use OCA\Settings\SetupChecks\DefaultPhoneRegionSet; +use OCA\Settings\SetupChecks\EmailTestSuccessful; +use OCA\Settings\SetupChecks\FileLocking; +use OCA\Settings\SetupChecks\ForwardedForHeaders; +use OCA\Settings\SetupChecks\HttpsUrlGeneration; +use OCA\Settings\SetupChecks\InternetConnectivity; +use OCA\Settings\SetupChecks\JavaScriptModules; +use OCA\Settings\SetupChecks\JavaScriptSourceMaps; +use OCA\Settings\SetupChecks\LegacySSEKeyFormat; +use OCA\Settings\SetupChecks\MaintenanceWindowStart; +use OCA\Settings\SetupChecks\MemcacheConfigured; +use OCA\Settings\SetupChecks\MimeTypeMigrationAvailable; +use OCA\Settings\SetupChecks\MysqlRowFormat; +use OCA\Settings\SetupChecks\MysqlUnicodeSupport; +use OCA\Settings\SetupChecks\OcxProviders; +use OCA\Settings\SetupChecks\OverwriteCliUrl; +use OCA\Settings\SetupChecks\PhpApcuConfig; +use OCA\Settings\SetupChecks\PhpDefaultCharset; +use OCA\Settings\SetupChecks\PhpDisabledFunctions; +use OCA\Settings\SetupChecks\PhpFreetypeSupport; +use OCA\Settings\SetupChecks\PhpGetEnv; +use OCA\Settings\SetupChecks\PhpMemoryLimit; +use OCA\Settings\SetupChecks\PhpModules; +use OCA\Settings\SetupChecks\PhpOpcacheSetup; +use OCA\Settings\SetupChecks\PhpOutdated; +use OCA\Settings\SetupChecks\PhpOutputBuffering; +use OCA\Settings\SetupChecks\PushService; +use OCA\Settings\SetupChecks\RandomnessSecure; +use OCA\Settings\SetupChecks\ReadOnlyConfig; +use OCA\Settings\SetupChecks\SchedulingTableSize; +use OCA\Settings\SetupChecks\SecurityHeaders; +use OCA\Settings\SetupChecks\SupportedDatabase; +use OCA\Settings\SetupChecks\SystemIs64bit; +use OCA\Settings\SetupChecks\TaskProcessingPickupSpeed; +use OCA\Settings\SetupChecks\TempSpaceAvailable; +use OCA\Settings\SetupChecks\TransactionIsolation; +use OCA\Settings\SetupChecks\WellKnownUrls; +use OCA\Settings\SetupChecks\Woff2Loading; +use OCA\Settings\UserMigration\AccountMigrator; +use OCA\Settings\WellKnown\ChangePasswordHandler; +use OCA\Settings\WellKnown\SecurityTxtHandler; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\IAppContainer; +use OCP\AppFramework\QueryException; +use OCP\Defaults; +use OCP\Group\Events\GroupDeletedEvent; +use OCP\Group\Events\UserAddedEvent; +use OCP\Group\Events\UserRemovedEvent; +use OCP\IServerContainer; +use OCP\Settings\Events\DeclarativeSettingsGetValueEvent; +use OCP\Settings\Events\DeclarativeSettingsSetValueEvent; +use OCP\Settings\IManager; +use OCP\Util; + +class Application extends App implements IBootstrap { + public const APP_ID = 'settings'; + + /** + * @param array $urlParams + */ + public function __construct(array $urlParams = []) { + parent::__construct(self::APP_ID, $urlParams); + } + + public function register(IRegistrationContext $context): void { + // Register Middleware + $context->registerServiceAlias('SubadminMiddleware', SubadminMiddleware::class); + $context->registerMiddleware(SubadminMiddleware::class); + $context->registerSearchProvider(SectionSearch::class); + $context->registerSearchProvider(AppSearch::class); + $context->registerSearchProvider(UserSearch::class); + + // Register listeners + $context->registerEventListener(AppPasswordCreatedEvent::class, AppPasswordCreatedActivityListener::class); + $context->registerEventListener(UserAddedEvent::class, UserAddedToGroupActivityListener::class); + $context->registerEventListener(UserRemovedEvent::class, UserRemovedFromGroupActivityListener::class); + $context->registerEventListener(GroupDeletedEvent::class, GroupRemovedListener::class); + + // Register Mail Provider listeners + $context->registerEventListener(DeclarativeSettingsGetValueEvent::class, MailProviderListener::class); + $context->registerEventListener(DeclarativeSettingsSetValueEvent::class, MailProviderListener::class); + + // Register well-known handlers + $context->registerWellKnownHandler(SecurityTxtHandler::class); + $context->registerWellKnownHandler(ChangePasswordHandler::class); + + // Register Settings Form(s) + $context->registerDeclarativeSettings(MailProvider::class); + + /** + * Core class wrappers + */ + $context->registerService(IProvider::class, function (IAppContainer $appContainer) { + /** @var IServerContainer $serverContainer */ + $serverContainer = $appContainer->query(IServerContainer::class); + return $serverContainer->query(IProvider::class); + }); + $context->registerService(IManager::class, function (IAppContainer $appContainer) { + /** @var IServerContainer $serverContainer */ + $serverContainer = $appContainer->query(IServerContainer::class); + return $serverContainer->getSettingsManager(); + }); + + $context->registerService(NewUserMailHelper::class, function (IAppContainer $appContainer) { + /** @var Server $server */ + $server = $appContainer->query(IServerContainer::class); + /** @var Defaults $defaults */ + $defaults = $server->query(Defaults::class); + + return new NewUserMailHelper( + $defaults, + $server->getURLGenerator(), + $server->getL10NFactory(), + $server->getMailer(), + $server->getSecureRandom(), + new TimeFactory(), + $server->getConfig(), + $server->getCrypto(), + Util::getDefaultEmailAddress('no-reply') + ); + }); + $context->registerSetupCheck(AllowedAdminRanges::class); + $context->registerSetupCheck(AppDirsWithDifferentOwner::class); + $context->registerSetupCheck(BruteForceThrottler::class); + $context->registerSetupCheck(CheckUserCertificates::class); + $context->registerSetupCheck(CodeIntegrity::class); + $context->registerSetupCheck(CronErrors::class); + $context->registerSetupCheck(CronInfo::class); + $context->registerSetupCheck(DatabaseHasMissingColumns::class); + $context->registerSetupCheck(DatabaseHasMissingIndices::class); + $context->registerSetupCheck(DatabaseHasMissingPrimaryKeys::class); + $context->registerSetupCheck(DatabasePendingBigIntConversions::class); + $context->registerSetupCheck(DataDirectoryProtected::class); + $context->registerSetupCheck(DebugMode::class); + $context->registerSetupCheck(DefaultPhoneRegionSet::class); + $context->registerSetupCheck(EmailTestSuccessful::class); + $context->registerSetupCheck(FileLocking::class); + $context->registerSetupCheck(ForwardedForHeaders::class); + $context->registerSetupCheck(HttpsUrlGeneration::class); + $context->registerSetupCheck(InternetConnectivity::class); + $context->registerSetupCheck(JavaScriptSourceMaps::class); + $context->registerSetupCheck(JavaScriptModules::class); + $context->registerSetupCheck(LegacySSEKeyFormat::class); + $context->registerSetupCheck(MaintenanceWindowStart::class); + $context->registerSetupCheck(MemcacheConfigured::class); + $context->registerSetupCheck(MimeTypeMigrationAvailable::class); + $context->registerSetupCheck(MysqlRowFormat::class); + $context->registerSetupCheck(MysqlUnicodeSupport::class); + $context->registerSetupCheck(OcxProviders::class); + $context->registerSetupCheck(OverwriteCliUrl::class); + $context->registerSetupCheck(PhpDefaultCharset::class); + $context->registerSetupCheck(PhpDisabledFunctions::class); + $context->registerSetupCheck(PhpFreetypeSupport::class); + $context->registerSetupCheck(PhpApcuConfig::class); + $context->registerSetupCheck(PhpGetEnv::class); + // Temporarily disabled $context->registerSetupCheck(PhpMaxFileSize::class); + $context->registerSetupCheck(PhpMemoryLimit::class); + $context->registerSetupCheck(PhpModules::class); + $context->registerSetupCheck(PhpOpcacheSetup::class); + $context->registerSetupCheck(PhpOutdated::class); + $context->registerSetupCheck(PhpOutputBuffering::class); + $context->registerSetupCheck(RandomnessSecure::class); + $context->registerSetupCheck(ReadOnlyConfig::class); + $context->registerSetupCheck(SecurityHeaders::class); + $context->registerSetupCheck(SchedulingTableSize::class); + $context->registerSetupCheck(SupportedDatabase::class); + $context->registerSetupCheck(SystemIs64bit::class); + $context->registerSetupCheck(TaskProcessingPickupSpeed::class); + $context->registerSetupCheck(TempSpaceAvailable::class); + $context->registerSetupCheck(TransactionIsolation::class); + $context->registerSetupCheck(PushService::class); + $context->registerSetupCheck(WellKnownUrls::class); + $context->registerSetupCheck(Woff2Loading::class); + + $context->registerUserMigrator(AccountMigrator::class); + } + + public function boot(IBootContext $context): void { + Util::connectHook('OC_User', 'post_setPassword', $this, 'onChangePassword'); + Util::connectHook('OC_User', 'changeUser', $this, 'onChangeInfo'); + } + + /** + * @param array $parameters + * @throws \InvalidArgumentException + * @throws \BadMethodCallException + * @throws \Exception + * @throws QueryException + */ + public function onChangePassword(array $parameters) { + /** @var Hooks $hooks */ + $hooks = $this->getContainer()->query(Hooks::class); + $hooks->onChangePassword($parameters['uid']); + } + + /** + * @param array $parameters + * @throws \InvalidArgumentException + * @throws \BadMethodCallException + * @throws \Exception + * @throws QueryException + */ + public function onChangeInfo(array $parameters) { + if ($parameters['feature'] !== 'eMailAddress') { + return; + } + + /** @var Hooks $hooks */ + $hooks = $this->getContainer()->query(Hooks::class); + $hooks->onChangeEmail($parameters['user'], $parameters['old_value']); + } +} diff --git a/apps/settings/lib/BackgroundJobs/VerifyUserData.php b/apps/settings/lib/BackgroundJobs/VerifyUserData.php new file mode 100644 index 00000000000..eb66644ad91 --- /dev/null +++ b/apps/settings/lib/BackgroundJobs/VerifyUserData.php @@ -0,0 +1,243 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\BackgroundJobs; + +use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\Job; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class VerifyUserData extends Job { + /** @var bool */ + private bool $retainJob = true; + + /** @var int max number of attempts to send the request */ + private int $maxTry = 24; + + /** @var int how much time should be between two tries (1 hour) */ + private int $interval = 3600; + private string $lookupServerUrl; + + public function __construct( + private IAccountManager $accountManager, + private IUserManager $userManager, + private IClientService $httpClientService, + private LoggerInterface $logger, + ITimeFactory $timeFactory, + private IConfig $config, + ) { + parent::__construct($timeFactory); + + $lookupServerUrl = $config->getSystemValue('lookup_server', 'https://lookup.nextcloud.com'); + $this->lookupServerUrl = rtrim($lookupServerUrl, '/'); + } + + public function start(IJobList $jobList): void { + if ($this->shouldRun($this->argument)) { + parent::start($jobList); + $jobList->remove($this, $this->argument); + if ($this->retainJob) { + $this->reAddJob($jobList, $this->argument); + } else { + $this->resetVerificationState(); + } + } + } + + protected function run($argument) { + $try = (int)$argument['try'] + 1; + + switch ($argument['type']) { + case IAccountManager::PROPERTY_WEBSITE: + $result = $this->verifyWebsite($argument); + break; + case IAccountManager::PROPERTY_TWITTER: + case IAccountManager::PROPERTY_EMAIL: + $result = $this->verifyViaLookupServer($argument, $argument['type']); + break; + default: + // no valid type given, no need to retry + $this->logger->error($argument['type'] . ' is no valid type for user account data.'); + $result = true; + } + + if ($result === true || $try > $this->maxTry) { + $this->retainJob = false; + } + } + + /** + * verify web page + * + * @param array $argument + * @return bool true if we could check the verification code, otherwise false + */ + protected function verifyWebsite(array $argument) { + $result = false; + + $url = rtrim($argument['data'], '/') . '/.well-known/' . 'CloudIdVerificationCode.txt'; + + $client = $this->httpClientService->newClient(); + try { + $response = $client->get($url); + } catch (\Exception $e) { + return false; + } + + if ($response->getStatusCode() === Http::STATUS_OK) { + $result = true; + $publishedCode = $response->getBody(); + // remove new lines and spaces + $publishedCodeSanitized = trim(preg_replace('/\s\s+/', ' ', $publishedCode)); + $user = $this->userManager->get($argument['uid']); + // we don't check a valid user -> give up + if ($user === null) { + $this->logger->error($argument['uid'] . ' doesn\'t exist, can\'t verify user data.'); + return $result; + } + $userAccount = $this->accountManager->getAccount($user); + $websiteProp = $userAccount->getProperty(IAccountManager::PROPERTY_WEBSITE); + $websiteProp->setVerified($publishedCodeSanitized === $argument['verificationCode'] + ? IAccountManager::VERIFIED + : IAccountManager::NOT_VERIFIED + ); + $this->accountManager->updateAccount($userAccount); + } + + return $result; + } + + protected function verifyViaLookupServer(array $argument, string $dataType): bool { + // TODO: Consider to enable for non-global-scale setups by checking 'files_sharing', 'lookupServerUploadEnabled' + if (!$this->config->getSystemValueBool('gs.enabled', false) + || empty($this->lookupServerUrl) + || $this->config->getSystemValue('has_internet_connection', true) === false + ) { + return true; + } + + $user = $this->userManager->get($argument['uid']); + + // we don't check a valid user -> give up + if ($user === null) { + $this->logger->info($argument['uid'] . ' doesn\'t exist, can\'t verify user data.'); + return true; + } + + $cloudId = $user->getCloudId(); + $lookupServerData = $this->queryLookupServer($cloudId); + + // for some reasons we couldn't read any data from the lookup server, try again later + if (empty($lookupServerData) || empty($lookupServerData[$dataType])) { + return false; + } + + // lookup server has verification data for wrong user data (e.g. email address), try again later + if ($lookupServerData[$dataType]['value'] !== $argument['data']) { + return false; + } + + // lookup server hasn't verified the email address so far, try again later + if ($lookupServerData[$dataType]['verified'] === IAccountManager::NOT_VERIFIED) { + return false; + } + + try { + $userAccount = $this->accountManager->getAccount($user); + $property = $userAccount->getProperty($dataType); + $property->setVerified(IAccountManager::VERIFIED); + $this->accountManager->updateAccount($userAccount); + } catch (PropertyDoesNotExistException $e) { + return false; + } + + return true; + } + + /** + * @param string $cloudId + * @return array + */ + protected function queryLookupServer($cloudId) { + try { + $client = $this->httpClientService->newClient(); + $response = $client->get( + $this->lookupServerUrl . '/users?search=' . urlencode($cloudId) . '&exactCloudId=1', + [ + 'timeout' => 10, + 'connect_timeout' => 3, + ] + ); + + $body = json_decode($response->getBody(), true); + + if (is_array($body) && isset($body['federationId']) && $body['federationId'] === $cloudId) { + return $body; + } + } catch (\Exception $e) { + // do nothing, we will just re-try later + } + + return []; + } + + /** + * re-add background job with new arguments + * + * @param IJobList $jobList + * @param array $argument + */ + protected function reAddJob(IJobList $jobList, array $argument) { + $jobList->add(VerifyUserData::class, + [ + 'verificationCode' => $argument['verificationCode'], + 'data' => $argument['data'], + 'type' => $argument['type'], + 'uid' => $argument['uid'], + 'try' => (int)$argument['try'] + 1, + 'lastRun' => time() + ] + ); + } + + /** + * test if it is time for the next run + * + * @param array $argument + * @return bool + */ + protected function shouldRun(array $argument) { + $lastRun = (int)$argument['lastRun']; + return ((time() - $lastRun) > $this->interval); + } + + + /** + * reset verification state after max tries are reached + */ + protected function resetVerificationState(): void { + $user = $this->userManager->get($this->argument['uid']); + if ($user !== null) { + $userAccount = $this->accountManager->getAccount($user); + try { + $property = $userAccount->getProperty($this->argument['type']); + $property->setVerified(IAccountManager::NOT_VERIFIED); + $this->accountManager->updateAccount($userAccount); + } catch (PropertyDoesNotExistException $e) { + return; + } + } + } +} diff --git a/apps/settings/lib/Command/AdminDelegation/Add.php b/apps/settings/lib/Command/AdminDelegation/Add.php new file mode 100644 index 00000000000..5cbef5c5d15 --- /dev/null +++ b/apps/settings/lib/Command/AdminDelegation/Add.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types = 1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Command\AdminDelegation; + +use OC\Core\Command\Base; +use OCA\Settings\Service\AuthorizedGroupService; +use OCP\IGroupManager; +use OCP\Settings\IDelegatedSettings; +use OCP\Settings\IManager; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class Add extends Base { + public function __construct( + private IManager $settingManager, + private AuthorizedGroupService $authorizedGroupService, + private IGroupManager $groupManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('admin-delegation:add') + ->setDescription('add setting delegation to a group') + ->addArgument('settingClass', InputArgument::REQUIRED, 'Admin setting class') + ->addArgument('groupId', InputArgument::REQUIRED, 'Delegate to group ID') + ->addUsage('\'OCA\Settings\Settings\Admin\Server\' mygroup') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $settingClass = $input->getArgument('settingClass'); + if (!in_array(IDelegatedSettings::class, (array)class_implements($settingClass), true)) { + $io->error('The specified class isn’t a valid delegated setting.'); + return 2; + } + + $groupId = $input->getArgument('groupId'); + if (!$this->groupManager->groupExists($groupId)) { + $io->error('The specified group didn’t exist.'); + return 3; + } + + $this->authorizedGroupService->create($groupId, $settingClass); + + $io->success('Administration of ' . $settingClass . ' delegated to ' . $groupId . '.'); + + return 0; + } +} diff --git a/apps/settings/lib/Command/AdminDelegation/Remove.php b/apps/settings/lib/Command/AdminDelegation/Remove.php new file mode 100644 index 00000000000..6b5347ce89f --- /dev/null +++ b/apps/settings/lib/Command/AdminDelegation/Remove.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types = 1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Command\AdminDelegation; + +use OC\Core\Command\Base; +use OCA\Settings\Service\AuthorizedGroupService; +use OCP\IGroupManager; +use OCP\Settings\IManager; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class Remove extends Base { + public function __construct( + private IManager $settingManager, + private AuthorizedGroupService $authorizedGroupService, + private IGroupManager $groupManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('admin-delegation:remove') + ->setDescription('remove settings delegation from a group') + ->addArgument('settingClass', InputArgument::REQUIRED, 'Admin setting class') + ->addArgument('groupId', InputArgument::REQUIRED, 'Group ID to remove') + ->addUsage('\'OCA\Settings\Settings\Admin\Server\' mygroup') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $settingClass = $input->getArgument('settingClass'); + $groups = $this->authorizedGroupService->findExistingGroupsForClass($settingClass); + $groupId = $input->getArgument('groupId'); + foreach ($groups as $group) { + if ($group->getGroupId() === $groupId) { + $this->authorizedGroupService->delete($group->getId()); + $io->success('Removed delegation of ' . $settingClass . ' to ' . $groupId . '.'); + return 0; + } + } + + $io->success('Group ' . $groupId . ' didn’t have delegation for ' . $settingClass . '.'); + + return 0; + } +} diff --git a/apps/settings/lib/Command/AdminDelegation/Show.php b/apps/settings/lib/Command/AdminDelegation/Show.php new file mode 100644 index 00000000000..9aba6bc0cb7 --- /dev/null +++ b/apps/settings/lib/Command/AdminDelegation/Show.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types = 1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Command\AdminDelegation; + +use OC\Core\Command\Base; +use OC\Settings\AuthorizedGroup; +use OCA\Settings\Service\AuthorizedGroupService; +use OCP\Settings\IDelegatedSettings; +use OCP\Settings\IManager; +use OCP\Settings\ISettings; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class Show extends Base { + public function __construct( + private IManager $settingManager, + private AuthorizedGroupService $authorizedGroupService, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('admin-delegation:show') + ->setDescription('show delegated settings') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $io->title('Current delegations'); + + $sections = $this->settingManager->getAdminSections(); + $settings = []; + $headers = ['Name', 'SettingId', 'Delegated to groups']; + foreach ($sections as $sectionPriority) { + foreach ($sectionPriority as $section) { + $sectionSettings = $this->settingManager->getAdminSettings($section->getId()); + $sectionSettings = array_reduce($sectionSettings, [$this, 'getDelegatedSettings'], []); + if (empty($sectionSettings)) { + continue; + } + + $io->section('Section: ' . $section->getID()); + $io->table($headers, array_map(function (IDelegatedSettings $setting) use ($section) { + $className = get_class($setting); + $groups = array_map( + static fn (AuthorizedGroup $group) => $group->getGroupId(), + $this->authorizedGroupService->findExistingGroupsForClass($className) + ); + natsort($groups); + return [ + $setting->getName() ?: 'Global', + $className, + implode(', ', $groups), + ]; + }, $sectionSettings)); + } + } + + return 0; + } + + /** + * @param IDelegatedSettings[] $settings + * @param array $innerSection + */ + private function getDelegatedSettings(array $settings, array $innerSection): array { + return $settings + array_filter($innerSection, fn (ISettings $setting) => $setting instanceof IDelegatedSettings); + } +} 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([]); + } +} diff --git a/apps/settings/lib/Events/BeforeTemplateRenderedEvent.php b/apps/settings/lib/Events/BeforeTemplateRenderedEvent.php new file mode 100644 index 00000000000..b5feeffe30a --- /dev/null +++ b/apps/settings/lib/Events/BeforeTemplateRenderedEvent.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Events; + +use OCP\EventDispatcher\Event; + +/** + * This event is triggered right before the user management template is rendered. + * + * @since 20.0.0 + */ +class BeforeTemplateRenderedEvent extends Event { +} diff --git a/apps/settings/lib/Hooks.php b/apps/settings/lib/Hooks.php new file mode 100644 index 00000000000..f59013ca5e1 --- /dev/null +++ b/apps/settings/lib/Hooks.php @@ -0,0 +1,180 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings; + +use OCA\Settings\Activity\Provider; +use OCP\Activity\IManager as IActivityManager; +use OCP\Defaults; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Mail\IMailer; + +class Hooks { + + public function __construct( + protected IActivityManager $activityManager, + protected IGroupManager $groupManager, + protected IUserManager $userManager, + protected IUserSession $userSession, + protected IURLGenerator $urlGenerator, + protected IMailer $mailer, + protected IConfig $config, + protected IFactory $languageFactory, + protected Defaults $defaults, + ) { + } + + /** + * @param string $uid + * @throws \InvalidArgumentException + * @throws \BadMethodCallException + * @throws \Exception + */ + public function onChangePassword($uid) { + $user = $this->userManager->get($uid); + + if (!$user instanceof IUser || $user->getLastLogin() === 0) { + // User didn't login, so don't create activities and emails. + return; + } + + $event = $this->activityManager->generateEvent(); + $event->setApp('settings') + ->setType('personal_settings') + ->setAffectedUser($user->getUID()); + + $instanceName = $this->defaults->getName(); + $instanceUrl = $this->urlGenerator->getAbsoluteURL('/'); + $language = $this->languageFactory->getUserLanguage($user); + $l = $this->languageFactory->get('settings', $language); + + $actor = $this->userSession->getUser(); + if ($actor instanceof IUser) { + if ($actor->getUID() !== $user->getUID()) { + // Admin changed the password through the user panel + $text = $l->t('%1$s changed your password on %2$s.', [$actor->getDisplayName(), $instanceUrl]); + $event->setAuthor($actor->getUID()) + ->setSubject(Provider::PASSWORD_CHANGED_BY, [$actor->getUID()]); + } else { + // User changed their password themselves through settings + $text = $l->t('Your password on %s was changed.', [$instanceUrl]); + $event->setAuthor($actor->getUID()) + ->setSubject(Provider::PASSWORD_CHANGED_SELF); + } + } else { + if (\OC::$CLI) { + // Admin used occ to reset the password + $text = $l->t('Your password on %s was reset by an administrator.', [$instanceUrl]); + $event->setSubject(Provider::PASSWORD_RESET); + } else { + // User reset their password from Lost page + $text = $l->t('Your password on %s was reset.', [$instanceUrl]); + $event->setSubject(Provider::PASSWORD_RESET_SELF); + } + } + + $this->activityManager->publish($event); + + if ($user->getEMailAddress() !== null) { + $template = $this->mailer->createEMailTemplate('settings.PasswordChanged', [ + 'displayname' => $user->getDisplayName(), + 'emailAddress' => $user->getEMailAddress(), + 'instanceUrl' => $instanceUrl, + ]); + + $template->setSubject($l->t('Password for %1$s changed on %2$s', [$user->getDisplayName(), $instanceName])); + $template->addHeader(); + $template->addHeading($l->t('Password changed for %s', [$user->getDisplayName()]), false); + $template->addBodyText($text . ' ' . $l->t('If you did not request this, please contact an administrator.')); + $template->addFooter(); + + + $message = $this->mailer->createMessage(); + $message->setTo([$user->getEMailAddress() => $user->getDisplayName()]); + $message->useTemplate($template); + $this->mailer->send($message); + } + } + + /** + * @param IUser $user + * @param string|null $oldMailAddress + * @throws \InvalidArgumentException + * @throws \BadMethodCallException + */ + public function onChangeEmail(IUser $user, $oldMailAddress) { + if ($oldMailAddress === $user->getEMailAddress() + || $user->getLastLogin() === 0) { + // Email didn't really change or user didn't login, + // so don't create activities and emails. + return; + } + + $event = $this->activityManager->generateEvent(); + $event->setApp('settings') + ->setType('personal_settings') + ->setAffectedUser($user->getUID()); + + $instanceName = $this->defaults->getName(); + $instanceUrl = $this->urlGenerator->getAbsoluteURL('/'); + $language = $this->languageFactory->getUserLanguage($user); + $l = $this->languageFactory->get('settings', $language); + + $actor = $this->userSession->getUser(); + if ($actor instanceof IUser) { + $subject = Provider::EMAIL_CHANGED_SELF; + if ($actor->getUID() !== $user->getUID()) { + // set via the OCS API + if ($this->config->getAppValue('settings', 'disable_activity.email_address_changed_by_admin', 'no') === 'yes') { + return; + } + $subject = Provider::EMAIL_CHANGED; + } + $text = $l->t('Your email address on %s was changed.', [$instanceUrl]); + $event->setAuthor($actor->getUID()) + ->setSubject($subject); + } else { + // set with occ + if ($this->config->getAppValue('settings', 'disable_activity.email_address_changed_by_admin', 'no') === 'yes') { + return; + } + $text = $l->t('Your email address on %s was changed by an administrator.', [$instanceUrl]); + $event->setSubject(Provider::EMAIL_CHANGED); + } + $this->activityManager->publish($event); + + + if ($oldMailAddress !== null) { + $template = $this->mailer->createEMailTemplate('settings.EmailChanged', [ + 'displayname' => $user->getDisplayName(), + 'newEMailAddress' => $user->getEMailAddress(), + 'oldEMailAddress' => $oldMailAddress, + 'instanceUrl' => $instanceUrl, + ]); + + $template->setSubject($l->t('Email address for %1$s changed on %2$s', [$user->getDisplayName(), $instanceName])); + $template->addHeader(); + $template->addHeading($l->t('Email address changed for %s', [$user->getDisplayName()]), false); + $template->addBodyText($text . ' ' . $l->t('If you did not request this, please contact an administrator.')); + if ($user->getEMailAddress()) { + $template->addBodyText($l->t('The new email address is %s', [$user->getEMailAddress()])); + } + $template->addFooter(); + + + $message = $this->mailer->createMessage(); + $message->setTo([$oldMailAddress => $user->getDisplayName()]); + $message->useTemplate($template); + $this->mailer->send($message); + } + } +} diff --git a/apps/settings/lib/Listener/AppPasswordCreatedActivityListener.php b/apps/settings/lib/Listener/AppPasswordCreatedActivityListener.php new file mode 100644 index 00000000000..a51eee1a799 --- /dev/null +++ b/apps/settings/lib/Listener/AppPasswordCreatedActivityListener.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Listener; + +use BadMethodCallException; +use OC\Authentication\Events\AppPasswordCreatedEvent; +use OCA\Settings\Activity\Provider; +use OCP\Activity\IManager as IActivityManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener<\OC\Authentication\Events\AppPasswordCreatedEvent> + */ +class AppPasswordCreatedActivityListener implements IEventListener { + public function __construct( + private IActivityManager $activityManager, + private IUserSession $userSession, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof AppPasswordCreatedEvent)) { + return; + } + + $activity = $this->activityManager->generateEvent(); + $activity->setApp('settings') + ->setType('security') + ->setAffectedUser($event->getToken()->getUID()) + ->setAuthor($this->userSession->getUser() ? $this->userSession->getUser()->getUID() : '') + ->setSubject(Provider::APP_TOKEN_CREATED, ['name' => $event->getToken()->getName()]) + ->setObject('app_token', $event->getToken()->getId()); + + try { + $this->activityManager->publish($activity); + } catch (BadMethodCallException $e) { + $this->logger->warning('Could not publish activity: ' . $e->getMessage(), [ + 'exception' => $e + ]); + } + } +} diff --git a/apps/settings/lib/Listener/GroupRemovedListener.php b/apps/settings/lib/Listener/GroupRemovedListener.php new file mode 100644 index 00000000000..c1b3f888ea8 --- /dev/null +++ b/apps/settings/lib/Listener/GroupRemovedListener.php @@ -0,0 +1,33 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Listener; + +use OCA\Settings\Service\AuthorizedGroupService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Group\Events\GroupDeletedEvent; + +/** @template-implements IEventListener<GroupDeletedEvent> */ +class GroupRemovedListener implements IEventListener { + + public function __construct( + private AuthorizedGroupService $authorizedGroupService, + ) { + } + + /** + * @inheritDoc + */ + public function handle(Event $event): void { + if (!($event instanceof GroupDeletedEvent)) { + return; + } + + /** @var GroupDeletedEvent $event */ + $this->authorizedGroupService->removeAuthorizationAssociatedTo($event->getGroup()); + } +} diff --git a/apps/settings/lib/Listener/MailProviderListener.php b/apps/settings/lib/Listener/MailProviderListener.php new file mode 100644 index 00000000000..61446f1e6cb --- /dev/null +++ b/apps/settings/lib/Listener/MailProviderListener.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Listener; + +use OCA\Settings\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IAppConfig; +use OCP\Settings\Events\DeclarativeSettingsGetValueEvent; +use OCP\Settings\Events\DeclarativeSettingsSetValueEvent; + +/** @template-implements IEventListener<DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent> */ +class MailProviderListener implements IEventListener { + + public function __construct( + private IAppConfig $config, + ) { + } + + public function handle(Event $event): void { + + /** @var DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent $event */ + if ($event->getApp() !== Application::APP_ID) { + return; + } + + if ($event instanceof DeclarativeSettingsGetValueEvent) { + $this->handleGetValue($event); + return; + } + + if ($event instanceof DeclarativeSettingsSetValueEvent) { + $this->handleSetValue($event); + return; + } + + } + + private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void { + + if ($event->getFieldId() === 'mail_providers_enabled') { + $event->setValue((int)$this->config->getValueBool('core', 'mail_providers_enabled', true)); + } + + } + + private function handleSetValue(DeclarativeSettingsSetValueEvent $event): void { + + if ($event->getFieldId() === 'mail_providers_enabled') { + $this->config->setValueBool('core', 'mail_providers_enabled', (bool)$event->getValue()); + $event->stopPropagation(); + } + + } + +} diff --git a/apps/settings/lib/Listener/UserAddedToGroupActivityListener.php b/apps/settings/lib/Listener/UserAddedToGroupActivityListener.php new file mode 100644 index 00000000000..87cd66efdbc --- /dev/null +++ b/apps/settings/lib/Listener/UserAddedToGroupActivityListener.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Listener; + +use OC\Group\Manager; +use OCA\Settings\Activity\GroupProvider; +use OCP\Activity\IManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Group\Events\UserAddedEvent; +use OCP\IUser; +use OCP\IUserSession; + +/** @template-implements IEventListener<UserAddedEvent> */ +class UserAddedToGroupActivityListener implements IEventListener { + + public function __construct( + private Manager $groupManager, + private IManager $activityManager, + private IUserSession $userSession, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof UserAddedEvent)) { + return; + } + + $user = $event->getUser(); + $group = $event->getGroup(); + + $subAdminManager = $this->groupManager->getSubAdmin(); + $usersToNotify = $subAdminManager->getGroupsSubAdmins($group); + $usersToNotify[] = $user; + + + $event = $this->activityManager->generateEvent(); + $event->setApp('settings') + ->setType('group_settings'); + + $actor = $this->userSession->getUser(); + if ($actor instanceof IUser) { + $event->setAuthor($actor->getUID()) + ->setSubject(GroupProvider::ADDED_TO_GROUP, [ + 'user' => $user->getUID(), + 'group' => $group->getGID(), + 'actor' => $actor->getUID(), + ]); + } else { + $event->setSubject(GroupProvider::ADDED_TO_GROUP, [ + 'user' => $user->getUID(), + 'group' => $group->getGID(), + ]); + } + + foreach ($usersToNotify as $userToNotify) { + $event->setAffectedUser($userToNotify->getUID()); + $this->activityManager->publish($event); + } + } +} diff --git a/apps/settings/lib/Listener/UserRemovedFromGroupActivityListener.php b/apps/settings/lib/Listener/UserRemovedFromGroupActivityListener.php new file mode 100644 index 00000000000..803d86d555d --- /dev/null +++ b/apps/settings/lib/Listener/UserRemovedFromGroupActivityListener.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Listener; + +use OC\Group\Manager; +use OCA\Settings\Activity\GroupProvider; +use OCP\Activity\IManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Group\Events\UserRemovedEvent; +use OCP\IUser; +use OCP\IUserSession; + +/** @template-implements IEventListener<UserRemovedEvent> */ +class UserRemovedFromGroupActivityListener implements IEventListener { + + public function __construct( + private Manager $groupManager, + private IManager $activityManager, + private IUserSession $userSession, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof UserRemovedEvent)) { + return; + } + + $user = $event->getUser(); + $group = $event->getGroup(); + + $subAdminManager = $this->groupManager->getSubAdmin(); + $usersToNotify = $subAdminManager->getGroupsSubAdmins($group); + $usersToNotify[] = $user; + + + $event = $this->activityManager->generateEvent(); + $event->setApp('settings') + ->setType('group_settings'); + + $actor = $this->userSession->getUser(); + if ($actor instanceof IUser) { + $event->setAuthor($actor->getUID()) + ->setSubject(GroupProvider::REMOVED_FROM_GROUP, [ + 'user' => $user->getUID(), + 'group' => $group->getGID(), + 'actor' => $actor->getUID(), + ]); + } else { + $event->setSubject(GroupProvider::REMOVED_FROM_GROUP, [ + 'user' => $user->getUID(), + 'group' => $group->getGID(), + ]); + } + + foreach ($usersToNotify as $userToNotify) { + $event->setAffectedUser($userToNotify->getUID()); + $this->activityManager->publish($event); + } + } +} diff --git a/apps/settings/lib/Mailer/NewUserMailHelper.php b/apps/settings/lib/Mailer/NewUserMailHelper.php new file mode 100644 index 00000000000..202495a020e --- /dev/null +++ b/apps/settings/lib/Mailer/NewUserMailHelper.php @@ -0,0 +1,139 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Mailer; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Defaults; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\L10N\IFactory; +use OCP\Mail\Headers\AutoSubmitted; +use OCP\Mail\IEMailTemplate; +use OCP\Mail\IMailer; +use OCP\Security\ICrypto; +use OCP\Security\ISecureRandom; + +class NewUserMailHelper { + /** + * @param Defaults $themingDefaults + * @param IURLGenerator $urlGenerator + * @param IFactory $l10nFactory + * @param IMailer $mailer + * @param ISecureRandom $secureRandom + * @param ITimeFactory $timeFactory + * @param IConfig $config + * @param ICrypto $crypto + * @param string $fromAddress + */ + public function __construct( + private Defaults $themingDefaults, + private IURLGenerator $urlGenerator, + private IFactory $l10nFactory, + private IMailer $mailer, + private ISecureRandom $secureRandom, + private ITimeFactory $timeFactory, + private IConfig $config, + private ICrypto $crypto, + private $fromAddress, + ) { + } + + /** + * @param IUser $user + * @param bool $generatePasswordResetToken + * @return IEMailTemplate + */ + public function generateTemplate(IUser $user, $generatePasswordResetToken = false) { + $userId = $user->getUID(); + $lang = $this->l10nFactory->getUserLanguage($user); + $l10n = $this->l10nFactory->get('settings', $lang); + + if ($generatePasswordResetToken) { + $token = $this->secureRandom->generate( + 21, + ISecureRandom::CHAR_ALPHANUMERIC + ); + $tokenValue = $this->timeFactory->getTime() . ':' . $token; + $mailAddress = ($user->getEMailAddress() !== null) ? $user->getEMailAddress() : ''; + $encryptedValue = $this->crypto->encrypt($tokenValue, $mailAddress . $this->config->getSystemValue('secret')); + $this->config->setUserValue($user->getUID(), 'core', 'lostpassword', $encryptedValue); + $link = $this->urlGenerator->linkToRouteAbsolute('core.lost.resetform', ['userId' => $user->getUID(), 'token' => $token]); + } else { + $link = $this->urlGenerator->getAbsoluteURL('/'); + } + $displayName = $user->getDisplayName(); + + $emailTemplate = $this->mailer->createEMailTemplate('settings.Welcome', [ + 'link' => $link, + 'displayname' => $displayName, + 'userid' => $userId, + 'instancename' => $this->themingDefaults->getName(), + 'resetTokenGenerated' => $generatePasswordResetToken, + ]); + + $emailTemplate->setSubject($l10n->t('Your %s account was created', [$this->themingDefaults->getName()])); + $emailTemplate->addHeader(); + if ($displayName === $userId) { + $emailTemplate->addHeading($l10n->t('Welcome aboard')); + } else { + $emailTemplate->addHeading($l10n->t('Welcome aboard %s', [$displayName])); + } + $emailTemplate->addBodyText($l10n->t('Welcome to your %s account, you can add, protect, and share your data.', [$this->themingDefaults->getName()])); + if ($user->getBackendClassName() !== 'LDAP') { + $emailTemplate->addBodyText($l10n->t('Your Login is: %s', [$userId])); + } + if ($generatePasswordResetToken) { + $leftButtonText = $l10n->t('Set your password'); + } else { + $leftButtonText = $l10n->t('Go to %s', [$this->themingDefaults->getName()]); + } + + $clientDownload = $this->config->getSystemValue('customclient_desktop', 'https://nextcloud.com/install/#install-clients'); + if ($clientDownload === '') { + $emailTemplate->addBodyButton( + $leftButtonText, + $link + ); + } else { + $emailTemplate->addBodyButtonGroup( + $leftButtonText, + $link, + $l10n->t('Install Client'), + $clientDownload + ); + } + + $emailTemplate->addFooter('', $lang); + + return $emailTemplate; + } + + /** + * Sends a welcome mail to $user + * + * @param IUser $user + * @param IEmailTemplate $emailTemplate + * @throws \Exception If mail could not be sent + */ + public function sendMail(IUser $user, + IEMailTemplate $emailTemplate): void { + + // Be sure to never try to send to an empty e-mail + $email = $user->getEMailAddress(); + if ($email === null) { + return; + } + + $message = $this->mailer->createMessage(); + $message->setTo([$email => $user->getDisplayName()]); + $message->setFrom([$this->fromAddress => $this->themingDefaults->getName()]); + $message->useTemplate($emailTemplate); + $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED); + $this->mailer->send($message); + } +} diff --git a/apps/settings/lib/Middleware/SubadminMiddleware.php b/apps/settings/lib/Middleware/SubadminMiddleware.php new file mode 100644 index 00000000000..02d68e138da --- /dev/null +++ b/apps/settings/lib/Middleware/SubadminMiddleware.php @@ -0,0 +1,75 @@ +<?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\Middleware; + +use OC\AppFramework\Http; +use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\Group\ISubAdmin; +use OCP\IL10N; +use OCP\IUserSession; + +/** + * Verifies whether an user has at least subadmin rights. + * To bypass use the `@NoSubAdminRequired` annotation + */ +class SubadminMiddleware extends Middleware { + public function __construct( + protected ControllerMethodReflector $reflector, + protected IUserSession $userSession, + protected ISubAdmin $subAdminManager, + private IL10N $l10n, + ) { + } + + private function isSubAdmin(): bool { + $userObject = $this->userSession->getUser(); + if ($userObject === null) { + return false; + } + return $this->subAdminManager->isSubAdmin($userObject); + } + + /** + * Check if sharing is enabled before the controllers is executed + * @param Controller $controller + * @param string $methodName + * @throws \Exception + */ + public function beforeController($controller, $methodName) { + if (!$this->reflector->hasAnnotation('NoSubAdminRequired') && !$this->reflector->hasAnnotation('AuthorizedAdminSetting')) { + if (!$this->isSubAdmin()) { + throw new NotAdminException($this->l10n->t('Logged in account must be a sub admin')); + } + } + } + + /** + * Return 403 page in case of an exception + * @param Controller $controller + * @param string $methodName + * @param \Exception $exception + * @return TemplateResponse + * @throws \Exception + */ + public function afterException($controller, $methodName, \Exception $exception) { + if ($exception instanceof NotAdminException) { + $response = new TemplateResponse('core', '403', [], 'guest'); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + throw $exception; + } +} diff --git a/apps/settings/lib/ResponseDefinitions.php b/apps/settings/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..12adefda91f --- /dev/null +++ b/apps/settings/lib/ResponseDefinitions.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings; + +/** + * @psalm-type SettingsDeclarativeFormField = array{ + * id: string, + * title: string, + * description?: string, + * type: 'text'|'password'|'email'|'tel'|'url'|'number'|'checkbox'|'multi-checkbox'|'radio'|'select'|'multi-select', + * placeholder?: string, + * label?: string, + * default: mixed, + * options?: list<string|array{name: string, value: mixed}>, + * value: string|int|float|bool|list<string>, + * sensitive?: boolean, + * } + * + * @psalm-type SettingsDeclarativeForm = array{ + * id: string, + * priority: int, + * section_type: 'admin'|'personal', + * section_id: string, + * storage_type: 'internal'|'external', + * title: string, + * description?: string, + * doc_url?: string, + * app: string, + * fields: list<SettingsDeclarativeFormField>, + * } + */ +class ResponseDefinitions { +} diff --git a/apps/settings/lib/Search/AppSearch.php b/apps/settings/lib/Search/AppSearch.php new file mode 100644 index 00000000000..19c2bce5451 --- /dev/null +++ b/apps/settings/lib/Search/AppSearch.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Search; + +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use OCP\Search\SearchResultEntry; + +class AppSearch implements IProvider { + public function __construct( + protected INavigationManager $navigationManager, + protected IL10N $l, + ) { + } + + public function getId(): string { + return 'settings_apps'; + } + + public function getName(): string { + return $this->l->t('Apps'); + } + + public function getOrder(string $route, array $routeParameters): int { + return $route === 'settings.AppSettings.viewApps' ? -50 : 100; + } + + public function search(IUser $user, ISearchQuery $query): SearchResult { + $entries = $this->navigationManager->getAll('all'); + + $searchTitle = $this->l->t('Apps'); + $term = $query->getFilter('term')?->get(); + if (empty($term)) { + return SearchResult::complete($searchTitle, []); + } + + $result = []; + foreach ($entries as $entry) { + if ( + stripos($entry['name'], $term) === false + && stripos($entry['id'], $term) === false + ) { + continue; + } + + if (str_starts_with($query->getRoute(), $entry['id'] . '.')) { + // Skip the current app, unlikely this is intended + continue; + } + + if ($entry['href'] === '') { + // Nothing we can open, so ignore + continue; + } + + $result[] = new SearchResultEntry( + '', + $entry['name'], + '', + $entry['href'], + 'icon-confirm' + ); + } + + return SearchResult::complete($searchTitle, $result); + } +} diff --git a/apps/settings/lib/Search/SectionSearch.php b/apps/settings/lib/Search/SectionSearch.php new file mode 100644 index 00000000000..52f0c9b08db --- /dev/null +++ b/apps/settings/lib/Search/SectionSearch.php @@ -0,0 +1,136 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Search; + +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use OCP\Search\SearchResultEntry; +use OCP\Settings\IIconSection; +use OCP\Settings\IManager; + +class SectionSearch implements IProvider { + + public function __construct( + protected IManager $settingsManager, + protected IGroupManager $groupManager, + protected IURLGenerator $urlGenerator, + protected IL10N $l, + ) { + } + + /** + * @inheritDoc + */ + public function getId(): string { + return 'settings'; + } + + /** + * @inheritDoc + */ + public function getName(): string { + return $this->l->t('Settings'); + } + + /** + * @inheritDoc + */ + public function getOrder(string $route, array $routeParameters): int { + if ($route === 'settings.PersonalSettings.index' || $route === 'settings.AdminSettings.index') { + return -1; + } + // At the very bottom + return 500; + } + + /** + * @inheritDoc + */ + public function search(IUser $user, ISearchQuery $query): SearchResult { + $isAdmin = $this->groupManager->isAdmin($user->getUID()); + + $personalSections = $this->settingsManager->getPersonalSections(); + foreach ($personalSections as $priority => $sections) { + $personalSections[$priority] = array_values(array_filter( + $sections, + fn (IIconSection $section) => !empty($this->settingsManager->getPersonalSettings($section->getID())), + )); + } + + $adminSections = $this->settingsManager->getAdminSections(); + foreach ($adminSections as $priority => $sections) { + $adminSections[$priority] = array_values(array_filter( + $sections, + fn (IIconSection $section) => !empty($this->settingsManager->getAllowedAdminSettings($section->getID(), $user)), + )); + } + + $result = $this->searchSections( + $query, + $personalSections, + $isAdmin ? $this->l->t('Personal') : '', + 'settings.PersonalSettings.index' + ); + + if ($this->groupManager->isAdmin($user->getUID())) { + $result = array_merge($result, $this->searchSections( + $query, + $adminSections, + $this->l->t('Administration'), + 'settings.AdminSettings.index' + )); + } + + return SearchResult::complete( + $this->l->t('Settings'), + $result + ); + } + + /** + * @param ISearchQuery $query + * @param IIconSection[][] $sections + * @param string $subline + * @param string $routeName + * @return array + */ + public function searchSections(ISearchQuery $query, array $sections, string $subline, string $routeName): array { + $result = []; + foreach ($sections as $priority => $sectionsByPriority) { + foreach ($sectionsByPriority as $section) { + if ( + stripos($section->getName(), $query->getTerm()) === false + && stripos($section->getID(), $query->getTerm()) === false + ) { + continue; + } + + /** + * We can't use the icon URL at the moment as they don't invert correctly for dark theme + * $iconUrl = $section->getIcon(); + */ + + $result[] = new SearchResultEntry( + '', + $section->getName(), + $subline, + $this->urlGenerator->linkToRouteAbsolute($routeName, ['section' => $section->getID()]), + 'icon-settings-dark' + ); + } + } + + return $result; + } +} diff --git a/apps/settings/lib/Search/UserSearch.php b/apps/settings/lib/Search/UserSearch.php new file mode 100644 index 00000000000..5326f4cefff --- /dev/null +++ b/apps/settings/lib/Search/UserSearch.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Search; + +use OCP\IL10N; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; + +class UserSearch implements IProvider { + public function __construct( + private IL10N $l, + ) { + } + + public function getId(): string { + return 'users'; + } + + public function getName(): string { + return $this->l->t('Users'); + } + + public function getOrder(string $route, array $routeParameters): ?int { + return str_starts_with($route, 'settings.Users.usersList') + ? 300 + : null; + } + + public function search(IUser $user, ISearchQuery $query): SearchResult { + return SearchResult::complete($this->l->t('Users'), []); + } +} diff --git a/apps/settings/lib/Sections/Admin/Additional.php b/apps/settings/lib/Sections/Admin/Additional.php new file mode 100644 index 00000000000..0d83a98bbe5 --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Additional.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Additional implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/settings-dark.svg'); + } + + public function getID(): string { + return 'additional'; + } + + public function getName(): string { + return $this->l->t('Additional settings'); + } + + public function getPriority(): int { + return 98; + } +} diff --git a/apps/settings/lib/Sections/Admin/ArtificialIntelligence.php b/apps/settings/lib/Sections/Admin/ArtificialIntelligence.php new file mode 100644 index 00000000000..2a300c260c0 --- /dev/null +++ b/apps/settings/lib/Sections/Admin/ArtificialIntelligence.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class ArtificialIntelligence implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('settings', 'ai.svg'); + } + + public function getID(): string { + return 'ai'; + } + + public function getName(): string { + return $this->l->t('Assistant'); + } + + public function getPriority(): int { + return 40; + } +} diff --git a/apps/settings/lib/Sections/Admin/Delegation.php b/apps/settings/lib/Sections/Admin/Delegation.php new file mode 100644 index 00000000000..0dd3b48c20b --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Delegation.php @@ -0,0 +1,56 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Delegation implements IIconSection { + /** + * @param IURLGenerator $url + * @param IL10N $l + */ + public function __construct( + private IURLGenerator $url, + private IL10N $l, + ) { + } + + /** + * {@inheritdoc} + * @return string + */ + public function getID() { + return 'admindelegation'; + } + + /** + * {@inheritdoc} + * @return string + */ + public function getName() { + return $this->l->t('Administration privileges'); + } + + /** + * {@inheritdoc} + * @return int + */ + public function getPriority() { + return 54; + } + + /** + * {@inheritdoc} + * @return string + */ + public function getIcon() { + return $this->url->imagePath('core', 'actions/user-admin.svg'); + } +} diff --git a/apps/settings/lib/Sections/Admin/Groupware.php b/apps/settings/lib/Sections/Admin/Groupware.php new file mode 100644 index 00000000000..57d92b9cc72 --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Groupware.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Groupware implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'places/contacts.svg'); + } + + public function getID(): string { + return 'groupware'; + } + + public function getName(): string { + return $this->l->t('Groupware'); + } + + public function getPriority(): int { + return 50; + } +} diff --git a/apps/settings/lib/Sections/Admin/Overview.php b/apps/settings/lib/Sections/Admin/Overview.php new file mode 100644 index 00000000000..0145a2eca93 --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Overview.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Overview implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('settings', 'admin.svg'); + } + + public function getID(): string { + return 'overview'; + } + + public function getName(): string { + return $this->l->t('Overview'); + } + + public function getPriority(): int { + return 0; + } +} diff --git a/apps/settings/lib/Sections/Admin/Security.php b/apps/settings/lib/Sections/Admin/Security.php new file mode 100644 index 00000000000..10027be32fb --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Security.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Security implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/password.svg'); + } + + public function getID(): string { + return 'security'; + } + + public function getName(): string { + return $this->l->t('Security'); + } + + public function getPriority(): int { + return 10; + } +} diff --git a/apps/settings/lib/Sections/Admin/Server.php b/apps/settings/lib/Sections/Admin/Server.php new file mode 100644 index 00000000000..c6a02efa4e3 --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Server.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Server implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/settings-dark.svg'); + } + + public function getID(): string { + return 'server'; + } + + public function getName(): string { + return $this->l->t('Basic settings'); + } + + public function getPriority(): int { + return 1; + } +} diff --git a/apps/settings/lib/Sections/Admin/Sharing.php b/apps/settings/lib/Sections/Admin/Sharing.php new file mode 100644 index 00000000000..c7598bb1157 --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Sharing.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Admin; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Sharing implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'actions/share.svg'); + } + + public function getID(): string { + return 'sharing'; + } + + public function getName(): string { + return $this->l->t('Sharing'); + } + + public function getPriority(): int { + return 5; + } +} diff --git a/apps/settings/lib/Sections/Personal/Availability.php b/apps/settings/lib/Sections/Personal/Availability.php new file mode 100644 index 00000000000..e12e41ea800 --- /dev/null +++ b/apps/settings/lib/Sections/Personal/Availability.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\Sections\Personal; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Availability implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('dav', 'schedule.svg'); + } + + public function getID(): string { + return 'availability'; + } + + public function getName(): string { + return $this->l->t('Availability'); + } + + public function getPriority(): int { + return 50; + } +} diff --git a/apps/settings/lib/Sections/Personal/Calendar.php b/apps/settings/lib/Sections/Personal/Calendar.php new file mode 100644 index 00000000000..602b7598f15 --- /dev/null +++ b/apps/settings/lib/Sections/Personal/Calendar.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\Sections\Personal; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Calendar implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('dav', 'calendar.svg'); + } + + public function getID(): string { + return 'calendar'; + } + + public function getName(): string { + return $this->l->t('Calendar'); + } + + public function getPriority(): int { + return 50; + } +} diff --git a/apps/settings/lib/Sections/Personal/PersonalInfo.php b/apps/settings/lib/Sections/Personal/PersonalInfo.php new file mode 100644 index 00000000000..35c3e6d2926 --- /dev/null +++ b/apps/settings/lib/Sections/Personal/PersonalInfo.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Personal; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class PersonalInfo implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon() { + return $this->urlGenerator->imagePath('core', 'actions/user.svg'); + } + + public function getID(): string { + return 'personal-info'; + } + + public function getName(): string { + return $this->l->t('Personal info'); + } + + public function getPriority(): int { + return 0; + } +} diff --git a/apps/settings/lib/Sections/Personal/Security.php b/apps/settings/lib/Sections/Personal/Security.php new file mode 100644 index 00000000000..d7eb65724ec --- /dev/null +++ b/apps/settings/lib/Sections/Personal/Security.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Personal; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Security implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon() { + return $this->urlGenerator->imagePath('settings', 'password.svg'); + } + + public function getID(): string { + return 'security'; + } + + public function getName(): string { + return $this->l->t('Security'); + } + + public function getPriority(): int { + return 5; + } +} diff --git a/apps/settings/lib/Sections/Personal/SyncClients.php b/apps/settings/lib/Sections/Personal/SyncClients.php new file mode 100644 index 00000000000..3f221956f0c --- /dev/null +++ b/apps/settings/lib/Sections/Personal/SyncClients.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Sections\Personal; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class SyncClients implements IIconSection { + + public function __construct( + private IL10N $l, + private IURLGenerator $urlGenerator, + ) { + } + + public function getIcon() { + return $this->urlGenerator->imagePath('core', 'clients/phone.svg'); + } + + public function getID(): string { + return 'sync-clients'; + } + + public function getName(): string { + return $this->l->t('Mobile & desktop'); + } + + public function getPriority(): int { + return 15; + } +} diff --git a/apps/settings/lib/Service/AuthorizedGroupService.php b/apps/settings/lib/Service/AuthorizedGroupService.php new file mode 100644 index 00000000000..15aca94198a --- /dev/null +++ b/apps/settings/lib/Service/AuthorizedGroupService.php @@ -0,0 +1,96 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Service; + +use OC\Settings\AuthorizedGroup; +use OC\Settings\AuthorizedGroupMapper; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\DB\Exception; +use OCP\IGroup; + +class AuthorizedGroupService { + + public function __construct( + private AuthorizedGroupMapper $mapper, + ) { + } + + /** + * @return AuthorizedGroup[] + */ + public function findAll(): array { + return $this->mapper->findAll(); + } + + /** + * Find AuthorizedGroup by id. + * + * @param int $id + */ + public function find(int $id): ?AuthorizedGroup { + return $this->mapper->find($id); + } + + /** + * @param $e + * @throws NotFoundException + */ + private function handleException(\Exception $e): void { + if ($e instanceof DoesNotExistException + || $e instanceof MultipleObjectsReturnedException) { + throw new NotFoundException('AuthorizedGroup not found'); + } else { + throw $e; + } + } + + /** + * Create a new AuthorizedGroup + * + * @param string $groupId + * @param string $class + * @return AuthorizedGroup + * @throws Exception + */ + public function create(string $groupId, string $class): AuthorizedGroup { + $authorizedGroup = new AuthorizedGroup(); + $authorizedGroup->setGroupId($groupId); + $authorizedGroup->setClass($class); + return $this->mapper->insert($authorizedGroup); + } + + /** + * @throws NotFoundException + */ + public function delete(int $id): void { + try { + $authorizedGroup = $this->mapper->find($id); + $this->mapper->delete($authorizedGroup); + } catch (\Exception $e) { + $this->handleException($e); + } + } + + public function findExistingGroupsForClass(string $class): array { + try { + $authorizedGroup = $this->mapper->findExistingGroupsForClass($class); + return $authorizedGroup; + } catch (\Exception $e) { + return []; + } + } + + public function removeAuthorizationAssociatedTo(IGroup $group): void { + try { + $this->mapper->removeGroup($group->getGID()); + } catch (\Exception $e) { + $this->handleException($e); + } + } +} diff --git a/apps/settings/lib/Service/NotFoundException.php b/apps/settings/lib/Service/NotFoundException.php new file mode 100644 index 00000000000..3abdcf72cd4 --- /dev/null +++ b/apps/settings/lib/Service/NotFoundException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Service; + +class NotFoundException extends ServiceException { +} diff --git a/apps/settings/lib/Service/ServiceException.php b/apps/settings/lib/Service/ServiceException.php new file mode 100644 index 00000000000..79c848779ca --- /dev/null +++ b/apps/settings/lib/Service/ServiceException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Service; + +class ServiceException extends \Exception { +} diff --git a/apps/settings/lib/Settings/Admin/ArtificialIntelligence.php b/apps/settings/lib/Settings/Admin/ArtificialIntelligence.php new file mode 100644 index 00000000000..aaec0049b20 --- /dev/null +++ b/apps/settings/lib/Settings/Admin/ArtificialIntelligence.php @@ -0,0 +1,218 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\Settings\IDelegatedSettings; +use OCP\SpeechToText\ISpeechToTextManager; +use OCP\SpeechToText\ISpeechToTextProviderWithId; +use OCP\TextProcessing\IManager; +use OCP\TextProcessing\IProvider; +use OCP\TextProcessing\IProviderWithId; +use OCP\TextProcessing\ITaskType; +use OCP\Translation\ITranslationManager; +use OCP\Translation\ITranslationProviderWithId; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; + +class ArtificialIntelligence implements IDelegatedSettings { + public function __construct( + private IAppConfig $appConfig, + private IL10N $l, + private IInitialState $initialState, + private ITranslationManager $translationManager, + private ISpeechToTextManager $sttManager, + private IManager $textProcessingManager, + private ContainerInterface $container, + private \OCP\TextToImage\IManager $text2imageManager, + private \OCP\TaskProcessing\IManager $taskProcessingManager, + private LoggerInterface $logger, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm() { + $translationProviders = []; + $translationPreferences = []; + foreach ($this->translationManager->getProviders() as $provider) { + $translationProviders[] = [ + 'class' => $provider instanceof ITranslationProviderWithId ? $provider->getId() : $provider::class, + 'name' => $provider->getName(), + ]; + $translationPreferences[] = $provider instanceof ITranslationProviderWithId ? $provider->getId() : $provider::class; + } + + $sttProviders = []; + foreach ($this->sttManager->getProviders() as $provider) { + $sttProviders[] = [ + 'class' => $provider instanceof ISpeechToTextProviderWithId ? $provider->getId() : $provider::class, + 'name' => $provider->getName(), + ]; + } + + $textProcessingProviders = []; + /** @var array<class-string<ITaskType>, string|class-string<IProvider>> $textProcessingSettings */ + $textProcessingSettings = []; + foreach ($this->textProcessingManager->getProviders() as $provider) { + $textProcessingProviders[] = [ + 'class' => $provider instanceof IProviderWithId ? $provider->getId() : $provider::class, + 'name' => $provider->getName(), + 'taskType' => $provider->getTaskType(), + ]; + if (!isset($textProcessingSettings[$provider->getTaskType()])) { + $textProcessingSettings[$provider->getTaskType()] = $provider instanceof IProviderWithId ? $provider->getId() : $provider::class; + } + } + $textProcessingTaskTypes = []; + foreach ($textProcessingSettings as $taskTypeClass => $providerClass) { + /** @var ITaskType $taskType */ + try { + $taskType = $this->container->get($taskTypeClass); + } catch (NotFoundExceptionInterface $e) { + continue; + } catch (ContainerExceptionInterface $e) { + continue; + } + $textProcessingTaskTypes[] = [ + 'class' => $taskTypeClass, + 'name' => $taskType->getName(), + 'description' => $taskType->getDescription(), + ]; + } + + $text2imageProviders = []; + foreach ($this->text2imageManager->getProviders() as $provider) { + $text2imageProviders[] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + ]; + } + + $taskProcessingProviders = []; + /** @var array<class-string<ITaskType>, string|class-string<IProvider>> $taskProcessingSettings */ + $taskProcessingSettings = []; + foreach ($this->taskProcessingManager->getProviders() as $provider) { + $taskProcessingProviders[] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'taskType' => $provider->getTaskTypeId(), + ]; + if (!isset($taskProcessingSettings[$provider->getTaskTypeId()])) { + $taskProcessingSettings[$provider->getTaskTypeId()] = $provider->getId(); + } + } + $taskProcessingTaskTypes = []; + $taskProcessingTypeSettings = []; + foreach ($this->taskProcessingManager->getAvailableTaskTypes(true) as $taskTypeId => $taskTypeDefinition) { + $taskProcessingTaskTypes[] = [ + 'id' => $taskTypeId, + 'name' => $taskTypeDefinition['name'], + 'description' => $taskTypeDefinition['description'], + ]; + $taskProcessingTypeSettings[$taskTypeId] = true; + } + + + $this->initialState->provideInitialState('ai-stt-providers', $sttProviders); + $this->initialState->provideInitialState('ai-translation-providers', $translationProviders); + $this->initialState->provideInitialState('ai-text-processing-providers', $textProcessingProviders); + $this->initialState->provideInitialState('ai-text-processing-task-types', $textProcessingTaskTypes); + $this->initialState->provideInitialState('ai-text2image-providers', $text2imageProviders); + $this->initialState->provideInitialState('ai-task-processing-providers', $taskProcessingProviders); + $this->initialState->provideInitialState('ai-task-processing-task-types', $taskProcessingTaskTypes); + + $settings = [ + 'ai.stt_provider' => count($sttProviders) > 0 ? $sttProviders[0]['class'] : null, + 'ai.translation_provider_preferences' => $translationPreferences, + 'ai.textprocessing_provider_preferences' => $textProcessingSettings, + 'ai.text2image_provider' => count($text2imageProviders) > 0 ? $text2imageProviders[0]['id'] : null, + 'ai.taskprocessing_provider_preferences' => $taskProcessingSettings, + 'ai.taskprocessing_type_preferences' => $taskProcessingTypeSettings, + 'ai.taskprocessing_guests' => false, + ]; + foreach ($settings as $key => $defaultValue) { + $value = $defaultValue; + $json = $this->appConfig->getValueString('core', $key, '', lazy: in_array($key, \OC\TaskProcessing\Manager::LAZY_CONFIG_KEYS, true)); + if ($json !== '') { + try { + $value = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('Failed to get settings. JSON Error in ' . $key, ['exception' => $e]); + if ($key === 'ai.taskprocessing_type_preferences') { + $value = []; + foreach ($taskProcessingTypeSettings as $taskTypeId => $taskTypeValue) { + $value[$taskTypeId] = false; + } + $settings[$key] = $value; + } + continue; + } + + switch ($key) { + case 'ai.taskprocessing_provider_preferences': + case 'ai.taskprocessing_type_preferences': + case 'ai.textprocessing_provider_preferences': + // fill $value with $defaultValue values + $value = array_merge($defaultValue, $value); + break; + case 'ai.translation_provider_preferences': + // Only show entries from $value (saved pref list) that are in $defaultValue (enabled providers) + // and add all providers that are enabled but not in the pref list + if (!is_array($defaultValue)) { + break; + } + $value = array_values(array_unique(array_merge(array_intersect($value, $defaultValue), $defaultValue), SORT_STRING)); + break; + default: + break; + } + } + $settings[$key] = $value; + } + + $this->initialState->provideInitialState('ai-settings', $settings); + + return new TemplateResponse('settings', 'settings/admin/ai'); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'ai'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority() { + return 10; + } + + public function getName(): ?string { + return $this->l->t('Artificial Intelligence'); + } + + public function getAuthorizedAppConfig(): array { + return [ + 'core' => ['/ai..*/'], + ]; + } +} diff --git a/apps/settings/lib/Settings/Admin/Delegation.php b/apps/settings/lib/Settings/Admin/Delegation.php new file mode 100644 index 00000000000..59a26d1ac04 --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Delegation.php @@ -0,0 +1,120 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OCA\Settings\AppInfo\Application; +use OCA\Settings\Service\AuthorizedGroupService; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\IGroupManager; +use OCP\IURLGenerator; +use OCP\Settings\IDelegatedSettings; +use OCP\Settings\IManager; +use OCP\Settings\ISettings; + +class Delegation implements ISettings { + public function __construct( + private IManager $settingManager, + private IInitialState $initialStateService, + private IGroupManager $groupManager, + private AuthorizedGroupService $authorizedGroupService, + private IURLGenerator $urlGenerator, + ) { + } + + /** + * Filter out the ISettings that are not IDelegatedSettings from $innerSection + * and add them to $settings. + * + * @param IDelegatedSettings[] $settings + * @param ISettings[] $innerSection + * @return IDelegatedSettings[] + */ + private function getDelegatedSettings(array $settings, array $innerSection): array { + foreach ($innerSection as $setting) { + if ($setting instanceof IDelegatedSettings) { + $settings[] = $setting; + } + } + return $settings; + } + + private function initSettingState(): void { + // Available settings page initialization + $sections = $this->settingManager->getAdminSections(); + $settings = []; + foreach ($sections as $sectionPriority) { + foreach ($sectionPriority as $section) { + $sectionSettings = $this->settingManager->getAdminSettings($section->getId()); + $sectionSettings = array_reduce($sectionSettings, [$this, 'getDelegatedSettings'], []); + $settings = array_merge( + $settings, + array_map(function (IDelegatedSettings $setting) use ($section) { + $sectionName = $section->getName() . ($setting->getName() !== null ? ' - ' . $setting->getName() : ''); + return [ + 'class' => get_class($setting), + 'sectionName' => $sectionName, + 'id' => mb_strtolower(str_replace(' ', '-', $sectionName)), + 'priority' => $section->getPriority(), + ]; + }, $sectionSettings) + ); + } + } + usort($settings, function (array $a, array $b) { + if ($a['priority'] == $b['priority']) { + return 0; + } + return ($a['priority'] < $b['priority']) ? -1 : 1; + }); + $this->initialStateService->provideInitialState('available-settings', $settings); + } + + public function initAvailableGroupState(): void { + // Available groups initialization + $groups = []; + $groupsClass = $this->groupManager->search(''); + foreach ($groupsClass as $group) { + if ($group->getGID() === 'admin') { + continue; // Admin already have access to everything + } + $groups[] = [ + 'displayName' => $group->getDisplayName(), + 'gid' => $group->getGID(), + ]; + } + $this->initialStateService->provideInitialState('available-groups', $groups); + } + + public function initAuthorizedGroupState(): void { + // Already set authorized groups + $this->initialStateService->provideInitialState('authorized-groups', $this->authorizedGroupService->findAll()); + } + + public function getForm(): TemplateResponse { + $this->initSettingState(); + $this->initAvailableGroupState(); + $this->initAuthorizedGroupState(); + $this->initialStateService->provideInitialState('authorized-settings-doc-link', $this->urlGenerator->linkToDocs('admin-delegation')); + + return new TemplateResponse(Application::APP_ID, 'settings/admin/delegation', [], ''); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'admindelegation'; + } + + /* + * @inheritdoc + */ + public function getPriority() { + return 75; + } +} diff --git a/apps/settings/lib/Settings/Admin/Mail.php b/apps/settings/lib/Settings/Admin/Mail.php new file mode 100644 index 00000000000..8bf2342a59c --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Mail.php @@ -0,0 +1,84 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IBinaryFinder; +use OCP\IConfig; +use OCP\IL10N; +use OCP\Server; +use OCP\Settings\IDelegatedSettings; + +class Mail implements IDelegatedSettings { + /** + * @param IConfig $config + * @param IL10N $l + */ + public function __construct( + private IConfig $config, + private IL10N $l, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm() { + $finder = Server::get(IBinaryFinder::class); + + $parameters = [ + // Mail + 'sendmail_is_available' => $finder->findBinaryPath('sendmail') !== false, + 'mail_domain' => $this->config->getSystemValue('mail_domain', ''), + 'mail_from_address' => $this->config->getSystemValue('mail_from_address', ''), + 'mail_smtpmode' => $this->config->getSystemValue('mail_smtpmode', ''), + 'mail_smtpsecure' => $this->config->getSystemValue('mail_smtpsecure', ''), + 'mail_smtphost' => $this->config->getSystemValue('mail_smtphost', ''), + 'mail_smtpport' => $this->config->getSystemValue('mail_smtpport', ''), + 'mail_smtpauth' => $this->config->getSystemValue('mail_smtpauth', false), + 'mail_smtpname' => $this->config->getSystemValue('mail_smtpname', ''), + 'mail_smtppassword' => $this->config->getSystemValue('mail_smtppassword', ''), + 'mail_sendmailmode' => $this->config->getSystemValue('mail_sendmailmode', 'smtp'), + ]; + + if ($parameters['mail_smtppassword'] !== '') { + $parameters['mail_smtppassword'] = '********'; + } + + if ($parameters['mail_smtpmode'] === '' || $parameters['mail_smtpmode'] === 'php') { + $parameters['mail_smtpmode'] = 'smtp'; + } + + return new TemplateResponse('settings', 'settings/admin/additional-mail', $parameters, ''); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'server'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority() { + return 10; + } + + public function getName(): ?string { + return $this->l->t('Email server'); + } + + public function getAuthorizedAppConfig(): array { + return []; + } +} diff --git a/apps/settings/lib/Settings/Admin/MailProvider.php b/apps/settings/lib/Settings/Admin/MailProvider.php new file mode 100644 index 00000000000..c1e72378d20 --- /dev/null +++ b/apps/settings/lib/Settings/Admin/MailProvider.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OCP\IL10N; +use OCP\Settings\DeclarativeSettingsTypes; +use OCP\Settings\IDeclarativeSettingsForm; + +class MailProvider implements IDeclarativeSettingsForm { + + public function __construct( + private IL10N $l, + ) { + } + + public function getSchema(): array { + return [ + 'id' => 'mail-provider-support', + 'priority' => 10, + 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, + 'section_id' => 'server', + 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL, + 'title' => $this->l->t('Mail Providers'), + 'description' => $this->l->t('Mail provider enables sending emails directly through the user\'s personal email account. At present, this functionality is limited to calendar invitations. It requires Nextcloud Mail 4.1 and an email account in Nextcloud Mail that matches the user\'s email address in Nextcloud.'), + + 'fields' => [ + [ + 'id' => 'mail_providers_enabled', + 'title' => $this->l->t('Send emails using'), + 'type' => DeclarativeSettingsTypes::RADIO, + 'default' => 1, + 'options' => [ + [ + 'name' => $this->l->t('User\'s email account'), + 'value' => 1 + ], + [ + 'name' => $this->l->t('System email account'), + 'value' => 0 + ], + ], + ], + ], + ]; + } + +} diff --git a/apps/settings/lib/Settings/Admin/Overview.php b/apps/settings/lib/Settings/Admin/Overview.php new file mode 100644 index 00000000000..355200372f1 --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Overview.php @@ -0,0 +1,60 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IConfig; +use OCP\IL10N; +use OCP\ServerVersion; +use OCP\Settings\IDelegatedSettings; + +class Overview implements IDelegatedSettings { + public function __construct( + private ServerVersion $serverVersion, + private IConfig $config, + private IL10N $l, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm() { + $parameters = [ + 'checkForWorkingWellKnownSetup' => $this->config->getSystemValue('check_for_working_wellknown_setup', true), + 'version' => $this->serverVersion->getHumanVersion(), + ]; + + return new TemplateResponse('settings', 'settings/admin/overview', $parameters, ''); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'overview'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority() { + return 10; + } + + public function getName(): ?string { + return $this->l->t('Security & setup checks'); + } + + public function getAuthorizedAppConfig(): array { + return []; + } +} diff --git a/apps/settings/lib/Settings/Admin/Security.php b/apps/settings/lib/Settings/Admin/Security.php new file mode 100644 index 00000000000..c4efdb478c7 --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Security.php @@ -0,0 +1,73 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OC\Authentication\TwoFactorAuth\MandatoryTwoFactor; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Encryption\IManager; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\Settings\ISettings; + +class Security implements ISettings { + private MandatoryTwoFactor $mandatoryTwoFactor; + + public function __construct( + private IManager $manager, + private IUserManager $userManager, + MandatoryTwoFactor $mandatoryTwoFactor, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, + ) { + $this->mandatoryTwoFactor = $mandatoryTwoFactor; + } + + /** + * @return TemplateResponse + */ + public function getForm(): TemplateResponse { + $encryptionModules = $this->manager->getEncryptionModules(); + $defaultEncryptionModuleId = $this->manager->getDefaultEncryptionModuleId(); + $encryptionModuleList = []; + foreach ($encryptionModules as $module) { + $encryptionModuleList[$module['id']]['displayName'] = $module['displayName']; + $encryptionModuleList[$module['id']]['default'] = false; + if ($module['id'] === $defaultEncryptionModuleId) { + $encryptionModuleList[$module['id']]['default'] = true; + } + } + + $this->initialState->provideInitialState('mandatory2FAState', $this->mandatoryTwoFactor->getState()); + $this->initialState->provideInitialState('two-factor-admin-doc', $this->urlGenerator->linkToDocs('admin-2fa')); + $this->initialState->provideInitialState('encryption-enabled', $this->manager->isEnabled()); + $this->initialState->provideInitialState('encryption-ready', $this->manager->isReady()); + $this->initialState->provideInitialState('external-backends-enabled', count($this->userManager->getBackends()) > 1); + $this->initialState->provideInitialState('encryption-modules', $encryptionModuleList); + $this->initialState->provideInitialState('encryption-admin-doc', $this->urlGenerator->linkToDocs('admin-encryption')); + + return new TemplateResponse('settings', 'settings/admin/security', [], ''); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection(): string { + return 'security'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 10; + } +} diff --git a/apps/settings/lib/Settings/Admin/Server.php b/apps/settings/lib/Settings/Admin/Server.php new file mode 100644 index 00000000000..c0f29ce8f34 --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Server.php @@ -0,0 +1,112 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OC\Profile\ProfileManager; +use OC\Profile\TProfileHelper; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IDelegatedSettings; + +class Server implements IDelegatedSettings { + use TProfileHelper; + + public function __construct( + private IDBConnection $connection, + private IInitialState $initialStateService, + private ProfileManager $profileManager, + private ITimeFactory $timeFactory, + private IURLGenerator $urlGenerator, + private IConfig $config, + private IAppConfig $appConfig, + private IL10N $l, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm() { + $ownerConfigFile = fileowner(\OC::$configDir . 'config.php'); + $cliBasedCronPossible = function_exists('posix_getpwuid') && $ownerConfigFile !== false; + $cliBasedCronUser = $cliBasedCronPossible ? (posix_getpwuid($ownerConfigFile)['name'] ?? '') : ''; + + // Background jobs + $this->initialStateService->provideInitialState('backgroundJobsMode', $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax')); + $this->initialStateService->provideInitialState('lastCron', $this->appConfig->getValueInt('core', 'lastcron', 0)); + $this->initialStateService->provideInitialState('cronMaxAge', $this->cronMaxAge()); + $this->initialStateService->provideInitialState('cronErrors', $this->config->getAppValue('core', 'cronErrors')); + $this->initialStateService->provideInitialState('cliBasedCronPossible', $cliBasedCronPossible); + $this->initialStateService->provideInitialState('cliBasedCronUser', $cliBasedCronUser); + $this->initialStateService->provideInitialState('backgroundJobsDocUrl', $this->urlGenerator->linkToDocs('admin-background-jobs')); + + // Profile page + $this->initialStateService->provideInitialState('profileEnabledGlobally', $this->profileManager->isProfileEnabled()); + $this->initialStateService->provideInitialState('profileEnabledByDefault', $this->isProfileEnabledByDefault($this->config)); + + // Basic settings + $this->initialStateService->provideInitialState('restrictSystemTagsCreationToAdmin', $this->appConfig->getValueBool('systemtags', 'restrict_creation_to_admin', false)); + + return new TemplateResponse('settings', 'settings/admin/server', [ + 'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(), + ], ''); + } + + protected function cronMaxAge(): int { + $query = $this->connection->getQueryBuilder(); + $query->select('last_checked') + ->from('jobs') + ->orderBy('last_checked', 'ASC') + ->setMaxResults(1); + + $result = $query->execute(); + if ($row = $result->fetch()) { + $maxAge = (int)$row['last_checked']; + } else { + $maxAge = $this->timeFactory->getTime(); + } + $result->closeCursor(); + + return $maxAge; + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection(): string { + return 'server'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 0; + } + + public function getName(): ?string { + return $this->l->t('Background jobs'); + } + + public function getAuthorizedAppConfig(): array { + return [ + 'core' => [ + '/mail_general_settings/', + ], + ]; + } +} diff --git a/apps/settings/lib/Settings/Admin/Sharing.php b/apps/settings/lib/Settings/Admin/Sharing.php new file mode 100644 index 00000000000..ec5dcdf624d --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Sharing.php @@ -0,0 +1,125 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Admin; + +use OC\Core\AppInfo\ConfigLexicon; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Constants; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IDelegatedSettings; +use OCP\Share\IManager; +use OCP\Util; + +class Sharing implements IDelegatedSettings { + public function __construct( + private IConfig $config, + private IAppConfig $appConfig, + private IL10N $l, + private IManager $shareManager, + private IAppManager $appManager, + private IURLGenerator $urlGenerator, + private IInitialState $initialState, + private string $appName, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm() { + $excludedGroups = $this->config->getAppValue('core', 'shareapi_exclude_groups_list', ''); + $linksExcludedGroups = $this->config->getAppValue('core', 'shareapi_allow_links_exclude_groups', ''); + $excludedPasswordGroups = $this->config->getAppValue('core', 'shareapi_enforce_links_password_excluded_groups', ''); + $onlyShareWithGroupMembersExcludeGroupList = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members_exclude_group_list', ''); + + $parameters = [ + // Built-In Sharing + 'enabled' => $this->getHumanBooleanConfig('core', 'shareapi_enabled', true), + 'allowGroupSharing' => $this->getHumanBooleanConfig('core', 'shareapi_allow_group_sharing', true), + 'allowLinks' => $this->getHumanBooleanConfig('core', 'shareapi_allow_links', true), + 'allowLinksExcludeGroups' => json_decode($linksExcludedGroups, true) ?? [], + 'allowPublicUpload' => $this->getHumanBooleanConfig('core', 'shareapi_allow_public_upload', true), + 'allowResharing' => $this->getHumanBooleanConfig('core', 'shareapi_allow_resharing', true), + 'allowShareDialogUserEnumeration' => $this->getHumanBooleanConfig('core', 'shareapi_allow_share_dialog_user_enumeration', true), + 'allowFederationOnPublicShares' => $this->appConfig->getValueBool('core', ConfigLexicon::SHAREAPI_ALLOW_FEDERATION_ON_PUBLIC_SHARES), + 'restrictUserEnumerationToGroup' => $this->getHumanBooleanConfig('core', 'shareapi_restrict_user_enumeration_to_group'), + 'restrictUserEnumerationToPhone' => $this->getHumanBooleanConfig('core', 'shareapi_restrict_user_enumeration_to_phone'), + 'restrictUserEnumerationFullMatch' => $this->getHumanBooleanConfig('core', 'shareapi_restrict_user_enumeration_full_match', true), + 'restrictUserEnumerationFullMatchUserId' => $this->getHumanBooleanConfig('core', 'shareapi_restrict_user_enumeration_full_match_userid', true), + 'restrictUserEnumerationFullMatchEmail' => $this->getHumanBooleanConfig('core', 'shareapi_restrict_user_enumeration_full_match_email', true), + 'restrictUserEnumerationFullMatchIgnoreSecondDN' => $this->getHumanBooleanConfig('core', 'shareapi_restrict_user_enumeration_full_match_ignore_second_dn'), + 'enforceLinksPassword' => Util::isPublicLinkPasswordRequired(false), + 'enforceLinksPasswordExcludedGroups' => json_decode($excludedPasswordGroups) ?? [], + 'enforceLinksPasswordExcludedGroupsEnabled' => $this->config->getSystemValueBool('sharing.allow_disabled_password_enforcement_groups', false), + 'onlyShareWithGroupMembers' => $this->shareManager->shareWithGroupMembersOnly(), + 'onlyShareWithGroupMembersExcludeGroupList' => json_decode($onlyShareWithGroupMembersExcludeGroupList) ?? [], + 'defaultExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_default_expire_date'), + 'expireAfterNDays' => $this->config->getAppValue('core', 'shareapi_expire_after_n_days', '7'), + 'enforceExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_enforce_expire_date'), + 'excludeGroups' => $this->config->getAppValue('core', 'shareapi_exclude_groups', 'no'), + 'excludeGroupsList' => json_decode($excludedGroups, true) ?? [], + 'publicShareDisclaimerText' => $this->config->getAppValue('core', 'shareapi_public_link_disclaimertext'), + 'enableLinkPasswordByDefault' => $this->appConfig->getValueBool('core', ConfigLexicon::SHARE_LINK_PASSWORD_DEFAULT), + 'defaultPermissions' => (int)$this->config->getAppValue('core', 'shareapi_default_permissions', (string)Constants::PERMISSION_ALL), + 'defaultInternalExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_default_internal_expire_date'), + 'internalExpireAfterNDays' => $this->config->getAppValue('core', 'shareapi_internal_expire_after_n_days', '7'), + 'enforceInternalExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_enforce_internal_expire_date'), + 'defaultRemoteExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_default_remote_expire_date'), + 'remoteExpireAfterNDays' => $this->config->getAppValue('core', 'shareapi_remote_expire_after_n_days', '7'), + 'enforceRemoteExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_enforce_remote_expire_date'), + 'allowCustomTokens' => $this->shareManager->allowCustomTokens(), + 'allowViewWithoutDownload' => $this->shareManager->allowViewWithoutDownload(), + ]; + + $this->initialState->provideInitialState('sharingAppEnabled', $this->appManager->isEnabledForUser('files_sharing')); + $this->initialState->provideInitialState('sharingDocumentation', $this->urlGenerator->linkToDocs('admin-sharing')); + $this->initialState->provideInitialState('sharingSettings', $parameters); + + Util::addScript($this->appName, 'vue-settings-admin-sharing'); + return new TemplateResponse($this->appName, 'settings/admin/sharing', [], ''); + } + + /** + * Helper function to retrive boolean values from human readable strings ('yes' / 'no') + */ + private function getHumanBooleanConfig(string $app, string $key, bool $default = false): bool { + return $this->config->getAppValue($app, $key, $default ? 'yes' : 'no') === 'yes'; + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'sharing'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority() { + return 0; + } + + public function getAuthorizedAppConfig(): array { + return [ + 'core' => ['/shareapi_.*/'], + ]; + } + + public function getName(): ?string { + return null; + } +} diff --git a/apps/settings/lib/Settings/Admin/Users.php b/apps/settings/lib/Settings/Admin/Users.php new file mode 100644 index 00000000000..c569890a0dc --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Users.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\Settings\Admin; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IL10N; +use OCP\Settings\IDelegatedSettings; + +/** + * Empty settings class, used only for admin delegation. + */ +class Users implements IDelegatedSettings { + + public function __construct( + protected string $appName, + private IL10N $l10n, + ) { + } + + /** + * Empty template response + */ + public function getForm(): TemplateResponse { + + return new /** @template-extends TemplateResponse<\OCP\AppFramework\Http::STATUS_OK, array{}> */ class($this->appName, '') extends TemplateResponse { + public function render(): string { + return ''; + } + }; + } + + public function getSection(): ?string { + return 'admindelegation'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 0; + } + + public function getName(): string { + return $this->l10n->t('Users'); + } + + public function getAuthorizedAppConfig(): array { + return []; + } +} diff --git a/apps/settings/lib/Settings/Personal/Additional.php b/apps/settings/lib/Settings/Personal/Additional.php new file mode 100644 index 00000000000..58fe08a63b7 --- /dev/null +++ b/apps/settings/lib/Settings/Personal/Additional.php @@ -0,0 +1,41 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Personal; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Settings\ISettings; + +class Additional implements ISettings { + + /** + * @return TemplateResponse returns the instance with all parameters set, ready to be rendered + * @since 9.1 + */ + public function getForm(): TemplateResponse { + return new TemplateResponse('settings', 'settings/empty'); + } + + /** + * @return string the section ID, e.g. 'sharing' + * @since 9.1 + */ + public function getSection(): string { + return 'additional'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + * @since 9.1 + */ + public function getPriority(): int { + return 5; + } +} diff --git a/apps/settings/lib/Settings/Personal/PersonalInfo.php b/apps/settings/lib/Settings/Personal/PersonalInfo.php new file mode 100644 index 00000000000..9a12b18bb5e --- /dev/null +++ b/apps/settings/lib/Settings/Personal/PersonalInfo.php @@ -0,0 +1,320 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\Settings\Personal; + +use OC\Profile\ProfileManager; +use OCA\FederatedFileSharing\FederatedShareProvider; +use OCA\Provisioning_API\Controller\AUserDataOCSController; +use OCP\Accounts\IAccount; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\IAccountProperty; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Files\FileInfo; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IUser; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Notification\IManager; +use OCP\Server; +use OCP\Settings\ISettings; +use OCP\Util; + +class PersonalInfo implements ISettings { + + /** @var ProfileManager */ + private $profileManager; + + public function __construct( + private IConfig $config, + private IUserManager $userManager, + private IGroupManager $groupManager, + private IAccountManager $accountManager, + ProfileManager $profileManager, + private IAppManager $appManager, + private IFactory $l10nFactory, + private IL10N $l, + private IInitialState $initialStateService, + private IManager $manager, + ) { + $this->profileManager = $profileManager; + } + + public function getForm(): TemplateResponse { + $federationEnabled = $this->appManager->isEnabledForUser('federation'); + $federatedFileSharingEnabled = $this->appManager->isEnabledForUser('federatedfilesharing'); + $lookupServerUploadEnabled = false; + if ($federatedFileSharingEnabled) { + /** @var FederatedShareProvider $shareProvider */ + $shareProvider = Server::get(FederatedShareProvider::class); + $lookupServerUploadEnabled = $shareProvider->isLookupServerUploadEnabled(); + } + + $uid = \OC_User::getUser(); + $user = $this->userManager->get($uid); + $account = $this->accountManager->getAccount($user); + + // make sure FS is setup before querying storage related stuff... + \OC_Util::setupFS($user->getUID()); + + $storageInfo = \OC_Helper::getStorageInfo('/'); + if ($storageInfo['quota'] === FileInfo::SPACE_UNLIMITED) { + $totalSpace = $this->l->t('Unlimited'); + } else { + $totalSpace = Util::humanFileSize($storageInfo['total']); + } + + $messageParameters = $this->getMessageParameters($account); + + $parameters = [ + 'lookupServerUploadEnabled' => $lookupServerUploadEnabled, + 'isFairUseOfFreePushService' => $this->isFairUseOfFreePushService(), + 'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(), + ] + $messageParameters; + + $personalInfoParameters = [ + 'userId' => $uid, + 'avatar' => $this->getProperty($account, IAccountManager::PROPERTY_AVATAR), + 'groups' => $this->getGroups($user), + 'quota' => $storageInfo['quota'], + 'totalSpace' => $totalSpace, + 'usage' => Util::humanFileSize($storageInfo['used']), + 'usageRelative' => round($storageInfo['relative']), + 'displayName' => $this->getProperty($account, IAccountManager::PROPERTY_DISPLAYNAME), + 'emailMap' => $this->getEmailMap($account), + 'phone' => $this->getProperty($account, IAccountManager::PROPERTY_PHONE), + 'defaultPhoneRegion' => $this->config->getSystemValueString('default_phone_region'), + 'location' => $this->getProperty($account, IAccountManager::PROPERTY_ADDRESS), + 'website' => $this->getProperty($account, IAccountManager::PROPERTY_WEBSITE), + 'twitter' => $this->getProperty($account, IAccountManager::PROPERTY_TWITTER), + 'bluesky' => $this->getProperty($account, IAccountManager::PROPERTY_BLUESKY), + 'fediverse' => $this->getProperty($account, IAccountManager::PROPERTY_FEDIVERSE), + 'languageMap' => $this->getLanguageMap($user), + 'localeMap' => $this->getLocaleMap($user), + 'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(), + 'profileEnabled' => $this->profileManager->isProfileEnabled($user), + 'organisation' => $this->getProperty($account, IAccountManager::PROPERTY_ORGANISATION), + 'role' => $this->getProperty($account, IAccountManager::PROPERTY_ROLE), + 'headline' => $this->getProperty($account, IAccountManager::PROPERTY_HEADLINE), + 'biography' => $this->getProperty($account, IAccountManager::PROPERTY_BIOGRAPHY), + 'birthdate' => $this->getProperty($account, IAccountManager::PROPERTY_BIRTHDATE), + 'firstDayOfWeek' => $this->config->getUserValue($uid, 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK), + 'pronouns' => $this->getProperty($account, IAccountManager::PROPERTY_PRONOUNS), + ]; + + $accountParameters = [ + 'avatarChangeSupported' => $user->canChangeAvatar(), + 'displayNameChangeSupported' => $user->canChangeDisplayName(), + 'emailChangeSupported' => $user->canChangeEmail(), + 'federationEnabled' => $federationEnabled, + 'lookupServerUploadEnabled' => $lookupServerUploadEnabled, + ]; + + $profileParameters = [ + 'profileConfig' => $this->profileManager->getProfileConfigWithMetadata($user, $user), + ]; + + $this->initialStateService->provideInitialState('profileEnabledGlobally', $this->profileManager->isProfileEnabled()); + $this->initialStateService->provideInitialState('personalInfoParameters', $personalInfoParameters); + $this->initialStateService->provideInitialState('accountParameters', $accountParameters); + $this->initialStateService->provideInitialState('profileParameters', $profileParameters); + + return new TemplateResponse('settings', 'settings/personal/personal.info', $parameters, ''); + } + + /** + * Check if is fair use of free push service + * @return boolean + */ + private function isFairUseOfFreePushService(): bool { + return $this->manager->isFairUseOfFreePushService(); + } + + /** + * returns the property data in an + * associative array + */ + private function getProperty(IAccount $account, string $property): array { + $property = [ + 'name' => $account->getProperty($property)->getName(), + 'value' => $account->getProperty($property)->getValue(), + 'scope' => $account->getProperty($property)->getScope(), + 'verified' => $account->getProperty($property)->getVerified(), + ]; + + return $property; + } + + /** + * returns the section ID string, e.g. 'sharing' + * @since 9.1 + */ + public function getSection(): string { + return 'personal-info'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + * @since 9.1 + */ + public function getPriority(): int { + return 10; + } + + /** + * returns a sorted list of the user's group GIDs + */ + private function getGroups(IUser $user): array { + $groups = array_map( + static function (IGroup $group) { + return $group->getDisplayName(); + }, + $this->groupManager->getUserGroups($user) + ); + sort($groups); + + return $groups; + } + + /** + * returns the primary email and additional emails in an + * associative array + */ + private function getEmailMap(IAccount $account): array { + $systemEmail = [ + 'name' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getName(), + 'value' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue(), + 'scope' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getScope(), + 'verified' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getVerified(), + ]; + + $additionalEmails = array_map( + function (IAccountProperty $property) { + return [ + 'name' => $property->getName(), + 'value' => $property->getValue(), + 'scope' => $property->getScope(), + 'verified' => $property->getVerified(), + 'locallyVerified' => $property->getLocallyVerified(), + ]; + }, + $account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties(), + ); + + $emailMap = [ + 'primaryEmail' => $systemEmail, + 'additionalEmails' => $additionalEmails, + 'notificationEmail' => (string)$account->getUser()->getPrimaryEMailAddress(), + ]; + + return $emailMap; + } + + /** + * returns the user's active language, common languages, and other languages in an + * associative array + */ + private function getLanguageMap(IUser $user): array { + $forceLanguage = $this->config->getSystemValue('force_language', false); + if ($forceLanguage !== false) { + return []; + } + + $uid = $user->getUID(); + + $userConfLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage()); + $languages = $this->l10nFactory->getLanguages(); + + // associate the user language with the proper array + $userLangIndex = array_search($userConfLang, array_column($languages['commonLanguages'], 'code')); + $userLang = $languages['commonLanguages'][$userLangIndex]; + // search in the other languages + if ($userLangIndex === false) { + $userLangIndex = array_search($userConfLang, array_column($languages['otherLanguages'], 'code')); + $userLang = $languages['otherLanguages'][$userLangIndex]; + } + // if user language is not available but set somehow: show the actual code as name + if (!is_array($userLang)) { + $userLang = [ + 'code' => $userConfLang, + 'name' => $userConfLang, + ]; + } + + return array_merge( + ['activeLanguage' => $userLang], + $languages + ); + } + + private function getLocaleMap(IUser $user): array { + $forceLanguage = $this->config->getSystemValue('force_locale', false); + if ($forceLanguage !== false) { + return []; + } + + $uid = $user->getUID(); + $userLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage()); + $userLocaleString = $this->config->getUserValue($uid, 'core', 'locale', $this->l10nFactory->findLocale($userLang)); + $localeCodes = $this->l10nFactory->findAvailableLocales(); + $userLocale = array_filter($localeCodes, fn ($value) => $userLocaleString === $value['code']); + + if (!empty($userLocale)) { + $userLocale = reset($userLocale); + } + + $localesForLanguage = array_values(array_filter($localeCodes, fn ($localeCode) => str_starts_with($localeCode['code'], $userLang))); + $otherLocales = array_values(array_filter($localeCodes, fn ($localeCode) => !str_starts_with($localeCode['code'], $userLang))); + + if (!$userLocale) { + $userLocale = [ + 'code' => 'en', + 'name' => 'English' + ]; + } + + return [ + 'activeLocaleLang' => $userLocaleString, + 'activeLocale' => $userLocale, + 'localesForLanguage' => $localesForLanguage, + 'otherLocales' => $otherLocales, + ]; + } + + /** + * returns the message parameters + */ + private function getMessageParameters(IAccount $account): array { + $needVerifyMessage = [IAccountManager::PROPERTY_EMAIL, IAccountManager::PROPERTY_WEBSITE, IAccountManager::PROPERTY_TWITTER]; + $messageParameters = []; + foreach ($needVerifyMessage as $property) { + switch ($account->getProperty($property)->getVerified()) { + case IAccountManager::VERIFIED: + $message = $this->l->t('Verifying'); + break; + case IAccountManager::VERIFICATION_IN_PROGRESS: + $message = $this->l->t('Verifying …'); + break; + default: + $message = $this->l->t('Verify'); + } + $messageParameters[$property . 'Message'] = $message; + } + return $messageParameters; + } +} diff --git a/apps/settings/lib/Settings/Personal/Security/Authtokens.php b/apps/settings/lib/Settings/Personal/Security/Authtokens.php new file mode 100644 index 00000000000..e0509b22a9c --- /dev/null +++ b/apps/settings/lib/Settings/Personal/Security/Authtokens.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Personal\Security; + +use OC\Authentication\Token\INamedToken; +use OC\Authentication\Token\IProvider as IAuthTokenProvider; +use OC\Authentication\Token\IToken; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\ISession; +use OCP\IUserSession; +use OCP\Session\Exceptions\SessionNotAvailableException; +use OCP\Settings\ISettings; +use function array_map; + +class Authtokens implements ISettings { + + public function __construct( + private IAuthTokenProvider $tokenProvider, + private ISession $session, + private IUserSession $userSession, + private IInitialState $initialState, + private ?string $userId, + ) { + } + + public function getForm(): TemplateResponse { + $this->initialState->provideInitialState( + 'app_tokens', + $this->getAppTokens() + ); + + $this->initialState->provideInitialState( + 'can_create_app_token', + $this->userSession->getImpersonatingUserID() === null + ); + + return new TemplateResponse('settings', 'settings/personal/security/authtokens'); + } + + public function getSection(): string { + return 'security'; + } + + public function getPriority(): int { + return 100; + } + + private function getAppTokens(): array { + $tokens = $this->tokenProvider->getTokenByUser($this->userId); + + try { + $sessionId = $this->session->getId(); + } catch (SessionNotAvailableException $ex) { + return []; + } + try { + $sessionToken = $this->tokenProvider->getToken($sessionId); + } catch (InvalidTokenException $ex) { + return []; + } + + return array_map(function (IToken $token) use ($sessionToken) { + $data = $token->jsonSerialize(); + $data['canDelete'] = true; + $data['canRename'] = $token instanceof INamedToken && $data['type'] !== IToken::WIPE_TOKEN; + if ($sessionToken->getId() === $token->getId()) { + $data['canDelete'] = false; + $data['canRename'] = false; + $data['current'] = true; + } + return $data; + }, $tokens); + } +} diff --git a/apps/settings/lib/Settings/Personal/Security/Password.php b/apps/settings/lib/Settings/Personal/Security/Password.php new file mode 100644 index 00000000000..8184dae9560 --- /dev/null +++ b/apps/settings/lib/Settings/Personal/Security/Password.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Personal\Security; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IUserManager; +use OCP\Settings\ISettings; + +class Password implements ISettings { + + public function __construct( + private IUserManager $userManager, + private ?string $userId, + ) { + } + + public function getForm(): TemplateResponse { + $user = $this->userManager->get($this->userId); + $passwordChangeSupported = false; + if ($user !== null) { + $passwordChangeSupported = $user->canChangePassword(); + } + + return new TemplateResponse('settings', 'settings/personal/security/password', [ + 'passwordChangeSupported' => $passwordChangeSupported, + ]); + } + + public function getSection(): string { + return 'security'; + } + + public function getPriority(): int { + return 10; + } +} diff --git a/apps/settings/lib/Settings/Personal/Security/TwoFactor.php b/apps/settings/lib/Settings/Personal/Security/TwoFactor.php new file mode 100644 index 00000000000..0c419cb6fa7 --- /dev/null +++ b/apps/settings/lib/Settings/Personal/Security/TwoFactor.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Personal\Security; + +use Exception; +use OC\Authentication\TwoFactorAuth\MandatoryTwoFactor; +use OC\Authentication\TwoFactorAuth\ProviderLoader; +use OCA\TwoFactorBackupCodes\Provider\BackupCodesProvider; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Authentication\TwoFactorAuth\IProvider; +use OCP\Authentication\TwoFactorAuth\IProvidesPersonalSettings; +use OCP\IConfig; +use OCP\IUserSession; +use OCP\Settings\ISettings; +use function array_filter; +use function array_map; +use function is_null; + +class TwoFactor implements ISettings { + + /** @var ProviderLoader */ + private $providerLoader; + + /** @var MandatoryTwoFactor */ + private $mandatoryTwoFactor; + + public function __construct( + ProviderLoader $providerLoader, + MandatoryTwoFactor $mandatoryTwoFactor, + private IUserSession $userSession, + private IConfig $config, + private ?string $userId, + ) { + $this->providerLoader = $providerLoader; + $this->mandatoryTwoFactor = $mandatoryTwoFactor; + } + + public function getForm(): TemplateResponse { + return new TemplateResponse('settings', 'settings/personal/security/twofactor', [ + 'twoFactorProviderData' => $this->getTwoFactorProviderData(), + ]); + } + + public function getSection(): ?string { + if (!$this->shouldShow()) { + return null; + } + return 'security'; + } + + public function getPriority(): int { + return 15; + } + + private function shouldShow(): bool { + $user = $this->userSession->getUser(); + if (is_null($user)) { + // Actually impossible, but still … + return false; + } + + // Anyone who's supposed to use 2FA should see 2FA settings + if ($this->mandatoryTwoFactor->isEnforcedFor($user)) { + return true; + } + + // If there is at least one provider with personal settings but it's not + // the backup codes provider, then these settings should show. + try { + $providers = $this->providerLoader->getProviders($user); + } catch (Exception $e) { + // Let's hope for the best + return true; + } + foreach ($providers as $provider) { + if ($provider instanceof IProvidesPersonalSettings + && !($provider instanceof BackupCodesProvider)) { + return true; + } + } + return false; + } + + private function getTwoFactorProviderData(): array { + $user = $this->userSession->getUser(); + if (is_null($user)) { + // Actually impossible, but still … + return []; + } + + return [ + 'providers' => array_map(function (IProvidesPersonalSettings $provider) use ($user) { + return [ + 'provider' => $provider, + 'settings' => $provider->getPersonalSettings($user) + ]; + }, array_filter($this->providerLoader->getProviders($user), function (IProvider $provider) { + return $provider instanceof IProvidesPersonalSettings; + })) + ]; + } +} diff --git a/apps/settings/lib/Settings/Personal/Security/WebAuthn.php b/apps/settings/lib/Settings/Personal/Security/WebAuthn.php new file mode 100644 index 00000000000..a6ba4e9522a --- /dev/null +++ b/apps/settings/lib/Settings/Personal/Security/WebAuthn.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Personal\Security; + +use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper; +use OC\Authentication\WebAuthn\Manager; +use OCA\Settings\AppInfo\Application; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IInitialStateService; +use OCP\Settings\ISettings; + +class WebAuthn implements ISettings { + + /** @var PublicKeyCredentialMapper */ + private $mapper; + + /** @var Manager */ + private $manager; + + public function __construct( + PublicKeyCredentialMapper $mapper, + private string $userId, + private IInitialStateService $initialStateService, + Manager $manager, + ) { + $this->mapper = $mapper; + $this->manager = $manager; + } + + public function getForm() { + $this->initialStateService->provideInitialState( + Application::APP_ID, + 'webauthn-devices', + $this->mapper->findAllForUid($this->userId) + ); + + return new TemplateResponse('settings', 'settings/personal/security/webauthn'); + } + + public function getSection(): ?string { + if (!$this->manager->isWebAuthnAvailable()) { + return null; + } + + return 'security'; + } + + public function getPriority(): int { + return 20; + } +} diff --git a/apps/settings/lib/Settings/Personal/ServerDevNotice.php b/apps/settings/lib/Settings/Personal/ServerDevNotice.php new file mode 100644 index 00000000000..c9993484abd --- /dev/null +++ b/apps/settings/lib/Settings/Personal/ServerDevNotice.php @@ -0,0 +1,79 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Settings\Personal; + +use OCA\Viewer\Event\LoadViewer; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Settings\ISettings; +use OCP\Support\Subscription\IRegistry; +use OCP\Util; + +class ServerDevNotice implements ISettings { + + public function __construct( + private IRegistry $registry, + private IEventDispatcher $eventDispatcher, + private IRootFolder $rootFolder, + private IUserSession $userSession, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm(): TemplateResponse { + $userFolder = $this->rootFolder->getUserFolder($this->userSession->getUser()->getUID()); + + $hasInitialState = false; + + // If the Reasons to use Nextcloud.pdf file is here, let's init Viewer, also check that Viewer is there + if (class_exists(LoadViewer::class) && $userFolder->nodeExists('Reasons to use Nextcloud.pdf')) { + /** + * @psalm-suppress UndefinedClass, InvalidArgument + */ + $this->eventDispatcher->dispatch(LoadViewer::class, new LoadViewer()); + $hasInitialState = true; + } + + // Always load the script + Util::addScript('settings', 'vue-settings-nextcloud-pdf'); + $this->initialState->provideInitialState('has-reasons-use-nextcloud-pdf', $hasInitialState); + + return new TemplateResponse('settings', 'settings/personal/development.notice', [ + 'reasons-use-nextcloud-pdf-link' => $this->urlGenerator->linkToRoute('settings.Reasons.getPdf') + ]); + } + + /** + * @return string|null the section ID, e.g. 'sharing' + */ + public function getSection(): ?string { + if ($this->registry->delegateHasValidSubscription()) { + return null; + } + + return 'personal-info'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 1000; + } +} diff --git a/apps/settings/lib/SetupChecks/AllowedAdminRanges.php b/apps/settings/lib/SetupChecks/AllowedAdminRanges.php new file mode 100644 index 00000000000..5116676dd43 --- /dev/null +++ b/apps/settings/lib/SetupChecks/AllowedAdminRanges.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OC\Security\Ip\Range; +use OC\Security\Ip\RemoteAddress; +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class AllowedAdminRanges implements ISetupCheck { + public function __construct( + private IConfig $config, + private IL10N $l10n, + ) { + } + + public function getCategory(): string { + return 'system'; + } + + public function getName(): string { + return $this->l10n->t('Allowed admin IP ranges'); + } + + public function run(): SetupResult { + $allowedAdminRanges = $this->config->getSystemValue(RemoteAddress::SETTING_NAME, false); + if ( + $allowedAdminRanges === false + || (is_array($allowedAdminRanges) && empty($allowedAdminRanges)) + ) { + return SetupResult::success($this->l10n->t('Admin IP filtering isn\'t applied.')); + } + + if (!is_array($allowedAdminRanges)) { + return SetupResult::error( + $this->l10n->t( + 'Configuration key "%1$s" expects an array (%2$s found). Admin IP range validation will not be applied.', + [RemoteAddress::SETTING_NAME, gettype($allowedAdminRanges)], + ) + ); + } + + $invalidRanges = array_filter($allowedAdminRanges, static fn (mixed $range): bool => !is_string($range) || !Range::isValid($range)); + if (!empty($invalidRanges)) { + return SetupResult::warning( + $this->l10n->t( + 'Configuration key "%1$s" contains invalid IP range(s): "%2$s"', + [RemoteAddress::SETTING_NAME, implode('", "', $invalidRanges)], + ), + ); + } + + return SetupResult::success($this->l10n->t('Admin IP filtering is correctly configured.')); + } +} diff --git a/apps/settings/lib/SetupChecks/AppDirsWithDifferentOwner.php b/apps/settings/lib/SetupChecks/AppDirsWithDifferentOwner.php new file mode 100644 index 00000000000..0d18037c3b5 --- /dev/null +++ b/apps/settings/lib/SetupChecks/AppDirsWithDifferentOwner.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class AppDirsWithDifferentOwner implements ISetupCheck { + public function __construct( + private IL10N $l10n, + ) { + } + + public function getName(): string { + return $this->l10n->t('App directories owner'); + } + + public function getCategory(): string { + return 'security'; + } + + /** + * Iterates through the configured app roots and + * tests if the subdirectories are owned by the same user than the current user. + * + * @return string[] + */ + private function getAppDirsWithDifferentOwner(int $currentUser): array { + $appDirsWithDifferentOwner = [[]]; + + foreach (\OC::$APPSROOTS as $appRoot) { + if ($appRoot['writable'] === true) { + $appDirsWithDifferentOwner[] = $this->getAppDirsWithDifferentOwnerForAppRoot($currentUser, $appRoot); + } + } + + $appDirsWithDifferentOwner = array_merge(...$appDirsWithDifferentOwner); + sort($appDirsWithDifferentOwner); + + return $appDirsWithDifferentOwner; + } + + /** + * Tests if the directories for one apps directory are writable by the current user. + * + * @param int $currentUser The current user + * @param array $appRoot The app root config + * @return string[] The none writable directory paths inside the app root + */ + private function getAppDirsWithDifferentOwnerForAppRoot(int $currentUser, array $appRoot): array { + $appDirsWithDifferentOwner = []; + $appsPath = $appRoot['path']; + $appsDir = new \DirectoryIterator($appRoot['path']); + + foreach ($appsDir as $fileInfo) { + if ($fileInfo->isDir() && !$fileInfo->isDot()) { + $absAppPath = $appsPath . DIRECTORY_SEPARATOR . $fileInfo->getFilename(); + $appDirUser = fileowner($absAppPath); + if ($appDirUser !== $currentUser) { + $appDirsWithDifferentOwner[] = $absAppPath; + } + } + } + + return $appDirsWithDifferentOwner; + } + + public function run(): SetupResult { + $currentUser = posix_getuid(); + $currentUserInfos = posix_getpwuid($currentUser) ?: []; + $appDirsWithDifferentOwner = $this->getAppDirsWithDifferentOwner($currentUser); + if (count($appDirsWithDifferentOwner) > 0) { + return SetupResult::warning( + $this->l10n->t("Some app directories are owned by a different user than the web server one. This may be the case if apps have been installed manually. Check the permissions of the following app directories:\n%s", implode("\n", $appDirsWithDifferentOwner)) + ); + } else { + return SetupResult::success($this->l10n->t('App directories have the correct owner "%s"', [$currentUserInfos['name'] ?? ''])); + } + } +} diff --git a/apps/settings/lib/SetupChecks/BruteForceThrottler.php b/apps/settings/lib/SetupChecks/BruteForceThrottler.php new file mode 100644 index 00000000000..e97e363944f --- /dev/null +++ b/apps/settings/lib/SetupChecks/BruteForceThrottler.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\Security\Bruteforce\IThrottler; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class BruteForceThrottler implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IRequest $request, + private IThrottler $throttler, + ) { + } + + public function getCategory(): string { + return 'system'; + } + + public function getName(): string { + return $this->l10n->t('Brute-force Throttle'); + } + + public function run(): SetupResult { + $address = $this->request->getRemoteAddress(); + if ($address === '') { + if (\OC::$CLI) { + /* We were called from CLI */ + return SetupResult::info($this->l10n->t('Your remote address could not be determined.')); + } else { + /* Should never happen */ + return SetupResult::error($this->l10n->t('Your remote address could not be determined.')); + } + } elseif ($this->throttler->showBruteforceWarning($address)) { + return SetupResult::error( + $this->l10n->t('Your remote address was identified as "%s" and is brute-force throttled at the moment slowing down the performance of various requests. If the remote address is not your address this can be an indication that a proxy is not configured correctly.', [$address]), + $this->urlGenerator->linkToDocs('admin-reverse-proxy') + ); + } else { + return SetupResult::success( + $this->l10n->t('Your remote address "%s" is not brute-force throttled.', [$address]) + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/CheckUserCertificates.php b/apps/settings/lib/SetupChecks/CheckUserCertificates.php new file mode 100644 index 00000000000..d1e3551c085 --- /dev/null +++ b/apps/settings/lib/SetupChecks/CheckUserCertificates.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class CheckUserCertificates implements ISetupCheck { + private string $configValue; + + public function __construct( + private IL10N $l10n, + IConfig $config, + ) { + $this->configValue = $config->getAppValue('files_external', 'user_certificate_scan', ''); + } + + public function getCategory(): string { + return 'security'; + } + + public function getName(): string { + return $this->l10n->t('Old administration imported certificates'); + } + + public function run(): SetupResult { + // all fine if neither "not-run-yet" nor a result + if ($this->configValue === '') { + return SetupResult::success(); + } + if ($this->configValue === 'not-run-yet') { + return SetupResult::info($this->l10n->t('A background job is pending that checks for administration imported SSL certificates. Please check back later.')); + } + return SetupResult::error($this->l10n->t('There are some administration imported SSL certificates present, that are not used anymore with Nextcloud 21. They can be imported on the command line via "occ security:certificates:import" command. Their paths inside the data directory are shown below.')); + } +} diff --git a/apps/settings/lib/SetupChecks/CodeIntegrity.php b/apps/settings/lib/SetupChecks/CodeIntegrity.php new file mode 100644 index 00000000000..2b4271fae9c --- /dev/null +++ b/apps/settings/lib/SetupChecks/CodeIntegrity.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OC\IntegrityCheck\Checker; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class CodeIntegrity implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private Checker $checker, + ) { + } + + public function getName(): string { + return $this->l10n->t('Code integrity'); + } + + public function getCategory(): string { + return 'security'; + } + + public function run(): SetupResult { + if (!$this->checker->isCodeCheckEnforced()) { + return SetupResult::info($this->l10n->t('Integrity checker has been disabled. Integrity cannot be verified.')); + } + + // If there are no results we need to run the verification + if ($this->checker->getResults() === null) { + $this->checker->runInstanceVerification(); + } + + if ($this->checker->hasPassedCheck()) { + return SetupResult::success($this->l10n->t('No altered files')); + } else { + return SetupResult::error( + $this->l10n->t('Some files have not passed the integrity check. {link1} {link2}'), + $this->urlGenerator->linkToDocs('admin-code-integrity'), + [ + 'link1' => [ + 'type' => 'highlight', + 'id' => 'getFailedIntegrityCheckFiles', + 'name' => 'List of invalid files…', + 'link' => $this->urlGenerator->linkToRoute('settings.CheckSetup.getFailedIntegrityCheckFiles'), + ], + 'link2' => [ + 'type' => 'highlight', + 'id' => 'rescanFailedIntegrityCheck', + 'name' => 'Rescan…', + 'link' => $this->urlGenerator->linkToRoute('settings.CheckSetup.rescanFailedIntegrityCheck'), + ], + ], + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/CronErrors.php b/apps/settings/lib/SetupChecks/CronErrors.php new file mode 100644 index 00000000000..dc625b04477 --- /dev/null +++ b/apps/settings/lib/SetupChecks/CronErrors.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class CronErrors implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + ) { + } + + public function getCategory(): string { + return 'system'; + } + + public function getName(): string { + return $this->l10n->t('Cron errors'); + } + + public function run(): SetupResult { + $errors = json_decode($this->config->getAppValue('core', 'cronErrors', ''), true); + if (is_array($errors) && count($errors) > 0) { + return SetupResult::error( + $this->l10n->t( + "It was not possible to execute the cron job via CLI. The following technical errors have appeared:\n%s", + implode("\n", array_map(fn (array $error) => '- ' . $error['error'] . ' ' . $error['hint'], $errors)) + ) + ); + } else { + return SetupResult::success($this->l10n->t('The last cron job ran without errors.')); + } + } +} diff --git a/apps/settings/lib/SetupChecks/CronInfo.php b/apps/settings/lib/SetupChecks/CronInfo.php new file mode 100644 index 00000000000..f18148c9d14 --- /dev/null +++ b/apps/settings/lib/SetupChecks/CronInfo.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IDateTimeFormatter; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class CronInfo implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IAppConfig $appConfig, + private IURLGenerator $urlGenerator, + private IDateTimeFormatter $dateTimeFormatter, + ) { + } + + public function getCategory(): string { + return 'system'; + } + + public function getName(): string { + return $this->l10n->t('Cron last run'); + } + + public function run(): SetupResult { + $lastCronRun = $this->appConfig->getValueInt('core', 'lastcron', 0); + $relativeTime = $this->dateTimeFormatter->formatTimeSpan($lastCronRun); + + if ((time() - $lastCronRun) > 3600) { + return SetupResult::error( + $this->l10n->t( + 'Last background job execution ran %s. Something seems wrong. {link}.', + [$relativeTime] + ), + descriptionParameters:[ + 'link' => [ + 'type' => 'highlight', + 'id' => 'backgroundjobs', + 'name' => 'Check the background job settings', + 'link' => $this->urlGenerator->linkToRoute('settings.AdminSettings.index', ['section' => 'server']) . '#backgroundjobs', + ], + ], + ); + } else { + return SetupResult::success( + $this->l10n->t( + 'Last background job execution ran %s.', + [$relativeTime] + ) + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/DataDirectoryProtected.php b/apps/settings/lib/SetupChecks/DataDirectoryProtected.php new file mode 100644 index 00000000000..e572c345079 --- /dev/null +++ b/apps/settings/lib/SetupChecks/DataDirectoryProtected.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +/** + * Checks if the data directory can not be accessed from outside + */ +class DataDirectoryProtected implements ISetupCheck { + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('Data directory protected'); + } + + public function run(): SetupResult { + $dataDir = str_replace(\OC::$SERVERROOT . '/', '', $this->config->getSystemValueString('datadirectory', '')); + $dataUrl = $this->urlGenerator->linkTo('', $dataDir . '/.ncdata'); + + $noResponse = true; + foreach ($this->runRequest('GET', $dataUrl, [ 'httpErrors' => false ]) as $response) { + $noResponse = false; + if ($response->getStatusCode() < 400) { + // Read the response body + $body = $response->getBody(); + if (is_resource($body)) { + $body = stream_get_contents($body, 64); + } + + if (str_contains($body, '# Nextcloud data directory')) { + return SetupResult::error($this->l10n->t('Your data directory and files are probably accessible from the internet. The .htaccess file is not working. It is strongly recommended that you configure your web server so that the data directory is no longer accessible, or move the data directory outside the web server document root.')); + } + } else { + $this->logger->debug('[expected] Could not access data directory from outside.', ['url' => $dataUrl]); + } + } + + if ($noResponse) { + return SetupResult::warning($this->l10n->t('Could not check that the data directory is protected. Please check manually that your server does not allow access to the data directory.') . "\n" . $this->serverConfigHelp()); + } + return SetupResult::success(); + + } +} diff --git a/apps/settings/lib/SetupChecks/DatabaseHasMissingColumns.php b/apps/settings/lib/SetupChecks/DatabaseHasMissingColumns.php new file mode 100644 index 00000000000..ec004f73021 --- /dev/null +++ b/apps/settings/lib/SetupChecks/DatabaseHasMissingColumns.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OC\DB\Connection; +use OC\DB\MissingColumnInformation; +use OC\DB\SchemaWrapper; +use OCP\DB\Events\AddMissingColumnsEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class DatabaseHasMissingColumns implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private Connection $connection, + private IEventDispatcher $dispatcher, + ) { + } + + public function getCategory(): string { + return 'database'; + } + + public function getName(): string { + return $this->l10n->t('Database missing columns'); + } + + private function getMissingColumns(): array { + $columnInfo = new MissingColumnInformation(); + // Dispatch event so apps can also hint for pending column updates if needed + $event = new AddMissingColumnsEvent(); + $this->dispatcher->dispatchTyped($event); + $missingColumns = $event->getMissingColumns(); + + if (!empty($missingColumns)) { + $schema = new SchemaWrapper($this->connection); + foreach ($missingColumns as $missingColumn) { + if ($schema->hasTable($missingColumn['tableName'])) { + $table = $schema->getTable($missingColumn['tableName']); + if (!$table->hasColumn($missingColumn['columnName'])) { + $columnInfo->addHintForMissingColumn($missingColumn['tableName'], $missingColumn['columnName']); + } + } + } + } + + return $columnInfo->getListOfMissingColumns(); + } + + public function run(): SetupResult { + $missingColumns = $this->getMissingColumns(); + if (empty($missingColumns)) { + return SetupResult::success('None'); + } else { + $list = ''; + foreach ($missingColumns as $missingColumn) { + $list .= "\n" . $this->l10n->t('Missing optional column "%s" in table "%s".', [$missingColumn['columnName'], $missingColumn['tableName']]); + } + return SetupResult::warning( + $this->l10n->t('The database is missing some optional columns. Due to the fact that adding columns on big tables could take some time they were not added automatically when they can be optional. By running "occ db:add-missing-columns" those missing columns could be added manually while the instance keeps running. Once the columns are added some features might improve responsiveness or usability.') . $list + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/DatabaseHasMissingIndices.php b/apps/settings/lib/SetupChecks/DatabaseHasMissingIndices.php new file mode 100644 index 00000000000..97e80c2aaa9 --- /dev/null +++ b/apps/settings/lib/SetupChecks/DatabaseHasMissingIndices.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OC\DB\Connection; +use OC\DB\MissingIndexInformation; +use OC\DB\SchemaWrapper; +use OCP\DB\Events\AddMissingIndicesEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class DatabaseHasMissingIndices implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private Connection $connection, + private IEventDispatcher $dispatcher, + private IURLGenerator $urlGenerator, + ) { + } + + public function getCategory(): string { + return 'database'; + } + + public function getName(): string { + return $this->l10n->t('Database missing indices'); + } + + private function getMissingIndices(): array { + $indexInfo = new MissingIndexInformation(); + // Dispatch event so apps can also hint for pending index updates if needed + $event = new AddMissingIndicesEvent(); + $this->dispatcher->dispatchTyped($event); + $missingIndices = $event->getMissingIndices(); + $indicesToReplace = $event->getIndicesToReplace(); + + if (!empty($missingIndices)) { + $schema = new SchemaWrapper($this->connection); + foreach ($missingIndices as $missingIndex) { + if ($schema->hasTable($missingIndex['tableName'])) { + $table = $schema->getTable($missingIndex['tableName']); + if (!$table->hasIndex($missingIndex['indexName'])) { + $indexInfo->addHintForMissingIndex($missingIndex['tableName'], $missingIndex['indexName']); + } + } + } + } + + if (!empty($indicesToReplace)) { + $schema = new SchemaWrapper($this->connection); + foreach ($indicesToReplace as $indexToReplace) { + if ($schema->hasTable($indexToReplace['tableName'])) { + $table = $schema->getTable($indexToReplace['tableName']); + if (!$table->hasIndex($indexToReplace['newIndexName'])) { + $indexInfo->addHintForMissingIndex($indexToReplace['tableName'], $indexToReplace['newIndexName']); + } + } + } + } + + return $indexInfo->getListOfMissingIndices(); + } + + public function run(): SetupResult { + $missingIndices = $this->getMissingIndices(); + if (empty($missingIndices)) { + return SetupResult::success('None'); + } else { + $processed = 0; + $list = $this->l10n->t('Missing indices:'); + foreach ($missingIndices as $missingIndex) { + $processed++; + $list .= "\n " . $this->l10n->t('"%s" in table "%s"', [$missingIndex['indexName'], $missingIndex['tableName']]); + if (count($missingIndices) > $processed) { + $list .= ', '; + } + } + return SetupResult::warning( + $this->l10n->t('Detected some missing optional indices. Occasionally new indices are added (by Nextcloud or installed applications) to improve database performance. Adding indices can sometimes take awhile and temporarily hurt performance so this is not done automatically during upgrades. Once the indices are added, queries to those tables should be faster. Use the command `occ db:add-missing-indices` to add them.') . "\n" . $list, + $this->urlGenerator->linkToDocs('admin-long-running-migration-steps') + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/DatabaseHasMissingPrimaryKeys.php b/apps/settings/lib/SetupChecks/DatabaseHasMissingPrimaryKeys.php new file mode 100644 index 00000000000..03810ca8faf --- /dev/null +++ b/apps/settings/lib/SetupChecks/DatabaseHasMissingPrimaryKeys.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OC\DB\Connection; +use OC\DB\MissingPrimaryKeyInformation; +use OC\DB\SchemaWrapper; +use OCP\DB\Events\AddMissingPrimaryKeyEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class DatabaseHasMissingPrimaryKeys implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private Connection $connection, + private IEventDispatcher $dispatcher, + ) { + } + + public function getCategory(): string { + return 'database'; + } + + public function getName(): string { + return $this->l10n->t('Database missing primary keys'); + } + + private function getMissingPrimaryKeys(): array { + $primaryKeyInfo = new MissingPrimaryKeyInformation(); + // Dispatch event so apps can also hint for pending primary key updates if needed + $event = new AddMissingPrimaryKeyEvent(); + $this->dispatcher->dispatchTyped($event); + $missingPrimaryKeys = $event->getMissingPrimaryKeys(); + + if (!empty($missingPrimaryKeys)) { + $schema = new SchemaWrapper($this->connection); + foreach ($missingPrimaryKeys as $missingPrimaryKey) { + if ($schema->hasTable($missingPrimaryKey['tableName'])) { + $table = $schema->getTable($missingPrimaryKey['tableName']); + if ($table->getPrimaryKey() === null) { + $primaryKeyInfo->addHintForMissingPrimaryKey($missingPrimaryKey['tableName']); + } + } + } + } + + return $primaryKeyInfo->getListOfMissingPrimaryKeys(); + } + + public function run(): SetupResult { + $missingPrimaryKeys = $this->getMissingPrimaryKeys(); + if (empty($missingPrimaryKeys)) { + return SetupResult::success('None'); + } else { + $list = ''; + foreach ($missingPrimaryKeys as $missingPrimaryKey) { + $list .= "\n" . $this->l10n->t('Missing primary key on table "%s".', [$missingPrimaryKey['tableName']]); + } + return SetupResult::warning( + $this->l10n->t('The database is missing some primary keys. Due to the fact that adding primary keys on big tables could take some time they were not added automatically. By running "occ db:add-missing-primary-keys" those missing primary keys could be added manually while the instance keeps running.') . $list + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/DatabasePendingBigIntConversions.php b/apps/settings/lib/SetupChecks/DatabasePendingBigIntConversions.php new file mode 100644 index 00000000000..bb9794c1e03 --- /dev/null +++ b/apps/settings/lib/SetupChecks/DatabasePendingBigIntConversions.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use Doctrine\DBAL\Types\BigIntType; +use OC\Core\Command\Db\ConvertFilecacheBigInt; +use OC\DB\Connection; +use OC\DB\SchemaWrapper; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class DatabasePendingBigIntConversions implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private Connection $db, + private IEventDispatcher $dispatcher, + private IDBConnection $connection, + ) { + } + + public function getCategory(): string { + return 'database'; + } + + public function getName(): string { + return $this->l10n->t('Database pending bigint migrations'); + } + + protected function getBigIntConversionPendingColumns(): array { + $tables = ConvertFilecacheBigInt::getColumnsByTable(); + + $schema = new SchemaWrapper($this->db); + $isSqlite = $this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE; + $pendingColumns = []; + + foreach ($tables as $tableName => $columns) { + if (!$schema->hasTable($tableName)) { + continue; + } + + $table = $schema->getTable($tableName); + foreach ($columns as $columnName) { + $column = $table->getColumn($columnName); + $isAutoIncrement = $column->getAutoincrement(); + $isAutoIncrementOnSqlite = $isSqlite && $isAutoIncrement; + if (!($column->getType() instanceof BigIntType) && !$isAutoIncrementOnSqlite) { + $pendingColumns[] = $tableName . '.' . $columnName; + } + } + } + + return $pendingColumns; + } + + public function run(): SetupResult { + $pendingColumns = $this->getBigIntConversionPendingColumns(); + if (empty($pendingColumns)) { + return SetupResult::success('None'); + } else { + $list = ''; + foreach ($pendingColumns as $pendingColumn) { + $list .= "\n$pendingColumn"; + } + $list .= "\n"; + return SetupResult::info( + $this->l10n->t('Some columns in the database are missing a conversion to big int. Due to the fact that changing column types on big tables could take some time they were not changed automatically. By running "occ db:convert-filecache-bigint" those pending changes could be applied manually. This operation needs to be made while the instance is offline.') . $list, + $this->urlGenerator->linkToDocs('admin-bigint-conversion') + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/DebugMode.php b/apps/settings/lib/SetupChecks/DebugMode.php new file mode 100644 index 00000000000..8841ecc607d --- /dev/null +++ b/apps/settings/lib/SetupChecks/DebugMode.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class DebugMode implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + ) { + } + + public function getName(): string { + return $this->l10n->t('Debug mode'); + } + + public function getCategory(): string { + return 'system'; + } + + public function run(): SetupResult { + if ($this->config->getSystemValueBool('debug', false)) { + return SetupResult::warning($this->l10n->t('This instance is running in debug mode. Only enable this for local development and not in production environments.')); + } else { + return SetupResult::success($this->l10n->t('Debug mode is disabled.')); + } + } +} diff --git a/apps/settings/lib/SetupChecks/DefaultPhoneRegionSet.php b/apps/settings/lib/SetupChecks/DefaultPhoneRegionSet.php new file mode 100644 index 00000000000..fa94cd9d059 --- /dev/null +++ b/apps/settings/lib/SetupChecks/DefaultPhoneRegionSet.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class DefaultPhoneRegionSet implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + ) { + } + + public function getName(): string { + return $this->l10n->t('Default phone region'); + } + + public function getCategory(): string { + return 'config'; + } + + public function run(): SetupResult { + if ($this->config->getSystemValueString('default_phone_region', '') !== '') { + return SetupResult::success($this->config->getSystemValueString('default_phone_region', '')); + } else { + return SetupResult::info( + $this->l10n->t('Your installation has no default phone region set. This is required to validate phone numbers in the profile settings without a country code. To allow numbers without a country code, please add "default_phone_region" with the respective ISO 3166-1 code of the region to your config file.'), + 'https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements' + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/EmailTestSuccessful.php b/apps/settings/lib/SetupChecks/EmailTestSuccessful.php new file mode 100644 index 00000000000..8cad8e82156 --- /dev/null +++ b/apps/settings/lib/SetupChecks/EmailTestSuccessful.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class EmailTestSuccessful implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('Email test'); + } + + public function getCategory(): string { + return 'config'; + } + + protected function wasEmailTestSuccessful(): bool { + // Handle the case that the configuration was set before the check was introduced or it was only set via command line and not from the UI + if ($this->config->getAppValue('core', 'emailTestSuccessful', '') === '' && $this->config->getSystemValue('mail_domain', '') === '') { + return false; + } + + // The mail test was unsuccessful or the config was changed using the UI without verifying with a testmail, hence return false + if ($this->config->getAppValue('core', 'emailTestSuccessful', '') === '0') { + return false; + } + + return true; + } + + public function run(): SetupResult { + if ($this->config->getSystemValueString('mail_smtpmode', 'smtp') === 'null') { + return SetupResult::success($this->l10n->t('Mail delivery is disabled by instance config "%s".', ['mail_smtpmode'])); + } elseif ($this->wasEmailTestSuccessful()) { + return SetupResult::success($this->l10n->t('Email test was successfully sent')); + } else { + // If setup check could link to settings pages, this one should link to OC.generateUrl('/settings/admin') + return SetupResult::info( + $this->l10n->t('You have not set or verified your email server configuration, yet. Please head over to the "Basic settings" in order to set them. Afterwards, use the "Send email" button below the form to verify your settings.'), + $this->urlGenerator->linkToDocs('admin-email') + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/FileLocking.php b/apps/settings/lib/SetupChecks/FileLocking.php new file mode 100644 index 00000000000..f683ee05f03 --- /dev/null +++ b/apps/settings/lib/SetupChecks/FileLocking.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OC\Lock\DBLockingProvider; +use OC\Lock\NoopLockingProvider; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Lock\ILockingProvider; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class FileLocking implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private ILockingProvider $lockingProvider, + ) { + } + + public function getName(): string { + return $this->l10n->t('Transactional File Locking'); + } + + public function getCategory(): string { + return 'system'; + } + + protected function hasWorkingFileLocking(): bool { + return !($this->lockingProvider instanceof NoopLockingProvider); + } + + protected function hasDBFileLocking(): bool { + return ($this->lockingProvider instanceof DBLockingProvider); + } + + public function run(): SetupResult { + if (!$this->hasWorkingFileLocking()) { + return SetupResult::error( + $this->l10n->t('Transactional File Locking is disabled. This is not a a supported configuraton. It may lead to difficult to isolate problems including file corruption. Please remove the `\'filelocking.enabled\' => false` configuration entry from your `config.php` to avoid these problems.'), + $this->urlGenerator->linkToDocs('admin-transactional-locking') + ); + } + + if ($this->hasDBFileLocking()) { + return SetupResult::info( + $this->l10n->t('The database is used for transactional file locking. To enhance performance, please configure memcache, if available.'), + $this->urlGenerator->linkToDocs('admin-transactional-locking') + ); + } + + return SetupResult::success(); + } +} diff --git a/apps/settings/lib/SetupChecks/ForwardedForHeaders.php b/apps/settings/lib/SetupChecks/ForwardedForHeaders.php new file mode 100644 index 00000000000..8238ce07554 --- /dev/null +++ b/apps/settings/lib/SetupChecks/ForwardedForHeaders.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class ForwardedForHeaders implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + private IRequest $request, + ) { + } + + public function getCategory(): string { + return 'security'; + } + + public function getName(): string { + return $this->l10n->t('Forwarded for headers'); + } + + public function run(): SetupResult { + $trustedProxies = $this->config->getSystemValue('trusted_proxies', []); + $remoteAddress = $this->request->getHeader('REMOTE_ADDR'); + $detectedRemoteAddress = $this->request->getRemoteAddress(); + + if (!\is_array($trustedProxies)) { + return SetupResult::error($this->l10n->t('Your "trusted_proxies" setting is not correctly set, it should be an array.')); + } + + foreach ($trustedProxies as $proxy) { + $addressParts = explode('/', $proxy, 2); + if (filter_var($addressParts[0], FILTER_VALIDATE_IP) === false || !ctype_digit($addressParts[1] ?? '24')) { + return SetupResult::error( + $this->l10n->t('Your "trusted_proxies" setting is not correctly set, it should be an array of IP addresses - optionally with range in CIDR notation.'), + $this->urlGenerator->linkToDocs('admin-reverse-proxy'), + ); + } + } + + if (($remoteAddress === '') && ($detectedRemoteAddress === '')) { + if (\OC::$CLI) { + /* We were called from CLI */ + return SetupResult::info($this->l10n->t('Your remote address could not be determined.')); + } else { + /* Should never happen */ + return SetupResult::error($this->l10n->t('Your remote address could not be determined.')); + } + } + + if (empty($trustedProxies) && $this->request->getHeader('X-Forwarded-Host') !== '') { + return SetupResult::error( + $this->l10n->t('The reverse proxy header configuration is incorrect. This is a security issue and can allow an attacker to spoof their IP address as visible to the Nextcloud.'), + $this->urlGenerator->linkToDocs('admin-reverse-proxy') + ); + } + + if (\in_array($remoteAddress, $trustedProxies, true) && ($remoteAddress !== '127.0.0.1')) { + if ($remoteAddress !== $detectedRemoteAddress) { + /* Remote address was successfuly fixed */ + return SetupResult::success($this->l10n->t('Your IP address was resolved as %s', [$detectedRemoteAddress])); + } else { + return SetupResult::warning( + $this->l10n->t('The reverse proxy header configuration is incorrect, or you are accessing Nextcloud from a trusted proxy. If not, this is a security issue and can allow an attacker to spoof their IP address as visible to the Nextcloud.'), + $this->urlGenerator->linkToDocs('admin-reverse-proxy') + ); + } + } + + /* Either not enabled or working correctly */ + return SetupResult::success(); + } +} diff --git a/apps/settings/lib/SetupChecks/HttpsUrlGeneration.php b/apps/settings/lib/SetupChecks/HttpsUrlGeneration.php new file mode 100644 index 00000000000..7f76297748b --- /dev/null +++ b/apps/settings/lib/SetupChecks/HttpsUrlGeneration.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class HttpsUrlGeneration implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IRequest $request, + ) { + } + + public function getCategory(): string { + return 'security'; + } + + public function getName(): string { + return $this->l10n->t('HTTPS access and URLs'); + } + + public function run(): SetupResult { + if (!\OC::$CLI && $this->request->getServerProtocol() !== 'https') { + if (!preg_match('/(?:^(?:localhost|127\.0\.0\.1|::1)|\.onion)$/', $this->request->getInsecureServerHost())) { + return SetupResult::error( + $this->l10n->t('Accessing site insecurely via HTTP. You are strongly advised to set up your server to require HTTPS instead. Without it some important web functionality like "copy to clipboard" or "service workers" will not work!'), + $this->urlGenerator->linkToDocs('admin-security') + ); + } else { + return SetupResult::info( + $this->l10n->t('Accessing site insecurely via HTTP.'), + $this->urlGenerator->linkToDocs('admin-security') + ); + } + } + $generatedUrl = $this->urlGenerator->getAbsoluteURL('index.php'); + if (!str_starts_with($generatedUrl, 'https://')) { + if (!\OC::$CLI) { + return SetupResult::warning( + $this->l10n->t('You are accessing your instance over a secure connection, however your instance is generating insecure URLs. This likely means that your instance is behind a reverse proxy and the Nextcloud `overwrite*` config values are not set correctly.'), + $this->urlGenerator->linkToDocs('admin-reverse-proxy') + ); + /* We were called from CLI so we can't be 100% sure which scenario is applicable */ + } else { + return SetupResult::info( + $this->l10n->t('Your instance is generating insecure URLs. If you access your instance over HTTPS, this likely means that your instance is behind a reverse proxy and the Nextcloud `overwrite*` config values are not set correctly.'), + $this->urlGenerator->linkToDocs('admin-reverse-proxy') + ); + } + } + return SetupResult::success($this->l10n->t('You are accessing your instance over a secure connection, and your instance is generating secure URLs.')); + } +} diff --git a/apps/settings/lib/SetupChecks/InternetConnectivity.php b/apps/settings/lib/SetupChecks/InternetConnectivity.php new file mode 100644 index 00000000000..18f2af63b8d --- /dev/null +++ b/apps/settings/lib/SetupChecks/InternetConnectivity.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +/** + * Checks if the server can connect to the internet using HTTPS and HTTP + */ +class InternetConnectivity implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IClientService $clientService, + private LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('Internet connectivity'); + } + + public function run(): SetupResult { + if ($this->config->getSystemValue('has_internet_connection', true) === false) { + return SetupResult::success($this->l10n->t('Internet connectivity is disabled in configuration file.')); + } + + $siteArray = $this->config->getSystemValue('connectivity_check_domains', [ + 'https://www.nextcloud.com', 'https://www.startpage.com', 'https://www.eff.org', 'https://www.edri.org' + ]); + + foreach ($siteArray as $site) { + if ($this->isSiteReachable($site)) { + // successful as soon as one connection succeeds + return SetupResult::success(); + } + } + return SetupResult::warning($this->l10n->t('This server has no working internet connection: Multiple endpoints could not be reached. This means that some of the features like mounting external storage, notifications about updates or installation of third-party apps will not work. Accessing files remotely and sending of notification emails might not work, either. Establish a connection from this server to the internet to enjoy all features.')); + } + + /** + * Checks if the Nextcloud server can connect to a specific URL + * @param string $site site domain or full URL with http/https protocol + * @return bool success/failure + */ + private function isSiteReachable(string $site): bool { + // if there is no protocol specified, test http:// first then, if necessary, https:// + if (preg_match('/^https?:\/\//', $site) !== 1) { + $httpSite = 'http://' . $site . '/'; + $httpsSite = 'https://' . $site . '/'; + return $this->isSiteReachable($httpSite) || $this->isSiteReachable($httpsSite); + } + try { + $client = $this->clientService->newClient(); + $client->get($site); + } catch (\Exception $e) { + $this->logger->error('Cannot connect to: ' . $site, [ + 'app' => 'internet_connection_check', + 'exception' => $e, + ]); + return false; + } + return true; + } +} diff --git a/apps/settings/lib/SetupChecks/JavaScriptModules.php b/apps/settings/lib/SetupChecks/JavaScriptModules.php new file mode 100644 index 00000000000..72f58405811 --- /dev/null +++ b/apps/settings/lib/SetupChecks/JavaScriptModules.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +/** + * Checks if the webserver serves '.mjs' files using the correct MIME type + */ +class JavaScriptModules implements ISetupCheck { + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('JavaScript modules support'); + } + + public function run(): SetupResult { + $testFile = $this->urlGenerator->linkTo('settings', 'js/esm-test.mjs'); + + $noResponse = true; + foreach ($this->runRequest('HEAD', $testFile) as $response) { + $noResponse = false; + if (preg_match('/(text|application)\/javascript/i', $response->getHeader('Content-Type'))) { + return SetupResult::success(); + } + } + + if ($noResponse) { + return SetupResult::warning($this->l10n->t('Unable to run check for JavaScript support. Please remedy or confirm manually if your webserver serves `.mjs` files using the JavaScript MIME type.') . "\n" . $this->serverConfigHelp()); + } + return SetupResult::error($this->l10n->t('Your webserver does not serve `.mjs` files using the JavaScript MIME type. This will break some apps by preventing browsers from executing the JavaScript files. You should configure your webserver to serve `.mjs` files with either the `text/javascript` or `application/javascript` MIME type.')); + + } +} diff --git a/apps/settings/lib/SetupChecks/JavaScriptSourceMaps.php b/apps/settings/lib/SetupChecks/JavaScriptSourceMaps.php new file mode 100644 index 00000000000..dcfc40192b9 --- /dev/null +++ b/apps/settings/lib/SetupChecks/JavaScriptSourceMaps.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +/** + * Checks if the webserver serves '.map' files using the correct MIME type + */ +class JavaScriptSourceMaps implements ISetupCheck { + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('JavaScript source map support'); + } + + public function run(): SetupResult { + $testFile = $this->urlGenerator->linkTo('settings', 'js/map-test.js.map'); + + foreach ($this->runRequest('HEAD', $testFile) as $response) { + return SetupResult::success(); + } + + return SetupResult::warning($this->l10n->t('Your webserver is not set up to serve `.js.map` files. Without these files, JavaScript Source Maps won\'t function properly, making it more challenging to troubleshoot and debug any issues that may arise.')); + } +} diff --git a/apps/settings/lib/SetupChecks/LegacySSEKeyFormat.php b/apps/settings/lib/SetupChecks/LegacySSEKeyFormat.php new file mode 100644 index 00000000000..47594e201cb --- /dev/null +++ b/apps/settings/lib/SetupChecks/LegacySSEKeyFormat.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class LegacySSEKeyFormat implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + ) { + } + + public function getCategory(): string { + return 'security'; + } + + public function getName(): string { + return $this->l10n->t('Old server-side-encryption'); + } + + public function run(): SetupResult { + if ($this->config->getSystemValueBool('encryption.legacy_format_support', false) === false) { + return SetupResult::success($this->l10n->t('Disabled')); + } + return SetupResult::warning($this->l10n->t('The old server-side-encryption format is enabled. We recommend disabling this.'), $this->urlGenerator->linkToDocs('admin-sse-legacy-format')); + } +} diff --git a/apps/settings/lib/SetupChecks/LoggingLevel.php b/apps/settings/lib/SetupChecks/LoggingLevel.php new file mode 100644 index 00000000000..b9e1dbe700d --- /dev/null +++ b/apps/settings/lib/SetupChecks/LoggingLevel.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\ILogger; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class LoggingLevel implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('Logging level'); + } + + public function getCategory(): string { + return 'system'; + } + + public function run(): SetupResult { + $configLogLevel = $this->config->getSystemValue('loglevel', ILogger::WARN); + if (!is_int($configLogLevel) + || $configLogLevel < ILogger::DEBUG + || $configLogLevel > ILogger::FATAL + ) { + return SetupResult::error( + $this->l10n->t('The %1$s configuration option must be a valid integer value.', ['`loglevel`']), + $this->urlGenerator->linkToDocs('admin-logging'), + ); + } + + if ($configLogLevel === ILogger::DEBUG) { + return SetupResult::warning( + $this->l10n->t('The logging level is set to debug level. Use debug level only when you have a problem to diagnose, and then reset your log level to a less-verbose level as it outputs a lot of information, and can affect your server performance.'), + $this->urlGenerator->linkToDocs('admin-logging'), + ); + } + + return SetupResult::success($this->l10n->t('Logging level configured correctly.')); + } +} diff --git a/apps/settings/lib/SetupChecks/MaintenanceWindowStart.php b/apps/settings/lib/SetupChecks/MaintenanceWindowStart.php new file mode 100644 index 00000000000..ca8df039b1e --- /dev/null +++ b/apps/settings/lib/SetupChecks/MaintenanceWindowStart.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class MaintenanceWindowStart implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IConfig $config, + ) { + } + + public function getCategory(): string { + return 'system'; + } + + public function getName(): string { + return $this->l10n->t('Maintenance window start'); + } + + public function run(): SetupResult { + $configValue = $this->config->getSystemValue('maintenance_window_start', null); + if ($configValue === null) { + return SetupResult::warning( + $this->l10n->t('Server has no maintenance window start time configured. This means resource intensive daily background jobs will also be executed during your main usage time. We recommend to set it to a time of low usage, so users are less impacted by the load caused from these heavy tasks.'), + $this->urlGenerator->linkToDocs('admin-background-jobs') + ); + } + + $startValue = (int)$configValue; + $endValue = ($startValue + 6) % 24; + return SetupResult::success( + str_replace( + ['{start}', '{end}'], + [$startValue, $endValue], + $this->l10n->t('Maintenance window to execute heavy background jobs is between {start}:00 UTC and {end}:00 UTC') + ) + ); + } +} diff --git a/apps/settings/lib/SetupChecks/MemcacheConfigured.php b/apps/settings/lib/SetupChecks/MemcacheConfigured.php new file mode 100644 index 00000000000..e3601d428bb --- /dev/null +++ b/apps/settings/lib/SetupChecks/MemcacheConfigured.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OC\Memcache\Memcached; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class MemcacheConfigured implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + private ICacheFactory $cacheFactory, + ) { + } + + public function getName(): string { + return $this->l10n->t('Memcache'); + } + + public function getCategory(): string { + return 'system'; + } + + public function run(): SetupResult { + $memcacheDistributedClass = $this->config->getSystemValue('memcache.distributed', null); + $memcacheLockingClass = $this->config->getSystemValue('memcache.locking', null); + $memcacheLocalClass = $this->config->getSystemValue('memcache.local', null); + $caches = array_filter([$memcacheDistributedClass,$memcacheLockingClass,$memcacheLocalClass]); + if (in_array(Memcached::class, array_map(fn (string $class) => ltrim($class, '\\'), $caches))) { + // wrong PHP module is installed + if (extension_loaded('memcache') && !extension_loaded('memcached')) { + return SetupResult::warning( + $this->l10n->t('Memcached is configured as distributed cache, but the wrong PHP module ("memcache") is installed. Please install the PHP module "memcached".') + ); + } + // required PHP module is missing + if (!extension_loaded('memcached')) { + return SetupResult::warning( + $this->l10n->t('Memcached is configured as distributed cache, but the PHP module "memcached" is not installed. Please install the PHP module "memcached".') + ); + } + } + if ($memcacheLocalClass === null) { + return SetupResult::info( + $this->l10n->t('No memory cache has been configured. To enhance performance, please configure a memcache, if available.'), + $this->urlGenerator->linkToDocs('admin-performance') + ); + } + + if ($this->cacheFactory->isLocalCacheAvailable()) { + $random = bin2hex(random_bytes(64)); + $local = $this->cacheFactory->createLocal('setupcheck.local'); + try { + $local->set('test', $random); + $local2 = $this->cacheFactory->createLocal('setupcheck.local'); + $actual = $local2->get('test'); + $local->remove('test'); + } catch (\Throwable) { + $actual = null; + } + + if ($actual !== $random) { + return SetupResult::error($this->l10n->t('Failed to write and read a value from local cache.')); + } + } + + if ($this->cacheFactory->isAvailable()) { + $random = bin2hex(random_bytes(64)); + $distributed = $this->cacheFactory->createDistributed('setupcheck'); + try { + $distributed->set('test', $random); + $distributed2 = $this->cacheFactory->createDistributed('setupcheck'); + $actual = $distributed2->get('test'); + $distributed->remove('test'); + } catch (\Throwable) { + $actual = null; + } + + if ($actual !== $random) { + return SetupResult::error($this->l10n->t('Failed to write and read a value from distributed cache.')); + } + } + + return SetupResult::success($this->l10n->t('Configured')); + } +} diff --git a/apps/settings/lib/SetupChecks/MimeTypeMigrationAvailable.php b/apps/settings/lib/SetupChecks/MimeTypeMigrationAvailable.php new file mode 100644 index 00000000000..cf237f68670 --- /dev/null +++ b/apps/settings/lib/SetupChecks/MimeTypeMigrationAvailable.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OC\Repair\RepairMimeTypes; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class MimeTypeMigrationAvailable implements ISetupCheck { + + public function __construct( + private RepairMimeTypes $repairMimeTypes, + private IL10N $l10n, + ) { + } + + public function getCategory(): string { + return 'system'; + } + + public function getName(): string { + return $this->l10n->t('Mimetype migrations available'); + } + + public function run(): SetupResult { + if ($this->repairMimeTypes->migrationsAvailable()) { + return SetupResult::warning( + $this->l10n->t('One or more mimetype migrations are available. Occasionally new mimetypes are added to better handle certain file types. Migrating the mimetypes take a long time on larger instances so this is not done automatically during upgrades. Use the command `occ maintenance:repair --include-expensive` to perform the migrations.'), + ); + } else { + return SetupResult::success('None'); + } + } +} diff --git a/apps/settings/lib/SetupChecks/MysqlRowFormat.php b/apps/settings/lib/SetupChecks/MysqlRowFormat.php new file mode 100644 index 00000000000..3c27b73db89 --- /dev/null +++ b/apps/settings/lib/SetupChecks/MysqlRowFormat.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use Doctrine\DBAL\Platforms\MySQLPlatform; +use OC\DB\Connection; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class MysqlRowFormat implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private Connection $connection, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('MySQL row format'); + } + + public function getCategory(): string { + return 'database'; + } + + public function run(): SetupResult { + if (!$this->connection->getDatabasePlatform() instanceof MySQLPlatform) { + return SetupResult::success($this->l10n->t('You are not using MySQL')); + } + + $wrongRowFormatTables = $this->getRowNotDynamicTables(); + if (empty($wrongRowFormatTables)) { + return SetupResult::success($this->l10n->t('None of your tables use ROW_FORMAT=Compressed')); + } + + return SetupResult::warning( + $this->l10n->t( + 'Incorrect row format found in your database. ROW_FORMAT=Dynamic offers the best database performances for Nextcloud. Please update row format on the following list: %s.', + [implode(', ', $wrongRowFormatTables)], + ), + 'https://dev.mysql.com/doc/refman/en/innodb-row-format.html', + ); + } + + /** + * @return string[] + */ + private function getRowNotDynamicTables(): array { + $sql = "SELECT table_name + FROM information_schema.tables + WHERE table_schema = ? + AND table_name LIKE '*PREFIX*%' + AND row_format != 'Dynamic';"; + + return $this->connection->executeQuery( + $sql, + [$this->config->getSystemValueString('dbname')], + )->fetchFirstColumn(); + } +} diff --git a/apps/settings/lib/SetupChecks/MysqlUnicodeSupport.php b/apps/settings/lib/SetupChecks/MysqlUnicodeSupport.php new file mode 100644 index 00000000000..ba2fa93e094 --- /dev/null +++ b/apps/settings/lib/SetupChecks/MysqlUnicodeSupport.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class MysqlUnicodeSupport implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('MySQL Unicode support'); + } + + public function getCategory(): string { + return 'database'; + } + + public function run(): SetupResult { + if ($this->config->getSystemValueString('dbtype') !== 'mysql') { + return SetupResult::success($this->l10n->t('You are not using MySQL')); + } + if ($this->config->getSystemValueBool('mysql.utf8mb4', false)) { + return SetupResult::success($this->l10n->t('MySQL is used as database and does support 4-byte characters')); + } else { + return SetupResult::warning( + $this->l10n->t('MySQL is used as database but does not support 4-byte characters. To be able to handle 4-byte characters (like emojis) without issues in filenames or comments for example it is recommended to enable the 4-byte support in MySQL.'), + $this->urlGenerator->linkToDocs('admin-mysql-utf8mb4'), + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/OcxProviders.php b/apps/settings/lib/SetupChecks/OcxProviders.php new file mode 100644 index 00000000000..c53e8087bd9 --- /dev/null +++ b/apps/settings/lib/SetupChecks/OcxProviders.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +/** + * Checks if the webserver serves the OCM and OCS providers + */ +class OcxProviders implements ISetupCheck { + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('OCS provider resolving'); + } + + public function run(): SetupResult { + // List of providers that work + $workingProviders = []; + // List of providers we tested (in case one or multiple do not yield any response) + $testedProviders = []; + // All providers that we need to test + $providers = [ + '/ocm-provider/', + '/ocs-provider/', + ]; + + foreach ($providers as $provider) { + foreach ($this->runRequest('HEAD', $provider, ['httpErrors' => false]) as $response) { + $testedProviders[$provider] = true; + if ($response->getStatusCode() === 200) { + $workingProviders[] = $provider; + break; + } + } + } + + if (count($testedProviders) < count($providers)) { + return SetupResult::warning( + $this->l10n->t('Could not check if your web server properly resolves the OCM and OCS provider URLs.', ) . "\n" . $this->serverConfigHelp(), + ); + } + + $missingProviders = array_diff($providers, $workingProviders); + if (empty($missingProviders)) { + return SetupResult::success(); + } + + return SetupResult::warning( + $this->l10n->t('Your web server is not properly set up to resolve %1$s. +This is most likely related to a web server configuration that was not updated to deliver this folder directly. +Please compare your configuration against the shipped rewrite rules in ".htaccess" for Apache or the provided one in the documentation for Nginx. +On Nginx those are typically the lines starting with "location ~" that need an update.', [join(', ', array_map(fn ($s) => '"' . $s . '"', $missingProviders))]), + $this->urlGenerator->linkToDocs('admin-nginx'), + ); + } +} diff --git a/apps/settings/lib/SetupChecks/OverwriteCliUrl.php b/apps/settings/lib/SetupChecks/OverwriteCliUrl.php new file mode 100644 index 00000000000..6fe0a1260cc --- /dev/null +++ b/apps/settings/lib/SetupChecks/OverwriteCliUrl.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class OverwriteCliUrl implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IRequest $request, + ) { + } + + public function getCategory(): string { + return 'config'; + } + + public function getName(): string { + return $this->l10n->t('Overwrite CLI URL'); + } + + public function run(): SetupResult { + $currentOverwriteCliUrl = $this->config->getSystemValue('overwrite.cli.url', ''); + $suggestedOverwriteCliUrl = $this->request->getServerProtocol() . '://' . $this->request->getInsecureServerHost() . \OC::$WEBROOT; + + // Check correctness by checking if it is a valid URL + if (filter_var($currentOverwriteCliUrl, FILTER_VALIDATE_URL)) { + if ($currentOverwriteCliUrl == $suggestedOverwriteCliUrl) { + return SetupResult::success( + $this->l10n->t( + 'The "overwrite.cli.url" option in your config.php is correctly set to "%s".', + [$currentOverwriteCliUrl] + ) + ); + } else { + return SetupResult::success( + $this->l10n->t( + 'The "overwrite.cli.url" option in your config.php is set to "%s" which is a correct URL. Suggested URL is "%s".', + [$currentOverwriteCliUrl, $suggestedOverwriteCliUrl] + ) + ); + } + } else { + return SetupResult::warning( + $this->l10n->t( + 'Please make sure to set the "overwrite.cli.url" option in your config.php file to the URL that your users mainly use to access this Nextcloud. Suggestion: "%s". Otherwise there might be problems with the URL generation via cron. (It is possible though that the suggested URL is not the URL that your users mainly use to access this Nextcloud. Best is to double check this in any case.)', + [$suggestedOverwriteCliUrl] + ) + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PhpApcuConfig.php b/apps/settings/lib/SetupChecks/PhpApcuConfig.php new file mode 100644 index 00000000000..c91a8cefec1 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpApcuConfig.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OC\Memcache\APCu; +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpApcuConfig implements ISetupCheck { + public const USAGE_RATE_WARNING = 90; + public const AGE_WARNING = 3600 * 8; + + public function __construct( + private IL10N $l10n, + private IConfig $config, + ) { + } + + public function getCategory(): string { + return 'php'; + } + + public function getName(): string { + return $this->l10n->t('PHP APCu configuration'); + } + + public function run(): SetupResult { + $localIsApcu = ltrim($this->config->getSystemValueString('memcache.local'), '\\') === APCu::class; + $distributedIsApcu = ltrim($this->config->getSystemValueString('memcache.distributed'), '\\') === APCu::class; + if (!$localIsApcu && !$distributedIsApcu) { + return SetupResult::success(); + } + + if (!APCu::isAvailable()) { + return SetupResult::success(); + } + + $cache = apcu_cache_info(true); + $mem = apcu_sma_info(true); + if ($cache === false || $mem === false) { + return SetupResult::success(); + } + + $expunges = $cache['expunges']; + $memSize = $mem['num_seg'] * $mem['seg_size']; + $memAvailable = $mem['avail_mem']; + $memUsed = $memSize - $memAvailable; + $usageRate = round($memUsed / $memSize * 100, 0); + $elapsed = max(time() - $cache['start_time'], 1); + + if ($expunges > 0 && $elapsed < self::AGE_WARNING) { + return SetupResult::warning($this->l10n->t('Your APCu cache has been running full, consider increasing the apc.shm_size php setting.')); + } + + if ($usageRate > self::USAGE_RATE_WARNING) { + return SetupResult::warning($this->l10n->t('Your APCu cache is almost full at %s%%, consider increasing the apc.shm_size php setting.', [$usageRate])); + } + + return SetupResult::success(); + } +} diff --git a/apps/settings/lib/SetupChecks/PhpDefaultCharset.php b/apps/settings/lib/SetupChecks/PhpDefaultCharset.php new file mode 100644 index 00000000000..580db8cbd02 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpDefaultCharset.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpDefaultCharset implements ISetupCheck { + public function __construct( + private IL10N $l10n, + ) { + } + + public function getName(): string { + return $this->l10n->t('PHP default charset'); + } + + public function getCategory(): string { + return 'php'; + } + + public function run(): SetupResult { + if (strtoupper(trim(ini_get('default_charset'))) === 'UTF-8') { + return SetupResult::success('UTF-8'); + } else { + return SetupResult::warning($this->l10n->t('PHP configuration option "default_charset" should be UTF-8')); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PhpDisabledFunctions.php b/apps/settings/lib/SetupChecks/PhpDisabledFunctions.php new file mode 100644 index 00000000000..b82b5921989 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpDisabledFunctions.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpDisabledFunctions implements ISetupCheck { + + public function __construct( + private IL10N $l10n, + ) { + } + + public function getName(): string { + return $this->l10n->t('PHP set_time_limit'); + } + + public function getCategory(): string { + return 'php'; + } + + public function run(): SetupResult { + if (function_exists('set_time_limit') && !str_contains(ini_get('disable_functions'), 'set_time_limit')) { + return SetupResult::success($this->l10n->t('The function is available.')); + } else { + return SetupResult::warning( + $this->l10n->t('The PHP function "set_time_limit" is not available. This could result in scripts being halted mid-execution, breaking your installation. Enabling this function is strongly recommended.'), + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PhpFreetypeSupport.php b/apps/settings/lib/SetupChecks/PhpFreetypeSupport.php new file mode 100644 index 00000000000..ec5d6c7e146 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpFreetypeSupport.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpFreetypeSupport implements ISetupCheck { + public function __construct( + private IL10N $l10n, + ) { + } + + public function getName(): string { + return $this->l10n->t('Freetype'); + } + + public function getCategory(): string { + return 'php'; + } + + /** + * Check if the required FreeType functions are present + */ + protected function hasFreeTypeSupport(): bool { + return function_exists('imagettfbbox') && function_exists('imagettftext'); + } + + public function run(): SetupResult { + if ($this->hasFreeTypeSupport()) { + return SetupResult::success($this->l10n->t('Supported')); + } else { + return SetupResult::info( + $this->l10n->t('Your PHP does not have FreeType support, resulting in breakage of profile pictures and the settings interface.'), + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PhpGetEnv.php b/apps/settings/lib/SetupChecks/PhpGetEnv.php new file mode 100644 index 00000000000..279a23a9691 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpGetEnv.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpGetEnv implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('PHP getenv'); + } + + public function getCategory(): string { + return 'php'; + } + + public function run(): SetupResult { + if (!empty(getenv('PATH'))) { + return SetupResult::success(); + } else { + return SetupResult::warning($this->l10n->t('PHP does not seem to be setup properly to query system environment variables. The test with getenv("PATH") only returns an empty response.'), $this->urlGenerator->linkToDocs('admin-php-fpm')); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PhpMaxFileSize.php b/apps/settings/lib/SetupChecks/PhpMaxFileSize.php new file mode 100644 index 00000000000..d81cbe6d45c --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpMaxFileSize.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use bantu\IniGetWrapper\IniGetWrapper; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use OCP\Util; + +class PhpMaxFileSize implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IniGetWrapper $iniGetWrapper, + ) { + } + + public function getCategory(): string { + return 'php'; + } + + public function getName(): string { + return $this->l10n->t('PHP file size upload limit'); + } + + public function run(): SetupResult { + $upload_max_filesize = (string)$this->iniGetWrapper->getString('upload_max_filesize'); + $post_max_size = (string)$this->iniGetWrapper->getString('post_max_size'); + $max_input_time = (int)$this->iniGetWrapper->getString('max_input_time'); + $max_execution_time = (int)$this->iniGetWrapper->getString('max_execution_time'); + + $warnings = []; + $recommendedSize = 16 * 1024 * 1024 * 1024; + $recommendedTime = 3600; + + // Check if the PHP upload limit is too low + if (Util::computerFileSize($upload_max_filesize) < $recommendedSize) { + $warnings[] = $this->l10n->t('The PHP upload_max_filesize is too low. A size of at least %1$s is recommended. Current value: %2$s.', [ + Util::humanFileSize($recommendedSize), + $upload_max_filesize, + ]); + } + if (Util::computerFileSize($post_max_size) < $recommendedSize) { + $warnings[] = $this->l10n->t('The PHP post_max_size is too low. A size of at least %1$s is recommended. Current value: %2$s.', [ + Util::humanFileSize($recommendedSize), + $post_max_size, + ]); + } + + // Check if the PHP execution time is too low + if ($max_input_time < $recommendedTime && $max_input_time !== -1) { + $warnings[] = $this->l10n->t('The PHP max_input_time is too low. A time of at least %1$s is recommended. Current value: %2$s.', [ + $recommendedTime, + $max_input_time, + ]); + } + + if ($max_execution_time < $recommendedTime && $max_execution_time !== -1) { + $warnings[] = $this->l10n->t('The PHP max_execution_time is too low. A time of at least %1$s is recommended. Current value: %2$s.', [ + $recommendedTime, + $max_execution_time, + ]); + } + + if (!empty($warnings)) { + return SetupResult::warning(join(' ', $warnings), $this->urlGenerator->linkToDocs('admin-big-file-upload')); + } + + return SetupResult::success(); + } +} diff --git a/apps/settings/lib/SetupChecks/PhpMemoryLimit.php b/apps/settings/lib/SetupChecks/PhpMemoryLimit.php new file mode 100644 index 00000000000..7b693169f10 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpMemoryLimit.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OC\MemoryInfo; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use OCP\Util; + +class PhpMemoryLimit implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private MemoryInfo $memoryInfo, + ) { + } + + public function getCategory(): string { + return 'php'; + } + + public function getName(): string { + return $this->l10n->t('PHP memory limit'); + } + + public function run(): SetupResult { + if ($this->memoryInfo->isMemoryLimitSufficient()) { + return SetupResult::success(Util::humanFileSize($this->memoryInfo->getMemoryLimit())); + } else { + return SetupResult::error($this->l10n->t('The PHP memory limit is below the recommended value of %s. Some features or apps - including the Updater - may not function properly.', Util::humanFileSize(MemoryInfo::RECOMMENDED_MEMORY_LIMIT))); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PhpModules.php b/apps/settings/lib/SetupChecks/PhpModules.php new file mode 100644 index 00000000000..b0b4f106f4a --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpModules.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpModules implements ISetupCheck { + protected const REQUIRED_MODULES = [ + 'ctype', + 'curl', + 'dom', + 'fileinfo', + 'gd', + 'json', + 'mbstring', + 'openssl', + 'posix', + 'session', + 'xml', + 'xmlreader', + 'xmlwriter', + 'zip', + 'zlib', + ]; + protected const RECOMMENDED_MODULES = [ + 'exif', + 'gmp', + 'intl', + 'sodium', + 'sysvsem', + ]; + + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('PHP modules'); + } + + public function getCategory(): string { + return 'php'; + } + + protected function getRecommendedModuleDescription(string $module): string { + return match($module) { + 'intl' => $this->l10n->t('increases language translation performance and fixes sorting of non-ASCII characters'), + 'sodium' => $this->l10n->t('for Argon2 for password hashing'), + 'gmp' => $this->l10n->t('required for SFTP storage and recommended for WebAuthn performance'), + 'exif' => $this->l10n->t('for picture rotation in server and metadata extraction in the Photos app'), + default => '', + }; + } + + public function run(): SetupResult { + $missingRecommendedModules = $this->getMissingModules(self::RECOMMENDED_MODULES); + $missingRequiredModules = $this->getMissingModules(self::REQUIRED_MODULES); + if (!empty($missingRequiredModules)) { + return SetupResult::error( + $this->l10n->t('This instance is missing some required PHP modules. It is required to install them: %s.', implode(', ', $missingRequiredModules)), + $this->urlGenerator->linkToDocs('admin-php-modules') + ); + } elseif (!empty($missingRecommendedModules)) { + $moduleList = implode( + "\n", + array_map( + fn (string $module) => '- ' . $module . ' ' . $this->getRecommendedModuleDescription($module), + $missingRecommendedModules + ) + ); + return SetupResult::info( + $this->l10n->t("This instance is missing some recommended PHP modules. For improved performance and better compatibility it is highly recommended to install them:\n%s", $moduleList), + $this->urlGenerator->linkToDocs('admin-php-modules') + ); + } else { + return SetupResult::success(); + } + } + + /** + * Checks for potential PHP modules that would improve the instance + * + * @param string[] $modules modules to test + * @return string[] A list of PHP modules which are missing + */ + protected function getMissingModules(array $modules): array { + return array_values(array_filter( + $modules, + fn (string $module) => !extension_loaded($module), + )); + } +} diff --git a/apps/settings/lib/SetupChecks/PhpOpcacheSetup.php b/apps/settings/lib/SetupChecks/PhpOpcacheSetup.php new file mode 100644 index 00000000000..83b7be1c390 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpOpcacheSetup.php @@ -0,0 +1,136 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use bantu\IniGetWrapper\IniGetWrapper; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpOpcacheSetup implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IniGetWrapper $iniGetWrapper, + ) { + } + + public function getName(): string { + return $this->l10n->t('PHP opcache'); + } + + public function getCategory(): string { + return 'php'; + } + + /** + * Checks whether a PHP OPcache is properly set up + * @return array{'warning'|'error',list<string>} The level and the list of OPcache setup recommendations + */ + protected function getOpcacheSetupRecommendations(): array { + $level = 'warning'; + + // If the module is not loaded, return directly to skip inapplicable checks + if (!extension_loaded('Zend OPcache')) { + return ['error',[$this->l10n->t('The PHP OPcache module is not loaded. For better performance it is recommended to load it into your PHP installation.')]]; + } + + $recommendations = []; + + // Check whether Nextcloud is allowed to use the OPcache API + $isPermitted = true; + $permittedPath = (string)$this->iniGetWrapper->getString('opcache.restrict_api'); + if ($permittedPath !== '' && !str_starts_with(\OC::$SERVERROOT, rtrim($permittedPath, '/'))) { + $isPermitted = false; + } + + if (!$this->iniGetWrapper->getBool('opcache.enable')) { + $recommendations[] = $this->l10n->t('OPcache is disabled. For better performance, it is recommended to apply "opcache.enable=1" to your PHP configuration.'); + $level = 'error'; + } elseif ($this->iniGetWrapper->getBool('opcache.file_cache_only')) { + $recommendations[] = $this->l10n->t('The shared memory based OPcache is disabled. For better performance, it is recommended to apply "opcache.file_cache_only=0" to your PHP configuration and use the file cache as second level cache only.'); + } else { + // Check whether opcache_get_status has been explicitly disabled and in case skip usage based checks + $disabledFunctions = $this->iniGetWrapper->getString('disable_functions'); + if (isset($disabledFunctions) && str_contains($disabledFunctions, 'opcache_get_status')) { + return [$level, $recommendations]; + } + + $status = opcache_get_status(false); + + if ($status === false) { + $recommendations[] = $this->l10n->t('OPcache is not working as it should, opcache_get_status() returns false, please check configuration.'); + $level = 'error'; + } + + // Check whether OPcache is full, which can be either the overall OPcache size or limit of cached keys reached. + // If the limit of cached keys has been reached, num_cached_keys equals max_cached_keys. The recommendation contains this value instead of opcache.max_accelerated_files, since the effective limit is a next higher prime number: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.max-accelerated-files + // Else, the remaining $status['memory_usage']['free_memory'] was too low to store another script. Aside of used_memory, this can be also due to wasted_memory, remaining cache keys from scripts changed on disk. + // Wasted memory is cleared only via opcache_reset(), or if $status['memory_usage']['current_wasted_percentage'] reached opcache.max_wasted_percentage, which triggers an engine restart and hence OPcache reset. Due to this complexity, we check for $status['cache_full'] only. + if ($status['cache_full'] === true) { + if ($status['opcache_statistics']['num_cached_keys'] === $status['opcache_statistics']['max_cached_keys']) { + $recommendations[] = $this->l10n->t('The maximum number of OPcache keys is nearly exceeded. To assure that all scripts can be kept in the cache, it is recommended to apply "opcache.max_accelerated_files" to your PHP configuration with a value higher than "%s".', [($status['opcache_statistics']['max_cached_keys'] ?: 'currently')]); + } else { + $recommendations[] = $this->l10n->t('The OPcache buffer is nearly full. To assure that all scripts can be hold in cache, it is recommended to apply "opcache.memory_consumption" to your PHP configuration with a value higher than "%s".', [($this->iniGetWrapper->getNumeric('opcache.memory_consumption') ?: 'currently')]); + } + } + + // Interned strings buffer: recommend to raise size if more than 90% is used + $interned_strings_buffer = $this->iniGetWrapper->getNumeric('opcache.interned_strings_buffer') ?? 0; + $memory_consumption = $this->iniGetWrapper->getNumeric('opcache.memory_consumption') ?? 0; + if ( + // Do not recommend to raise the interned strings buffer size above a quarter of the total OPcache size + ($interned_strings_buffer < ($memory_consumption / 4)) + && ( + empty($status['interned_strings_usage']['free_memory']) + || ($status['interned_strings_usage']['used_memory'] / $status['interned_strings_usage']['free_memory'] > 9) + ) + ) { + $recommendations[] = $this->l10n->t('The OPcache interned strings buffer is nearly full. To assure that repeating strings can be effectively cached, it is recommended to apply "opcache.interned_strings_buffer" to your PHP configuration with a value higher than "%s".', [($this->iniGetWrapper->getNumeric('opcache.interned_strings_buffer') ?: 'currently')]); + } + } + + // Check for saved comments only when OPcache is currently disabled. If it was enabled, opcache.save_comments=0 would break Nextcloud in the first place. + if (!$this->iniGetWrapper->getBool('opcache.save_comments')) { + $recommendations[] = $this->l10n->t('OPcache is configured to remove code comments. With OPcache enabled, "opcache.save_comments=1" must be set for Nextcloud to function.'); + $level = 'error'; + } + + if (!$isPermitted) { + $recommendations[] = $this->l10n->t('Nextcloud is not allowed to use the OPcache API. With OPcache enabled, it is highly recommended to include all Nextcloud directories with "opcache.restrict_api" or unset this setting to disable OPcache API restrictions, to prevent errors during Nextcloud core or app upgrades.'); + $level = 'error'; + } + + return [$level, $recommendations]; + } + + public function run(): SetupResult { + // Skip OPcache checks if running from CLI + if (\OC::$CLI && !$this->iniGetWrapper->getBool('opcache.enable_cli')) { + return SetupResult::success($this->l10n->t('Checking from CLI, OPcache checks have been skipped.')); + } + + [$level,$recommendations] = $this->getOpcacheSetupRecommendations(); + if (!empty($recommendations)) { + return match($level) { + 'error' => SetupResult::error( + $this->l10n->t('The PHP OPcache module is not properly configured. %s.', implode("\n", $recommendations)), + $this->urlGenerator->linkToDocs('admin-php-opcache') + ), + default => SetupResult::warning( + $this->l10n->t('The PHP OPcache module is not properly configured. %s.', implode("\n", $recommendations)), + $this->urlGenerator->linkToDocs('admin-php-opcache') + ), + }; + } else { + return SetupResult::success($this->l10n->t('Correctly configured')); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PhpOutdated.php b/apps/settings/lib/SetupChecks/PhpOutdated.php new file mode 100644 index 00000000000..d0d8e03c705 --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpOutdated.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpOutdated implements ISetupCheck { + public const DEPRECATED_PHP_VERSION = '8.1'; + public const DEPRECATED_SINCE = '30'; + public const FUTURE_REQUIRED_PHP_VERSION = '8.2'; + public const FUTURE_REQUIRED_STARTING = '32'; + + public function __construct( + private IL10N $l10n, + ) { + } + + public function getCategory(): string { + return 'security'; + } + + public function getName(): string { + return $this->l10n->t('PHP version'); + } + + public function run(): SetupResult { + if (PHP_VERSION_ID < 80200) { + return SetupResult::warning($this->l10n->t('You are currently running PHP %1$s. PHP %2$s is deprecated since Nextcloud %3$s. Nextcloud %4$s may require at least PHP %5$s. Please upgrade to one of the officially supported PHP versions provided by the PHP Group as soon as possible.', [ + PHP_VERSION, + self::DEPRECATED_PHP_VERSION, + self::DEPRECATED_SINCE, + self::FUTURE_REQUIRED_STARTING, + self::FUTURE_REQUIRED_PHP_VERSION, + ]), 'https://secure.php.net/supported-versions.php'); + } + return SetupResult::success($this->l10n->t('You are currently running PHP %s.', [PHP_VERSION])); + } +} diff --git a/apps/settings/lib/SetupChecks/PhpOutputBuffering.php b/apps/settings/lib/SetupChecks/PhpOutputBuffering.php new file mode 100644 index 00000000000..be8154fbb1b --- /dev/null +++ b/apps/settings/lib/SetupChecks/PhpOutputBuffering.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpOutputBuffering implements ISetupCheck { + public function __construct( + private IL10N $l10n, + ) { + } + + public function getCategory(): string { + return 'php'; + } + + public function getName(): string { + return $this->l10n->t('PHP "output_buffering" option'); + } + + public function run(): SetupResult { + $value = trim(ini_get('output_buffering')); + if ($value === '' || $value === '0') { + return SetupResult::success($this->l10n->t('Disabled')); + } else { + return SetupResult::error($this->l10n->t('PHP configuration option "output_buffering" must be disabled')); + } + } +} diff --git a/apps/settings/lib/SetupChecks/PushService.php b/apps/settings/lib/SetupChecks/PushService.php new file mode 100644 index 00000000000..1f03404d80e --- /dev/null +++ b/apps/settings/lib/SetupChecks/PushService.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\IL10N; +use OCP\Notification\IManager; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use OCP\Support\Subscription\IRegistry; + +class PushService implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IManager $notificationsManager, + private IRegistry $subscriptionRegistry, + private ITimeFactory $timeFactory, + ) { + } + + public function getName(): string { + return $this->l10n->t('Push service'); + } + + public function getCategory(): string { + return 'system'; + } + + /** + * Check if is fair use of free push service + */ + private function isFairUseOfFreePushService(): bool { + $rateLimitReached = (int)$this->config->getAppValue('notifications', 'rate_limit_reached', '0'); + if ($rateLimitReached >= ($this->timeFactory->now()->getTimestamp() - 7 * 24 * 3600)) { + // Notifications app is showing a message already + return true; + } + return $this->notificationsManager->isFairUseOfFreePushService(); + } + + public function run(): SetupResult { + if ($this->subscriptionRegistry->delegateHasValidSubscription()) { + return SetupResult::success($this->l10n->t('Valid enterprise license')); + } + + if ($this->isFairUseOfFreePushService()) { + return SetupResult::success($this->l10n->t('Free push service')); + } + + return SetupResult::error( + $this->l10n->t('This is the unsupported community build of Nextcloud. Given the size of this instance, performance, reliability and scalability cannot be guaranteed. Push notifications are limited to avoid overloading our free service. Learn more about the benefits of Nextcloud Enterprise at {link}.'), + descriptionParameters:[ + 'link' => [ + 'type' => 'highlight', + 'id' => 'link', + 'name' => 'https://nextcloud.com/enterprise', + 'link' => 'https://nextcloud.com/enterprise', + ], + ], + ); + } +} diff --git a/apps/settings/lib/SetupChecks/RandomnessSecure.php b/apps/settings/lib/SetupChecks/RandomnessSecure.php new file mode 100644 index 00000000000..045ddde0b9d --- /dev/null +++ b/apps/settings/lib/SetupChecks/RandomnessSecure.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Security\ISecureRandom; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class RandomnessSecure implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + private ISecureRandom $secureRandom, + ) { + } + + public function getName(): string { + return $this->l10n->t('Random generator'); + } + + public function getCategory(): string { + return 'security'; + } + + public function run(): SetupResult { + try { + $this->secureRandom->generate(1); + } catch (\Exception $ex) { + return SetupResult::error( + $this->l10n->t('No suitable source for randomness found by PHP which is highly discouraged for security reasons.'), + $this->urlGenerator->linkToDocs('admin-security') + ); + } + return SetupResult::success($this->l10n->t('Secure')); + } +} diff --git a/apps/settings/lib/SetupChecks/ReadOnlyConfig.php b/apps/settings/lib/SetupChecks/ReadOnlyConfig.php new file mode 100644 index 00000000000..b616f8a7155 --- /dev/null +++ b/apps/settings/lib/SetupChecks/ReadOnlyConfig.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class ReadOnlyConfig implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + ) { + } + + public function getName(): string { + return $this->l10n->t('Configuration file access rights'); + } + + public function getCategory(): string { + return 'config'; + } + + public function run(): SetupResult { + if ($this->config->getSystemValueBool('config_is_read_only', false)) { + return SetupResult::info($this->l10n->t('The read-only config has been enabled. This prevents setting some configurations via the web-interface. Furthermore, the file needs to be made writable manually for every update.')); + } else { + return SetupResult::success($this->l10n->t('Nextcloud configuration file is writable')); + } + } +} diff --git a/apps/settings/lib/SetupChecks/SchedulingTableSize.php b/apps/settings/lib/SetupChecks/SchedulingTableSize.php new file mode 100644 index 00000000000..b23972ca7dc --- /dev/null +++ b/apps/settings/lib/SetupChecks/SchedulingTableSize.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class SchedulingTableSize implements ISetupCheck { + public const MAX_SCHEDULING_ENTRIES = 50000; + + public function __construct( + private IL10N $l10n, + private IDBConnection $connection, + ) { + } + + public function getName(): string { + return $this->l10n->t('Scheduling objects table size'); + } + + public function getCategory(): string { + return 'database'; + } + + public function run(): SetupResult { + $qb = $this->connection->getQueryBuilder(); + $qb->select($qb->func()->count('id')) + ->from('schedulingobjects'); + $query = $qb->executeQuery(); + $count = $query->fetchOne(); + $query->closeCursor(); + + if ($count > self::MAX_SCHEDULING_ENTRIES) { + return SetupResult::warning( + $this->l10n->t('You have more than %s rows in the scheduling objects table. Please run the expensive repair jobs via occ maintenance:repair --include-expensive.', [ + self::MAX_SCHEDULING_ENTRIES, + ]) + ); + } + return SetupResult::success( + $this->l10n->t('Scheduling objects table size is within acceptable range.') + ); + } +} diff --git a/apps/settings/lib/SetupChecks/SecurityHeaders.php b/apps/settings/lib/SetupChecks/SecurityHeaders.php new file mode 100644 index 00000000000..9cc6856a170 --- /dev/null +++ b/apps/settings/lib/SetupChecks/SecurityHeaders.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +class SecurityHeaders implements ISetupCheck { + + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'security'; + } + + public function getName(): string { + return $this->l10n->t('HTTP headers'); + } + + public function run(): SetupResult { + $urls = [ + ['get', $this->urlGenerator->linkToRoute('heartbeat'), [200]], + ]; + $securityHeaders = [ + 'X-Content-Type-Options' => ['nosniff', null], + 'X-Robots-Tag' => ['noindex,nofollow', null], + 'X-Frame-Options' => ['sameorigin', 'deny'], + 'X-Permitted-Cross-Domain-Policies' => ['none', null], + ]; + + foreach ($urls as [$verb,$url,$validStatuses]) { + $works = null; + foreach ($this->runRequest($verb, $url, ['httpErrors' => false]) as $response) { + // Check that the response status matches + if (!in_array($response->getStatusCode(), $validStatuses)) { + $works = false; + continue; + } + $msg = ''; + $msgParameters = []; + foreach ($securityHeaders as $header => [$expected, $accepted]) { + /* Convert to lowercase and remove spaces after comas */ + $value = preg_replace('/,\s+/', ',', strtolower($response->getHeader($header))); + if ($value !== $expected) { + if ($accepted !== null && $value === $accepted) { + $msg .= $this->l10n->t('- The `%1$s` HTTP header is not set to `%2$s`. Some features might not work correctly, as it is recommended to adjust this setting accordingly.', [$header, $expected]) . "\n"; + } else { + $msg .= $this->l10n->t('- The `%1$s` HTTP header is not set to `%2$s`. This is a potential security or privacy risk, as it is recommended to adjust this setting accordingly.', [$header, $expected]) . "\n"; + } + } + } + + $referrerPolicy = $response->getHeader('Referrer-Policy'); + if (!preg_match('/(no-referrer(-when-downgrade)?|strict-origin(-when-cross-origin)?|same-origin)(,|$)/', $referrerPolicy)) { + $msg .= $this->l10n->t( + '- The `%1$s` HTTP header is not set to `%2$s`, `%3$s`, `%4$s`, `%5$s` or `%6$s`. This can leak referer information. See the {w3c-recommendation}.', + [ + 'Referrer-Policy', + 'no-referrer', + 'no-referrer-when-downgrade', + 'strict-origin', + 'strict-origin-when-cross-origin', + 'same-origin', + ] + ) . "\n"; + $msgParameters['w3c-recommendation'] = [ + 'type' => 'highlight', + 'id' => 'w3c-recommendation', + 'name' => 'W3C Recommendation', + 'link' => 'https://www.w3.org/TR/referrer-policy/', + ]; + } + + $transportSecurityValidity = $response->getHeader('Strict-Transport-Security'); + $minimumSeconds = 15552000; + if (preg_match('/^max-age=(\d+)(;.*)?$/', $transportSecurityValidity, $m)) { + $transportSecurityValidity = (int)$m[1]; + if ($transportSecurityValidity < $minimumSeconds) { + $msg .= $this->l10n->t('- The `Strict-Transport-Security` HTTP header is not set to at least `%d` seconds (current value: `%d`). For enhanced security, it is recommended to use a long HSTS policy.', [$minimumSeconds, $transportSecurityValidity]) . "\n"; + } + } elseif (!empty($transportSecurityValidity)) { + $msg .= $this->l10n->t('- The `Strict-Transport-Security` HTTP header is malformed: `%s`. For enhanced security, it is recommended to enable HSTS.', [$transportSecurityValidity]) . "\n"; + } else { + $msg .= $this->l10n->t('- The `Strict-Transport-Security` HTTP header is not set (should be at least `%d` seconds). For enhanced security, it is recommended to enable HSTS.', [$minimumSeconds]) . "\n"; + } + + if (!empty($msg)) { + return SetupResult::warning( + $this->l10n->t('Some headers are not set correctly on your instance') . "\n" . $msg, + $this->urlGenerator->linkToDocs('admin-security'), + $msgParameters, + ); + } + // Skip the other requests if one works + $works = true; + break; + } + // If 'works' is null then we could not connect to the server + if ($works === null) { + return SetupResult::info( + $this->l10n->t('Could not check that your web server serves security headers correctly. Please check manually.'), + $this->urlGenerator->linkToDocs('admin-security'), + ); + } + // Otherwise if we fail we can abort here + if ($works === false) { + return SetupResult::warning( + $this->l10n->t('Could not check that your web server serves security headers correctly, unable to query `%s`', [$url]), + $this->urlGenerator->linkToDocs('admin-security'), + ); + } + } + return SetupResult::success( + $this->l10n->t('Your server is correctly configured to send security headers.') + ); + } +} diff --git a/apps/settings/lib/SetupChecks/SupportedDatabase.php b/apps/settings/lib/SetupChecks/SupportedDatabase.php new file mode 100644 index 00000000000..d083958d16e --- /dev/null +++ b/apps/settings/lib/SetupChecks/SupportedDatabase.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\OraclePlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class SupportedDatabase implements ISetupCheck { + + private const MIN_MARIADB = '10.6'; + private const MAX_MARIADB = '11.8'; + private const MIN_MYSQL = '8.0'; + private const MAX_MYSQL = '8.4'; + private const MIN_POSTGRES = '13'; + private const MAX_POSTGRES = '17'; + + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IDBConnection $connection, + ) { + } + + public function getCategory(): string { + return 'database'; + } + + public function getName(): string { + return $this->l10n->t('Database version'); + } + + public function run(): SetupResult { + $version = null; + $databasePlatform = $this->connection->getDatabasePlatform(); + if ($databasePlatform instanceof MySQLPlatform) { + $statement = $this->connection->prepare("SHOW VARIABLES LIKE 'version';"); + $result = $statement->execute(); + $row = $result->fetch(); + $version = $row['Value']; + $versionlc = strtolower($version); + // we only care about X.Y not X.Y.Z differences + [$major, $minor, ] = explode('.', $versionlc); + $versionConcern = $major . '.' . $minor; + if (str_contains($versionlc, 'mariadb')) { + if (version_compare($versionConcern, '10.3', '=')) { + return SetupResult::info( + $this->l10n->t( + 'MariaDB version 10.3 detected, this version is end-of-life and only supported as part of Ubuntu 20.04. MariaDB >=%1$s and <=%2$s is suggested for best performance, stability and functionality with this version of Nextcloud.', + [ + self::MIN_MARIADB, + self::MAX_MARIADB, + ] + ), + ); + } elseif (version_compare($versionConcern, self::MIN_MARIADB, '<') || version_compare($versionConcern, self::MAX_MARIADB, '>')) { + return SetupResult::warning( + $this->l10n->t( + 'MariaDB version "%1$s" detected. MariaDB >=%2$s and <=%3$s is suggested for best performance, stability and functionality with this version of Nextcloud.', + [ + $version, + self::MIN_MARIADB, + self::MAX_MARIADB, + ], + ), + ); + } + } else { + if (version_compare($versionConcern, self::MIN_MYSQL, '<') || version_compare($versionConcern, self::MAX_MYSQL, '>')) { + return SetupResult::warning( + $this->l10n->t( + 'MySQL version "%1$s" detected. MySQL >=%2$s and <=%3$s is suggested for best performance, stability and functionality with this version of Nextcloud.', + [ + $version, + self::MIN_MYSQL, + self::MAX_MYSQL, + ], + ), + ); + } + } + } elseif ($databasePlatform instanceof PostgreSQLPlatform) { + $statement = $this->connection->prepare('SHOW server_version;'); + $result = $statement->execute(); + $row = $result->fetch(); + $version = $row['server_version']; + $versionlc = strtolower($version); + // we only care about X not X.Y or X.Y.Z differences + [$major, ] = explode('.', $versionlc); + $versionConcern = $major; + if (version_compare($versionConcern, self::MIN_POSTGRES, '<') || version_compare($versionConcern, self::MAX_POSTGRES, '>')) { + return SetupResult::warning( + $this->l10n->t( + 'PostgreSQL version "%1$s" detected. PostgreSQL >=%2$s and <=%3$s is suggested for best performance, stability and functionality with this version of Nextcloud.', + [ + $version, + self::MIN_POSTGRES, + self::MAX_POSTGRES, + ]) + ); + } + } elseif ($databasePlatform instanceof OraclePlatform) { + $version = 'Oracle'; + } elseif ($databasePlatform instanceof SqlitePlatform) { + return SetupResult::warning( + $this->l10n->t('SQLite is currently being used as the backend database. For larger installations we recommend that you switch to a different database backend. This is particularly recommended when using the desktop client for file synchronisation. To migrate to another database use the command line tool: "occ db:convert-type".'), + $this->urlGenerator->linkToDocs('admin-db-conversion') + ); + } else { + return SetupResult::error($this->l10n->t('Unknown database platform')); + } + return SetupResult::success($version); + } +} diff --git a/apps/settings/lib/SetupChecks/SystemIs64bit.php b/apps/settings/lib/SetupChecks/SystemIs64bit.php new file mode 100644 index 00000000000..308011c218e --- /dev/null +++ b/apps/settings/lib/SetupChecks/SystemIs64bit.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class SystemIs64bit implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('Architecture'); + } + + public function getCategory(): string { + return 'system'; + } + + protected function is64bit(): bool { + if (PHP_INT_SIZE < 8) { + return false; + } else { + return true; + } + } + + public function run(): SetupResult { + if ($this->is64bit()) { + return SetupResult::success($this->l10n->t('64-bit')); + } else { + return SetupResult::warning( + $this->l10n->t('It seems like you are running a 32-bit PHP version. Nextcloud needs 64-bit to run well. Please upgrade your OS and PHP to 64-bit!'), + $this->urlGenerator->linkToDocs('admin-system-requirements') + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/TaskProcessingPickupSpeed.php b/apps/settings/lib/SetupChecks/TaskProcessingPickupSpeed.php new file mode 100644 index 00000000000..83168ac0f3e --- /dev/null +++ b/apps/settings/lib/SetupChecks/TaskProcessingPickupSpeed.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use OCP\TaskProcessing\IManager; + +class TaskProcessingPickupSpeed implements ISetupCheck { + public const MAX_SLOW_PERCENTAGE = 0.2; + public const TIME_SPAN = 24; + + public function __construct( + private IL10N $l10n, + private IManager $taskProcessingManager, + private ITimeFactory $timeFactory, + ) { + } + + public function getCategory(): string { + return 'ai'; + } + + public function getName(): string { + return $this->l10n->t('Task Processing pickup speed'); + } + + public function run(): SetupResult { + $tasks = $this->taskProcessingManager->getTasks(userId: '', scheduleAfter: $this->timeFactory->now()->getTimestamp() - 60 * 60 * self::TIME_SPAN); // userId: '' means no filter, whereas null would mean guest + $taskCount = count($tasks); + if ($taskCount === 0) { + return SetupResult::success($this->l10n->n('No scheduled tasks in the last %n hour.', 'No scheduled tasks in the last %n hours.', self::TIME_SPAN)); + } + $slowCount = 0; + foreach ($tasks as $task) { + if ($task->getStartedAt() === null) { + continue; // task was not picked up yet + } + if ($task->getScheduledAt() === null) { + continue; // task was not scheduled yet -- should not happen, but the API specifies null as return value + } + $pickupDelay = $task->getScheduledAt() - $task->getStartedAt(); + if ($pickupDelay > 60 * 4) { + $slowCount++; // task pickup took longer than 4 minutes + } + } + + if ($slowCount / $taskCount < self::MAX_SLOW_PERCENTAGE) { + return SetupResult::success($this->l10n->n('The task pickup speed has been ok in the last %n hour.', 'The task pickup speed has been ok in the last %n hours.', self::TIME_SPAN)); + } else { + return SetupResult::warning($this->l10n->n('The task pickup speed has been slow in the last %n hour. Many tasks took longer than 4 minutes to be picked up. Consider setting up a worker to process tasks in the background.', 'The task pickup speed has been slow in the last %n hours. Many tasks took longer than 4 minutes to be picked up. Consider setting up a worker to process tasks in the background.', self::TIME_SPAN), 'https://docs.nextcloud.com/server/latest/admin_manual/ai/overview.html#improve-ai-task-pickup-speed'); + } + } +} diff --git a/apps/settings/lib/SetupChecks/TempSpaceAvailable.php b/apps/settings/lib/SetupChecks/TempSpaceAvailable.php new file mode 100644 index 00000000000..49dc0d377e7 --- /dev/null +++ b/apps/settings/lib/SetupChecks/TempSpaceAvailable.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\ITempManager; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class TempSpaceAvailable implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + private ITempManager $tempManager, + ) { + } + + public function getName(): string { + return $this->l10n->t('Temporary space available'); + } + + public function getCategory(): string { + return 'system'; + } + + private function isPrimaryStorageS3(): bool { + $objectStore = $this->config->getSystemValue('objectstore', null); + $objectStoreMultibucket = $this->config->getSystemValue('objectstore_multibucket', null); + + if (!isset($objectStoreMultibucket) && !isset($objectStore)) { + return false; + } + + if (isset($objectStoreMultibucket['class']) && $objectStoreMultibucket['class'] !== 'OC\\Files\\ObjectStore\\S3') { + return false; + } + + if (isset($objectStore['class']) && $objectStore['class'] !== 'OC\\Files\\ObjectStore\\S3') { + return false; + } + + return true; + } + + public function run(): SetupResult { + $phpTempPath = sys_get_temp_dir(); + $nextcloudTempPath = ''; + try { + $nextcloudTempPath = $this->tempManager->getTempBaseDir(); + } catch (\Exception $e) { + } + + if (empty($nextcloudTempPath)) { + return SetupResult::error('The temporary directory of this instance points to an either non-existing or non-writable directory.'); + } + + if (!is_dir($phpTempPath)) { + return SetupResult::error($this->l10n->t('Error while checking the temporary PHP path - it was not properly set to a directory. Returned value: %s', [$phpTempPath])); + } + + if (!function_exists('disk_free_space')) { + return SetupResult::info($this->l10n->t('The PHP function "disk_free_space" is disabled, which prevents the check for enough space in the temporary directories.')); + } + + $freeSpaceInTemp = disk_free_space($phpTempPath); + if ($freeSpaceInTemp === false) { + return SetupResult::error($this->l10n->t('Error while checking the available disk space of temporary PHP path or no free disk space returned. Temporary path: %s', [$phpTempPath])); + } + + /** Build details data about temporary directory, either one or two of them */ + $freeSpaceInTempInGB = $freeSpaceInTemp / 1024 / 1024 / 1024; + $spaceDetail = $this->l10n->t('- %.1f GiB available in %s (PHP temporary directory)', [round($freeSpaceInTempInGB, 1),$phpTempPath]); + if ($nextcloudTempPath !== $phpTempPath) { + $freeSpaceInNextcloudTemp = disk_free_space($nextcloudTempPath); + if ($freeSpaceInNextcloudTemp === false) { + return SetupResult::error($this->l10n->t('Error while checking the available disk space of temporary PHP path or no free disk space returned. Temporary path: %s', [$nextcloudTempPath])); + } + $freeSpaceInNextcloudTempInGB = $freeSpaceInNextcloudTemp / 1024 / 1024 / 1024; + $spaceDetail .= "\n" . $this->l10n->t('- %.1f GiB available in %s (Nextcloud temporary directory)', [round($freeSpaceInNextcloudTempInGB, 1),$nextcloudTempPath]); + } + + if (!$this->isPrimaryStorageS3()) { + return SetupResult::success( + $this->l10n->t("Temporary directory is correctly configured:\n%s", [$spaceDetail]) + ); + } + + if ($freeSpaceInTempInGB > 50) { + return SetupResult::success( + $this->l10n->t( + "This instance uses an S3 based object store as primary storage, and has enough space in the temporary directory.\n%s", + [$spaceDetail] + ) + ); + } + + return SetupResult::warning( + $this->l10n->t( + "This instance uses an S3 based object store as primary storage. The uploaded files are stored temporarily on the server and thus it is recommended to have 50 GiB of free space available in the temp directory of PHP. To improve this please change the temporary directory in the php.ini or make more space available in that path. \nChecking the available space in the temporary path resulted in %.1f GiB instead of the recommended 50 GiB. Path: %s", + [round($freeSpaceInTempInGB, 1),$phpTempPath] + ) + ); + } +} diff --git a/apps/settings/lib/SetupChecks/TransactionIsolation.php b/apps/settings/lib/SetupChecks/TransactionIsolation.php new file mode 100644 index 00000000000..892c0ecbda6 --- /dev/null +++ b/apps/settings/lib/SetupChecks/TransactionIsolation.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use Doctrine\DBAL\Exception; +use Doctrine\DBAL\TransactionIsolationLevel; +use OC\DB\Connection; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class TransactionIsolation implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IDBConnection $connection, + private Connection $db, + ) { + } + + public function getName(): string { + return $this->l10n->t('Database transaction isolation level'); + } + + public function getCategory(): string { + return 'database'; + } + + public function run(): SetupResult { + try { + if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE) { + return SetupResult::success(); + } + + if ($this->db->getTransactionIsolation() === TransactionIsolationLevel::READ_COMMITTED) { + return SetupResult::success('Read committed'); + } else { + return SetupResult::error( + $this->l10n->t('Your database does not run with "READ COMMITTED" transaction isolation level. This can cause problems when multiple actions are executed in parallel.'), + $this->urlGenerator->linkToDocs('admin-db-transaction') + ); + } + } catch (Exception $e) { + return SetupResult::warning( + $this->l10n->t('Was not able to get transaction isolation level: %s', $e->getMessage()) + ); + } + } +} diff --git a/apps/settings/lib/SetupChecks/WellKnownUrls.php b/apps/settings/lib/SetupChecks/WellKnownUrls.php new file mode 100644 index 00000000000..4eeaff8f3c4 --- /dev/null +++ b/apps/settings/lib/SetupChecks/WellKnownUrls.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +class WellKnownUrls implements ISetupCheck { + + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('.well-known URLs'); + } + + public function run(): SetupResult { + if (!$this->config->getSystemValueBool('check_for_working_wellknown_setup', true)) { + return SetupResult::info($this->l10n->t('`check_for_working_wellknown_setup` is set to false in your configuration, so this check was skipped.')); + } + + $urls = [ + ['get', '/.well-known/webfinger', [200, 400, 404], true], // 400 indicates a handler is installed but (correctly) failed because we didn't specify a resource + ['get', '/.well-known/nodeinfo', [200, 404], true], + ['propfind', '/.well-known/caldav', [207], false], + ['propfind', '/.well-known/carddav', [207], false], + ]; + + $requestOptions = ['httpErrors' => false, 'options' => ['allow_redirects' => ['track_redirects' => true]]]; + foreach ($urls as [$verb,$url,$validStatuses,$checkCustomHeader]) { + $works = null; + foreach ($this->runRequest($verb, $url, $requestOptions, isRootRequest: true) as $response) { + // Check that the response status matches + $works = in_array($response->getStatusCode(), $validStatuses); + // and (if needed) the custom Nextcloud header is set + if ($checkCustomHeader) { + $works = $works && !empty($response->getHeader('X-NEXTCLOUD-WELL-KNOWN')); + } else { + // For default DAV endpoints we lack authorization, but we still can check that the redirect works as expected + if (!$works && $response->getStatusCode() === 401) { + $redirectHops = explode(',', $response->getHeader('X-Guzzle-Redirect-History')); + $effectiveUri = end($redirectHops); + $works = str_ends_with(rtrim($effectiveUri, '/'), '/remote.php/dav'); + } + } + // Skip the other requests if one works + if ($works === true) { + break; + } + } + // If 'works' is null then we could not connect to the server + if ($works === null) { + return SetupResult::info( + $this->l10n->t('Could not check that your web server serves `.well-known` correctly. Please check manually.') . "\n" . $this->serverConfigHelp(), + $this->urlGenerator->linkToDocs('admin-setup-well-known-URL'), + ); + } + // Otherwise if we fail we can abort here + if ($works === false) { + return SetupResult::warning( + $this->l10n->t("Your web server is not properly set up to resolve `.well-known` URLs, failed on:\n`%s`", [$url]), + $this->urlGenerator->linkToDocs('admin-setup-well-known-URL'), + ); + } + } + return SetupResult::success( + $this->l10n->t('Your server is correctly configured to serve `.well-known` URLs.') + ); + } +} diff --git a/apps/settings/lib/SetupChecks/Woff2Loading.php b/apps/settings/lib/SetupChecks/Woff2Loading.php new file mode 100644 index 00000000000..27aff4ea999 --- /dev/null +++ b/apps/settings/lib/SetupChecks/Woff2Loading.php @@ -0,0 +1,81 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +/** + * Check whether the OTF and WOFF2 URLs works + */ +class Woff2Loading implements ISetupCheck { + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('Font file loading'); + } + + public function run(): SetupResult { + $result = $this->checkFont('otf', $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.otf')); + if ($result->getSeverity() !== SetupResult::SUCCESS) { + return $result; + } + return $this->checkFont('woff2', $this->urlGenerator->linkTo('', 'core/fonts/NotoSans-Regular-latin.woff2')); + } + + protected function checkFont(string $fileExtension, string $url): SetupResult { + $noResponse = true; + $responses = $this->runRequest('HEAD', $url); + foreach ($responses as $response) { + $noResponse = false; + if ($response->getStatusCode() === 200) { + return SetupResult::success(); + } + } + + if ($noResponse) { + return SetupResult::info( + str_replace( + '{extension}', + $fileExtension, + $this->l10n->t('Could not check for {extension} loading support. Please check manually if your webserver serves `.{extension}` files.') . "\n" . $this->serverConfigHelp(), + ), + $this->urlGenerator->linkToDocs('admin-nginx'), + ); + } + return SetupResult::warning( + str_replace( + '{extension}', + $fileExtension, + $this->l10n->t('Your web server is not properly set up to deliver .{extension} files. This is typically an issue with the Nginx configuration. For Nextcloud 15 it needs an adjustment to also deliver .{extension} files. Compare your Nginx configuration to the recommended configuration in our documentation.'), + ), + $this->urlGenerator->linkToDocs('admin-nginx'), + ); + + } +} diff --git a/apps/settings/lib/UserMigration/AccountMigrator.php b/apps/settings/lib/UserMigration/AccountMigrator.php new file mode 100644 index 00000000000..1c51aec5104 --- /dev/null +++ b/apps/settings/lib/UserMigration/AccountMigrator.php @@ -0,0 +1,195 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\UserMigration; + +use InvalidArgumentException; +use OC\Accounts\TAccountsHelper; +use OC\Core\Db\ProfileConfigMapper; +use OC\NotSquareException; +use OC\Profile\ProfileManager; +use OCA\Settings\AppInfo\Application; +use OCP\Accounts\IAccountManager; +use OCP\IAvatarManager; +use OCP\IL10N; +use OCP\Image; +use OCP\IUser; +use OCP\UserMigration\IExportDestination; +use OCP\UserMigration\IImportSource; +use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; +use OCP\UserMigration\TMigratorBasicVersionHandling; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; + +class AccountMigrator implements IMigrator, ISizeEstimationMigrator { + use TMigratorBasicVersionHandling; + + use TAccountsHelper; + + private ProfileManager $profileManager; + + private const PATH_ROOT = Application::APP_ID . '/'; + + private const PATH_ACCOUNT_FILE = AccountMigrator::PATH_ROOT . 'account.json'; + + private const AVATAR_BASENAME = 'avatar'; + + private const PATH_CONFIG_FILE = AccountMigrator::PATH_ROOT . 'config.json'; + + public function __construct( + private IAccountManager $accountManager, + private IAvatarManager $avatarManager, + ProfileManager $profileManager, + private ProfileConfigMapper $configMapper, + private IL10N $l10n, + ) { + $this->profileManager = $profileManager; + } + + /** + * {@inheritDoc} + */ + public function getEstimatedExportSize(IUser $user): int|float { + $size = 100; // 100KiB for account JSON + + try { + $avatar = $this->avatarManager->getAvatar($user->getUID()); + if ($avatar->isCustomAvatar()) { + $avatarFile = $avatar->getFile(-1); + $size += $avatarFile->getSize() / 1024; + } + } catch (Throwable $e) { + // Skip avatar in size estimate on failure + } + + return ceil($size); + } + + /** + * {@inheritDoc} + */ + public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln('Exporting account information in ' . AccountMigrator::PATH_ACCOUNT_FILE . '…'); + + try { + $account = $this->accountManager->getAccount($user); + $exportDestination->addFileContents(AccountMigrator::PATH_ACCOUNT_FILE, json_encode($account)); + } catch (Throwable $e) { + throw new AccountMigratorException('Could not export account information', 0, $e); + } + + try { + $avatar = $this->avatarManager->getAvatar($user->getUID()); + if ($avatar->isCustomAvatar()) { + $avatarFile = $avatar->getFile(-1); + $exportPath = AccountMigrator::PATH_ROOT . AccountMigrator::AVATAR_BASENAME . '.' . $avatarFile->getExtension(); + + $output->writeln('Exporting avatar to ' . $exportPath . '…'); + $exportDestination->addFileAsStream($exportPath, $avatarFile->read()); + } + } catch (Throwable $e) { + throw new AccountMigratorException('Could not export avatar', 0, $e); + } + + try { + $output->writeln('Exporting profile config in ' . AccountMigrator::PATH_CONFIG_FILE . '…'); + $config = $this->profileManager->getProfileConfig($user, $user); + $exportDestination->addFileContents(AccountMigrator::PATH_CONFIG_FILE, json_encode($config)); + } catch (Throwable $e) { + throw new AccountMigratorException('Could not export profile config', 0, $e); + } + } + + /** + * {@inheritDoc} + */ + public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { + if ($importSource->getMigratorVersion($this->getId()) === null) { + $output->writeln('No version for ' . static::class . ', skipping import…'); + return; + } + + $output->writeln('Importing account information from ' . AccountMigrator::PATH_ACCOUNT_FILE . '…'); + + $account = $this->accountManager->getAccount($user); + + /** @var array<string, array<string, string>>|array<string, array<int, array<string, string>>> $data */ + $data = json_decode($importSource->getFileContents(AccountMigrator::PATH_ACCOUNT_FILE), true, 512, JSON_THROW_ON_ERROR); + $account->setAllPropertiesFromJson($data); + + try { + $this->accountManager->updateAccount($account); + } catch (InvalidArgumentException $e) { + throw new AccountMigratorException('Failed to import account information'); + } + + /** @var array<int, string> $avatarFiles */ + $avatarFiles = array_filter( + $importSource->getFolderListing(AccountMigrator::PATH_ROOT), + fn (string $filename) => pathinfo($filename, PATHINFO_FILENAME) === AccountMigrator::AVATAR_BASENAME, + ); + + if (!empty($avatarFiles)) { + if (count($avatarFiles) > 1) { + $output->writeln('Expected single avatar image file, using first file found'); + } + + $importPath = AccountMigrator::PATH_ROOT . reset($avatarFiles); + + $output->writeln('Importing avatar from ' . $importPath . '…'); + $stream = $importSource->getFileAsStream($importPath); + $image = new Image(); + $image->loadFromFileHandle($stream); + + try { + $avatar = $this->avatarManager->getAvatar($user->getUID()); + $avatar->set($image); + } catch (NotSquareException $e) { + throw new AccountMigratorException('Avatar image must be square'); + } catch (Throwable $e) { + throw new AccountMigratorException('Failed to import avatar', 0, $e); + } + } + + try { + $output->writeln('Importing profile config from ' . AccountMigrator::PATH_CONFIG_FILE . '…'); + /** @var array $configData */ + $configData = json_decode($importSource->getFileContents(AccountMigrator::PATH_CONFIG_FILE), true, 512, JSON_THROW_ON_ERROR); + // Ensure that a profile config entry exists in the database + $this->profileManager->getProfileConfig($user, $user); + $config = $this->configMapper->get($user->getUID()); + $config->setConfigArray($configData); + $this->configMapper->update($config); + } catch (Throwable $e) { + throw new AccountMigratorException('Failed to import profile config'); + } + } + + /** + * {@inheritDoc} + */ + public function getId(): string { + return 'account'; + } + + /** + * {@inheritDoc} + */ + public function getDisplayName(): string { + return $this->l10n->t('Profile information'); + } + + /** + * {@inheritDoc} + */ + public function getDescription(): string { + return $this->l10n->t('Profile picture, full name, email, phone number, address, website, Twitter, organisation, role, headline, biography, and whether your profile is enabled'); + } +} diff --git a/apps/settings/lib/UserMigration/AccountMigratorException.php b/apps/settings/lib/UserMigration/AccountMigratorException.php new file mode 100644 index 00000000000..279daec45b3 --- /dev/null +++ b/apps/settings/lib/UserMigration/AccountMigratorException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\UserMigration; + +use OCP\UserMigration\UserMigrationException; + +class AccountMigratorException extends UserMigrationException { +} diff --git a/apps/settings/lib/WellKnown/ChangePasswordHandler.php b/apps/settings/lib/WellKnown/ChangePasswordHandler.php new file mode 100644 index 00000000000..9609579ef0a --- /dev/null +++ b/apps/settings/lib/WellKnown/ChangePasswordHandler.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\WellKnown; + +use OCP\AppFramework\Http\RedirectResponse; +use OCP\Http\WellKnown\GenericResponse; +use OCP\Http\WellKnown\IHandler; +use OCP\Http\WellKnown\IRequestContext; +use OCP\Http\WellKnown\IResponse; +use OCP\IURLGenerator; + +class ChangePasswordHandler implements IHandler { + + public function __construct( + private IURLGenerator $urlGenerator, + ) { + } + + public function handle(string $service, IRequestContext $context, ?IResponse $previousResponse): ?IResponse { + if ($service !== 'change-password') { + return $previousResponse; + } + + $response = new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('settings.PersonalSettings.index', ['section' => 'security'])); + return new GenericResponse($response); + } +} diff --git a/apps/settings/lib/WellKnown/SecurityTxtHandler.php b/apps/settings/lib/WellKnown/SecurityTxtHandler.php new file mode 100644 index 00000000000..60ab2d57a38 --- /dev/null +++ b/apps/settings/lib/WellKnown/SecurityTxtHandler.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\WellKnown; + +use OCP\AppFramework\Http\TextPlainResponse; +use OCP\Http\WellKnown\GenericResponse; +use OCP\Http\WellKnown\IHandler; +use OCP\Http\WellKnown\IRequestContext; +use OCP\Http\WellKnown\IResponse; + +class SecurityTxtHandler implements IHandler { + public function handle(string $service, IRequestContext $context, ?IResponse $previousResponse): ?IResponse { + if ($service !== 'security.txt') { + return $previousResponse; + } + + $response = 'Contact: https://hackerone.com/nextcloud +Expires: 2025-08-31T23:00:00.000Z +Acknowledgments: https://hackerone.com/nextcloud/thanks +Acknowledgments: https://github.com/nextcloud/security-advisories/security/advisories +Policy: https://hackerone.com/nextcloud +Preferred-Languages: en +'; + + return new GenericResponse(new TextPlainResponse($response, 200)); + } +} |