diff --git a/lib/private/Template/JSConfigHelper.php b/lib/private/Template/JSConfigHelper.php index 0035e52be8c92..4b591bb83d3c1 100644 --- a/lib/private/Template/JSConfigHelper.php +++ b/lib/private/Template/JSConfigHelper.php @@ -39,70 +39,99 @@ use OCP\User\Backend\IPasswordConfirmationBackend; use OCP\Util; +/** + * Builds frontend bootstrap configuration for the web UI. + * + * This class collects server, user, sharing, localization, and theme settings, + * provides selected values through the initial state service, and renders the + * JavaScript configuration payload used during page initialization. + */ class JSConfigHelper { - /** @var array user back-ends excluded from password verification */ - private $excludedUserBackEnds = ['user_saml' => true, 'user_globalsiteselector' => true]; + /** + * Backend class names for which password confirmation should be treated as unavailable. + * + * @var array + */ + private $passwordConfirmationExcludedBackends = [ + 'user_saml' => true, + 'user_globalsiteselector' => true, + ]; public function __construct( - protected ServerVersion $serverVersion, - protected IL10N $l, - protected Defaults $defaults, - protected IAppManager $appManager, - protected ISession $session, - protected ?IUser $currentUser, - protected IConfig $config, - protected readonly IAppConfig $appConfig, - protected IGroupManager $groupManager, - protected IniGetWrapper $iniWrapper, - protected IURLGenerator $urlGenerator, - protected CapabilitiesManager $capabilitiesManager, - protected IInitialStateService $initialStateService, - protected IProvider $tokenProvider, - protected FilenameValidator $filenameValidator, + private readonly ServerVersion $serverVersion, + private readonly IL10N $l, + private readonly Defaults $defaults, + private readonly IAppManager $appManager, + private readonly ISession $session, + private readonly ?IUser $currentUser, + private readonly IConfig $config, + private readonly IAppConfig $appConfig, + private readonly IGroupManager $groupManager, + private readonly IniGetWrapper $iniWrapper, + private readonly IURLGenerator $urlGenerator, + private readonly CapabilitiesManager $capabilitiesManager, + private readonly IInitialStateService $initialStateService, + private readonly IProvider $tokenProvider, + private readonly FilenameValidator $filenameValidator, ) { } + /** + * Builds the JavaScript configuration payload for page initialization. + * + * @return string JavaScript source containing global variable assignments. + */ public function getConfig(): string { + // Determine whether the current user/session can perform password confirmation. $userBackendAllowsPasswordConfirmation = true; if ($this->currentUser !== null) { $uid = $this->currentUser->getUID(); + $canValidatePassword = $this->canUserValidatePassword(); + $userBackend = $this->currentUser->getBackend(); + $userBackendClassName = $this->currentUser->getBackendClassName(); - $backend = $this->currentUser->getBackend(); - if ($backend instanceof IPasswordConfirmationBackend) { - $userBackendAllowsPasswordConfirmation = $backend->canConfirmPassword($uid) && $this->canUserValidatePassword(); - } elseif (isset($this->excludedUserBackEnds[$this->currentUser->getBackendClassName()])) { + if ($userBackend instanceof IPasswordConfirmationBackend) { + $userBackendAllowsPasswordConfirmation = $userBackend->canConfirmPassword($uid) && $canValidatePassword; + } elseif (isset($this->passwordConfirmationExcludedBackends[$$userBackendClassName])) { $userBackendAllowsPasswordConfirmation = false; } else { - $userBackendAllowsPasswordConfirmation = $this->canUserValidatePassword(); + $userBackendAllowsPasswordConfirmation = $canValidatePassword; } } else { $uid = null; } - // Get the config - $apps_paths = []; + $isAdmin = $uid !== null && $this->groupManager->isAdmin($uid); + + // Build the map of enabled app IDs to their public web paths for the current context. + /** @var array $appWebPaths */ + $appWebPaths = []; if ($this->currentUser === null) { - $apps = $this->appManager->getEnabledApps(); + $enabledApps = $this->appManager->getEnabledApps(); } else { - $apps = $this->appManager->getEnabledAppsForUser($this->currentUser); + $enabledApps = $this->appManager->getEnabledAppsForUser($this->currentUser); } - foreach ($apps as $app) { + // Resolve enabled app web paths for frontend bootstrapping. + foreach ($enabledApps as $app) { try { - $apps_paths[$app] = $this->appManager->getAppWebPath($app); + $appWebPaths[$app] = $this->appManager->getAppWebPath($app); } catch (AppPathNotFoundException $e) { - $apps_paths[$app] = false; + // If an app's filesystem path cannot be resolved, mark it as unavailable + // instead of aborting JS config generation for all apps. + $appWebPaths[$app] = false; } } + // Collect sharing defaults exposed to the frontend. $enableLinkPasswordByDefault = $this->appConfig->getValueBool('core', ConfigLexicon::SHARE_LINK_PASSWORD_DEFAULT); $defaultExpireDateEnabled = $this->appConfig->getValueBool('core', ConfigLexicon::SHARE_LINK_EXPIRE_DATE_DEFAULT); - $defaultExpireDate = $enforceDefaultExpireDate = null; + $defaultExpireDate = $defaultExpireDateEnforced = null; if ($defaultExpireDateEnabled) { $defaultExpireDate = (int)$this->config->getAppValue('core', 'shareapi_expire_after_n_days', '7'); - $enforceDefaultExpireDate = $this->appConfig->getValueBool('core', ConfigLexicon::SHARE_LINK_EXPIRE_DATE_ENFORCED); + $defaultExpireDateEnforced = $this->appConfig->getValueBool('core', ConfigLexicon::SHARE_LINK_EXPIRE_DATE_ENFORCED); } $outgoingServer2serverShareEnabled = $this->config->getAppValue('files_sharing', 'outgoing_server2server_share_enabled', 'yes') === 'yes'; @@ -120,10 +149,12 @@ public function getConfig(): string { $defaultRemoteExpireDateEnforced = $this->config->getAppValue('core', 'shareapi_enforce_remote_expire_date', 'no') === 'yes'; } - $countOfDataLocation = 0; - $dataLocation = str_replace(\OC::$SERVERROOT . '/', '', $this->config->getSystemValue('datadirectory', ''), $countOfDataLocation); - if ($countOfDataLocation !== 1 || $uid === null || !$this->groupManager->isAdmin($uid)) { - $dataLocation = false; + // Expose the data directory only when it is a child of the server root and the + // current user is an admin; otherwise keep it hidden from the client. + $dataDirectoryPrefixReplacementCount = 0; + $relativeDataDirectory = str_replace(\OC::$SERVERROOT . '/', '', $this->config->getSystemValue('datadirectory', ''), $dataDirectoryPrefixReplacementCount); + if ($dataDirectoryPrefixReplacementCount !== 1 || $uid === null || !$isAdmin) { + $relativeDataDirectory = false; } if ($this->currentUser instanceof IUser) { @@ -133,6 +164,8 @@ public function getConfig(): string { $lastConfirmTimestamp = 0; } } else { + // Use a sentinel value so the frontend treats password confirmation as already satisfied + // when this user/session cannot perform password validation. $lastConfirmTimestamp = PHP_INT_MAX; } } else { @@ -141,14 +174,14 @@ public function getConfig(): string { $capabilities = $this->capabilitiesManager->getCapabilities(false, true); - $firstDay = $this->config->getUserValue($uid, 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK, ''); - if ($firstDay === '') { - $firstDay = (int)$this->l->l('firstday', null); + $firstDayOfWeek = $this->config->getUserValue($uid, 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK, ''); + if ($firstDayOfWeek === '') { + $firstDayOfWeek = (int)$this->l->l('firstday', null); } else { - $firstDay = (int)$firstDay; + $firstDayOfWeek = (int)$firstDayOfWeek; } - $config = [ + $coreConfig = [ /** @deprecated 30.0.0 - use files capabilities instead */ 'blacklist_files_regex' => FileInfo::BLACKLIST_FILES_REGEX, /** @deprecated 30.0.0 - use files capabilities instead */ @@ -172,13 +205,16 @@ public function getConfig(): string { $shareManager = Server::get(IShareManager::class); - $array = [ + // Values in this map must already be serialized as JavaScript literals because + // they are concatenated directly into `var = ;` statements below. + /** @var array $legacyJsGlobals */ + $legacyJsGlobals = [ '_oc_debug' => $this->config->getSystemValue('debug', false) ? 'true' : 'false', - '_oc_isadmin' => $uid !== null && $this->groupManager->isAdmin($uid) ? 'true' : 'false', + '_oc_isadmin' => $isAdmin ? 'true' : 'false', 'backendAllowsPasswordConfirmation' => $userBackendAllowsPasswordConfirmation ? 'true' : 'false', - 'oc_dataURL' => is_string($dataLocation) ? '"' . $dataLocation . '"' : 'false', + 'oc_dataURL' => is_string($relativeDataDirectory) ? '"' . $relativeDataDirectory . '"' : 'false', '_oc_webroot' => '"' . \OC::$WEBROOT . '"', - '_oc_appswebroots' => str_replace('\\/', '/', json_encode($apps_paths)), // Ugly unescape slashes waiting for better solution + '_oc_appswebroots' => str_replace('\\/', '/', json_encode($appWebPaths)), // Ugly unescape slashes waiting for better solution 'datepickerFormatDate' => json_encode($this->l->l('jsdate', null)), 'nc_lastLogin' => $lastConfirmTimestamp, 'nc_pageLoad' => time(), @@ -237,13 +273,13 @@ public function getConfig(): string { $this->l->t('Nov.'), $this->l->t('Dec.') ]), - 'firstDay' => json_encode($firstDay), - '_oc_config' => json_encode($config), + 'firstDay' => json_encode($firstDayOfWeek), + '_oc_config' => json_encode($coreConfig), 'oc_appconfig' => json_encode([ 'core' => [ 'defaultExpireDateEnabled' => $defaultExpireDateEnabled, 'defaultExpireDate' => $defaultExpireDate, - 'defaultExpireDateEnforced' => $enforceDefaultExpireDate, + 'defaultExpireDateEnforced' => $defaultExpireDateEnforced, 'enforcePasswordForPublicLink' => Util::isPublicLinkPasswordRequired(), 'enableLinkPasswordByDefault' => $enableLinkPasswordByDefault, 'sharingDisabledForUser' => $shareManager->sharingDisabledForUser($uid), @@ -275,7 +311,7 @@ public function getConfig(): string { ]; if ($this->currentUser !== null) { - $array['oc_userconfig'] = json_encode([ + $legacyJsGlobals['oc_userconfig'] = json_encode([ 'avatar' => [ 'version' => (int)$this->config->getUserValue($uid, 'avatar', 'version', 0), 'generated' => $this->config->getUserValue($uid, 'avatar', 'generated', 'true') === 'true', @@ -283,29 +319,38 @@ public function getConfig(): string { ]); } + // Provide structured initial state for modern consumers in addition to the legacy JS globals below. $this->initialStateService->provideInitialState('core', 'projects_enabled', $this->config->getSystemValueBool('projects.enabled', false)); - - $this->initialStateService->provideInitialState('core', 'config', $config); + $this->initialStateService->provideInitialState('core', 'config', $coreConfig); $this->initialStateService->provideInitialState('core', 'capabilities', $capabilities); - // Allow hooks to modify the output values - \OC_Hook::emit('\OCP\Config', 'js', ['array' => &$array]); + // Allow legacy hooks to amend the generated JavaScript globals before rendering. + \OC_Hook::emit('\OCP\Config', 'js', ['array' => &$legacyJsGlobals]); - $result = ''; + $jsBootstrap = ''; - // Echo it - foreach ($array as $setting => $value) { - $result .= 'var ' . $setting . '=' . $value . ';' . PHP_EOL; + // Render the globals as legacy `var` assignments. + foreach ($legacyJsGlobals as $globalName => $serializedValue) { + $jsBootstrap .= 'var ' . $globalName . '=' . $serializedValue . ';' . PHP_EOL; } - return $result; + return $jsBootstrap; } + /** + * Returns whether the current session token allows password validation. + * + * If the token cannot be resolved from the current session, this method falls + * back to `true` to avoid incorrectly disabling password confirmation flows. + * + * FIXME: make private / declare @internal? + */ protected function canUserValidatePassword(): bool { try { - $token = $this->tokenProvider->getToken($this->session->getId()); + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); } catch (ExpiredTokenException|WipeTokenException|InvalidTokenException|SessionNotAvailableException) { - // actually we do not know, so we fall back to this statement + // If the session token cannot be inspected, keep password validation enabled by default. return true; } $scope = $token->getScopeAsArray(); diff --git a/tests/lib/Template/JSConfigHelperTest.php b/tests/lib/Template/JSConfigHelperTest.php new file mode 100644 index 0000000000000..43b1907eeec56 --- /dev/null +++ b/tests/lib/Template/JSConfigHelperTest.php @@ -0,0 +1,351 @@ +serverVersion = $this->createMock(ServerVersion::class); + $this->l10n = $this->createMock(IL10N::class); + $this->defaults = $this->createMock(Defaults::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->session = $this->createMock(ISession::class); + $this->config = $this->createMock(IConfig::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->iniWrapper = $this->createMock(IniGetWrapper::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->capabilitiesManager = $this->createMock(CapabilitiesManager::class); + $this->initialStateService = $this->createMock(IInitialStateService::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->filenameValidator = $this->createMock(FilenameValidator::class); + $this->shareManager = $this->createMock(IShareManager::class); + + $this->overwriteService(IShareManager::class, $this->shareManager); + + $this->serverVersion->method('getVersion')->willReturn([31, 0, 0, 0]); + $this->serverVersion->method('getVersionString')->willReturn('31.0.0'); + + $this->l10n->method('l')->willReturnCallback( + static fn (string $key, mixed $value = null): string => match ($key) { + 'firstday' => '1', + 'jsdate' => 'dd.MM.yyyy', + default => '', + } + ); + $this->l10n->method('t')->willReturnCallback(static fn (string $text): string => $text); + + $this->defaults->method('getEntity')->willReturn('Nextcloud'); + $this->defaults->method('getName')->willReturn('Nextcloud'); + $this->defaults->method('getProductName')->willReturn('Nextcloud'); + $this->defaults->method('getTitle')->willReturn('Nextcloud'); + $this->defaults->method('getBaseUrl')->willReturn('https://example.com'); + $this->defaults->method('getSyncClientUrl')->willReturn('https://example.com/desktop'); + $this->defaults->method('getDocBaseUrl')->willReturn('https://docs.example.com'); + $this->defaults->method('buildDocLinkToKey')->willReturn('https://docs.example.com/PLACEHOLDER'); + $this->defaults->method('getSlogan')->willReturn('A safe home for all your data'); + + $this->iniWrapper->method('getNumeric')->with('session.gc_maxlifetime')->willReturn(1440); + + $this->urlGenerator->method('linkToDocs') + ->with('user-sharing-federated') + ->willReturn('https://docs.example.com/user-sharing-federated'); + + $this->capabilitiesManager->method('getCapabilities') + ->with(false, true) + ->willReturn(['files' => ['chunked_upload' => true]]); + + $this->filenameValidator->method('getForbiddenCharacters') + ->willReturn(["\0", '/']); + + $this->shareManager->method('sharingDisabledForUser')->willReturn(false); + $this->shareManager->method('allowGroupSharing')->willReturn(true); + + $this->appConfig->method('getValueBool')->willReturnCallback( + static function (string $app, string $key, bool $default = false): bool { + return match ([$app, $key, $default]) { + ['core', 'shareapi_link_default_password', false] => false, + ['core', 'shareapi_default_expire_date', false] => false, + ['core', 'shareapi_enforce_expire_date', false] => false, + ['core', 'shareapi_allow_resharing', true] => true, + default => $default, + }; + } + ); + + $this->config->method('getAppValue')->willReturnCallback( + static function (string $app, string $key, string $default): string { + return match ([$app, $key, $default]) { + ['files_sharing', 'outgoing_server2server_share_enabled', 'yes'] => 'yes', + ['core', 'shareapi_default_internal_expire_date', 'no'] => 'no', + ['core', 'shareapi_default_remote_expire_date', 'no'] => 'no', + default => $default, + }; + } + ); + + $this->config->method('getSystemValue')->willReturnCallback( + static function (string $key, mixed $default = null): mixed { + return match ($key) { + 'datadirectory' => \OC::$SERVERROOT . '/data', + 'loglevel_frontend' => $default, + 'loglevel' => 2, + 'lost_password_link' => null, + 'htaccess.IgnoreFrontController' => false, + 'no_unsupported_browser_warning' => false, + 'session_keepalive' => true, + 'session_lifetime' => 900, + 'debug' => false, + 'auto_logout' => false, + default => $default, + }; + } + ); + + $this->config->method('getSystemValueBool')->willReturnCallback( + static function (string $key, bool $default = false): bool { + return match ([$key, $default]) { + ['projects.enabled', false] => false, + ['enable_non-accessible_features', true] => true, + default => $default, + }; + } + ); + + $this->config->method('getSystemValueInt')->willReturnCallback( + static function (string $key, int $default): int { + return $default; + } + ); + + $this->config->method('getUserValue')->willReturnCallback( + static function (?string $uid, string $app, string $key, string $default): string { + return match ([$uid, $app, $key]) { + ['alice', 'core', 'first_day_of_week'] => '', + ['alice', 'avatar', 'version'] => '7', + ['alice', 'avatar', 'generated'] => 'false', + default => $default, + }; + } + ); + + $this->session->method('get') + ->with('last-password-confirm') + ->willReturn(123456); + } + + public function testAnonymousUserUsesGlobalEnabledAppsAndPublishesInitialState(): void { + $this->appManager->expects(self::once()) + ->method('getEnabledApps') + ->willReturn(['files']); + + $this->appManager->expects(self::never()) + ->method('getEnabledAppsForUser'); + + $this->appManager->expects(self::once()) + ->method('getAppWebPath') + ->with('files') + ->willReturn('/apps/files'); + + $calls = []; + $this->initialStateService->expects(self::exactly(3)) + ->method('provideInitialState') + ->willReturnCallback(function (string $app, string $key, mixed $value) use (&$calls): void { + $calls[] = [$app, $key, $value]; + }); + + $helper = $this->createHelper(null); + + $result = $helper->getConfig(); + + self::assertSame('core', $calls[0][0]); + self::assertSame('projects_enabled', $calls[0][1]); + self::assertFalse($calls[0][2]); + self::assertSame(['core', 'config'], array_slice($calls[1], 0, 2)); + self::assertIsArray($calls[1][2]); + self::assertSame(['core', 'capabilities', ['files' => ['chunked_upload' => true]]], $calls[2]); + self::assertIsString($result); + self::assertStringContainsString('var _oc_appswebroots=', $result); + self::assertStringContainsString('/apps/files', $result); + self::assertStringContainsString('var _oc_config=', $result); + self::assertStringContainsString('var _theme=', $result); + self::assertStringNotContainsString('var oc_userconfig=', $result); + } + + public function testLoggedInUserUsesUserEnabledAppsAndIncludesUserConfig(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->appManager->expects(self::never()) + ->method('getEnabledApps'); + + $this->appManager->expects(self::once()) + ->method('getEnabledAppsForUser') + ->with($user) + ->willReturn(['files']); + + $this->appManager->expects(self::once()) + ->method('getAppWebPath') + ->with('files') + ->willReturn('/apps/files'); + + $this->groupManager->expects(self::once()) + ->method('isAdmin') + ->with('alice') + ->willReturn(false); + + $this->session->expects(self::once()) + ->method('getId') + ->willReturn('session-id'); + + $token = $this->createToken([]); + + $this->tokenProvider->expects(self::once()) + ->method('getToken') + ->with('session-id') + ->willReturn($token); + + $this->initialStateService->expects(self::exactly(3)) + ->method('provideInitialState'); + + $helper = $this->createHelper($user); + + $result = $helper->getConfig(); + + self::assertStringContainsString('var oc_userconfig=', $result); + self::assertStringContainsString('"version":7', $result); + self::assertStringContainsString('"generated":false', $result); + } + + public function testTokenLookupExceptionFallsBackToPasswordConfirmationEnabled(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $user->method('getBackend')->willReturn(new class { + }); + $user->method('getBackendClassName')->willReturn('some_backend'); + + $this->appManager->method('getEnabledAppsForUser') + ->with($user) + ->willReturn([]); + + $this->groupManager->method('isAdmin') + ->with('alice') + ->willReturn(false); + + $this->session->expects(self::once()) + ->method('getId') + ->willReturn('session-id'); + + $this->tokenProvider->expects(self::once()) + ->method('getToken') + ->with('session-id') + ->willThrowException(new ExpiredTokenException('expired')); + + $helper = $this->createHelper($user); + + $result = $helper->getConfig(); + + self::assertStringContainsString('var backendAllowsPasswordConfirmation=true;', $result); + } + + public function testMissingAppPathIsSerializedAsFalse(): void { + $this->appManager->expects(self::once()) + ->method('getEnabledApps') + ->willReturn(['files', 'broken']); + + $this->appManager->expects(self::exactly(2)) + ->method('getAppWebPath') + ->willReturnCallback(static function (string $app): string { + return match ($app) { + 'files' => '/apps/files', + 'broken' => throw new AppPathNotFoundException('broken'), + }; + }); + + $helper = $this->createHelper(null); + + $result = $helper->getConfig(); + + self::assertStringContainsString('"files":"/apps/files"', $result); + self::assertStringContainsString('"broken":false', $result); + } + + private function createHelper(?IUser $currentUser): JSConfigHelper { + return new JSConfigHelper( + $this->serverVersion, + $this->l10n, + $this->defaults, + $this->appManager, + $this->session, + $currentUser, + $this->config, + $this->appConfig, + $this->groupManager, + $this->iniWrapper, + $this->urlGenerator, + $this->capabilitiesManager, + $this->initialStateService, + $this->tokenProvider, + $this->filenameValidator, + ); + } + + /** + * Creates an IToken mock exposing the requested scope array. + */ + private function createToken(array $scope): IToken&MockObject { + $token = $this->createMock(IToken::class); + $token->method('getScopeAsArray') + ->willReturn($scope); + + return $token; + } +}