diff options
Diffstat (limited to 'apps/theming/lib')
29 files changed, 882 insertions, 468 deletions
diff --git a/apps/theming/lib/AppInfo/Application.php b/apps/theming/lib/AppInfo/Application.php index c031243361f..d08a1903265 100644 --- a/apps/theming/lib/AppInfo/Application.php +++ b/apps/theming/lib/AppInfo/Application.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/theming/lib/Capabilities.php b/apps/theming/lib/Capabilities.php index bd3dd45741d..d5d6e415e75 100644 --- a/apps/theming/lib/Capabilities.php +++ b/apps/theming/lib/Capabilities.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -20,32 +21,19 @@ use OCP\IUserSession; */ class Capabilities implements IPublicCapability { - /** @var ThemingDefaults */ - protected $theming; - - /** @var Util */ - protected $util; - - /** @var IURLGenerator */ - protected $url; - - /** @var IConfig */ - protected $config; - - protected IUserSession $userSession; - /** * @param ThemingDefaults $theming * @param Util $util * @param IURLGenerator $url * @param IConfig $config */ - public function __construct(ThemingDefaults $theming, Util $util, IURLGenerator $url, IConfig $config, IUserSession $userSession) { - $this->theming = $theming; - $this->util = $util; - $this->url = $url; - $this->config = $config; - $this->userSession = $userSession; + public function __construct( + protected ThemingDefaults $theming, + protected Util $util, + protected IURLGenerator $url, + protected IConfig $config, + protected IUserSession $userSession, + ) { } /** @@ -54,6 +42,7 @@ class Capabilities implements IPublicCapability { * @return array{ * theming: array{ * name: string, + * productName: string, * url: string, * slogan: string, * color: string, @@ -107,6 +96,7 @@ class Capabilities implements IPublicCapability { return [ 'theming' => [ 'name' => $this->theming->getName(), + 'productName' => $this->theming->getProductName(), 'url' => $this->theming->getBaseUrl(), 'slogan' => $this->theming->getSlogan(), 'color' => $color, diff --git a/apps/theming/lib/Command/UpdateConfig.php b/apps/theming/lib/Command/UpdateConfig.php index d3131fcb58f..6236f866445 100644 --- a/apps/theming/lib/Command/UpdateConfig.php +++ b/apps/theming/lib/Command/UpdateConfig.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -16,19 +17,15 @@ use Symfony\Component\Console\Output\OutputInterface; class UpdateConfig extends Command { public const SUPPORTED_KEYS = [ - 'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'primary_color', 'disable-user-theming' + 'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'primary_color', 'background_color', 'disable-user-theming' ]; - private $themingDefaults; - private $imageManager; - private $config; - - public function __construct(ThemingDefaults $themingDefaults, ImageManager $imageManager, IConfig $config) { + public function __construct( + private ThemingDefaults $themingDefaults, + private ImageManager $imageManager, + private IConfig $config, + ) { parent::__construct(); - - $this->themingDefaults = $themingDefaults; - $this->imageManager = $imageManager; - $this->config = $config; } protected function configure() { @@ -38,8 +35,8 @@ class UpdateConfig extends Command { ->addArgument( 'key', InputArgument::OPTIONAL, - 'Key to update the theming app configuration (leave empty to get a list of all configured values)' . PHP_EOL . - 'One of: ' . implode(', ', self::SUPPORTED_KEYS) + 'Key to update the theming app configuration (leave empty to get a list of all configured values)' . PHP_EOL + . 'One of: ' . implode(', ', self::SUPPORTED_KEYS) ) ->addArgument( 'value', @@ -111,9 +108,9 @@ class UpdateConfig extends Command { $value = $this->imageManager->updateImage($key, $value); $key = $key . 'Mime'; } - + if ($key === 'color') { - $output->writeln('<warning>Using "color" is depreacted, use "primary_color" instead'); + $output->writeln('<comment>Using "color" is deprecated, use "primary_color" instead</comment>'); $key = 'primary_color'; } diff --git a/apps/theming/lib/Controller/IconController.php b/apps/theming/lib/Controller/IconController.php index acbb24e0883..e82faf78a79 100644 --- a/apps/theming/lib/Controller/IconController.php +++ b/apps/theming/lib/Controller/IconController.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -12,6 +13,9 @@ use OCA\Theming\ThemingDefaults; use OCP\App\IAppManager; 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\PublicPage; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\NotFoundResponse; @@ -20,39 +24,23 @@ use OCP\Files\NotFoundException; use OCP\IRequest; class IconController extends Controller { - /** @var ThemingDefaults */ - private $themingDefaults; - /** @var IconBuilder */ - private $iconBuilder; - /** @var ImageManager */ - private $imageManager; /** @var FileAccessHelper */ private $fileAccessHelper; - /** @var IAppManager */ - private $appManager; public function __construct( $appName, IRequest $request, - ThemingDefaults $themingDefaults, - IconBuilder $iconBuilder, - ImageManager $imageManager, + private ThemingDefaults $themingDefaults, + private IconBuilder $iconBuilder, + private ImageManager $imageManager, FileAccessHelper $fileAccessHelper, - IAppManager $appManager + private IAppManager $appManager, ) { parent::__construct($appName, $request); - - $this->themingDefaults = $themingDefaults; - $this->iconBuilder = $iconBuilder; - $this->imageManager = $imageManager; $this->fileAccessHelper = $fileAccessHelper; - $this->appManager = $appManager; } /** - * @PublicPage - * @NoCSRFRequired - * * Get a themed icon * * @param string $app ID of the app @@ -63,6 +51,9 @@ class IconController extends Controller { * 200: Themed icon returned * 404: Themed icon not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getThemedIcon(string $app, string $image): Response { if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { $app = 'core'; @@ -87,9 +78,6 @@ class IconController extends Controller { /** * Return a 32x32 favicon as png * - * @PublicPage - * @NoCSRFRequired - * * @param string $app ID of the app * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * @throws \Exception @@ -97,6 +85,9 @@ class IconController extends Controller { * 200: Favicon returned * 404: Favicon not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getFavicon(string $app = 'core'): Response { if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { $app = 'core'; @@ -133,9 +124,6 @@ class IconController extends Controller { /** * Return a 512x512 icon for touch devices * - * @PublicPage - * @NoCSRFRequired - * * @param string $app ID of the app * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'|'image/png'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * @throws \Exception @@ -143,6 +131,9 @@ class IconController extends Controller { * 200: Touch icon returned * 404: Touch icon not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getTouchIcon(string $app = 'core'): Response { if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { $app = 'core'; diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php index 8fdb020e614..e5cee254fe8 100644 --- a/apps/theming/lib/Controller/ThemingController.php +++ b/apps/theming/lib/Controller/ThemingController.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -8,22 +9,30 @@ namespace OCA\Theming\Controller; use InvalidArgumentException; use OCA\Theming\ImageManager; use OCA\Theming\Service\ThemesService; +use OCA\Theming\Settings\Admin; use OCA\Theming\ThemingDefaults; use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Services\IAppConfig; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IConfig; use OCP\IL10N; +use OCP\INavigationManager; use OCP\IRequest; use OCP\IURLGenerator; -use ScssPhp\ScssPhp\Compiler; /** * Class ThemingController @@ -35,46 +44,33 @@ use ScssPhp\ScssPhp\Compiler; class ThemingController extends Controller { public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon']; - private ThemingDefaults $themingDefaults; - private IL10N $l10n; - private IConfig $config; - private IURLGenerator $urlGenerator; - private IAppManager $appManager; - private ImageManager $imageManager; - private ThemesService $themesService; - public function __construct( - $appName, + string $appName, IRequest $request, - IConfig $config, - ThemingDefaults $themingDefaults, - IL10N $l, - IURLGenerator $urlGenerator, - IAppManager $appManager, - ImageManager $imageManager, - ThemesService $themesService + private IConfig $config, + private IAppConfig $appConfig, + private ThemingDefaults $themingDefaults, + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IAppManager $appManager, + private ImageManager $imageManager, + private ThemesService $themesService, + private INavigationManager $navigationManager, ) { parent::__construct($appName, $request); - - $this->themingDefaults = $themingDefaults; - $this->l10n = $l; - $this->config = $config; - $this->urlGenerator = $urlGenerator; - $this->appManager = $appManager; - $this->imageManager = $imageManager; - $this->themesService = $themesService; } /** - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * @param string $setting * @param string $value * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function updateStylesheet($setting, $value) { $value = trim($value); $error = null; + $saved = false; switch ($setting) { case 'name': if (strlen($value) > 250) { @@ -113,16 +109,25 @@ class ThemingController extends Controller { case 'primary_color': if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { $error = $this->l10n->t('The given color is invalid'); + } else { + $this->appConfig->setAppValueString('primary_color', $value); + $saved = true; } break; case 'background_color': if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { $error = $this->l10n->t('The given color is invalid'); + } else { + $this->appConfig->setAppValueString('background_color', $value); + $saved = true; } break; case 'disable-user-theming': - if ($value !== 'yes' && $value !== 'no') { + if (!in_array($value, ['yes', 'true', 'no', 'false'])) { $error = $this->l10n->t('Disable-user-theming should be true or false'); + } else { + $this->appConfig->setAppValueBool('disable-user-theming', $value === 'yes' || $value === 'true'); + $saved = true; } break; } @@ -135,7 +140,9 @@ class ThemingController extends Controller { ], Http::STATUS_BAD_REQUEST); } - $this->themingDefaults->set($setting, $value); + if (!$saved) { + $this->themingDefaults->set($setting, $value); + } return new DataResponse([ 'data' => [ @@ -146,19 +153,19 @@ class ThemingController extends Controller { } /** - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * @param string $setting * @param mixed $value * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function updateAppMenu($setting, $value) { $error = null; switch ($setting) { case 'defaultApps': if (is_array($value)) { try { - $this->appManager->setDefaultApps($value); + $this->navigationManager->setDefaultEntryIds($value); } catch (InvalidArgumentException $e) { $error = $this->l10n->t('Invalid app given'); } @@ -187,18 +194,20 @@ class ThemingController extends Controller { } /** - * Check that a string is a valid http/https url + * Check that a string is a valid http/https url. + * Also validates that there is no way for XSS through HTML */ private function isValidUrl(string $url): bool { - return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) && - filter_var($url, FILTER_VALIDATE_URL) !== false); + return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) + && filter_var($url, FILTER_VALIDATE_URL) !== false) + && !str_contains($url, '"'); } /** - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function uploadImage(): DataResponse { $key = $this->request->getParam('key'); if (!in_array($key, self::VALID_UPLOAD_KEYS, true)) { @@ -262,8 +271,8 @@ class ThemingController extends Controller { return new DataResponse( [ - 'data' => - [ + 'data' + => [ 'name' => $name, 'url' => $this->imageManager->getImageUrl($key), 'message' => $this->l10n->t('Saved'), @@ -275,19 +284,19 @@ class ThemingController extends Controller { /** * Revert setting to default value - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * * @param string $setting setting which should be reverted * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function undo(string $setting): DataResponse { $value = $this->themingDefaults->undo($setting); return new DataResponse( [ - 'data' => - [ + 'data' + => [ 'value' => $value, 'message' => $this->l10n->t('Saved'), ], @@ -298,19 +307,19 @@ class ThemingController extends Controller { /** * Revert all theming settings to their default values - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function undoAll(): DataResponse { $this->themingDefaults->undoAll(); - $this->appManager->setDefaultApps([]); + $this->navigationManager->setDefaultEntryIds([]); return new DataResponse( [ - 'data' => - [ + 'data' + => [ 'message' => $this->l10n->t('Saved'), ], 'status' => 'success' @@ -319,8 +328,6 @@ class ThemingController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired * @NoSameSiteCookieRequired * * Get an image @@ -333,6 +340,9 @@ class ThemingController extends Controller { * 200: Image returned * 404: Image not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getImage(string $key, bool $useSvg = true) { try { $file = $this->imageManager->getImage($key, $useSvg); @@ -341,7 +351,7 @@ class ThemingController extends Controller { } $response = new FileDisplayResponse($file); - $csp = new Http\ContentSecurityPolicy(); + $csp = new ContentSecurityPolicy(); $csp->allowInlineStyle(); $response->setContentSecurityPolicy($csp); $response->cacheFor(3600); @@ -356,8 +366,6 @@ class ThemingController extends Controller { } /** - * @NoCSRFRequired - * @PublicPage * @NoSameSiteCookieRequired * @NoTwoFactorRequired * @@ -371,6 +379,9 @@ class ThemingController extends Controller { * 200: Stylesheet returned * 404: Theme not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) { $themes = $this->themesService->getThemes(); if (!in_array($themeId, array_keys($themes))) { @@ -391,10 +402,17 @@ class ThemingController extends Controller { $css = ":root { $variables } " . $customCss; } else { // If not set, we'll rely on the body class - $compiler = new Compiler(); - $compiledCss = $compiler->compileString("[data-theme-$themeId] { $variables $customCss }"); - $css = $compiledCss->getCss(); - ; + // We need to separate @-rules from normal selectors, as they can't be nested + // This is a replacement for the SCSS compiler that did this automatically before f1448fcf0777db7d4254cb0a3ef94d63be9f7a24 + // We need a better way to handle this, but for now we just remove comments and split the at-rules + // from the rest of the CSS. + $customCssWithoutComments = preg_replace('!/\*.*?\*/!s', '', $customCss); + $customCssWithoutComments = preg_replace('!//.*!', '', $customCssWithoutComments); + preg_match_all('/(@[^{]+{(?:[^{}]*|(?R))*})/', $customCssWithoutComments, $atRules); + $atRulesCss = implode('', $atRules[0]); + $scopedCss = preg_replace('/(@[^{]+{(?:[^{}]*|(?R))*})/', '', $customCssWithoutComments); + + $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss }"; } try { @@ -407,19 +425,19 @@ class ThemingController extends Controller { } /** - * @NoCSRFRequired - * @PublicPage - * @BruteForceProtection(action=manifest) - * * Get the manifest for an app * * @param string $app ID of the app * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type - * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: array{src: non-empty-string, type: string, sizes: string}[], display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}> + * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}> * * 200: Manifest returned * 404: App not found */ + #[PublicPage] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'manifest')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getManifest(string $app): JSONResponse { $cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0'); if ($app === 'core' || $app === 'settings') { @@ -455,8 +473,8 @@ class ThemingController extends Controller { 'theme_color' => $this->themingDefaults->getColorPrimary(), 'background_color' => $this->themingDefaults->getColorPrimary(), 'description' => $description, - 'icons' => - [ + 'icons' + => [ [ 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => $app]) . '?v=' . $cacheBusterValue, @@ -470,7 +488,8 @@ class ThemingController extends Controller { 'sizes' => '16x16' ] ], - 'display' => 'standalone' + 'display_override' => [$this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'minimal-ui' : ''], + 'display' => $this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'standalone' : 'browser' ]; $response = new JSONResponse($responseJS); $response->cacheFor(3600); diff --git a/apps/theming/lib/Controller/UserThemeController.php b/apps/theming/lib/Controller/UserThemeController.php index 33c6c5c8a3b..770f2ca922f 100644 --- a/apps/theming/lib/Controller/UserThemeController.php +++ b/apps/theming/lib/Controller/UserThemeController.php @@ -15,10 +15,14 @@ use OCA\Theming\Service\BackgroundService; use OCA\Theming\Service\ThemesService; use OCA\Theming\ThemingDefaults; 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\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCSController; @@ -34,23 +38,16 @@ class UserThemeController extends OCSController { protected ?string $userId = null; - private IConfig $config; - private ThemesService $themesService; - private ThemingDefaults $themingDefaults; - private BackgroundService $backgroundService; - - public function __construct(string $appName, + public function __construct( + string $appName, IRequest $request, - IConfig $config, + private IConfig $config, IUserSession $userSession, - ThemesService $themesService, - ThemingDefaults $themingDefaults, - BackgroundService $backgroundService) { + private ThemesService $themesService, + private ThemingDefaults $themingDefaults, + private BackgroundService $backgroundService, + ) { parent::__construct($appName, $request); - $this->config = $config; - $this->themesService = $themesService; - $this->themingDefaults = $themingDefaults; - $this->backgroundService = $backgroundService; $user = $userSession->getUser(); if ($user !== null) { @@ -59,17 +56,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Enable theme * * @param string $themeId the theme ID - * @return DataResponse<Http::STATUS_OK, array<empty>, array{}> + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> * @throws OCSBadRequestException Enabling theme is not possible * @throws PreConditionNotMetException * * 200: Theme enabled successfully */ + #[NoAdminRequired] public function enableTheme(string $themeId): DataResponse { $theme = $this->validateTheme($themeId); @@ -79,17 +75,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Disable theme * * @param string $themeId the theme ID - * @return DataResponse<Http::STATUS_OK, array<empty>, array{}> + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> * @throws OCSBadRequestException Disabling theme is not possible * @throws PreConditionNotMetException * * 200: Theme disabled successfully */ + #[NoAdminRequired] public function disableTheme(string $themeId): DataResponse { $theme = $this->validateTheme($themeId); @@ -128,16 +123,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * @NoCSRFRequired - * * Get the background image * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * * 200: Background image returned * 404: Background image not found */ - public function getBackground(): Http\Response { + #[NoAdminRequired] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getBackground(): Response { $file = $this->backgroundService->getBackground(); if ($file !== null) { $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]); @@ -148,14 +143,13 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Delete the background * * @return JSONResponse<Http::STATUS_OK, ThemingBackground, array{}> * * 200: Background deleted successfully */ + #[NoAdminRequired] public function deleteBackground(): JSONResponse { $currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0'); $this->backgroundService->deleteBackgroundImage(); @@ -168,8 +162,6 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Set the background * * @param string $type Type of background @@ -180,6 +172,7 @@ class UserThemeController extends OCSController { * 200: Background set successfully * 400: Setting background is not possible */ + #[NoAdminRequired] public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', ?string $color = null): JSONResponse { $currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0'); diff --git a/apps/theming/lib/IconBuilder.php b/apps/theming/lib/IconBuilder.php index a10e7442c46..63f4559970d 100644 --- a/apps/theming/lib/IconBuilder.php +++ b/apps/theming/lib/IconBuilder.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -10,13 +11,6 @@ use ImagickPixel; use OCP\Files\SimpleFS\ISimpleFile; class IconBuilder { - /** @var ThemingDefaults */ - private $themingDefaults; - /** @var Util */ - private $util; - /** @var ImageManager */ - private $imageManager; - /** * IconBuilder constructor. * @@ -25,13 +19,10 @@ class IconBuilder { * @param ImageManager $imageManager */ public function __construct( - ThemingDefaults $themingDefaults, - Util $util, - ImageManager $imageManager + private ThemingDefaults $themingDefaults, + private Util $util, + private ImageManager $imageManager, ) { - $this->themingDefaults = $themingDefaults; - $this->util = $util; - $this->imageManager = $imageManager; } /** @@ -44,12 +35,12 @@ class IconBuilder { } try { $favicon = new Imagick(); - $favicon->setFormat("ico"); + $favicon->setFormat('ico'); $icon = $this->renderAppIcon($app, 128); if ($icon === false) { return false; } - $icon->setImageFormat("png32"); + $icon->setImageFormat('png32'); $clone = clone $icon; $clone->scaleImage(16, 0); @@ -87,7 +78,7 @@ class IconBuilder { if ($icon === false) { return false; } - $icon->setImageFormat("png32"); + $icon->setImageFormat('png32'); $data = $icon->getImageBlob(); $icon->destroy(); return $data; @@ -100,58 +91,53 @@ class IconBuilder { * Render app icon on themed background color * fallback to logo * - * @param $app string app name - * @param $size int size of the icon in px + * @param string $app app name + * @param int $size size of the icon in px * @return Imagick|false */ public function renderAppIcon($app, $size) { $appIcon = $this->util->getAppIcon($app); - if ($appIcon === false) { - return false; - } if ($appIcon instanceof ISimpleFile) { $appIconContent = $appIcon->getContent(); $mime = $appIcon->getMimeType(); + } elseif (!file_exists($appIcon)) { + return false; } else { $appIconContent = file_get_contents($appIcon); $mime = mime_content_type($appIcon); } - if ($appIconContent === false || $appIconContent === "") { + if ($appIconContent === false || $appIconContent === '') { return false; } $color = $this->themingDefaults->getColorPrimary(); // generate background image with rounded corners - $background = '<?xml version="1.0" encoding="UTF-8"?>' . - '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:cc="http://creativecommons.org/ns#" width="512" height="512" xmlns:xlink="http://www.w3.org/1999/xlink">' . - '<rect x="0" y="0" rx="100" ry="100" width="512" height="512" style="fill:' . $color . ';" />' . - '</svg>'; + $cornerRadius = 0.2 * $size; + $background = '<?xml version="1.0" encoding="UTF-8"?>' + . '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:cc="http://creativecommons.org/ns#" width="' . $size . '" height="' . $size . '" xmlns:xlink="http://www.w3.org/1999/xlink">' + . '<rect x="0" y="0" rx="' . $cornerRadius . '" ry="' . $cornerRadius . '" width="' . $size . '" height="' . $size . '" style="fill:' . $color . ';" />' + . '</svg>'; // resize svg magic as this seems broken in Imagemagick - if ($mime === "image/svg+xml" || substr($appIconContent, 0, 4) === "<svg") { - if (substr($appIconContent, 0, 5) !== "<?xml") { - $svg = "<?xml version=\"1.0\"?>".$appIconContent; + if ($mime === 'image/svg+xml' || substr($appIconContent, 0, 4) === '<svg') { + if (substr($appIconContent, 0, 5) !== '<?xml') { + $svg = '<?xml version="1.0"?>' . $appIconContent; } else { $svg = $appIconContent; } $tmp = new Imagick(); + $tmp->setBackgroundColor(new ImagickPixel('transparent')); + $tmp->setResolution(72, 72); $tmp->readImageBlob($svg); $x = $tmp->getImageWidth(); $y = $tmp->getImageHeight(); - $res = $tmp->getImageResolution(); $tmp->destroy(); - if ($x > $y) { - $max = $x; - } else { - $max = $y; - } - // convert svg to resized image $appIconFile = new Imagick(); - $resX = (int)(512 * $res['x'] / $max * 2.53); - $resY = (int)(512 * $res['y'] / $max * 2.53); + $resX = (int)(72 * $size / $x); + $resY = (int)(72 * $size / $y); $appIconFile->setResolution($resX, $resY); $appIconFile->setBackgroundColor(new ImagickPixel('transparent')); $appIconFile->readImageBlob($svg); @@ -162,35 +148,34 @@ class IconBuilder { */ if ($this->util->isBrightColor($color) && !$appIcon instanceof ISimpleFile - && $app !== "core" + && $app !== 'core' ) { $appIconFile->negateImage(false); } - $appIconFile->scaleImage(512, 512, true); } else { $appIconFile = new Imagick(); $appIconFile->setBackgroundColor(new ImagickPixel('transparent')); $appIconFile->readImageBlob($appIconContent); - $appIconFile->scaleImage(512, 512, true); } // offset for icon positioning - $border_w = (int)($appIconFile->getImageWidth() * 0.05); - $border_h = (int)($appIconFile->getImageHeight() * 0.05); + $padding = 0.15; + $border_w = (int)($appIconFile->getImageWidth() * $padding); + $border_h = (int)($appIconFile->getImageHeight() * $padding); $innerWidth = ($appIconFile->getImageWidth() - $border_w * 2); $innerHeight = ($appIconFile->getImageHeight() - $border_h * 2); $appIconFile->adaptiveResizeImage($innerWidth, $innerHeight); // center icon - $offset_w = (int)(512 / 2 - $innerWidth / 2); - $offset_h = (int)(512 / 2 - $innerHeight / 2); + $offset_w = (int)($size / 2 - $innerWidth / 2); + $offset_h = (int)($size / 2 - $innerHeight / 2); $finalIconFile = new Imagick(); $finalIconFile->setBackgroundColor(new ImagickPixel('transparent')); $finalIconFile->readImageBlob($background); $finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT); - $finalIconFile->setImageArtifact('compose:args', "1,0,-0.5,0.5"); + $finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5'); $finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h); $finalIconFile->setImageFormat('png24'); - if (defined("Imagick::INTERPOLATE_BICUBIC") === true) { + if (defined('Imagick::INTERPOLATE_BICUBIC') === true) { $filter = Imagick::INTERPOLATE_BICUBIC; } else { $filter = Imagick::FILTER_LANCZOS; @@ -202,17 +187,17 @@ class IconBuilder { } /** - * @param $app string app name - * @param $image string relative path to svg file in app directory + * @param string $app app name + * @param string $image relative path to svg file in app directory * @return string|false content of a colorized svg file */ public function colorSvg($app, $image) { $imageFile = $this->util->getAppImage($app, $image); - if ($imageFile === false || $imageFile === "") { + if ($imageFile === false || $imageFile === '' || !file_exists($imageFile)) { return false; } $svg = file_get_contents($imageFile); - if ($svg !== false && $svg !== "") { + if ($svg !== false && $svg !== '') { $color = $this->util->elementColor($this->themingDefaults->getColorPrimary()); $svg = $this->util->colorizeSvg($svg, $color); return $svg; diff --git a/apps/theming/lib/ImageManager.php b/apps/theming/lib/ImageManager.php index c44d6de128b..309bf192bc3 100644 --- a/apps/theming/lib/ImageManager.php +++ b/apps/theming/lib/ImageManager.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -42,6 +43,9 @@ class ImageManager { $cacheBusterCounter = $this->config->getAppValue(Application::APP_ID, 'cachebuster', '0'); if ($this->hasImage($key)) { return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => $key ]) . '?v=' . $cacheBusterCounter; + } elseif ($key === 'backgroundDark' && $this->hasImage('background')) { + // Fall back to light variant + return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => 'background' ]) . '?v=' . $cacheBusterCounter; } switch ($key) { @@ -49,11 +53,16 @@ class ImageManager { case 'logoheader': case 'favicon': return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter; + case 'backgroundDark': case 'background': // Removing the background defines its mime as 'backgroundColor' $mimeSetting = $this->config->getAppValue('theming', 'backgroundMime', ''); if ($mimeSetting !== 'backgroundColor') { - return $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE); + $image = BackgroundService::DEFAULT_BACKGROUND_IMAGE; + if ($key === 'backgroundDark') { + $image = BackgroundService::SHIPPED_BACKGROUNDS[$image]['dark_variant'] ?? $image; + } + return $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$image"); } } return ''; @@ -144,7 +153,7 @@ class ImageManager { * * @param string $filename * @throws NotFoundException - * @return \OCP\Files\SimpleFS\ISimpleFile + * @return ISimpleFile * @throws NotPermittedException */ public function getCachedImage(string $filename): ISimpleFile { @@ -157,7 +166,7 @@ class ImageManager { * * @param string $filename * @param string $data - * @return \OCP\Files\SimpleFS\ISimpleFile + * @return ISimpleFile * @throws NotFoundException * @throws NotPermittedException */ @@ -186,6 +195,10 @@ class ImageManager { } catch (NotFoundException $e) { } catch (NotPermittedException $e) { } + + if ($key === 'logo') { + $this->config->deleteAppValue('theming', 'logoDimensions'); + } } public function updateImage(string $key, string $tmpFile): string { @@ -258,6 +271,25 @@ class ImageManager { $target->putContent(file_get_contents($tmpFile)); + if ($key === 'logo') { + $content = file_get_contents($tmpFile); + $newImage = @imagecreatefromstring($content); + if ($newImage !== false) { + $this->config->setAppValue('theming', 'logoDimensions', imagesx($newImage) . 'x' . imagesy($newImage)); + } elseif (str_starts_with($detectedMimeType, 'image/svg')) { + $matched = preg_match('/viewbox=["\']\d* \d* (\d*\.?\d*) (\d*\.?\d*)["\']/i', $content, $matches); + if ($matched) { + $this->config->setAppValue('theming', 'logoDimensions', $matches[1] . 'x' . $matches[2]); + } else { + $this->logger->warning('Could not read logo image dimensions to optimize for mail header'); + $this->config->deleteAppValue('theming', 'logoDimensions'); + } + } else { + $this->logger->warning('Could not read logo image dimensions to optimize for mail header'); + $this->config->deleteAppValue('theming', 'logoDimensions'); + } + } + return $detectedMimeType; } diff --git a/apps/theming/lib/Jobs/MigrateBackgroundImages.php b/apps/theming/lib/Jobs/MigrateBackgroundImages.php index aff13fc2910..62e58f5e722 100644 --- a/apps/theming/lib/Jobs/MigrateBackgroundImages.php +++ b/apps/theming/lib/Jobs/MigrateBackgroundImages.php @@ -30,31 +30,20 @@ class MigrateBackgroundImages extends QueuedJob { // will be saved in appdata/theming/global/ protected const STATE_FILE_NAME = '25_dashboard_to_theming_migration_users.json'; - private IAppDataFactory $appDataFactory; - private IJobList $jobList; - private IDBConnection $dbc; - private IAppData $appData; - private LoggerInterface $logger; - public function __construct( ITimeFactory $time, - IAppDataFactory $appDataFactory, - IJobList $jobList, - IDBConnection $dbc, - IAppData $appData, - LoggerInterface $logger + private IAppDataFactory $appDataFactory, + private IJobList $jobList, + private IDBConnection $dbc, + private IAppData $appData, + private LoggerInterface $logger, ) { parent::__construct($time); - $this->appDataFactory = $appDataFactory; - $this->jobList = $jobList; - $this->dbc = $dbc; - $this->appData = $appData; - $this->logger = $logger; } protected function run(mixed $argument): void { if (!is_array($argument) || !isset($argument['stage'])) { - throw new \Exception('Job '.self::class.' called with wrong argument'); + throw new \Exception('Job ' . self::class . ' called with wrong argument'); } switch ($argument['stage']) { diff --git a/apps/theming/lib/Jobs/RestoreBackgroundImageColor.php b/apps/theming/lib/Jobs/RestoreBackgroundImageColor.php new file mode 100644 index 00000000000..42662dacef2 --- /dev/null +++ b/apps/theming/lib/Jobs/RestoreBackgroundImageColor.php @@ -0,0 +1,205 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming\Jobs; + +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Service\BackgroundService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IConfig; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +class RestoreBackgroundImageColor extends QueuedJob { + + public const STAGE_PREPARE = 'prepare'; + public const STAGE_EXECUTE = 'execute'; + // will be saved in appdata/theming/global/ + protected const STATE_FILE_NAME = '30_background_image_color_restoration.json'; + + public function __construct( + ITimeFactory $time, + private IConfig $config, + private IAppData $appData, + private IJobList $jobList, + private IDBConnection $dbc, + private LoggerInterface $logger, + private BackgroundService $service, + ) { + parent::__construct($time); + } + + protected function run(mixed $argument): void { + if (!is_array($argument) || !isset($argument['stage'])) { + throw new \Exception('Job ' . self::class . ' called with wrong argument'); + } + + switch ($argument['stage']) { + case self::STAGE_PREPARE: + $this->runPreparation(); + break; + case self::STAGE_EXECUTE: + $this->runMigration(); + break; + default: + break; + } + } + + protected function runPreparation(): void { + try { + $qb = $this->dbc->getQueryBuilder(); + $qb2 = $this->dbc->getQueryBuilder(); + + $innerSQL = $qb2->select('userid') + ->from('preferences') + ->where($qb2->expr()->eq('configkey', $qb->createNamedParameter('background_color'))); + + // Get those users, that have a background_image set - not the default, but no background_color. + $result = $qb->selectDistinct('a.userid') + ->from('preferences', 'a') + ->leftJoin('a', $qb->createFunction('(' . $innerSQL->getSQL() . ')'), 'b', 'a.userid = b.userid') + ->where($qb2->expr()->eq('a.configkey', $qb->createNamedParameter('background_image'))) + ->andWhere($qb2->expr()->neq('a.configvalue', $qb->createNamedParameter(BackgroundService::BACKGROUND_DEFAULT))) + ->andWhere($qb2->expr()->isNull('b.userid')) + ->executeQuery(); + + $userIds = $result->fetchAll(\PDO::FETCH_COLUMN); + $this->logger->info('Prepare to restore background information for {users} users', ['users' => count($userIds)]); + $this->storeUserIdsToProcess($userIds); + } catch (\Throwable $t) { + $this->jobList->add(self::class, ['stage' => self::STAGE_PREPARE]); + throw $t; + } + $this->jobList->add(self::class, ['stage' => self::STAGE_EXECUTE]); + } + + /** + * @throws NotPermittedException + * @throws NotFoundException + */ + protected function runMigration(): void { + $allUserIds = $this->readUserIdsToProcess(); + $notSoFastMode = count($allUserIds) > 1000; + + $userIds = array_slice($allUserIds, 0, 1000); + foreach ($userIds as $userId) { + $backgroundColor = $this->config->getUserValue($userId, Application::APP_ID, 'background_color'); + if ($backgroundColor !== '') { + continue; + } + + $background = $this->config->getUserValue($userId, Application::APP_ID, 'background_image'); + switch ($background) { + case BackgroundService::BACKGROUND_DEFAULT: + $this->service->setDefaultBackground($userId); + break; + case BackgroundService::BACKGROUND_COLOR: + break; + case BackgroundService::BACKGROUND_CUSTOM: + $this->service->recalculateMeanColor($userId); + break; + default: + // shipped backgrounds + // do not alter primary color + $primary = $this->config->getUserValue($userId, Application::APP_ID, 'primary_color'); + if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$background])) { + $this->service->setShippedBackground($background, $userId); + } else { + $this->service->setDefaultBackground($userId); + } + // Restore primary + if ($primary !== '') { + $this->config->setUserValue($userId, Application::APP_ID, 'primary_color', $primary); + } + } + } + + if ($notSoFastMode) { + $remainingUserIds = array_slice($allUserIds, 1000); + $this->storeUserIdsToProcess($remainingUserIds); + $this->jobList->add(self::class, ['stage' => self::STAGE_EXECUTE]); + } else { + $this->deleteStateFile(); + } + } + + /** + * @throws NotPermittedException + * @throws NotFoundException + */ + protected function readUserIdsToProcess(): array { + $globalFolder = $this->appData->getFolder('global'); + if ($globalFolder->fileExists(self::STATE_FILE_NAME)) { + $file = $globalFolder->getFile(self::STATE_FILE_NAME); + try { + $userIds = \json_decode($file->getContent(), true); + } catch (NotFoundException $e) { + $userIds = []; + } + if ($userIds === null) { + $userIds = []; + } + } else { + $userIds = []; + } + return $userIds; + } + + /** + * @throws NotFoundException + */ + protected function storeUserIdsToProcess(array $userIds): void { + $storableUserIds = \json_encode($userIds); + $globalFolder = $this->appData->getFolder('global'); + try { + if ($globalFolder->fileExists(self::STATE_FILE_NAME)) { + $file = $globalFolder->getFile(self::STATE_FILE_NAME); + } else { + $file = $globalFolder->newFile(self::STATE_FILE_NAME); + } + $file->putContent($storableUserIds); + } catch (NotFoundException $e) { + } catch (NotPermittedException $e) { + $this->logger->warning('Lacking permissions to create {file}', + [ + 'app' => 'theming', + 'file' => self::STATE_FILE_NAME, + 'exception' => $e, + ] + ); + } + } + + /** + * @throws NotFoundException + */ + protected function deleteStateFile(): void { + $globalFolder = $this->appData->getFolder('global'); + if ($globalFolder->fileExists(self::STATE_FILE_NAME)) { + $file = $globalFolder->getFile(self::STATE_FILE_NAME); + try { + $file->delete(); + } catch (NotPermittedException $e) { + $this->logger->info('Could not delete {file} due to permissions. It is safe to delete manually inside data -> appdata -> theming -> global.', + [ + 'app' => 'theming', + 'file' => $file->getName(), + 'exception' => $e, + ] + ); + } + } + } +} diff --git a/apps/theming/lib/Listener/BeforePreferenceListener.php b/apps/theming/lib/Listener/BeforePreferenceListener.php index f21ccc94a1a..048deae50ce 100644 --- a/apps/theming/lib/Listener/BeforePreferenceListener.php +++ b/apps/theming/lib/Listener/BeforePreferenceListener.php @@ -17,6 +17,12 @@ use OCP\EventDispatcher\IEventListener; /** @template-implements IEventListener<BeforePreferenceDeletedEvent|BeforePreferenceSetEvent> */ class BeforePreferenceListener implements IEventListener { + + /** + * @var string[] + */ + private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color']; + public function __construct( private IAppManager $appManager, ) { @@ -38,15 +44,16 @@ class BeforePreferenceListener implements IEventListener { } private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { - $allowedKeys = ['shortcuts_disabled', 'primary_color']; - - if (!in_array($event->getConfigKey(), $allowedKeys)) { + if (!in_array($event->getConfigKey(), self::ALLOWED_KEYS)) { // Not allowed config key return; } if ($event instanceof BeforePreferenceSetEvent) { switch ($event->getConfigKey()) { + case 'force_enable_blur_filter': + $event->setValid($event->getConfigValue() === 'yes' || $event->getConfigValue() === 'no'); + break; case 'shortcuts_disabled': $event->setValid($event->getConfigValue() === 'yes'); break; @@ -56,6 +63,7 @@ class BeforePreferenceListener implements IEventListener { default: $event->setValid(false); } + return; } $event->setValid(true); diff --git a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php index cb72e46360e..18ab9392b97 100644 --- a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php +++ b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php @@ -19,29 +19,19 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IConfig; use OCP\IUserSession; +use OCP\Util; use Psr\Container\ContainerInterface; /** @template-implements IEventListener<BeforeTemplateRenderedEvent|BeforeLoginTemplateRenderedEvent> */ class BeforeTemplateRenderedListener implements IEventListener { - private IInitialState $initialState; - private ContainerInterface $container; - private ThemeInjectionService $themeInjectionService; - private IUserSession $userSession; - private IConfig $config; - public function __construct( - IInitialState $initialState, - ContainerInterface $container, - ThemeInjectionService $themeInjectionService, - IUserSession $userSession, - IConfig $config + private IInitialState $initialState, + private ContainerInterface $container, + private ThemeInjectionService $themeInjectionService, + private IUserSession $userSession, + private IConfig $config, ) { - $this->initialState = $initialState; - $this->container = $container; - $this->themeInjectionService = $themeInjectionService; - $this->userSession = $userSession; - $this->config = $config; } public function handle(Event $event): void { @@ -64,6 +54,6 @@ class BeforeTemplateRenderedListener implements IEventListener { $this->themeInjectionService->injectHeaders(); // Making sure to inject just after core - \OCP\Util::addScript('theming', 'theming', 'core'); + Util::addScript('theming', 'theming', 'core'); } } diff --git a/apps/theming/lib/Migration/InitBackgroundImagesMigration.php b/apps/theming/lib/Migration/InitBackgroundImagesMigration.php index a070ebb3d56..dea1bb3aa83 100644 --- a/apps/theming/lib/Migration/InitBackgroundImagesMigration.php +++ b/apps/theming/lib/Migration/InitBackgroundImagesMigration.php @@ -12,13 +12,13 @@ namespace OCA\Theming\Migration; use OCA\Theming\Jobs\MigrateBackgroundImages; use OCP\BackgroundJob\IJobList; use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; -class InitBackgroundImagesMigration implements \OCP\Migration\IRepairStep { +class InitBackgroundImagesMigration implements IRepairStep { - private IJobList $jobList; - - public function __construct(IJobList $jobList) { - $this->jobList = $jobList; + public function __construct( + private IJobList $jobList, + ) { } public function getName() { diff --git a/apps/theming/lib/Migration/Version2006Date20240905111627.php b/apps/theming/lib/Migration/Version2006Date20240905111627.php new file mode 100644 index 00000000000..8f4130cba46 --- /dev/null +++ b/apps/theming/lib/Migration/Version2006Date20240905111627.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming\Migration; + +use Closure; +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Jobs\RestoreBackgroundImageColor; +use OCP\BackgroundJob\IJobList; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\Migration\IMigrationStep; +use OCP\Migration\IOutput; + +// This can only be executed once because `background_color` is again used with Nextcloud 30, +// so this part only works when updating -> Nextcloud 29 -> 30 +class Version2006Date20240905111627 implements IMigrationStep { + + public function __construct( + private IJobList $jobList, + private IAppConfig $appConfig, + private IDBConnection $connection, + ) { + } + + public function name(): string { + return 'Restore custom primary color'; + } + + public function description(): string { + return 'Restore custom primary color after separating primary color from background color'; + } + + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + // nop + } + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + $this->restoreSystemColors($output); + + $userThemingEnabled = $this->appConfig->getValueBool('theming', 'disable-user-theming') === false; + if ($userThemingEnabled) { + $this->restoreUserColors($output); + } + + return null; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $output->info('Initialize restoring of background colors for custom background images'); + // This is done in a background job as this can take a lot of time for large instances + $this->jobList->add(RestoreBackgroundImageColor::class, ['stage' => RestoreBackgroundImageColor::STAGE_PREPARE]); + } + + private function restoreSystemColors(IOutput $output): void { + $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'color', ''); + if ($defaultColor === '') { + $output->info('No custom system color configured - skipping'); + } else { + // Restore legacy value into new field + $this->appConfig->setValueString(Application::APP_ID, 'background_color', $defaultColor); + $this->appConfig->setValueString(Application::APP_ID, 'primary_color', $defaultColor); + // Delete legacy field + $this->appConfig->deleteKey(Application::APP_ID, 'color'); + // give some feedback + $output->info('Global primary color restored'); + } + } + + private function restoreUserColors(IOutput $output): void { + $output->info('Restoring user primary color'); + // For performance let the DB handle this + $qb = $this->connection->getQueryBuilder(); + // Rename the `background_color` config to `primary_color` as this was the behavior on Nextcloud 29 and older + // with Nextcloud 30 `background_color` is a new option to define the background color independent of the primary color. + $qb->update('preferences') + ->set('configkey', $qb->createNamedParameter('primary_color')) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('background_color'))); + + try { + $qb->executeStatement(); + } catch (\Exception) { + $output->debug('Some users already configured the background color'); + $this->restoreUserColorsFallback($output); + } + + $output->info('Primary color of users restored'); + } + + /** + * Similar to restoreUserColors but also works if some users already setup a new value. + * This is only called if the first approach fails as this takes much longer on the DB. + */ + private function restoreUserColorsFallback(IOutput $output): void { + $qb = $this->connection->getQueryBuilder(); + $qb2 = $this->connection->getQueryBuilder(); + + $qb2->select('userid') + ->from('preferences') + ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('primary_color'))); + + // MySQL does not update on select of the same table, so this is a workaround: + if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_MYSQL) { + $subquery = 'SELECT * from ( ' . $qb2->getSQL() . ' ) preferences_alias'; + } else { + $subquery = $qb2->getSQL(); + } + + $qb->update('preferences') + ->set('configkey', $qb->createNamedParameter('primary_color')) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) + ->andWhere( + $qb->expr()->eq('configkey', $qb->createNamedParameter('background_color')), + $qb->expr()->notIn('userid', $qb->createFunction($subquery)), + ); + + $qb->executeStatement(); + } +} diff --git a/apps/theming/lib/Service/BackgroundService.php b/apps/theming/lib/Service/BackgroundService.php index 52925fdf980..ee9466c3a36 100644 --- a/apps/theming/lib/Service/BackgroundService.php +++ b/apps/theming/lib/Service/BackgroundService.php @@ -18,7 +18,9 @@ use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IAppConfig; use OCP\IConfig; +use OCP\Image; use OCP\Lock\LockedException; use OCP\PreConditionNotMetException; use RuntimeException; @@ -44,7 +46,7 @@ class BackgroundService { */ public const BACKGROUND_COLOR = 'color'; - public const DEFAULT_BACKGROUND_IMAGE = 'kamil-porembinski-clouds.jpg'; + public const DEFAULT_BACKGROUND_IMAGE = 'jenna-kim-the-globe.webp'; /** * 'attribution': Name, artist and license @@ -54,6 +56,21 @@ class BackgroundService { * 'primary_color': Recommended primary color for this theme / image */ public const SHIPPED_BACKGROUNDS = [ + 'jenna-kim-the-globe.webp' => [ + 'attribution' => 'Globe (Jenna Kim - Nextcloud GmbH, CC-BY-SA-4.0)', + 'description' => 'Background picture of white clouds on in front of a blue sky', + 'attribution_url' => 'https://nextcloud.com/trademarks/', + 'dark_variant' => 'jenna-kim-the-globe-dark.webp', + 'background_color' => self::DEFAULT_BACKGROUND_COLOR, + 'primary_color' => self::DEFAULT_COLOR, + ], + 'kamil-porembinski-clouds.jpg' => [ + 'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)', + 'description' => 'Background picture of white clouds on in front of a blue sky', + 'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/', + 'background_color' => self::DEFAULT_BACKGROUND_COLOR, + 'primary_color' => self::DEFAULT_COLOR, + ], 'hannah-maclean-soft-floral.jpg' => [ 'attribution' => 'Soft floral (Hannah MacLean, CC0)', 'description' => 'Abstract background picture in yellow and white color whith a flower on it', @@ -138,13 +155,6 @@ class BackgroundService { 'background_color' => '#333f47', 'primary_color' => '#4f6071', ], - 'kamil-porembinski-clouds.jpg' => [ - 'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)', - 'description' => 'Background picture of white clouds on in front of a blue sky', - 'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/', - 'background_color' => self::DEFAULT_BACKGROUND_COLOR, - 'primary_color' => self::DEFAULT_COLOR, - ], 'bernard-spragg-new-zealand-fern.jpg' => [ 'attribution' => 'New zealand fern (Bernard Spragg, CC0)', 'description' => 'Abstract background picture of fern leafes', @@ -192,15 +202,18 @@ class BackgroundService { public function __construct( private IRootFolder $rootFolder, private IAppData $appData, + private IAppConfig $appConfig, private IConfig $config, private ?string $userId, ) { } - public function setDefaultBackground(): void { - $this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_image'); - $this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_color'); - $this->config->deleteUserValue($this->userId, Application::APP_ID, 'primary_color'); + public function setDefaultBackground(?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + + $this->config->deleteUserValue($userId, Application::APP_ID, 'background_image'); + $this->config->deleteUserValue($userId, Application::APP_ID, 'background_color'); + $this->config->deleteUserValue($userId, Application::APP_ID, 'primary_color'); } /** @@ -211,17 +224,27 @@ class BackgroundService { * @throws PreConditionNotMetException * @throws NoUserException */ - public function setFileBackground($path): void { - if ($this->userId === null) { - throw new RuntimeException('No currently logged-in user'); - } - $userFolder = $this->rootFolder->getUserFolder($this->userId); + public function setFileBackground(string $path, ?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + $userFolder = $this->rootFolder->getUserFolder($userId); /** @var File $file */ $file = $userFolder->get($path); - $image = new \OCP\Image(); + $handle = $file->fopen('r'); + if ($handle === false) { + throw new InvalidArgumentException('Invalid image file'); + } + $this->getAppDataFolder()->newFile('background.jpg', $handle); - if ($image->loadFromFileHandle($file->fopen('r')) === false) { + $this->recalculateMeanColor(); + } + + public function recalculateMeanColor(?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + + $image = new Image(); + $handle = $this->getAppDataFolder($userId)->getFile('background.jpg')->read(); + if ($handle === false || $image->loadFromFileHandle($handle) === false) { throw new InvalidArgumentException('Invalid image file'); } @@ -229,50 +252,53 @@ class BackgroundService { if ($meanColor !== false) { $this->setColorBackground($meanColor); } - - $this->getAppDataFolder()->newFile('background.jpg', $file->fopen('r')); - $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM); } - public function setShippedBackground($fileName): void { - if ($this->userId === null) { - throw new RuntimeException('No currently logged-in user'); - } - if (!array_key_exists($fileName, self::SHIPPED_BACKGROUNDS)) { + /** + * Set background of user to a shipped background identified by the filename + * @param string $filename The shipped background filename + * @param null|string $userId The user to set - defaults to currently logged in user + * @throws RuntimeException If neither $userId is specified nor a user is logged in + * @throws InvalidArgumentException If the specified filename does not match any shipped background + */ + public function setShippedBackground(string $filename, ?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + + if (!array_key_exists($filename, self::SHIPPED_BACKGROUNDS)) { throw new InvalidArgumentException('The given file name is invalid'); } - $this->setColorBackground(self::SHIPPED_BACKGROUNDS[$fileName]['background_color']); - $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', $fileName); - $this->config->setUserValue($this->userId, Application::APP_ID, 'primary_color', self::SHIPPED_BACKGROUNDS[$fileName]['primary_color']); + $this->setColorBackground(self::SHIPPED_BACKGROUNDS[$filename]['background_color'], $userId); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', $filename); + $this->config->setUserValue($userId, Application::APP_ID, 'primary_color', self::SHIPPED_BACKGROUNDS[$filename]['primary_color']); } /** * Set the background to color only + * @param string|null $userId The user to set the color - default to current logged-in user */ - public function setColorBackground(string $color): void { - if ($this->userId === null) { - throw new RuntimeException('No currently logged-in user'); - } + public function setColorBackground(string $color, ?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) { throw new InvalidArgumentException('The given color is invalid'); } - $this->config->setUserValue($this->userId, Application::APP_ID, 'background_color', $color); - $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR); + $this->config->setUserValue($userId, Application::APP_ID, 'background_color', $color); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR); } - public function deleteBackgroundImage(): void { - if ($this->userId === null) { - throw new RuntimeException('No currently logged-in user'); - } - $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR); + public function deleteBackgroundImage(?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR); } - public function getBackground(): ?ISimpleFile { - $background = $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT); + public function getBackground(?string $userId = null): ?ISimpleFile { + $userId = $userId ?? $this->getUserId(); + $background = $this->config->getUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT); if ($background === self::BACKGROUND_CUSTOM) { try { return $this->getAppDataFolder()->getFile('background.jpg'); - } catch (NotFoundException | NotPermittedException $e) { + } catch (NotFoundException|NotPermittedException $e) { return null; } } @@ -285,14 +311,14 @@ class BackgroundService { * @param resource|string $path * @return string|null The fallback background color - if any */ - public function setGlobalBackground($path): string|null { - $image = new \OCP\Image(); + public function setGlobalBackground($path): ?string { + $image = new Image(); $handle = is_resource($path) ? $path : fopen($path, 'rb'); if ($handle && $image->loadFromFileHandle($handle) !== false) { $meanColor = $this->calculateMeanColor($image); if ($meanColor !== false) { - $this->config->setAppValue(Application::APP_ID, 'background_color', $meanColor); + $this->appConfig->setValueString(Application::APP_ID, 'background_color', $meanColor); return $meanColor; } } @@ -303,7 +329,7 @@ class BackgroundService { * Calculate mean color of an given image * It only takes the upper part into account so that a matching text color can be derived for the app menu */ - private function calculateMeanColor(\OCP\Image $image): false|string { + private function calculateMeanColor(Image $image): false|string { /** * Small helper to ensure one channel is returned as 8byte hex */ @@ -311,13 +337,13 @@ class BackgroundService { $hex = dechex($channel); return match (strlen($hex)) { 0 => '00', - 1 => '0'.$hex, + 1 => '0' . $hex, 2 => $hex, default => 'ff', }; } - $tempImage = new \OCP\Image(); + $tempImage = new Image(); // Crop to only analyze top bar $resource = $image->cropNew(0, 0, $image->width(), min(max(50, (int)($image->height() * 0.125)), $image->height())); @@ -358,19 +384,31 @@ class BackgroundService { /** * Storing the data in appdata/theming/users/USERID * - * @return ISimpleFolder + * @param string|null $userId The user to get the folder - default to current user * @throws NotPermittedException */ - private function getAppDataFolder(): ISimpleFolder { + private function getAppDataFolder(?string $userId = null): ISimpleFolder { + $userId = $userId ?? $this->getUserId(); + try { $rootFolder = $this->appData->getFolder('users'); - } catch (NotFoundException $e) { + } catch (NotFoundException) { $rootFolder = $this->appData->newFolder('users'); } try { - return $rootFolder->getFolder($this->userId); - } catch (NotFoundException $e) { - return $rootFolder->newFolder($this->userId); + return $rootFolder->getFolder($userId); + } catch (NotFoundException) { + return $rootFolder->newFolder($userId); + } + } + + /** + * @throws RuntimeException Thrown if a method that needs a user is called without any logged-in user + */ + private function getUserId(): string { + if ($this->userId === null) { + throw new RuntimeException('No currently logged-in user'); } + return $this->userId; } } diff --git a/apps/theming/lib/Service/ThemeInjectionService.php b/apps/theming/lib/Service/ThemeInjectionService.php index f65dde076bc..873d388081c 100644 --- a/apps/theming/lib/Service/ThemeInjectionService.php +++ b/apps/theming/lib/Service/ThemeInjectionService.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -14,25 +15,16 @@ use OCP\IUserSession; class ThemeInjectionService { - private IURLGenerator $urlGenerator; - private ThemesService $themesService; - private DefaultTheme $defaultTheme; - private Util $util; - private IConfig $config; private ?string $userId; - public function __construct(IURLGenerator $urlGenerator, - ThemesService $themesService, - DefaultTheme $defaultTheme, - Util $util, - IConfig $config, - IUserSession $userSession) { - $this->urlGenerator = $urlGenerator; - $this->themesService = $themesService; - $this->defaultTheme = $defaultTheme; - $this->util = $util; - $this->config = $config; - + public function __construct( + private IURLGenerator $urlGenerator, + private ThemesService $themesService, + private DefaultTheme $defaultTheme, + private Util $util, + private IConfig $config, + IUserSession $userSession, + ) { if ($userSession->getUser() !== null) { $this->userId = $userSession->getUser()->getUID(); } else { @@ -52,12 +44,12 @@ class ThemeInjectionService { $this->addThemeHeaders($defaultTheme); // Themes applied by media queries - foreach($mediaThemes as $theme) { + foreach ($mediaThemes as $theme) { $this->addThemeHeaders($theme, true, $theme->getMediaQuery()); } // Themes - foreach($this->themesService->getThemes() as $theme) { + foreach ($this->themesService->getThemes() as $theme) { // Ignore default theme as already processed first if ($theme->getId() === $this->defaultTheme->getId()) { continue; @@ -99,9 +91,9 @@ class ThemeInjectionService { $metaHeaders = []; // Meta headers - foreach($this->themesService->getThemes() as $theme) { + foreach ($this->themesService->getThemes() as $theme) { if (!empty($theme->getMeta())) { - foreach($theme->getMeta() as $meta) { + foreach ($theme->getMeta() as $meta) { if (!isset($meta['name']) || !isset($meta['content'])) { continue; } @@ -114,7 +106,7 @@ class ThemeInjectionService { } } - foreach($metaHeaders as $name => $content) { + foreach ($metaHeaders as $name => $content) { \OCP\Util::addHeader('meta', [ 'name' => $name, 'content' => join(' ', array_unique($content)), diff --git a/apps/theming/lib/Service/ThemesService.php b/apps/theming/lib/Service/ThemesService.php index d6e14b6ffcb..f49524cb62c 100644 --- a/apps/theming/lib/Service/ThemesService.php +++ b/apps/theming/lib/Service/ThemesService.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -16,24 +17,23 @@ use OCA\Theming\Themes\LightTheme; use OCP\IConfig; use OCP\IUser; use OCP\IUserSession; +use Psr\Log\LoggerInterface; class ThemesService { - private IUserSession $userSession; - private IConfig $config; - /** @var ITheme[] */ private array $themesProviders; - public function __construct(IUserSession $userSession, - IConfig $config, - DefaultTheme $defaultTheme, + public function __construct( + private IUserSession $userSession, + private IConfig $config, + private LoggerInterface $logger, + private DefaultTheme $defaultTheme, LightTheme $lightTheme, - DarkTheme $darkTheme, + private DarkTheme $darkTheme, HighContrastTheme $highContrastTheme, DarkHighContrastTheme $darkHighContrastTheme, - DyslexiaFont $dyslexiaFont) { - $this->userSession = $userSession; - $this->config = $config; + DyslexiaFont $dyslexiaFont, + ) { // Register themes $this->themesProviders = [ @@ -52,6 +52,28 @@ class ThemesService { * @return ITheme[] */ public function getThemes(): array { + // Enforced theme if configured + $enforcedTheme = $this->config->getSystemValueString('enforce_theme', ''); + if ($enforcedTheme !== '') { + if (!isset($this->themesProviders[$enforcedTheme])) { + $this->logger->error('Enforced theme not found', ['theme' => $enforcedTheme]); + return $this->themesProviders; + } + + $defaultTheme = $this->themesProviders[$this->defaultTheme->getId()]; + $darkTheme = $this->themesProviders[$this->darkTheme->getId()]; + $theme = $this->themesProviders[$enforcedTheme]; + return [ + // Leave the default theme as a fallback + $defaultTheme->getId() => $defaultTheme, + // Make sure we also have the dark theme to allow apps + // to scope sections of their UI to the dark theme + $darkTheme->getId() => $darkTheme, + // Finally, the enforced theme + $theme->getId() => $theme, + ]; + } + return $this->themesProviders; } @@ -106,7 +128,7 @@ class ThemesService { $this->setEnabledThemes($enabledThemes); return $enabledThemes; } - + return $themesIds; } @@ -127,19 +149,21 @@ class ThemesService { } /** - * Get the list of all enabled themes IDs - * for the logged-in user + * Get the list of all enabled themes IDs for the current user. * * @return string[] */ public function getEnabledThemes(): array { + $enforcedTheme = $this->config->getSystemValueString('enforce_theme', ''); $user = $this->userSession->getUser(); if ($user === null) { + if ($enforcedTheme !== '') { + return [$enforcedTheme]; + } return []; } - $enforcedTheme = $this->config->getSystemValueString('enforce_theme', ''); - $enabledThemes = json_decode($this->config->getUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', '[]')); + $enabledThemes = json_decode($this->config->getUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', '["default"]')); if ($enforcedTheme !== '') { return array_merge([$enforcedTheme], $enabledThemes); } diff --git a/apps/theming/lib/Settings/Admin.php b/apps/theming/lib/Settings/Admin.php index 8a7b47a44a0..9fa0f2bb0e7 100644 --- a/apps/theming/lib/Settings/Admin.php +++ b/apps/theming/lib/Settings/Admin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -14,6 +15,7 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; use OCP\IL10N; +use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\Settings\IDelegatedSettings; use OCP\Util; @@ -28,6 +30,7 @@ class Admin implements IDelegatedSettings { private IInitialState $initialState, private IURLGenerator $urlGenerator, private ImageManager $imageManager, + private INavigationManager $navigationManager, ) { } @@ -70,7 +73,7 @@ class Admin implements IDelegatedSettings { 'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'), 'canThemeIcons' => $this->imageManager->shouldReplaceIcons(), 'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(), - 'defaultApps' => array_filter(explode(',', $this->config->getSystemValueString('defaultapp', ''))), + 'defaultApps' => $this->navigationManager->getDefaultEntryIds(), ]); Util::addScript($this->appName, 'admin-theming'); @@ -87,8 +90,8 @@ class Admin implements IDelegatedSettings { /** * @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. + * 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 */ diff --git a/apps/theming/lib/Settings/AdminSection.php b/apps/theming/lib/Settings/AdminSection.php index 3170a826310..a1ea568d9f2 100644 --- a/apps/theming/lib/Settings/AdminSection.php +++ b/apps/theming/lib/Settings/AdminSection.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -10,14 +11,11 @@ use OCP\IURLGenerator; use OCP\Settings\IIconSection; class AdminSection implements IIconSection { - private string $appName; - private IL10N $l; - private IURLGenerator $url; - - public function __construct(string $appName, IURLGenerator $url, IL10N $l) { - $this->appName = $appName; - $this->url = $url; - $this->l = $l; + public function __construct( + private string $appName, + private IURLGenerator $url, + private IL10N $l, + ) { } /** @@ -42,8 +40,8 @@ class AdminSection implements IIconSection { /** * @return int whether the form should be rather on the top or bottom of - * the settings navigation. The sections are arranged in ascending order of - * the priority values. It is required to return a value between 0 and 99. + * the settings navigation. The sections are arranged in ascending order of + * the priority values. It is required to return a value between 0 and 99. * * E.g.: 70 */ diff --git a/apps/theming/lib/Settings/Personal.php b/apps/theming/lib/Settings/Personal.php index cdf0399946f..f14deeb35f0 100644 --- a/apps/theming/lib/Settings/Personal.php +++ b/apps/theming/lib/Settings/Personal.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -9,10 +10,10 @@ use OCA\Theming\ITheme; use OCA\Theming\Service\BackgroundService; use OCA\Theming\Service\ThemesService; use OCA\Theming\ThemingDefaults; -use OCP\App\IAppManager; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; +use OCP\INavigationManager; use OCP\Settings\ISettings; use OCP\Util; @@ -25,7 +26,7 @@ class Personal implements ISettings { private ThemesService $themesService, private IInitialState $initialStateService, private ThemingDefaults $themingDefaults, - private IAppManager $appManager, + private INavigationManager $navigationManager, ) { } @@ -49,8 +50,8 @@ class Personal implements ISettings { }); } - // Get the default app enforced by admin - $forcedDefaultApp = $this->appManager->getDefaultAppForUser(null, false); + // Get the default entry enforced by admin + $forcedDefaultEntry = $this->navigationManager->getDefaultEntryIdForUser(null, false); /** List of all shipped backgrounds */ $this->initialStateService->provideInitialState('shippedBackgrounds', BackgroundService::SHIPPED_BACKGROUNDS); @@ -75,9 +76,10 @@ class Personal implements ISettings { $this->initialStateService->provideInitialState('themes', array_values($themes)); $this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme); $this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled()); + $this->initialStateService->provideInitialState('enableBlurFilter', $this->config->getUserValue($this->userId, 'theming', 'force_enable_blur_filter', '')); $this->initialStateService->provideInitialState('navigationBar', [ 'userAppOrder' => json_decode($this->config->getUserValue($this->userId, 'core', 'apporder', '[]'), true, flags:JSON_THROW_ON_ERROR), - 'enforcedDefaultApp' => $forcedDefaultApp + 'enforcedDefaultApp' => $forcedDefaultEntry ]); Util::addScript($this->appName, 'personal-theming'); @@ -95,8 +97,8 @@ class Personal implements ISettings { /** * @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. + * 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 diff --git a/apps/theming/lib/Settings/PersonalSection.php b/apps/theming/lib/Settings/PersonalSection.php index 4411f605903..0a9361d5533 100644 --- a/apps/theming/lib/Settings/PersonalSection.php +++ b/apps/theming/lib/Settings/PersonalSection.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -11,15 +12,6 @@ use OCP\Settings\IIconSection; class PersonalSection implements IIconSection { - /** @var string */ - protected $appName; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var IL10N */ - private $l; - /** * Personal Section constructor. * @@ -27,12 +19,11 @@ class PersonalSection implements IIconSection { * @param IURLGenerator $urlGenerator * @param IL10N $l */ - public function __construct(string $appName, - IURLGenerator $urlGenerator, - IL10N $l) { - $this->appName = $appName; - $this->urlGenerator = $urlGenerator; - $this->l = $l; + public function __construct( + protected string $appName, + private IURLGenerator $urlGenerator, + private IL10N $l, + ) { } /** @@ -70,8 +61,8 @@ class PersonalSection implements IIconSection { /** * @return int whether the form should be rather on the top or bottom of - * the settings navigation. The sections are arranged in ascending order of - * the priority values. It is required to return a value between 0 and 99. + * the settings navigation. The sections are arranged in ascending order of + * the priority values. It is required to return a value between 0 and 99. * * E.g.: 70 * @since 9.1 diff --git a/apps/theming/lib/Themes/CommonThemeTrait.php b/apps/theming/lib/Themes/CommonThemeTrait.php index 99ad919abbf..74979770b70 100644 --- a/apps/theming/lib/Themes/CommonThemeTrait.php +++ b/apps/theming/lib/Themes/CommonThemeTrait.php @@ -17,6 +17,8 @@ trait CommonThemeTrait { public Util $util; public ThemingDefaults $themingDefaults; + protected bool $isDarkVariant = false; + /** * Generate primary-related variables * This is shared between multiple themes because colorMainBackground and colorMainText @@ -87,7 +89,7 @@ trait CommonThemeTrait { $variables["--image-$image"] = "url('" . $imageUrl . "')"; } elseif ($image === 'background') { // Apply default background if nothing is configured - $variables['--image-background'] = "url('" . $this->themingDefaults->getBackground() . "')"; + $variables['--image-background'] = "url('" . $this->themingDefaults->getBackground($this->isDarkVariant) . "')"; } } @@ -139,6 +141,10 @@ trait CommonThemeTrait { // The user picked a shipped background if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) { + $shippedBackground = BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage]; + if ($this->isDarkVariant && isset($shippedBackground['dark_variant'])) { + $backgroundImage = $shippedBackground['dark_variant']; + } $variables['--image-background'] = "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')"; } diff --git a/apps/theming/lib/Themes/DarkHighContrastTheme.php b/apps/theming/lib/Themes/DarkHighContrastTheme.php index 64804d24e76..0c8b436d660 100644 --- a/apps/theming/lib/Themes/DarkHighContrastTheme.php +++ b/apps/theming/lib/Themes/DarkHighContrastTheme.php @@ -89,7 +89,7 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme { '--color-info-hover' => $this->util->lighten($colorInfo, 10), '--color-info-text' => $this->util->lighten($colorInfo, 20), - '--color-scrollbar' => $this->util->lighten($colorMainBackground, 35), + '--color-scrollbar' => 'auto transparent', // used for the icon loading animation '--color-loading-light' => '#000000', diff --git a/apps/theming/lib/Themes/DarkTheme.php b/apps/theming/lib/Themes/DarkTheme.php index 661656f2d70..fd273d4697d 100644 --- a/apps/theming/lib/Themes/DarkTheme.php +++ b/apps/theming/lib/Themes/DarkTheme.php @@ -11,6 +11,8 @@ use OCA\Theming\ITheme; class DarkTheme extends DefaultTheme implements ITheme { + protected bool $isDarkVariant = true; + public function getId(): string { return 'dark'; } @@ -64,8 +66,6 @@ class DarkTheme extends DefaultTheme implements ITheme { '--color-main-background-rgb' => $colorMainBackgroundRGB, '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .85)', - '--color-scrollbar' => $this->util->lighten($colorMainBackground, 15), - '--color-background-hover' => $this->util->lighten($colorMainBackground, 4), '--color-background-dark' => $this->util->lighten($colorMainBackground, 7), '--color-background-darker' => $this->util->lighten($colorMainBackground, 14), diff --git a/apps/theming/lib/Themes/DefaultTheme.php b/apps/theming/lib/Themes/DefaultTheme.php index fa5dcb98c12..bdd3048a498 100644 --- a/apps/theming/lib/Themes/DefaultTheme.php +++ b/apps/theming/lib/Themes/DefaultTheme.php @@ -7,6 +7,7 @@ declare(strict_types=1); */ namespace OCA\Theming\Themes; +use OC\AppFramework\Http\Request; use OCA\Theming\ImageManager; use OCA\Theming\ITheme; use OCA\Theming\ThemingDefaults; @@ -14,41 +15,27 @@ use OCA\Theming\Util; use OCP\App\IAppManager; use OCP\IConfig; use OCP\IL10N; +use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; class DefaultTheme implements ITheme { use CommonThemeTrait; - public Util $util; - public ThemingDefaults $themingDefaults; - public IUserSession $userSession; - public IURLGenerator $urlGenerator; - public ImageManager $imageManager; - public IConfig $config; - public IL10N $l; - public IAppManager $appManager; - public string $defaultPrimaryColor; public string $primaryColor; - public function __construct(Util $util, - ThemingDefaults $themingDefaults, - IUserSession $userSession, - IURLGenerator $urlGenerator, - ImageManager $imageManager, - IConfig $config, - IL10N $l, - IAppManager $appManager) { - $this->util = $util; - $this->themingDefaults = $themingDefaults; - $this->userSession = $userSession; - $this->urlGenerator = $urlGenerator; - $this->imageManager = $imageManager; - $this->config = $config; - $this->l = $l; - $this->appManager = $appManager; - + public function __construct( + public Util $util, + public ThemingDefaults $themingDefaults, + public IUserSession $userSession, + public IURLGenerator $urlGenerator, + public ImageManager $imageManager, + public IConfig $config, + public IL10N $l, + public IAppManager $appManager, + private ?IRequest $request, + ) { $this->defaultPrimaryColor = $this->themingDefaults->getDefaultColorPrimary(); $this->primaryColor = $this->themingDefaults->getColorPrimary(); } @@ -96,12 +83,29 @@ class DefaultTheme implements ITheme { $colorSuccess = '#2d7b41'; $colorInfo = '#0071ad'; + $user = $this->userSession->getUser(); + // Chromium based browsers currently (2024) have huge performance issues with blur filters + $isChromium = $this->request !== null && $this->request->isUserAgent([Request::USER_AGENT_CHROME, Request::USER_AGENT_MS_EDGE]); + // Ignore MacOS because they always have hardware accelartion + $isChromium = $isChromium && !$this->request->isUserAgent(['/Macintosh/']); + // Allow to force the blur filter + $forceEnableBlur = $user === null ? false : $this->config->getUserValue( + $user->getUID(), + 'theming', + 'force_enable_blur_filter', + ); + $workingBlur = match($forceEnableBlur) { + 'yes' => true, + 'no' => false, + default => !$isChromium + }; + $variables = [ '--color-main-background' => $colorMainBackground, '--color-main-background-rgb' => $colorMainBackgroundRGB, '--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), .97)', '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .8)', - '--filter-background-blur' => 'blur(25px)', + '--filter-background-blur' => $workingBlur ? 'blur(25px)' : 'none', // to use like this: background-image: linear-gradient(0, var('--gradient-main-background)); '--gradient-main-background' => 'var(--color-main-background) 0%, var(--color-main-background-translucent) 85%, transparent 100%', @@ -122,7 +126,7 @@ class DefaultTheme implements ITheme { '--color-text-light' => 'var(--color-main-text)', // deprecated '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated - '--color-scrollbar' => 'rgba(' . $colorMainTextRgb . ', .15)', + '--color-scrollbar' => 'var(--color-border-maxcontrast) transparent', // error/warning/success/info feedback colours '--color-error' => $colorError, @@ -148,7 +152,7 @@ class DefaultTheme implements ITheme { '--color-loading-dark' => '#444444', '--color-box-shadow-rgb' => $colorBoxShadowRGB, - '--color-box-shadow' => "rgba(var(--color-box-shadow-rgb), 0.5)", + '--color-box-shadow' => 'rgba(var(--color-box-shadow-rgb), 0.5)', '--color-border' => $this->util->darken($colorMainBackground, 7), '--color-border-dark' => $this->util->darken($colorMainBackground, 14), @@ -156,6 +160,9 @@ class DefaultTheme implements ITheme { '--font-face' => "system-ui, -apple-system, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'", '--default-font-size' => '15px', + '--font-size-small' => '13px', + // 1.5 * font-size for accessibility + '--default-line-height' => '1.5', // TODO: support "(prefers-reduced-motion)" '--animation-quick' => '100ms', @@ -165,28 +172,47 @@ class DefaultTheme implements ITheme { // Border width for input elements such as text fields and selects '--border-width-input' => '1px', '--border-width-input-focused' => '2px', - '--border-radius' => '3px', - '--border-radius-large' => '10px', + + // Border radii (new values) + '--border-radius-small' => '4px', // For smaller elements + '--border-radius-element' => '8px', // For interactive elements such as buttons, input, navigation and list items + '--border-radius-container' => '12px', // For smaller containers like action menus + '--border-radius-container-large' => '16px', // For bigger containers like body or modals + + // Border radii (deprecated) + '--border-radius' => 'var(--border-radius-small)', + '--border-radius-large' => 'var(--border-radius-element)', '--border-radius-rounded' => '28px', - '--border-radius-element' => '10px', - // pill-style button, value is large so big buttons also have correct roundness '--border-radius-pill' => '100px', - '--default-clickable-area' => '44px', - '--default-line-height' => '24px', + '--default-clickable-area' => '34px', + '--clickable-area-large' => '48px', + '--clickable-area-small' => '24px', + '--default-grid-baseline' => '4px', - // various structure data + // header / navigation bar '--header-height' => '50px', + '--header-menu-item-height' => '44px', + /* An alpha mask to be applied to all icons on the navigation bar (header menu). + * Icons are have a size of 20px but usually we use MDI which have a content of 16px so 2px padding top bottom, + * for better gradient we must at first begin at those 2px (10% of height) as start and stop positions. + */ + '--header-menu-icon-mask' => 'linear-gradient(var(--color-background-plain-text) 25%, color-mix(in srgb, var(--color-background-plain-text), 55% transparent) 90%) alpha', + + // various structure data '--navigation-width' => '300px', '--sidebar-min-width' => '300px', '--sidebar-max-width' => '500px', - '--list-min-width' => '200px', - '--list-max-width' => '300px', - '--header-menu-item-height' => '44px', - '--header-menu-profile-item-height' => '66px', - // mobile. Keep in sync with core/js/js.js + // Border radius of the body container + '--body-container-radius' => 'var(--border-radius-container-large)', + // Margin of the body container + '--body-container-margin' => 'calc(var(--default-grid-baseline) * 2)', + // Height of the body container to fully fill the view port + '--body-height' => 'calc(100% - env(safe-area-inset-bottom) - var(--header-height) - var(--body-container-margin))', + + // mobile. Keep in sync with core/src/init.js '--breakpoint-mobile' => '1024px', '--background-invert-if-dark' => 'no', '--background-invert-if-bright' => 'invert(100%)', diff --git a/apps/theming/lib/Themes/DyslexiaFont.php b/apps/theming/lib/Themes/DyslexiaFont.php index 2552fc65724..2448de7b3c8 100644 --- a/apps/theming/lib/Themes/DyslexiaFont.php +++ b/apps/theming/lib/Themes/DyslexiaFont.php @@ -43,30 +43,22 @@ class DyslexiaFont extends DefaultTheme implements ITheme { } public function getCustomCss(): string { - $fontPathWoff = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.woff'); $fontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.otf'); - $fontPathTtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.ttf'); - $boldFontPathWoff = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.woff'); $boldFontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.otf'); - $boldFontPathTtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.ttf'); return " @font-face { font-family: 'OpenDyslexic'; font-style: normal; font-weight: 400; - src: url('$fontPathWoff') format('woff'), - url('$fontPathOtf') format('opentype'), - url('$fontPathTtf') format('truetype'); + src: url('$fontPathOtf') format('opentype'); } - + @font-face { font-family: 'OpenDyslexic'; font-style: normal; font-weight: 700; - src: url('$boldFontPathWoff') format('woff'), - url('$boldFontPathOtf') format('opentype'), - url('$boldFontPathTtf') format('truetype'); + src: url('$boldFontPathOtf') format('opentype'); } "; } diff --git a/apps/theming/lib/Themes/HighContrastTheme.php b/apps/theming/lib/Themes/HighContrastTheme.php index 6f33c0bbcd9..5b51114a32f 100644 --- a/apps/theming/lib/Themes/HighContrastTheme.php +++ b/apps/theming/lib/Themes/HighContrastTheme.php @@ -94,7 +94,7 @@ class HighContrastTheme extends DefaultTheme implements ITheme { '--color-favorite' => '#936B06', - '--color-scrollbar' => $this->util->darken($colorMainBackground, 25), + '--color-scrollbar' => 'auto transparent', // used for the icon loading animation '--color-loading-light' => '#dddddd', @@ -106,6 +106,9 @@ class HighContrastTheme extends DefaultTheme implements ITheme { '--color-border' => $this->util->darken($colorMainBackground, 50), '--color-border-dark' => $this->util->darken($colorMainBackground, 50), '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 56), + + // remove the gradient from the app icons + '--header-menu-icon-mask' => 'none', ] ); } diff --git a/apps/theming/lib/ThemingDefaults.php b/apps/theming/lib/ThemingDefaults.php index 1b31f91eeff..04f56895fa3 100644 --- a/apps/theming/lib/ThemingDefaults.php +++ b/apps/theming/lib/ThemingDefaults.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -11,6 +12,7 @@ use OCP\App\AppPathNotFoundException; use OCP\App\IAppManager; use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; +use OCP\IAppConfig; use OCP\ICacheFactory; use OCP\IConfig; use OCP\IL10N; @@ -39,6 +41,7 @@ class ThemingDefaults extends \OC_Defaults { */ public function __construct( private IConfig $config, + private IAppConfig $appConfig, private IL10N $l, private IUserSession $userSession, private IURLGenerator $urlGenerator, @@ -118,10 +121,10 @@ class ThemingDefaults extends \OC_Defaults { if ($entity !== '') { if ($baseUrl !== '') { - $footer = '<a href="' . $baseUrl . '" target="_blank"' . - ' rel="noreferrer noopener" class="entity-name">' . $entity . '</a>'; + $footer = '<a href="' . $baseUrl . '" target="_blank"' + . ' rel="noreferrer noopener" class="entity-name">' . $entity . '</a>'; } else { - $footer = '<span class="entity-name">' .$entity . '</span>'; + $footer = '<span class="entity-name">' . $entity . '</span>'; } } $footer .= ($slogan !== '' ? ' – ' . $slogan : ''); @@ -152,13 +155,13 @@ class ThemingDefaults extends \OC_Defaults { if ($link['url'] !== '' && filter_var($link['url'], FILTER_VALIDATE_URL) ) { - $legalLinks .= $divider . '<a href="' . $link['url'] . '" class="legal" target="_blank"' . - ' rel="noreferrer noopener">' . $link['text'] . '</a>'; + $legalLinks .= $divider . '<a href="' . $link['url'] . '" class="legal" target="_blank"' + . ' rel="noreferrer noopener">' . $link['text'] . '</a>'; $divider = ' · '; } } if ($legalLinks !== '') { - $footer .= '<br/>' . $legalLinks; + $footer .= '<br/><span class="footer__legal-links">' . $legalLinks . '</span>'; } return $footer; @@ -206,9 +209,9 @@ class ThemingDefaults extends \OC_Defaults { // user-defined background color if (!empty($user)) { - $userPrimaryColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', ''); - if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $userPrimaryColor)) { - return $userPrimaryColor; + $userBackgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', ''); + if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $userBackgroundColor)) { + return $userBackgroundColor; } } @@ -221,7 +224,7 @@ class ThemingDefaults extends \OC_Defaults { */ public function getDefaultColorPrimary(): string { // try admin color - $defaultColor = $this->config->getAppValue(Application::APP_ID, 'primary_color', ''); + $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'primary_color', ''); if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) { return $defaultColor; } @@ -234,7 +237,7 @@ class ThemingDefaults extends \OC_Defaults { * Default background color only taking admin setting into account */ public function getDefaultColorBackground(): string { - $defaultColor = $this->config->getAppValue(Application::APP_ID, 'background_color', ''); + $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'background_color'); if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) { return $defaultColor; } @@ -284,10 +287,11 @@ class ThemingDefaults extends \OC_Defaults { /** * Themed background image url * + * @param bool $darkVariant if the dark variant (if available) of the background should be used * @return string */ - public function getBackground(): string { - return $this->imageManager->getImageUrl('background'); + public function getBackground(bool $darkVariant = false): string { + return $this->imageManager->getImageUrl('background' . ($darkVariant ? 'Dark' : '')); } /** @@ -337,13 +341,13 @@ class ThemingDefaults extends \OC_Defaults { 'theming-favicon-mime' => "'" . $this->config->getAppValue('theming', 'faviconMime') . "'" ]; - $variables['image-logo'] = "url('".$this->imageManager->getImageUrl('logo')."')"; - $variables['image-logoheader'] = "url('".$this->imageManager->getImageUrl('logoheader')."')"; - $variables['image-favicon'] = "url('".$this->imageManager->getImageUrl('favicon')."')"; - $variables['image-login-background'] = "url('".$this->imageManager->getImageUrl('background')."')"; + $variables['image-logo'] = "url('" . $this->imageManager->getImageUrl('logo') . "')"; + $variables['image-logoheader'] = "url('" . $this->imageManager->getImageUrl('logoheader') . "')"; + $variables['image-favicon'] = "url('" . $this->imageManager->getImageUrl('favicon') . "')"; + $variables['image-login-background'] = "url('" . $this->imageManager->getImageUrl('background') . "')"; $variables['image-login-plain'] = 'false'; - if ($this->config->getAppValue('theming', 'primary_color', '') !== '') { + if ($this->appConfig->getValueString(Application::APP_ID, 'primary_color', '') !== '') { $variables['color-primary'] = $this->getColorPrimary(); $variables['color-primary-text'] = $this->getTextColorPrimary(); $variables['color-primary-element'] = $this->util->elementColor($this->getColorPrimary()); @@ -437,7 +441,11 @@ class ThemingDefaults extends \OC_Defaults { * Revert all settings to the default value */ public function undoAll(): void { + // Remember the current cachebuster value, as we do not want to reset this value + // Otherwise this can lead to caching issues as the value might be known to a browser already + $cacheBusterKey = $this->config->getAppValue('theming', 'cachebuster', '0'); $this->config->deleteAppValues('theming'); + $this->config->setAppValue('theming', 'cachebuster', $cacheBusterKey); $this->increaseCacheBuster(); } @@ -515,6 +523,6 @@ class ThemingDefaults extends \OC_Defaults { * Has the admin disabled user customization */ public function isUserThemingDisabled(): bool { - return $this->config->getAppValue('theming', 'disable-user-theming', 'no') === 'yes'; + return $this->appConfig->getValueBool(Application::APP_ID, 'disable-user-theming'); } } diff --git a/apps/theming/lib/Util.php b/apps/theming/lib/Util.php index ec85120413f..797456632fc 100644 --- a/apps/theming/lib/Util.php +++ b/apps/theming/lib/Util.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -13,19 +14,17 @@ use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\IConfig; use OCP\IUserSession; +use OCP\Server; +use OCP\ServerVersion; class Util { - - private IConfig $config; - private IAppManager $appManager; - private IAppData $appData; - private ImageManager $imageManager; - - public function __construct(IConfig $config, IAppManager $appManager, IAppData $appData, ImageManager $imageManager) { - $this->config = $config; - $this->appManager = $appManager; - $this->appData = $appData; - $this->imageManager = $imageManager; + public function __construct( + private ServerVersion $serverVersion, + private IConfig $config, + private IAppManager $appManager, + private IAppData $appData, + private ImageManager $imageManager, + ) { } /** @@ -188,8 +187,8 @@ class Util { * @return string base64 encoded radio button svg */ public function generateRadioButton($color) { - $radioButtonIcon = '<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16">' . - '<path d="M8 1a7 7 0 0 0-7 7 7 7 0 0 0 7 7 7 7 0 0 0 7-7 7 7 0 0 0-7-7zm0 1a6 6 0 0 1 6 6 6 6 0 0 1-6 6 6 6 0 0 1-6-6 6 6 0 0 1 6-6zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="'.$color.'"/></svg>'; + $radioButtonIcon = '<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16">' + . '<path d="M8 1a7 7 0 0 0-7 7 7 7 0 0 0 7 7 7 7 0 0 0 7-7 7 7 0 0 0-7-7zm0 1a6 6 0 0 1 6 6 6 6 0 0 1-6 6 6 6 0 0 1-6-6 6 6 0 0 1 6-6zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="' . $color . '"/></svg>'; return base64_encode($radioButtonIcon); } @@ -199,7 +198,7 @@ class Util { * @return string|ISimpleFile path to app icon / file of logo */ public function getAppIcon($app) { - $app = str_replace(['\0', '/', '\\', '..'], '', $app); + $app = $this->appManager->cleanAppId($app); try { $appPath = $this->appManager->getAppPath($app); $icon = $appPath . '/img/' . $app . '.svg'; @@ -230,9 +229,12 @@ class Util { * @return string|false absolute path to image */ public function getAppImage($app, $image) { - $app = str_replace(['\0', '/', '\\', '..'], '', $app); + $app = $this->appManager->cleanAppId($app); + /** + * @psalm-taint-escape file + */ $image = str_replace(['\0', '\\', '..'], '', $image); - if ($app === "core") { + if ($app === 'core') { $icon = \OC::$SERVERROOT . '/core/img/' . $image; if (file_exists($icon)) { return $icon; @@ -305,18 +307,20 @@ class Util { } public function getCacheBuster(): string { - $userSession = \OC::$server->get(IUserSession::class); + $userSession = Server::get(IUserSession::class); $userId = ''; $user = $userSession->getUser(); if (!is_null($user)) { $userId = $user->getUID(); } + $serverVersion = $this->serverVersion->getVersionString(); + $themingAppVersion = $this->appManager->getAppVersion('theming'); $userCacheBuster = ''; if ($userId) { $userCacheBusterValue = (int)$this->config->getUserValue($userId, 'theming', 'userCacheBuster', '0'); $userCacheBuster = $userId . '_' . $userCacheBusterValue; } $systemCacheBuster = $this->config->getAppValue('theming', 'cachebuster', '0'); - return substr(sha1($userCacheBuster . $systemCacheBuster), 0, 8); + return substr(sha1($serverVersion . $themingAppVersion . $userCacheBuster . $systemCacheBuster), 0, 8); } } |