diff options
Diffstat (limited to 'apps/theming/lib')
36 files changed, 2102 insertions, 1749 deletions
diff --git a/apps/theming/lib/AppInfo/Application.php b/apps/theming/lib/AppInfo/Application.php index 48bf42252c7..d08a1903265 100644 --- a/apps/theming/lib/AppInfo/Application.php +++ b/apps/theming/lib/AppInfo/Application.php @@ -1,36 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2019 Robin Appelman <robin@icewind.nl> - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\AppInfo; use OCA\Theming\Capabilities; use OCA\Theming\Listener\BeforePreferenceListener; use OCA\Theming\Listener\BeforeTemplateRenderedListener; +use OCA\Theming\SetupChecks\PhpImagickModule; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\Config\BeforePreferenceDeletedEvent; use OCP\Config\BeforePreferenceSetEvent; @@ -45,8 +29,10 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(BeforeLoginTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerEventListener(BeforePreferenceSetEvent::class, BeforePreferenceListener::class); $context->registerEventListener(BeforePreferenceDeletedEvent::class, BeforePreferenceListener::class); + $context->registerSetupCheck(PhpImagickModule::class); } public function boot(IBootContext $context): void { diff --git a/apps/theming/lib/Capabilities.php b/apps/theming/lib/Capabilities.php index 10828619e60..d5d6e415e75 100644 --- a/apps/theming/lib/Capabilities.php +++ b/apps/theming/lib/Capabilities.php @@ -1,34 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.com> - * - * @author Guillaume COMPAGNON <gcompagnon@outlook.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Julien Veyssier <eneiluj@posteo.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Service\BackgroundService; use OCP\Capabilities\IPublicCapability; use OCP\IConfig; use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; /** * Class Capabilities @@ -37,54 +21,93 @@ use OCP\IURLGenerator; */ class Capabilities implements IPublicCapability { - /** @var ThemingDefaults */ - protected $theming; - - /** @var Util */ - protected $util; - - /** @var IURLGenerator */ - protected $url; - - /** @var IConfig */ - protected $config; - /** * @param ThemingDefaults $theming * @param Util $util * @param IURLGenerator $url * @param IConfig $config */ - public function __construct(ThemingDefaults $theming, Util $util, IURLGenerator $url, IConfig $config) { - $this->theming = $theming; - $this->util = $util; - $this->url = $url; - $this->config = $config; + public function __construct( + protected ThemingDefaults $theming, + protected Util $util, + protected IURLGenerator $url, + protected IConfig $config, + protected IUserSession $userSession, + ) { } /** * Return this classes capabilities * - * @return array + * @return array{ + * theming: array{ + * name: string, + * productName: string, + * url: string, + * slogan: string, + * color: string, + * color-text: string, + * color-element: string, + * color-element-bright: string, + * color-element-dark: string, + * logo: string, + * background: string, + * background-text: string, + * background-plain: bool, + * background-default: bool, + * logoheader: string, + * favicon: string, + * }, + * } */ public function getCapabilities() { + $color = $this->theming->getDefaultColorPrimary(); + $colorText = $this->util->invertTextColor($color) ? '#000000' : '#ffffff'; + $backgroundLogo = $this->config->getAppValue('theming', 'backgroundMime', ''); - $color = $this->theming->getColorPrimary(); + $backgroundColor = $this->theming->getColorBackground(); + $backgroundText = $this->theming->getTextColorBackground(); + $backgroundPlain = $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $backgroundColor !== BackgroundService::DEFAULT_COLOR); + $background = $backgroundPlain ? $backgroundColor : $this->url->getAbsoluteURL($this->theming->getBackground()); + + $user = $this->userSession->getUser(); + if ($user instanceof IUser) { + /** + * Mimics the logic of generateUserBackgroundVariables() that generates the CSS variables. + * Also needs to be updated if the logic changes. + * @see \OCA\Theming\Themes\CommonThemeTrait::generateUserBackgroundVariables() + */ + $color = $this->theming->getColorPrimary(); + $colorText = $this->theming->getTextColorPrimary(); + + $backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT); + if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) { + $backgroundPlain = false; + $background = $this->url->linkToRouteAbsolute('theming.userTheme.getBackground'); + } elseif (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) { + $backgroundPlain = false; + $background = $this->url->linkTo(Application::APP_ID, "img/background/$backgroundImage"); + } elseif ($backgroundImage !== BackgroundService::BACKGROUND_DEFAULT) { + $backgroundPlain = true; + $background = $backgroundColor; + } + } + return [ 'theming' => [ 'name' => $this->theming->getName(), + 'productName' => $this->theming->getProductName(), 'url' => $this->theming->getBaseUrl(), 'slogan' => $this->theming->getSlogan(), 'color' => $color, - 'color-text' => $this->theming->getTextColorPrimary(), + 'color-text' => $colorText, 'color-element' => $this->util->elementColor($color), 'color-element-bright' => $this->util->elementColor($color), 'color-element-dark' => $this->util->elementColor($color, false), 'logo' => $this->url->getAbsoluteURL($this->theming->getLogo()), - 'background' => $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $this->theming->getColorPrimary() !== '#0082c9') ? - $this->theming->getColorPrimary() : - $this->url->getAbsoluteURL($this->theming->getBackground()), - 'background-plain' => $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $this->theming->getColorPrimary() !== '#0082c9'), + 'background' => $background, + 'background-text' => $backgroundText, + 'background-plain' => $backgroundPlain, 'background-default' => !$this->util->isBackgroundThemed(), 'logoheader' => $this->url->getAbsoluteURL($this->theming->getLogo()), 'favicon' => $this->url->getAbsoluteURL($this->theming->getLogo()), diff --git a/apps/theming/lib/Command/UpdateConfig.php b/apps/theming/lib/Command/UpdateConfig.php index 58dfcff8a8e..6236f866445 100644 --- a/apps/theming/lib/Command/UpdateConfig.php +++ b/apps/theming/lib/Command/UpdateConfig.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Command; @@ -33,19 +17,15 @@ use Symfony\Component\Console\Output\OutputInterface; class UpdateConfig extends Command { public const SUPPORTED_KEYS = [ - 'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', '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() { @@ -55,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,8 +91,13 @@ class UpdateConfig extends Command { return 0; } + if ($key === 'background' && $value === 'backgroundColor') { + $this->themingDefaults->undo($key); + $key = $key . 'Mime'; + } + if (in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) { - if (strpos($value, '/') !== 0) { + if (!str_starts_with($value, '/')) { $output->writeln('<error>The image file needs to be provided as an absolute path: ' . $value . '.</error>'); return 1; } @@ -124,7 +109,12 @@ class UpdateConfig extends Command { $key = $key . 'Mime'; } - if ($key === 'color' && !preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { + if ($key === 'color') { + $output->writeln('<comment>Using "color" is deprecated, use "primary_color" instead</comment>'); + $key = 'primary_color'; + } + + if ($key === 'primary_color' && !preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { $output->writeln('<error>The given color is invalid: ' . $value . '</error>'); return 1; } diff --git a/apps/theming/lib/Controller/IconController.php b/apps/theming/lib/Controller/IconController.php index 08ee71ac660..e82faf78a79 100644 --- a/apps/theming/lib/Controller/IconController.php +++ b/apps/theming/lib/Controller/IconController.php @@ -1,29 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Julius Haertl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Haertl <jus@bitgrid.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Michael Weimann <mail@michael-weimann.eu> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Controller; @@ -31,8 +10,12 @@ use OC\IntegrityCheck\Helpers\FileAccessHelper; use OCA\Theming\IconBuilder; use OCA\Theming\ImageManager; 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; @@ -41,51 +24,42 @@ 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; - /** - * IconController constructor. - * - * @param string $appName - * @param IRequest $request - * @param ThemingDefaults $themingDefaults - * @param IconBuilder $iconBuilder - * @param ImageManager $imageManager - * @param FileAccessHelper $fileAccessHelper - */ public function __construct( $appName, IRequest $request, - ThemingDefaults $themingDefaults, - IconBuilder $iconBuilder, - ImageManager $imageManager, - FileAccessHelper $fileAccessHelper + private ThemingDefaults $themingDefaults, + private IconBuilder $iconBuilder, + private ImageManager $imageManager, + FileAccessHelper $fileAccessHelper, + private IAppManager $appManager, ) { parent::__construct($appName, $request); - - $this->themingDefaults = $themingDefaults; - $this->iconBuilder = $iconBuilder; - $this->imageManager = $imageManager; $this->fileAccessHelper = $fileAccessHelper; } /** - * @PublicPage - * @NoCSRFRequired + * Get a themed icon * - * @param $app string app name - * @param $image string image file name (svg required) - * @return FileDisplayResponse|NotFoundResponse + * @param string $app ID of the app + * @param string $image image file name (svg required) + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/svg+xml'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * @throws \Exception + * + * 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'; + $image = 'favicon.png'; + } + $color = $this->themingDefaults->getColorPrimary(); try { $iconFileName = $this->imageManager->getCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image)); @@ -94,7 +68,7 @@ class IconController extends Controller { if ($icon === false || $icon === '') { return new NotFoundResponse(); } - $iconFileName = $this->imageManager->setCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image), $icon); + $iconFileName = $this->imageManager->setCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image), $icon); } $response = new FileDisplayResponse($iconFileName, Http::STATUS_OK, ['Content-Type' => 'image/svg+xml']); $response->cacheFor(86400, false, true); @@ -104,14 +78,21 @@ class IconController extends Controller { /** * Return a 32x32 favicon as png * - * @PublicPage - * @NoCSRFRequired - * - * @param $app string app name - * @return FileDisplayResponse|DataDisplayResponse|NotFoundResponse + * @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 + * + * 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'; + } + $response = null; $iconFile = null; try { @@ -120,14 +101,15 @@ class IconController extends Controller { } catch (NotFoundException $e) { } if ($iconFile === null && $this->imageManager->shouldReplaceIcons()) { + $color = $this->themingDefaults->getColorPrimary(); try { - $iconFile = $this->imageManager->getCachedImage('favIcon-' . $app); + $iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color); } catch (NotFoundException $exception) { $icon = $this->iconBuilder->getFavicon($app); if ($icon === false || $icon === '') { return new NotFoundResponse(); } - $iconFile = $this->imageManager->setCachedImage('favIcon-' . $app, $icon); + $iconFile = $this->imageManager->setCachedImage('favIcon-' . $app . $color, $icon); } $response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']); } @@ -142,14 +124,21 @@ class IconController extends Controller { /** * Return a 512x512 icon for touch devices * - * @PublicPage - * @NoCSRFRequired - * - * @param $app string app name - * @return DataDisplayResponse|FileDisplayResponse|NotFoundResponse + * @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 + * + * 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'; + } + $response = null; try { $iconFile = $this->imageManager->getImage('favicon'); @@ -157,14 +146,15 @@ class IconController extends Controller { } catch (NotFoundException $e) { } if ($this->imageManager->shouldReplaceIcons()) { + $color = $this->themingDefaults->getColorPrimary(); try { - $iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app); + $iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color); } catch (NotFoundException $exception) { $icon = $this->iconBuilder->getTouchIcon($app); if ($icon === false || $icon === '') { return new NotFoundResponse(); } - $iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app, $icon); + $iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app . $color, $icon); } $response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']); } diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php index a323bac180b..e5cee254fe8 100644 --- a/apps/theming/lib/Controller/ThemingController.php +++ b/apps/theming/lib/Controller/ThemingController.php @@ -1,61 +1,38 @@ <?php + /** - * @copyright Copyright (c) 2016 Bjoern Schiessle <bjoern@schiessle.org> - * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Jan-Christoph Borchardt <hey@jancborchardt.net> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Haertl <jus@bitgrid.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Kyle Fazzari <kyrofa@ubuntu.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author nhirokinet <nhirokinet@nhiroki.net> - * @author rakekniven <mark.ziegler@rakekniven.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace 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\Files\IAppData; +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\ITempManager; use OCP\IURLGenerator; -use ScssPhp\ScssPhp\Compiler; /** * Class ThemingController @@ -65,54 +42,35 @@ use ScssPhp\ScssPhp\Compiler; * @package OCA\Theming\Controller */ class ThemingController extends Controller { - const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon']; - - private ThemingDefaults $themingDefaults; - private IL10N $l10n; - private IConfig $config; - private ITempManager $tempManager; - private IAppData $appData; - private IURLGenerator $urlGenerator; - private IAppManager $appManager; - private ImageManager $imageManager; - private ThemesService $themesService; + public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon']; public function __construct( - $appName, + string $appName, IRequest $request, - IConfig $config, - ThemingDefaults $themingDefaults, - IL10N $l, - ITempManager $tempManager, - IAppData $appData, - 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->tempManager = $tempManager; - $this->appData = $appData; - $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) { @@ -148,14 +106,28 @@ class ThemingController extends Controller { $error = $this->l10n->t('The given slogan is too long'); } break; - case 'color': + 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; } @@ -168,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' => [ @@ -179,18 +153,61 @@ class ThemingController extends Controller { } /** - * Check that a string is a valid http/https url + * @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->navigationManager->setDefaultEntryIds($value); + } catch (InvalidArgumentException $e) { + $error = $this->l10n->t('Invalid app given'); + } + } else { + $error = $this->l10n->t('Invalid type for setting "defaultApp" given'); + } + break; + default: + $error = $this->l10n->t('Invalid setting key'); + } + if ($error !== null) { + return new DataResponse([ + 'data' => [ + 'message' => $error, + ], + 'status' => 'error' + ], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'data' => [ + 'message' => $this->l10n->t('Saved'), + ], + 'status' => 'success' + ]); + } + + /** + * 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 ((strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0) && - 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)) { @@ -254,8 +271,8 @@ class ThemingController extends Controller { return new DataResponse( [ - 'data' => - [ + 'data' + => [ 'name' => $name, 'url' => $this->imageManager->getImageUrl($key), 'message' => $this->l10n->t('Saved'), @@ -267,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'), ], @@ -290,18 +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->navigationManager->setDefaultEntryIds([]); return new DataResponse( [ - 'data' => - [ + 'data' + => [ 'message' => $this->l10n->t('Saved'), ], 'status' => 'success' @@ -310,15 +328,21 @@ class ThemingController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired * @NoSameSiteCookieRequired * - * @param string $key - * @param bool $useSvg - * @return FileDisplayResponse|NotFoundResponse + * Get an image + * + * @param string $key Key of the image + * @param bool $useSvg Return image as SVG + * @return FileDisplayResponse<Http::STATUS_OK, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * @throws NotPermittedException + * + * 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); @@ -327,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); @@ -342,13 +366,22 @@ class ThemingController extends Controller { } /** - * @NoCSRFRequired - * @PublicPage * @NoSameSiteCookieRequired * @NoTwoFactorRequired * - * @return DataDisplayResponse|NotFoundResponse + * Get the CSS stylesheet for a theme + * + * @param string $themeId ID of the theme + * @param bool $plain Let the browser decide the CSS priority + * @param bool $withCustomCss Include custom CSS + * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'text/css'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> + * + * 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))) { @@ -356,7 +389,7 @@ class ThemingController extends Controller { } $theme = $themes[$themeId]; - $customCss = $theme->getCustomCss(); + $customCss = $theme->getCustomCss(); // Generate variables $variables = ''; @@ -369,9 +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 { @@ -384,12 +425,20 @@ class ThemingController extends Controller { } /** - * @NoCSRFRequired - * @PublicPage + * 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: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}> * - * @return Http\JSONResponse + * 200: Manifest returned + * 404: App not found */ - public function getManifest($app) { + #[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') { $name = $this->themingDefaults->getName(); @@ -397,16 +446,26 @@ class ThemingController extends Controller { $startUrl = $this->urlGenerator->getBaseUrl(); $description = $this->themingDefaults->getSlogan(); } else { + if (!$this->appManager->isEnabledForUser($app)) { + $response = new JSONResponse([], Http::STATUS_NOT_FOUND); + $response->throttle(['action' => 'manifest', 'app' => $app]); + return $response; + } + $info = $this->appManager->getAppInfo($app, false, $this->l10n->getLanguageCode()); $name = $info['name'] . ' - ' . $this->themingDefaults->getName(); $shortName = $info['name']; - if (strpos($this->request->getRequestUri(), '/index.php/') !== false) { + if (str_contains($this->request->getRequestUri(), '/index.php/')) { $startUrl = $this->urlGenerator->getBaseUrl() . '/index.php/apps/' . $app . '/'; } else { $startUrl = $this->urlGenerator->getBaseUrl() . '/apps/' . $app . '/'; } $description = $info['summary'] ?? ''; } + /** + * @var string $description + * @var string $shortName + */ $responseJS = [ 'name' => $name, 'short_name' => $shortName, @@ -414,24 +473,25 @@ 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, + ['app' => $app]) . '?v=' . $cacheBusterValue, 'type' => 'image/png', 'sizes' => '512x512' ], [ 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', - ['app' => $app]) . '?v=' . $cacheBusterValue, + ['app' => $app]) . '?v=' . $cacheBusterValue, 'type' => 'image/svg+xml', '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 Http\JSONResponse($responseJS); + $response = new JSONResponse($responseJS); $response->cacheFor(3600); return $response; } diff --git a/apps/theming/lib/Controller/UserThemeController.php b/apps/theming/lib/Controller/UserThemeController.php index 6a58366c4f6..770f2ca922f 100644 --- a/apps/theming/lib/Controller/UserThemeController.php +++ b/apps/theming/lib/Controller/UserThemeController.php @@ -3,43 +3,26 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> - * @copyright Copyright (c) 2019 Janis Köhr <janiskoehr@icloud.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Janis Köhr <janis.koehr@novatec-gmbh.de> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Controller; use OCA\Theming\AppInfo\Application; use OCA\Theming\ITheme; +use OCA\Theming\ResponseDefinitions; 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; @@ -48,29 +31,23 @@ use OCP\IRequest; use OCP\IUserSession; use OCP\PreConditionNotMetException; +/** + * @psalm-import-type ThemingBackground from ResponseDefinitions + */ class UserThemeController extends OCSController { protected ?string $userId = null; - - private IConfig $config; - private IUserSession $userSession; - private ThemesService $themesService; - private ThemingDefaults $themingDefaults; - private BackgroundService $backgroundService; - - public function __construct(string $appName, - IRequest $request, - IConfig $config, - IUserSession $userSession, - ThemesService $themesService, - ThemingDefaults $themingDefaults, - BackgroundService $backgroundService) { + + public function __construct( + string $appName, + IRequest $request, + private IConfig $config, + IUserSession $userSession, + private ThemesService $themesService, + private ThemingDefaults $themingDefaults, + private BackgroundService $backgroundService, + ) { parent::__construct($appName, $request); - $this->config = $config; - $this->userSession = $userSession; - $this->themesService = $themesService; - $this->themingDefaults = $themingDefaults; - $this->backgroundService = $backgroundService; $user = $userSession->getUser(); if ($user !== null) { @@ -79,14 +56,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Enable theme * * @param string $themeId the theme ID - * @return DataResponse - * @throws OCSBadRequestException|PreConditionNotMetException + * @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); @@ -96,14 +75,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Disable theme * * @param string $themeId the theme ID - * @return DataResponse - * @throws OCSBadRequestException|PreConditionNotMetException + * @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); @@ -119,7 +100,8 @@ class UserThemeController extends OCSController { * * @param string $themeId the theme ID * @return ITheme - * @throws OCSBadRequestException|PreConditionNotMetException + * @throws OCSBadRequestException + * @throws PreConditionNotMetException */ private function validateTheme(string $themeId): ITheme { if ($themeId === '' || !$themeId) { @@ -141,10 +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()]); @@ -155,22 +143,37 @@ 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(); return new JSONResponse([ 'backgroundImage' => null, - 'backgroundColor' => $this->themingDefaults->getColorPrimary(), + 'backgroundColor' => $this->themingDefaults->getColorBackground(), + 'primaryColor' => $this->themingDefaults->getColorPrimary(), 'version' => $currentVersion, ]); } /** - * @NoAdminRequired + * Set the background + * + * @param string $type Type of background + * @param string $value Path of the background image + * @param string|null $color Color for the background + * @return JSONResponse<Http::STATUS_OK, ThemingBackground, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}> + * + * 200: Background set successfully + * 400: Setting background is not possible */ - public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', string $color = null): JSONResponse { + #[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'); // Set color if provided @@ -207,7 +210,8 @@ class UserThemeController extends OCSController { return new JSONResponse([ 'backgroundImage' => $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT), - 'backgroundColor' => $this->themingDefaults->getColorPrimary(), + 'backgroundColor' => $this->themingDefaults->getColorBackground(), + 'primaryColor' => $this->themingDefaults->getColorPrimary(), 'version' => $currentVersion, ]); } diff --git a/apps/theming/lib/ITheme.php b/apps/theming/lib/ITheme.php index a5c9cdf26a6..2e40e8e489b 100644 --- a/apps/theming/lib/ITheme.php +++ b/apps/theming/lib/ITheme.php @@ -1,24 +1,9 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; @@ -30,8 +15,8 @@ namespace OCA\Theming; */ interface ITheme { - const TYPE_THEME = 1; - const TYPE_FONT = 2; + public const TYPE_THEME = 1; + public const TYPE_FONT = 2; /** * Unique theme id @@ -71,6 +56,14 @@ interface ITheme { public function getDescription(): string; /** + * Get the meta attribute matching the theme + * e.g. https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme + * @return array{name?: string, content?: string}[] + * @since 29.0.0 + */ + public function getMeta(): array; + + /** * Get the media query triggering this theme * Optional, ignored if falsy * diff --git a/apps/theming/lib/IconBuilder.php b/apps/theming/lib/IconBuilder.php index fb6909089b5..63f4559970d 100644 --- a/apps/theming/lib/IconBuilder.php +++ b/apps/theming/lib/IconBuilder.php @@ -1,28 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jan-Christoph Borchardt <hey@jancborchardt.net> - * @author Julius Haertl <jus@bitgrid.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; @@ -31,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. * @@ -46,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; } /** @@ -65,27 +35,27 @@ 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); + $clone->scaleImage(16, 0); $favicon->addImage($clone); $clone = clone $icon; - $clone->scaleImage(32,0); + $clone->scaleImage(32, 0); $favicon->addImage($clone); $clone = clone $icon; - $clone->scaleImage(64,0); + $clone->scaleImage(64, 0); $favicon->addImage($clone); $clone = clone $icon; - $clone->scaleImage(128,0); + $clone->scaleImage(128, 0); $favicon->addImage($clone); $data = $favicon->getImagesBlob(); @@ -108,7 +78,7 @@ class IconBuilder { if ($icon === false) { return false; } - $icon->setImageFormat("png32"); + $icon->setImageFormat('png32'); $data = $icon->getImageBlob(); $icon->destroy(); return $data; @@ -121,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); @@ -181,37 +146,36 @@ class IconBuilder { * invert app icons for bright primary colors * the default nextcloud logo will not be inverted to black */ - if ($this->util->invertTextColor($color) + 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; @@ -223,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 f7b0c12844a..309bf192bc3 100644 --- a/apps/theming/lib/ImageManager.php +++ b/apps/theming/lib/ImageManager.php @@ -1,35 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Gary Kim <gary@garykim.dev> - * @author Jacob Neplokh <me@jacobneplokh.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julien Veyssier <eneiluj@posteo.net> - * @author Julius Haertl <jus@bitgrid.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Michael Weimann <mail@michael-weimann.eu> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author ste101 <stephan_bauer@gmx.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; @@ -42,38 +15,22 @@ use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; use OCP\ICacheFactory; use OCP\IConfig; -use OCP\ILogger; use OCP\ITempManager; use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; class ImageManager { public const SUPPORTED_IMAGE_KEYS = ['background', 'logo', 'logoheader', 'favicon']; - /** @var IConfig */ - private $config; - /** @var IAppData */ - private $appData; - /** @var IURLGenerator */ - private $urlGenerator; - /** @var ICacheFactory */ - private $cacheFactory; - /** @var ILogger */ - private $logger; - /** @var ITempManager */ - private $tempManager; - - public function __construct(IConfig $config, - IAppData $appData, - IURLGenerator $urlGenerator, - ICacheFactory $cacheFactory, - ILogger $logger, - ITempManager $tempManager) { - $this->config = $config; - $this->urlGenerator = $urlGenerator; - $this->cacheFactory = $cacheFactory; - $this->logger = $logger; - $this->tempManager = $tempManager; - $this->appData = $appData; + public function __construct( + private IConfig $config, + private IAppData $appData, + private IURLGenerator $urlGenerator, + private ICacheFactory $cacheFactory, + private LoggerInterface $logger, + private ITempManager $tempManager, + private BackgroundService $backgroundService, + ) { } /** @@ -86,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) { @@ -93,8 +53,17 @@ class ImageManager { case 'logoheader': case 'favicon': return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter; + case 'backgroundDark': case 'background': - return $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE); + // Removing the background defines its mime as 'backgroundColor' + $mimeSetting = $this->config->getAppValue('theming', 'backgroundMime', ''); + if ($mimeSetting !== 'backgroundColor') { + $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 ''; } @@ -114,10 +83,10 @@ class ImageManager { * @throws NotPermittedException */ public function getImage(string $key, bool $useSvg = true): ISimpleFile { - $logo = $this->config->getAppValue('theming', $key . 'Mime', ''); + $mime = $this->config->getAppValue('theming', $key . 'Mime', ''); $folder = $this->getRootFolder()->getFolder('images'); - if ($logo === '' || !$folder->fileExists($key)) { + if ($mime === '' || !$folder->fileExists($key)) { throw new NotFoundException(); } @@ -144,7 +113,8 @@ class ImageManager { public function hasImage(string $key): bool { $mimeSetting = $this->config->getAppValue('theming', $key . 'Mime', ''); - return $mimeSetting !== ''; + // Removing the background defines its mime as 'backgroundColor' + return $mimeSetting !== '' && $mimeSetting !== 'backgroundColor'; } /** @@ -183,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 { @@ -196,7 +166,7 @@ class ImageManager { * * @param string $filename * @param string $data - * @return \OCP\Files\SimpleFS\ISimpleFile + * @return ISimpleFile * @throws NotFoundException * @throws NotPermittedException */ @@ -225,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 { @@ -240,43 +214,118 @@ class ImageManager { $supportedFormats = $this->getSupportedUploadImageFormats($key); $detectedMimeType = mime_content_type($tmpFile); if (!in_array($detectedMimeType, $supportedFormats, true)) { - throw new \Exception('Unsupported image type'); + throw new \Exception('Unsupported image type: ' . $detectedMimeType); } - if ($key === 'background' && strpos($detectedMimeType, 'image/svg') === false && strpos($detectedMimeType, 'image/gif') === false) { - // Optimize the image since some people may upload images that will be - // either to big or are not progressive rendering. - $newImage = @imagecreatefromstring(file_get_contents($tmpFile)); - - // Preserve transparency - imagesavealpha($newImage, true); - imagealphablending($newImage, true); - - $tmpFile = $this->tempManager->getTemporaryFile(); - $newWidth = (int)(imagesx($newImage) < 4096 ? imagesx($newImage) : 4096); - $newHeight = (int)(imagesy($newImage) / (imagesx($newImage) / $newWidth)); - $outputImage = imagescale($newImage, $newWidth, $newHeight); + if ($key === 'background') { + if ($this->shouldOptimizeBackgroundImage($detectedMimeType, filesize($tmpFile))) { + try { + // Optimize the image since some people may upload images that will be + // either to big or are not progressive rendering. + $newImage = @imagecreatefromstring(file_get_contents($tmpFile)); + if ($newImage === false) { + throw new \Exception('Could not read background image, possibly corrupted.'); + } + + // Preserve transparency + imagesavealpha($newImage, true); + imagealphablending($newImage, true); + + $imageWidth = imagesx($newImage); + $imageHeight = imagesy($newImage); + + /** @var int */ + $newWidth = min(4096, $imageWidth); + $newHeight = intval($imageHeight / ($imageWidth / $newWidth)); + $outputImage = imagescale($newImage, $newWidth, $newHeight); + if ($outputImage === false) { + throw new \Exception('Could not scale uploaded background image.'); + } + + $newTmpFile = $this->tempManager->getTemporaryFile(); + imageinterlace($outputImage, true); + // Keep jpeg images encoded as jpeg + if (str_contains($detectedMimeType, 'image/jpeg')) { + if (!imagejpeg($outputImage, $newTmpFile, 90)) { + throw new \Exception('Could not recompress background image as JPEG'); + } + } else { + if (!imagepng($outputImage, $newTmpFile, 8)) { + throw new \Exception('Could not recompress background image as PNG'); + } + } + $tmpFile = $newTmpFile; + imagedestroy($outputImage); + } catch (\Exception $e) { + if (isset($outputImage) && is_resource($outputImage) || $outputImage instanceof \GdImage) { + imagedestroy($outputImage); + } + + $this->logger->debug($e->getMessage()); + } + } - imageinterlace($outputImage, 1); - imagepng($outputImage, $tmpFile, 8); - imagedestroy($outputImage); + // For background images we need to announce it + $this->backgroundService->setGlobalBackground($tmpFile); + } - $target->putContent(file_get_contents($tmpFile)); - } else { - $target->putContent(file_get_contents($tmpFile)); + $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; } /** + * Decide whether an image benefits from shrinking and reconverting + * + * @param string $mimeType the mime type of the image + * @param int $contentSize size of the image file + * @return bool + */ + private function shouldOptimizeBackgroundImage(string $mimeType, int $contentSize): bool { + // Do not touch SVGs + if (str_contains($mimeType, 'image/svg')) { + return false; + } + // GIF does not benefit from converting + if (str_contains($mimeType, 'image/gif')) { + return false; + } + // WebP also does not benefit from converting + // We could possibly try to convert to progressive image, but normally webP images are quite small + if (str_contains($mimeType, 'image/webp')) { + return false; + } + // As a rule of thumb background images should be max. 150-300 KiB, small images do not benefit from converting + return $contentSize > 150000; + } + + /** * Returns a list of supported mime types for image uploads. * "favicon" images are only allowed to be SVG when imagemagick with SVG support is available. * * @param string $key The image key, e.g. "favicon" * @return string[] */ - private function getSupportedUploadImageFormats(string $key): array { + public function getSupportedUploadImageFormats(string $key): array { $supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if ($key !== 'favicon' || $this->shouldReplaceIcons() === true) { diff --git a/apps/theming/lib/Jobs/MigrateBackgroundImages.php b/apps/theming/lib/Jobs/MigrateBackgroundImages.php index 62179e46a4b..62e58f5e722 100644 --- a/apps/theming/lib/Jobs/MigrateBackgroundImages.php +++ b/apps/theming/lib/Jobs/MigrateBackgroundImages.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Jobs; @@ -47,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 a1add86e600..048deae50ce 100644 --- a/apps/theming/lib/Listener/BeforePreferenceListener.php +++ b/apps/theming/lib/Listener/BeforePreferenceListener.php @@ -3,54 +3,96 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Listener; use OCA\Theming\AppInfo\Application; +use OCP\App\IAppManager; use OCP\Config\BeforePreferenceDeletedEvent; use OCP\Config\BeforePreferenceSetEvent; use OCP\EventDispatcher\Event; 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, + ) { + } + public function handle(Event $event): void { if (!$event instanceof BeforePreferenceSetEvent && !$event instanceof BeforePreferenceDeletedEvent) { + // Invalid event type return; } - if ($event->getAppId() !== Application::APP_ID) { - return; + switch ($event->getAppId()) { + case Application::APP_ID: $this->handleThemingValues($event); + break; + case 'core': $this->handleCoreValues($event); + break; } + } - if ($event->getConfigKey() !== 'shortcuts_disabled') { + private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { + if (!in_array($event->getConfigKey(), self::ALLOWED_KEYS)) { + // Not allowed config key return; } if ($event instanceof BeforePreferenceSetEvent) { - $event->setValid($event->getConfigValue() === 'yes'); + 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; + case 'primary_color': + $event->setValid(preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $event->getConfigValue()) === 1); + break; + default: + $event->setValid(false); + } return; } $event->setValid(true); } + + private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { + if ($event->getConfigKey() !== 'apporder') { + // Not allowed config key + return; + } + + if ($event instanceof BeforePreferenceDeletedEvent) { + $event->setValid(true); + return; + } + + $value = json_decode($event->getConfigValue(), true, flags:JSON_THROW_ON_ERROR); + if (!is_array(($value))) { + // Must be an array + return; + } + + foreach ($value as $id => $info) { + // required format: [ navigation_id: string => [ order: int, app?: string ] ] + if (!is_string($id) || !is_array($info) || empty($info) || !isset($info['order']) || !is_numeric($info['order']) || (isset($info['app']) && !$this->appManager->isEnabledForUser($info['app']))) { + // Invalid config value, refuse the change + return; + } + } + $event->setValid(true); + } } diff --git a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php index 380527ee024..18ab9392b97 100644 --- a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php +++ b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php @@ -3,32 +3,15 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Morris Jobke <hey@morrisjobke.de> - * - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Listener; use OCA\Theming\AppInfo\Application; -use OCA\Theming\Service\BackgroundService; use OCA\Theming\Service\JSDataService; use OCA\Theming\Service\ThemeInjectionService; +use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; @@ -36,28 +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 { @@ -66,7 +40,7 @@ class BeforeTemplateRenderedListener implements IEventListener { fn () => $this->container->get(JSDataService::class), ); - /** @var BeforeTemplateRenderedEvent $event */ + /** @var BeforeTemplateRenderedEvent|BeforeLoginTemplateRenderedEvent $event */ if ($event->getResponse()->getRenderAs() === TemplateResponse::RENDER_AS_USER) { $this->initialState->provideLazyInitialState('shortcutsDisabled', function () { if ($this->userSession->getUser()) { @@ -79,44 +53,7 @@ class BeforeTemplateRenderedListener implements IEventListener { $this->themeInjectionService->injectHeaders(); - $user = $this->userSession->getUser(); - - if (!empty($user)) { - $userId = $user->getUID(); - - /** User background */ - $this->initialState->provideInitialState( - 'backgroundImage', - $this->config->getUserValue($userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT), - ); - - /** User color */ - $this->initialState->provideInitialState( - 'backgroundColor', - $this->config->getUserValue($userId, Application::APP_ID, 'background_color', BackgroundService::DEFAULT_COLOR), - ); - - /** - * Admin background. `backgroundColor` if disabled, - * mime type if defined and empty by default - */ - $this->initialState->provideInitialState( - 'themingDefaultBackground', - $this->config->getAppValue('theming', 'backgroundMime', ''), - ); - $this->initialState->provideInitialState( - 'defaultShippedBackground', - BackgroundService::DEFAULT_BACKGROUND_IMAGE, - ); - - /** List of all shipped backgrounds */ - $this->initialState->provideInitialState( - 'shippedBackgrounds', - BackgroundService::SHIPPED_BACKGROUNDS, - ); - } - // 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 ff8783196ac..dea1bb3aa83 100644 --- a/apps/theming/lib/Migration/InitBackgroundImagesMigration.php +++ b/apps/theming/lib/Migration/InitBackgroundImagesMigration.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Migration; @@ -29,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/MigrateAdminConfig.php b/apps/theming/lib/Migration/MigrateAdminConfig.php deleted file mode 100644 index 913b29f0061..00000000000 --- a/apps/theming/lib/Migration/MigrateAdminConfig.php +++ /dev/null @@ -1,101 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -namespace OCA\Theming\Migration; - -use OCP\Files\IAppData; -use OCP\Files\NotFoundException; -use OCP\Files\SimpleFS\ISimpleFolder; -use OCP\IL10N; -use OCP\Migration\IOutput; -use OCP\Migration\IRepairStep; -use Throwable; - -class MigrateAdminConfig implements IRepairStep { - private IAppData $appData; - private IL10N $l10n; - - public function __construct( - IAppData $appData, - IL10N $l10n - ) { - $this->appData = $appData; - $this->l10n = $l10n; - } - - public function getName(): string { - return $this->l10n->t('Failed to clean up the old administration theming images folder'); - } - - public function run(IOutput $output): void { - $output->info('Migrating administration images'); - $this->migrateAdminImages($output); - $this->cleanupAdminImages($output); - } - - private function migrateAdminImages(IOutput $output): void { - try { - $images = $this->appData->getFolder('images'); - $output->info('Migrating administration images'); - - // get or init the global folder if any - try { - $global = $this->appData->getFolder('global'); - } catch (NotFoundException $e) { - $global = $this->appData->newFolder('global'); - } - - // get or init the new images folder if any - try { - $newImages = $global->getFolder('images'); - } catch (NotFoundException $e) { - $newImages = $global->newFolder('images'); - } - - $files = $images->getDirectoryListing(); - $output->startProgress(count($files)); - foreach($files as $file) { - $newImages->newFile($file->getName(), $file->getContent()); - $output->advance(); - } - - $output->finishProgress(); - } catch(NotFoundException $e) { - $output->info('No administration images to migrate'); - } - } - - - private function cleanupAdminImages(IOutput $output): void { - try { - $images = $this->appData->getFolder('images'); - $images->delete(); - } catch (NotFoundException $e) { - } catch (Throwable $e) { - $output->warning($this->l10n->t('Failed to cleanup the old administration image folder', [$e->getMessage()])); - } - } -} diff --git a/apps/theming/lib/Migration/MigrateUserConfig.php b/apps/theming/lib/Migration/MigrateUserConfig.php deleted file mode 100644 index 0f8d982dfa7..00000000000 --- a/apps/theming/lib/Migration/MigrateUserConfig.php +++ /dev/null @@ -1,123 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright Copyright (c) 2019 Janis Köhr <janiskoehr@icloud.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Janis Köhr <janis.koehr@novatec-gmbh.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -namespace OCA\Theming\Migration; - -use OCA\Theming\AppInfo\Application; -use OCA\Theming\Service\ThemesService; -use OCA\Theming\Themes\DarkHighContrastTheme; -use OCA\Theming\Themes\DarkTheme; -use OCA\Theming\Themes\DyslexiaFont; -use OCA\Theming\Themes\HighContrastTheme; -use OCP\IConfig; -use OCP\IUser; -use OCP\IUserManager; -use OCP\Migration\IOutput; -use OCP\Migration\IRepairStep; - -class MigrateUserConfig implements IRepairStep { - - protected IUserManager $userManager; - protected IConfig $config; - protected ThemesService $themesService; - protected DarkTheme $darkTheme; - protected DarkHighContrastTheme $darkHighContrastTheme; - protected HighContrastTheme $highContrastTheme; - protected DyslexiaFont $dyslexiaFont; - - /** - * MigrateUserConfig constructor. - */ - public function __construct(IConfig $config, - IUserManager $userManager, - ThemesService $themesService, - DarkTheme $darkTheme, - DarkHighContrastTheme $darkHighContrastTheme, - HighContrastTheme $highContrastTheme, - DyslexiaFont $dyslexiaFont) { - $this->config = $config; - $this->userManager = $userManager; - $this->themesService = $themesService; - - $this->darkTheme = $darkTheme; - $this->darkHighContrastTheme = $darkHighContrastTheme; - $this->highContrastTheme = $highContrastTheme; - $this->dyslexiaFont = $dyslexiaFont; - } - - /** - * Returns the step's name - * - * @return string - * @since 25.0.0 - */ - public function getName() { - return 'Migrate old user accessibility config'; - } - - /** - * Run repair step. - * Must throw exception on error. - * - * @param IOutput $output - * @throws \Exception in case of failure - * @since 25.0.0 - */ - public function run(IOutput $output) { - $output->startProgress(); - $this->userManager->callForSeenUsers(function (IUser $user) use ($output) { - $config = []; - - $font = $this->config->getUserValue($user->getUID(), 'accessibility', 'font', false); - $highcontrast = $this->config->getUserValue($user->getUID(), 'accessibility', 'highcontrast', false); - $theme = $this->config->getUserValue($user->getUID(), 'accessibility', 'theme', false); - - if ($highcontrast || $theme) { - if ($theme === 'dark' && $highcontrast === 'highcontrast') { - $config[] = $this->darkHighContrastTheme->getId(); - } else if ($theme === 'dark') { - $config[] = $this->darkTheme->getId(); - } else if ($highcontrast === 'highcontrast') { - $config[] = $this->highContrastTheme->getId(); - } - } - - if ($font === 'fontdyslexic') { - $config[] = $this->dyslexiaFont->getId(); - } - - if (!empty($config)) { - $this->config->setUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', json_encode(array_unique($config))); - } - - $output->advance(); - }); - - $this->config->deleteAppFromAllUsers('accessibility'); - - $output->finishProgress(); - } -} 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/ResponseDefinitions.php b/apps/theming/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..948fc792133 --- /dev/null +++ b/apps/theming/lib/ResponseDefinitions.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming; + +/** + * @psalm-type ThemingBackground = array{ + * backgroundImage: ?string, + * backgroundColor: string, + * primaryColor: string, + * version: int, + * } + */ +class ResponseDefinitions { +} diff --git a/apps/theming/lib/Service/BackgroundService.php b/apps/theming/lib/Service/BackgroundService.php index 4879ad1cbad..ee9466c3a36 100644 --- a/apps/theming/lib/Service/BackgroundService.php +++ b/apps/theming/lib/Service/BackgroundService.php @@ -3,34 +3,14 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net> - * - * @author Jan C. Borchardt <hey@jancborchardt.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Christopher Ng <chrng8@gmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Service; use InvalidArgumentException; use OC\User\NoUserException; use OCA\Theming\AppInfo\Application; -use OCA\Theming\ThemingDefaults; use OCP\Files\File; use OCP\Files\IAppData; use OCP\Files\IRootFolder; @@ -38,129 +18,202 @@ 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; class BackgroundService { - // true when the background is bright and need dark icons - public const THEMING_MODE_DARK = 'dark'; - public const DEFAULT_COLOR = '#0082c9'; - public const DEFAULT_ACCESSIBLE_COLOR = '#006aa3'; + public const DEFAULT_COLOR = '#00679e'; + public const DEFAULT_BACKGROUND_COLOR = '#00679e'; + /** + * One of our shipped background images is used + */ public const BACKGROUND_SHIPPED = 'shipped'; + /** + * A custom background image is used + */ public const BACKGROUND_CUSTOM = 'custom'; + /** + * The default background image is used + */ public const BACKGROUND_DEFAULT = 'default'; - public const BACKGROUND_DISABLED = 'disabled'; + /** + * Just a background color is used + */ + public const BACKGROUND_COLOR = 'color'; + + public const DEFAULT_BACKGROUND_IMAGE = 'jenna-kim-the-globe.webp'; - public const DEFAULT_BACKGROUND_IMAGE = 'kamil-porembinski-clouds.jpg'; + /** + * 'attribution': Name, artist and license + * 'description': Alternative text + * 'attribution_url': URL for attribution + * 'background_color': Cached mean color of the top part to calculate app menu colors and use as fallback + * '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', + 'attribution_url' => 'https://stocksnap.io/photo/soft-floral-XOYWCCW5PA', + 'background_color' => '#e4d2c1', + 'primary_color' => '#9f652f', + ], + 'ted-moravec-morning-fog.jpg' => [ + 'attribution' => 'Morning fog (Ted Moravec, Public Domain)', + 'description' => 'Background picture of a forest shrouded in fog', + 'attribution_url' => 'https://flickr.com/photos/tmoravec/52392410261', + 'background_color' => '#f6f7f6', + 'primary_color' => '#114c3b', + ], + 'stefanus-martanto-setyo-husodo-underwater-ocean.jpg' => [ + 'attribution' => 'Underwater ocean (Stefanus Martanto Setyo Husodo, CC0)', + 'description' => 'Background picture of an underwater ocean', + 'attribution_url' => 'https://stocksnap.io/photo/underwater-ocean-TJA9LBH4WS', + 'background_color' => '#003351', + 'primary_color' => '#04577e', + ], + 'zoltan-voros-rhythm-and-blues.jpg' => [ + 'attribution' => 'Rhythm and blues (Zoltán Vörös, CC BY)', + 'description' => 'Abstract background picture of sand dunes during night', + 'attribution_url' => 'https://flickr.com/photos/v923z/51634409289/', + 'background_color' => '#1c2437', + 'primary_color' => '#1c243c', + ], 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' => [ 'attribution' => 'Butterfly wing scale (Anatoly Mikhaltsov, CC BY-SA)', + 'description' => 'Background picture of a red-ish butterfly wing under microscope', 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:%D0%A7%D0%B5%D1%88%D1%83%D0%B9%D0%BA%D0%B8_%D0%BA%D1%80%D1%8B%D0%BB%D0%B0_%D0%B1%D0%B0%D0%B1%D0%BE%D1%87%D0%BA%D0%B8.jpg', + 'background_color' => '#652e11', 'primary_color' => '#a53c17', ], 'bernie-cetonia-aurata-take-off-composition.jpg' => [ 'attribution' => 'Cetonia aurata take off composition (Bernie, Public Domain)', + 'description' => 'Montage of a cetonia aurata bug that takes off with white background', 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Cetonia_aurata_take_off_composition_05172009.jpg', - 'theming' => self::THEMING_MODE_DARK, + 'background_color' => '#dee0d3', 'primary_color' => '#56633d', ], 'dejan-krsmanovic-ribbed-red-metal.jpg' => [ 'attribution' => 'Ribbed red metal (Dejan Krsmanovic, CC BY)', + 'description' => 'Abstract background picture of red ribbed metal with two horizontal white elements on top of it', 'attribution_url' => 'https://www.flickr.com/photos/dejankrsmanovic/42971456774/', + 'background_color' => '#9b171c', 'primary_color' => '#9c4236', ], 'eduardo-neves-pedra-azul.jpg' => [ 'attribution' => 'Pedra azul milky way (Eduardo Neves, CC BY-SA)', + 'description' => 'Background picture of the milky way during night with a mountain in front of it', 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Pedra_Azul_Milky_Way.jpg', + 'background_color' => '#1d242d', 'primary_color' => '#4f6071', ], 'european-space-agency-barents-bloom.jpg' => [ 'attribution' => 'Barents bloom (European Space Agency, CC BY-SA)', + 'description' => 'Abstract background picture of blooming barents in blue and green colors', 'attribution_url' => 'https://www.esa.int/ESA_Multimedia/Images/2016/08/Barents_bloom', + 'background_color' => '#1c383d', 'primary_color' => '#396475', ], 'hannes-fritz-flippity-floppity.jpg' => [ 'attribution' => 'Flippity floppity (Hannes Fritz, CC BY-SA)', + 'description' => 'Abstract background picture of many pairs of flip flops hanging on a wall in multiple colors', 'attribution_url' => 'http://hannes.photos/flippity-floppity', + 'background_color' => '#5b2d53', 'primary_color' => '#98415a', ], 'hannes-fritz-roulette.jpg' => [ 'attribution' => 'Roulette (Hannes Fritz, CC BY-SA)', + 'description' => 'Background picture of a rotating giant wheel during night', 'attribution_url' => 'http://hannes.photos/roulette', + 'background_color' => '#000000', 'primary_color' => '#845334', ], 'hannes-fritz-sea-spray.jpg' => [ 'attribution' => 'Sea spray (Hannes Fritz, CC BY-SA)', + 'description' => 'Background picture of a stone coast with fog and sea behind it', 'attribution_url' => 'http://hannes.photos/sea-spray', + 'background_color' => '#333f47', 'primary_color' => '#4f6071', ], - 'kamil-porembinski-clouds.jpg' => [ - 'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)', - 'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/', - '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', 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:NZ_Fern.(Blechnum_chambersii)_(11263534936).jpg', + 'background_color' => '#0c3c03', 'primary_color' => '#316b26', ], 'rawpixel-pink-tapioca-bubbles.jpg' => [ 'attribution' => 'Pink tapioca bubbles (Rawpixel, CC BY)', + 'description' => 'Abstract background picture of pink tapioca bubbles', 'attribution_url' => 'https://www.flickr.com/photos/byrawpixel/27665140298/in/photostream/', - 'theming' => self::THEMING_MODE_DARK, + 'background_color' => '#c56e95', 'primary_color' => '#7b4e7e', ], 'nasa-waxing-crescent-moon.jpg' => [ 'attribution' => 'Waxing crescent moon (NASA, Public Domain)', + 'description' => 'Background picture of glowing earth in foreground and moon in the background', 'attribution_url' => 'https://www.nasa.gov/image-feature/a-waxing-crescent-moon', + 'background_color' => '#000002', 'primary_color' => '#005ac1', ], 'tommy-chau-already.jpg' => [ 'attribution' => 'Cityscape (Tommy Chau, CC BY)', + 'description' => 'Background picture of a skyscraper city during night', 'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/16910999368', + 'background_color' => '#35229f', 'primary_color' => '#6a2af4', ], 'tommy-chau-lion-rock-hill.jpg' => [ 'attribution' => 'Lion rock hill (Tommy Chau, CC BY)', + 'description' => 'Background picture of mountains during sunset or sunrise', 'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/17136440246', - 'theming' => self::THEMING_MODE_DARK, + 'background_color' => '#cb92b7', 'primary_color' => '#7f4f70', ], 'lali-masriera-yellow-bricks.jpg' => [ 'attribution' => 'Yellow bricks (Lali Masriera, CC BY)', + 'description' => 'Background picture of yellow bricks with some yellow tubes', 'attribution_url' => 'https://www.flickr.com/photos/visualpanic/3982464447', - 'theming' => self::THEMING_MODE_DARK, + 'background_color' => '#c78a19', 'primary_color' => '#7f5700', ], ]; - private IRootFolder $rootFolder; - private IAppData $appData; - private IConfig $config; - private string $userId; - private ThemingDefaults $themingDefaults; - - public function __construct(IRootFolder $rootFolder, - IAppData $appData, - IConfig $config, - ?string $userId, - ThemingDefaults $themingDefaults) { - if ($userId === null) { - return; - } - - $this->rootFolder = $rootFolder; - $this->config = $config; - $this->userId = $userId; - $this->appData = $appData; - $this->themingDefaults = $themingDefaults; + 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'); + 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'); } /** @@ -171,46 +224,81 @@ class BackgroundService { * @throws PreConditionNotMetException * @throws NoUserException */ - public function setFileBackground($path): void { - $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM); - $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'); } - $this->getAppDataFolder()->newFile('background.jpg', $file->fopen('r')); + $meanColor = $this->calculateMeanColor($image); + if ($meanColor !== false) { + $this->setColorBackground($meanColor); + } + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM); } - public function setShippedBackground($fileName): void { - 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->config->setUserValue($this->userId, Application::APP_ID, 'background_image', $fileName); - $this->setColorBackground(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']); } - public function setColorBackground(string $color): void { + /** + * 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, ?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($userId, Application::APP_ID, 'background_color', $color); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR); } - public function deleteBackgroundImage(): void { - $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DISABLED); + 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; } } @@ -218,21 +306,109 @@ class BackgroundService { } /** + * Called when a new global background (backgroundMime) is uploaded (admin setting) + * This sets all necessary app config values + * @param resource|string $path + * @return string|null The fallback background color - if any + */ + 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->appConfig->setValueString(Application::APP_ID, 'background_color', $meanColor); + return $meanColor; + } + } + return null; + } + + /** + * 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(Image $image): false|string { + /** + * Small helper to ensure one channel is returned as 8byte hex + */ + function toHex(int $channel): string { + $hex = dechex($channel); + return match (strlen($hex)) { + 0 => '00', + 1 => '0' . $hex, + 2 => $hex, + default => 'ff', + }; + } + + $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())); + if ($resource === false) { + return false; + } + + $tempImage->setResource($resource); + if (!$tempImage->preciseResize(100, 7)) { + return false; + } + + $resource = $tempImage->resource(); + if ($resource === false) { + return false; + } + + $reds = []; + $greens = []; + $blues = []; + for ($y = 0; $y < 7; $y++) { + for ($x = 0; $x < 100; $x++) { + $value = imagecolorat($resource, $x, $y); + if ($value === false) { + continue; + } + $reds[] = ($value >> 16) & 0xFF; + $greens[] = ($value >> 8) & 0xFF; + $blues[] = $value & 0xFF; + } + } + $meanColor = '#' . toHex((int)(array_sum($reds) / count($reds))); + $meanColor .= toHex((int)(array_sum($greens) / count($greens))); + $meanColor .= toHex((int)(array_sum($blues) / count($blues))); + return $meanColor; + } + + /** * 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/JSDataService.php b/apps/theming/lib/Service/JSDataService.php index 90acd74b868..81198f8b3f5 100644 --- a/apps/theming/lib/Service/JSDataService.php +++ b/apps/theming/lib/Service/JSDataService.php @@ -3,64 +3,47 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Julien Veyssier <eneiluj@posteo.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Service; -use OCA\Theming\AppInfo\Application; use OCA\Theming\ThemingDefaults; use OCA\Theming\Util; -use OCP\IConfig; class JSDataService implements \JsonSerializable { - private ThemingDefaults $themingDefaults; - private Util $util; - private IConfig $appConfig; - private ThemesService $themesService; public function __construct( - ThemingDefaults $themingDefaults, - Util $util, - IConfig $appConfig, - ThemesService $themesService + private ThemingDefaults $themingDefaults, + private Util $util, + private ThemesService $themesService, ) { $this->themingDefaults = $themingDefaults; $this->util = $util; - $this->appConfig = $appConfig; $this->themesService = $themesService; } public function jsonSerialize(): array { return [ 'name' => $this->themingDefaults->getName(), - 'url' => $this->themingDefaults->getBaseUrl(), 'slogan' => $this->themingDefaults->getSlogan(), - 'color' => $this->themingDefaults->getColorPrimary(), - 'defaultColor' => $this->themingDefaults->getDefaultColorPrimary(), + + 'url' => $this->themingDefaults->getBaseUrl(), 'imprintUrl' => $this->themingDefaults->getImprintUrl(), 'privacyUrl' => $this->themingDefaults->getPrivacyUrl(), + + 'primaryColor' => $this->themingDefaults->getColorPrimary(), + 'backgroundColor' => $this->themingDefaults->getColorBackground(), + 'defaultPrimaryColor' => $this->themingDefaults->getDefaultColorPrimary(), + 'defaultBackgroundColor' => $this->themingDefaults->getDefaultColorBackground(), 'inverted' => $this->util->invertTextColor($this->themingDefaults->getColorPrimary()), + 'cacheBuster' => $this->util->getCacheBuster(), 'enabledThemes' => $this->themesService->getEnabledThemes(), + + // deprecated use primaryColor + 'color' => $this->themingDefaults->getColorPrimary(), + '' => 'color is deprecated since Nextcloud 29, use primaryColor instead' ]; } } diff --git a/apps/theming/lib/Service/ThemeInjectionService.php b/apps/theming/lib/Service/ThemeInjectionService.php index 8e55f614146..873d388081c 100644 --- a/apps/theming/lib/Service/ThemeInjectionService.php +++ b/apps/theming/lib/Service/ThemeInjectionService.php @@ -1,28 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Service; -use OCA\Theming\AppInfo\Application; +use OCA\Theming\ITheme; use OCA\Theming\Themes\DefaultTheme; use OCA\Theming\Util; use OCP\IConfig; @@ -31,24 +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 { @@ -56,42 +32,45 @@ class ThemeInjectionService { } } - public function injectHeaders() { + public function injectHeaders(): void { $themes = $this->themesService->getThemes(); $defaultTheme = $themes[$this->defaultTheme->getId()]; - $mediaThemes = array_filter($themes, function($theme) { + $mediaThemes = array_filter($themes, function ($theme) { // Check if the theme provides a media query return (bool)$theme->getMediaQuery(); }); // Default theme fallback - $this->addThemeHeader($defaultTheme->getId()); + $this->addThemeHeaders($defaultTheme); // Themes applied by media queries - foreach($mediaThemes as $theme) { - $this->addThemeHeader($theme->getId(), true, $theme->getMediaQuery()); + 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; } - $this->addThemeHeader($theme->getId(), false); + $this->addThemeHeaders($theme, false); } + + // Meta headers + $this->addThemeMetaHeaders($themes); } /** * Inject theme header into rendered page * - * @param string $themeId the theme ID + * @param ITheme $theme the theme * @param bool $plain request the :root syntax * @param string $media media query to use in the <link> element */ - private function addThemeHeader(string $themeId, bool $plain = true, string $media = null) { + private function addThemeHeaders(ITheme $theme, bool $plain = true, ?string $media = null): void { $linkToCSS = $this->urlGenerator->linkToRoute('theming.Theming.getThemeStylesheet', [ - 'themeId' => $themeId, + 'themeId' => $theme->getId(), 'plain' => $plain, 'v' => $this->util->getCacheBuster(), ]); @@ -102,4 +81,36 @@ class ThemeInjectionService { 'class' => 'theme' ]); } + + /** + * Inject meta headers into rendered page + * + * @param ITheme[] $themes the theme + */ + private function addThemeMetaHeaders(array $themes): void { + $metaHeaders = []; + + // Meta headers + foreach ($this->themesService->getThemes() as $theme) { + if (!empty($theme->getMeta())) { + foreach ($theme->getMeta() as $meta) { + if (!isset($meta['name']) || !isset($meta['content'])) { + continue; + } + + if (!isset($metaHeaders[$meta['name']])) { + $metaHeaders[$meta['name']] = []; + } + $metaHeaders[$meta['name']][] = $meta['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 eabd903734a..f49524cb62c 100644 --- a/apps/theming/lib/Service/ThemesService.php +++ b/apps/theming/lib/Service/ThemesService.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Service; @@ -33,48 +17,69 @@ 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, - LightTheme $lightTheme, - DarkTheme $darkTheme, - HighContrastTheme $highContrastTheme, - DarkHighContrastTheme $darkHighContrastTheme, - DyslexiaFont $dyslexiaFont) { - $this->userSession = $userSession; - $this->config = $config; + public function __construct( + private IUserSession $userSession, + private IConfig $config, + private LoggerInterface $logger, + private DefaultTheme $defaultTheme, + LightTheme $lightTheme, + private DarkTheme $darkTheme, + HighContrastTheme $highContrastTheme, + DarkHighContrastTheme $darkHighContrastTheme, + DyslexiaFont $dyslexiaFont, + ) { // Register themes $this->themesProviders = [ - $defaultTheme->getId() => $defaultTheme, - $lightTheme->getId() => $lightTheme, - $darkTheme->getId() => $darkTheme, - $highContrastTheme->getId() => $highContrastTheme, - $darkHighContrastTheme->getId() => $darkHighContrastTheme, - $dyslexiaFont->getId() => $dyslexiaFont, + $defaultTheme->getId() => $defaultTheme, + $lightTheme->getId() => $lightTheme, + $darkTheme->getId() => $darkTheme, + $highContrastTheme->getId() => $highContrastTheme, + $darkHighContrastTheme->getId() => $darkHighContrastTheme, + $dyslexiaFont->getId() => $dyslexiaFont, ]; } /** * Get the list of all registered themes - * + * * @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; } /** * Enable a theme for the logged-in user - * + * * @param ITheme $theme the theme to enable * @return string[] the enabled themes */ @@ -87,18 +92,18 @@ class ThemesService { } /** @var ITheme[] */ - $themes = array_filter(array_map(function($themeId) { + $themes = array_filter(array_map(function ($themeId) { return $this->getThemes()[$themeId]; }, $themesIds)); // Filtering all themes with the same type - $filteredThemes = array_filter($themes, function(ITheme $t) use ($theme) { + $filteredThemes = array_filter($themes, function (ITheme $t) use ($theme) { return $theme->getType() === $t->getType(); }); // Retrieve IDs only /** @var string[] */ - $filteredThemesIds = array_map(function(ITheme $t) { + $filteredThemesIds = array_map(function (ITheme $t) { return $t->getId(); }, array_values($filteredThemes)); @@ -110,7 +115,7 @@ class ThemesService { /** * Disable a theme for the logged-in user - * + * * @param ITheme $theme the theme to disable * @return string[] the enabled themes */ @@ -123,14 +128,14 @@ class ThemesService { $this->setEnabledThemes($enabledThemes); return $enabledThemes; } - + return $themesIds; } /** * Check whether a theme is enabled or not * for the logged-in user - * + * * @return bool */ public function isEnabled(ITheme $theme): bool { @@ -144,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); } @@ -169,9 +176,9 @@ class ThemesService { } /** - * Set the list of enabled themes + * Set the list of enabled themes * for the logged-in user - * + * * @param string[] $themes the list of enabled themes IDs */ private function setEnabledThemes(array $themes): void { diff --git a/apps/theming/lib/Settings/Admin.php b/apps/theming/lib/Settings/Admin.php index 0f0d85c147d..9fa0f2bb0e7 100644 --- a/apps/theming/lib/Settings/Admin.php +++ b/apps/theming/lib/Settings/Admin.php @@ -1,66 +1,37 @@ <?php + /** - * @copyright Copyright (c) 2016 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Settings; use OCA\Theming\AppInfo\Application; +use OCA\Theming\Controller\ThemingController; use OCA\Theming\ImageManager; +use OCA\Theming\Service\BackgroundService; use OCA\Theming\ThemingDefaults; 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; class Admin implements IDelegatedSettings { - private string $appName; - private IConfig $config; - private IL10N $l; - private ThemingDefaults $themingDefaults; - private IInitialState $initialState; - private IURLGenerator $urlGenerator; - private ImageManager $imageManager; - public function __construct(string $appName, - IConfig $config, - IL10N $l, - ThemingDefaults $themingDefaults, - IInitialState $initialState, - IURLGenerator $urlGenerator, - ImageManager $imageManager) { - $this->appName = $appName; - $this->config = $config; - $this->l = $l; - $this->themingDefaults = $themingDefaults; - $this->initialState = $initialState; - $this->urlGenerator = $urlGenerator; - $this->imageManager = $imageManager; + public function __construct( + private string $appName, + private IConfig $config, + private IL10N $l, + private ThemingDefaults $themingDefaults, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, + private ImageManager $imageManager, + private INavigationManager $navigationManager, + ) { } /** @@ -75,14 +46,24 @@ class Admin implements IDelegatedSettings { $errorMessage = $this->l->t('You are already using a custom theme. Theming app settings might be overwritten by that.'); } + $allowedMimeTypes = array_reduce(ThemingController::VALID_UPLOAD_KEYS, function ($carry, $key) { + $carry[$key] = $this->imageManager->getSupportedUploadImageFormats($key); + return $carry; + }, []); + $this->initialState->provideInitialState('adminThemingParameters', [ 'isThemable' => $themable, 'notThemableErrorMessage' => $errorMessage, 'name' => $this->themingDefaults->getEntity(), 'url' => $this->themingDefaults->getBaseUrl(), 'slogan' => $this->themingDefaults->getSlogan(), - 'color' => $this->themingDefaults->getDefaultColorPrimary(), + 'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(), + 'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(), 'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''), + 'allowedMimeTypes' => $allowedMimeTypes, + 'backgroundURL' => $this->imageManager->getImageUrl('background'), + 'defaultBackgroundURL' => $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE), + 'defaultBackgroundColor' => BackgroundService::DEFAULT_BACKGROUND_COLOR, 'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''), 'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''), 'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''), @@ -92,6 +73,7 @@ class Admin implements IDelegatedSettings { 'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'), 'canThemeIcons' => $this->imageManager->shouldReplaceIcons(), 'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(), + 'defaultApps' => $this->navigationManager->getDefaultEntryIds(), ]); Util::addScript($this->appName, 'admin-theming'); @@ -108,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 2fcc81a9279..a1ea568d9f2 100644 --- a/apps/theming/lib/Settings/AdminSection.php +++ b/apps/theming/lib/Settings/AdminSection.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Settings; @@ -27,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, + ) { } /** @@ -59,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 5b0dc742574..f14deeb35f0 100644 --- a/apps/theming/lib/Settings/Personal.php +++ b/apps/theming/lib/Settings/Personal.php @@ -1,63 +1,39 @@ <?php + /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * @copyright Copyright (c) 2019 Janis Köhr <janiskoehr@icloud.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Settings; use OCA\Theming\ITheme; +use OCA\Theming\Service\BackgroundService; use OCA\Theming\Service\ThemesService; use OCA\Theming\ThemingDefaults; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; +use OCP\INavigationManager; use OCP\Settings\ISettings; use OCP\Util; class Personal implements ISettings { - protected string $appName; - private IConfig $config; - private ThemesService $themesService; - private IInitialState $initialStateService; - private ThemingDefaults $themingDefaults; - - public function __construct(string $appName, - IConfig $config, - ThemesService $themesService, - IInitialState $initialStateService, - ThemingDefaults $themingDefaults) { - $this->appName = $appName; - $this->config = $config; - $this->themesService = $themesService; - $this->initialStateService = $initialStateService; - $this->themingDefaults = $themingDefaults; + public function __construct( + protected string $appName, + private string $userId, + private IConfig $config, + private ThemesService $themesService, + private IInitialState $initialStateService, + private ThemingDefaults $themingDefaults, + private INavigationManager $navigationManager, + ) { } public function getForm(): TemplateResponse { $enforcedTheme = $this->config->getSystemValueString('enforce_theme', ''); - $themes = array_map(function($theme) { + $themes = array_map(function ($theme) { return [ 'id' => $theme->getId(), 'type' => $theme->getType(), @@ -69,14 +45,42 @@ class Personal implements ISettings { }, $this->themesService->getThemes()); if ($enforcedTheme !== '') { - $themes = array_filter($themes, function($theme) use ($enforcedTheme) { + $themes = array_filter($themes, function ($theme) use ($enforcedTheme) { return $theme['type'] !== ITheme::TYPE_THEME || $theme['id'] === $enforcedTheme; }); } + // 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); + + /** + * Admin theming + */ + $this->initialStateService->provideInitialState('themingDefaults', [ + /** URL of admin configured background image */ + 'backgroundImage' => $this->themingDefaults->getBackground(), + /** `backgroundColor` if disabled, mime type if defined and empty by default */ + 'backgroundMime' => $this->config->getAppValue('theming', 'backgroundMime', ''), + /** Admin configured background color */ + 'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(), + /** Admin configured primary color */ + 'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(), + /** Nextcloud default background image */ + 'defaultShippedBackground' => BackgroundService::DEFAULT_BACKGROUND_IMAGE, + ]); + + $this->initialStateService->provideInitialState('userBackgroundImage', $this->config->getUserValue($this->userId, 'theming', 'background_image', BackgroundService::BACKGROUND_DEFAULT)); $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' => $forcedDefaultEntry + ]); Util::addScript($this->appName, 'personal-theming'); @@ -93,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 4165a733fb2..0a9361d5533 100644 --- a/apps/theming/lib/Settings/PersonalSection.php +++ b/apps/theming/lib/Settings/PersonalSection.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Settings; @@ -29,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. * @@ -45,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, + ) { } /** @@ -88,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/SetupChecks/PhpImagickModule.php b/apps/theming/lib/SetupChecks/PhpImagickModule.php new file mode 100644 index 00000000000..cf9e51eee33 --- /dev/null +++ b/apps/theming/lib/SetupChecks/PhpImagickModule.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\SetupChecks; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpImagickModule implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('PHP Imagick module'); + } + + public function getCategory(): string { + return 'php'; + } + + public function run(): SetupResult { + if (!extension_loaded('imagick')) { + return SetupResult::info( + $this->l10n->t('The PHP module "imagick" is not enabled although the theming app is. For favicon generation to work correctly, you need to install and enable this module.'), + $this->urlGenerator->linkToDocs('admin-php-modules') + ); + } elseif (count(\Imagick::queryFormats('SVG')) === 0) { + return SetupResult::info( + $this->l10n->t('The PHP module "imagick" in this instance has no SVG support. For better compatibility it is recommended to install it.'), + $this->urlGenerator->linkToDocs('admin-php-modules') + ); + } else { + return SetupResult::success(); + } + } +} diff --git a/apps/theming/lib/Themes/CommonThemeTrait.php b/apps/theming/lib/Themes/CommonThemeTrait.php index 17591c232bb..74979770b70 100644 --- a/apps/theming/lib/Themes/CommonThemeTrait.php +++ b/apps/theming/lib/Themes/CommonThemeTrait.php @@ -1,47 +1,35 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Themes; -use OCA\Theming\Util; -use OCA\Theming\ImageManager; use OCA\Theming\AppInfo\Application; +use OCA\Theming\ImageManager; use OCA\Theming\Service\BackgroundService; +use OCA\Theming\ThemingDefaults; +use OCA\Theming\Util; 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 * will change in between. */ - protected function generatePrimaryVariables(string $colorMainBackground, string $colorMainText): array { - $colorPrimaryLight = $this->util->mix($this->primaryColor, $colorMainBackground, -80); - $colorPrimaryElement = $this->util->elementColor($this->primaryColor); - $colorPrimaryElementDefault = $this->util->elementColor($this->defaultPrimaryColor); + protected function generatePrimaryVariables(string $colorMainBackground, string $colorMainText, bool $highContrast = false): array { + $isBrightColor = $this->util->isBrightColor($colorMainBackground); + $colorPrimaryElement = $this->util->elementColor($this->primaryColor, $isBrightColor, $colorMainBackground, $highContrast); + $colorPrimaryLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80); $colorPrimaryElementLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80); + $invertPrimaryTextColor = $this->util->invertTextColor($colorPrimaryElement); // primary related colours return [ @@ -52,26 +40,27 @@ trait CommonThemeTrait { // ⚠️ Using 'no' as a value to make sure we specify an // invalid one with no fallback. 'unset' could here fallback to some // other theme with media queries - '--primary-invert-if-bright' => $this->util->invertTextColor($this->primaryColor) ? 'invert(100%)' : 'no', + '--primary-invert-if-bright' => $this->util->invertTextColor($colorPrimaryElement) ? 'invert(100%)' : 'no', + '--primary-invert-if-dark' => $this->util->invertTextColor($colorPrimaryElement) ? 'no' : 'invert(100%)', '--color-primary' => $this->primaryColor, - '--color-primary-default' => $this->defaultPrimaryColor, '--color-primary-text' => $this->util->invertTextColor($this->primaryColor) ? '#000000' : '#ffffff', '--color-primary-hover' => $this->util->mix($this->primaryColor, $colorMainBackground, 60), '--color-primary-light' => $colorPrimaryLight, '--color-primary-light-text' => $this->util->mix($this->primaryColor, $this->util->invertTextColor($colorPrimaryLight) ? '#000000' : '#ffffff', -20), '--color-primary-light-hover' => $this->util->mix($colorPrimaryLight, $colorMainText, 90), - '--color-primary-text-dark' => $this->util->darken($this->util->invertTextColor($this->primaryColor) ? '#000000' : '#ffffff', 7), // used for buttons, inputs... '--color-primary-element' => $colorPrimaryElement, - '--color-primary-element-default-hover' => $this->util->mix($colorPrimaryElementDefault, $colorMainBackground, 60), - '--color-primary-element-text' => $this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff', - '--color-primary-element-hover' => $this->util->mix($colorPrimaryElement, $colorMainBackground, 60), + '--color-primary-element-hover' => $invertPrimaryTextColor ? $this->util->lighten($colorPrimaryElement, 4) : $this->util->darken($colorPrimaryElement, 4), + '--color-primary-element-text' => $invertPrimaryTextColor ? '#000000' : '#ffffff', + // mostly used for disabled states + '--color-primary-element-text-dark' => $invertPrimaryTextColor ? $this->util->lighten('#000000', 4) : $this->util->darken('#ffffff', 4), + + // used for hover/focus states '--color-primary-element-light' => $colorPrimaryElementLight, - '--color-primary-element-light-text' => $this->util->mix($colorPrimaryElement, $this->util->invertTextColor($colorPrimaryElementLight) ? '#000000' : '#ffffff', -20), '--color-primary-element-light-hover' => $this->util->mix($colorPrimaryElementLight, $colorMainText, 90), - '--color-primary-element-text-dark' => $this->util->darken($this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff', 7), + '--color-primary-element-light-text' => $this->util->mix($colorPrimaryElement, $this->util->invertTextColor($colorPrimaryElementLight) ? '#000000' : '#ffffff', -20), // to use like this: background-image: var(--gradient-primary-background); '--gradient-primary-background' => 'linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)', @@ -84,34 +73,34 @@ trait CommonThemeTrait { protected function generateGlobalBackgroundVariables(): array { $backgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor'; $hasCustomLogoHeader = $this->util->isLogoThemed(); - $isDefaultPrimaryBright = $this->util->invertTextColor($this->defaultPrimaryColor); - - $variables = []; + $backgroundColor = $this->themingDefaults->getColorBackground(); // Default last fallback values - $variables['--image-background-default'] = "url('" . $this->themingDefaults->getBackground() . "')"; - $variables['--color-background-plain'] = $this->defaultPrimaryColor; - - // If primary as background has been request or if we have a custom primary colour - // let's not define the background image - if ($backgroundDeleted) { - $variables['--color-background-plain'] = $this->defaultPrimaryColor; - $variables['--image-background-plain'] = 'yes'; - // If no background image is set, we need to check against the shown primary colour - $variables['--background-image-invert-if-bright'] = $isDefaultPrimaryBright ? 'invert(100%)' : 'no'; - } + $variables = [ + '--color-background-plain' => $backgroundColor, + '--color-background-plain-text' => $this->util->invertTextColor($backgroundColor) ? '#000000' : '#ffffff', + '--background-image-invert-if-bright' => $this->util->invertTextColor($backgroundColor) ? 'invert(100%)' : 'no', + ]; // Register image variables only if custom-defined foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $image) { if ($this->imageManager->hasImage($image)) { $imageUrl = $this->imageManager->getImageUrl($image); - // --image-background is overridden by user theming $variables["--image-$image"] = "url('" . $imageUrl . "')"; + } elseif ($image === 'background') { + // Apply default background if nothing is configured + $variables['--image-background'] = "url('" . $this->themingDefaults->getBackground($this->isDarkVariant) . "')"; } } + // If a background has been requested let's not define the background image + if ($backgroundDeleted) { + $variables['--image-background'] = 'none'; + } + if ($hasCustomLogoHeader) { - $variables["--image-logoheader-custom"] = 'true'; + // prevent inverting the logo on bright colors if customized + $variables['--image-logoheader-custom'] = 'true'; } return $variables; @@ -126,36 +115,40 @@ trait CommonThemeTrait { && !$this->themingDefaults->isUserThemingDisabled() && $this->appManager->isEnabledForUser(Application::APP_ID)) { $backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT); + $backgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', $this->themingDefaults->getColorBackground()); + $currentVersion = (int)$this->config->getUserValue($user->getUID(), Application::APP_ID, 'userCacheBuster', '0'); - $isPrimaryBright = $this->util->invertTextColor($this->primaryColor); - - // The user removed the background - if ($backgroundImage === BackgroundService::BACKGROUND_DISABLED) { - return [ - '--image-background' => 'no', - '--color-background-plain' => $this->primaryColor, - // If no background image is set, we need to check against the shown primary colour - '--background-image-invert-if-bright' => $isPrimaryBright ? 'invert(100%)' : 'no', - ]; + $isBackgroundBright = $this->util->invertTextColor($backgroundColor); + $backgroundTextColor = $this->util->invertTextColor($backgroundColor) ? '#000000' : '#ffffff'; + + $variables = [ + '--color-background-plain' => $backgroundColor, + '--color-background-plain-text' => $backgroundTextColor, + '--background-image-invert-if-bright' => $isBackgroundBright ? 'invert(100%)' : 'no', + ]; + + // Only use a background color without an image + if ($backgroundImage === BackgroundService::BACKGROUND_COLOR) { + // Might be defined already by admin theming, needs to be overridden + $variables['--image-background'] = 'none'; } // The user uploaded a custom background if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) { $cacheBuster = substr(sha1($user->getUID() . '_' . $currentVersion), 0, 8); - return [ - '--image-background' => "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')", - '--color-background-plain' => $this->themingDefaults->getColorPrimary(), - ]; + $variables['--image-background'] = "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')"; } // The user picked a shipped background if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) { - return [ - '--image-background' => "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')", - '--color-background-plain' => $this->themingDefaults->getColorPrimary(), - '--background-image-invert-if-bright' => BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage]['theming'] ?? null === BackgroundService::THEMING_MODE_DARK ? 'invert(100%)' : 'no', - ]; + $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") . "')"; } + + return $variables; } return []; diff --git a/apps/theming/lib/Themes/DarkHighContrastTheme.php b/apps/theming/lib/Themes/DarkHighContrastTheme.php index 5636dacf1d9..0c8b436d660 100644 --- a/apps/theming/lib/Themes/DarkHighContrastTheme.php +++ b/apps/theming/lib/Themes/DarkHighContrastTheme.php @@ -1,26 +1,9 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Themes; @@ -32,10 +15,6 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme { return 'dark-highcontrast'; } - public function getMediaQuery(): string { - return '(prefers-color-scheme: dark) and (prefers-contrast: more)'; - } - public function getTitle(): string { return $this->l->t('Dark theme with high contrast mode'); } @@ -48,6 +27,10 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme { return $this->l->t('Similar to the high contrast mode, but with dark colours.'); } + public function getMediaQuery(): string { + return '(prefers-color-scheme: dark) and (prefers-contrast: more)'; + } + /** * Keep this consistent with other HighContrast Themes */ @@ -56,19 +39,24 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme { $colorMainText = '#ffffff'; $colorMainBackground = '#000000'; - $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + + $colorError = '#ff5252'; + $colorWarning = '#ffcc00'; + $colorSuccess = '#42a942'; + $colorInfo = '#38c0ff'; return array_merge( $defaultVariables, - $this->generatePrimaryVariables($colorMainBackground, $colorMainText), + $this->generatePrimaryVariables($colorMainBackground, $colorMainText, true), [ '--color-main-background' => $colorMainBackground, '--color-main-background-rgb' => $colorMainBackgroundRGB, '--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), 1)', '--color-main-text' => $colorMainText, - '--color-background-dark' => $this->util->lighten($colorMainBackground, 30), - '--color-background-darker' => $this->util->lighten($colorMainBackground, 30), + '--color-background-dark' => $this->util->lighten($colorMainBackground, 25), + '--color-background-darker' => $this->util->lighten($colorMainBackground, 25), '--color-main-background-blur' => $colorMainBackground, '--filter-background-blur' => 'none', @@ -81,7 +69,27 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme { '--color-text-light' => $colorMainText, '--color-text-lighter' => $colorMainText, - '--color-scrollbar' => $this->util->lighten($colorMainBackground, 35), + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->lighten($colorError, 10), + '--color-error-text' => $this->util->lighten($colorError, 25), + + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->lighten($colorWarning, 10), + '--color-warning-text' => $this->util->lighten($colorWarning, 10), + + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->lighten($colorSuccess, 10), + '--color-success-text' => $this->util->lighten($colorSuccess, 35), + + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->lighten($colorInfo, 10), + '--color-info-text' => $this->util->lighten($colorInfo, 20), + + '--color-scrollbar' => 'auto transparent', // used for the icon loading animation '--color-loading-light' => '#000000', @@ -92,6 +100,7 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme { '--color-border' => $this->util->lighten($colorMainBackground, 50), '--color-border-dark' => $this->util->lighten($colorMainBackground, 50), + '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 55), ] ); } diff --git a/apps/theming/lib/Themes/DarkTheme.php b/apps/theming/lib/Themes/DarkTheme.php index 588f8f5a8a6..fd273d4697d 100644 --- a/apps/theming/lib/Themes/DarkTheme.php +++ b/apps/theming/lib/Themes/DarkTheme.php @@ -1,26 +1,9 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Themes; @@ -28,14 +11,12 @@ use OCA\Theming\ITheme; class DarkTheme extends DefaultTheme implements ITheme { + protected bool $isDarkVariant = true; + public function getId(): string { return 'dark'; } - public function getMediaQuery(): string { - return '(prefers-color-scheme: dark)'; - } - public function getTitle(): string { return $this->l->t('Dark theme'); } @@ -48,17 +29,34 @@ class DarkTheme extends DefaultTheme implements ITheme { return $this->l->t('A dark theme to ease your eyes by reducing the overall luminosity and brightness.'); } + public function getMediaQuery(): string { + return '(prefers-color-scheme: dark)'; + } + + public function getMeta(): array { + // https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme + return [[ + 'name' => 'color-scheme', + 'content' => 'dark', + ]]; + } + public function getCSSVariables(): array { $defaultVariables = parent::getCSSVariables(); - $colorMainText = '#D8D8D8'; + $colorMainText = '#EBEBEB'; $colorMainBackground = '#171717'; $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); - $colorTextMaxcontrast = $this->util->darken($colorMainText, 30); + $colorTextMaxcontrast = $this->util->darken($colorMainText, 32); $colorBoxShadow = $this->util->darken($colorMainBackground, 70); $colorBoxShadowRGB = join(',', $this->util->hexToRGB($colorBoxShadow)); + $colorError = '#FF3333'; + $colorWarning = '#FFCC00'; + $colorSuccess = '#3B973B'; + $colorInfo = '#00AEFF'; + return array_merge( $defaultVariables, $this->generatePrimaryVariables($colorMainBackground, $colorMainText), @@ -66,8 +64,7 @@ class DarkTheme extends DefaultTheme implements ITheme { '--color-main-text' => $colorMainText, '--color-main-background' => $colorMainBackground, '--color-main-background-rgb' => $colorMainBackgroundRGB, - - '--color-scrollbar' => $this->util->lighten($colorMainBackground, 15), + '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .85)', '--color-background-hover' => $this->util->lighten($colorMainBackground, 4), '--color-background-dark' => $this->util->lighten($colorMainBackground, 7), @@ -78,9 +75,27 @@ class DarkTheme extends DefaultTheme implements ITheme { '--color-text-maxcontrast' => $colorTextMaxcontrast, '--color-text-maxcontrast-default' => $colorTextMaxcontrast, - '--color-text-maxcontrast-background-blur' => $this->util->lighten($colorTextMaxcontrast, 2), - '--color-text-light' => $this->util->darken($colorMainText, 10), - '--color-text-lighter' => $this->util->darken($colorMainText, 20), + '--color-text-maxcontrast-background-blur' => $this->util->lighten($colorTextMaxcontrast, 6), + '--color-text-light' => 'var(--color-main-text)', // deprecated + '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated + + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->lighten($colorError, 10), + '--color-error-text' => $this->util->lighten($colorError, 15), + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->lighten($colorWarning, 10), + '--color-warning-text' => $colorWarning, + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->lighten($colorSuccess, 10), + '--color-success-text' => $this->util->lighten($colorSuccess, 15), + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->lighten($colorInfo, 10), + '--color-info-text' => $colorInfo, + '--color-favorite' => '#ffde00', // used for the icon loading animation '--color-loading-light' => '#777', @@ -91,7 +106,7 @@ class DarkTheme extends DefaultTheme implements ITheme { '--color-border' => $this->util->lighten($colorMainBackground, 7), '--color-border-dark' => $this->util->lighten($colorMainBackground, 14), - '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 30), + '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 40), '--background-invert-if-dark' => 'invert(100%)', '--background-invert-if-bright' => 'no', diff --git a/apps/theming/lib/Themes/DefaultTheme.php b/apps/theming/lib/Themes/DefaultTheme.php index cb4ed510068..bdd3048a498 100644 --- a/apps/theming/lib/Themes/DefaultTheme.php +++ b/apps/theming/lib/Themes/DefaultTheme.php @@ -1,79 +1,43 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Themes; +use OC\AppFramework\Http\Request; use OCA\Theming\ImageManager; use OCA\Theming\ITheme; -use OCA\Theming\Service\BackgroundService; use OCA\Theming\ThemingDefaults; 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(); - - // Override default defaultPrimaryColor if set to improve accessibility - if ($this->primaryColor === BackgroundService::DEFAULT_COLOR) { - $this->primaryColor = BackgroundService::DEFAULT_ACCESSIBLE_COLOR; - } } public function getId(): string { @@ -100,21 +64,48 @@ class DefaultTheme implements ITheme { return ''; } + public function getMeta(): array { + return []; + } + public function getCSSVariables(): array { $colorMainText = '#222222'; $colorMainTextRgb = join(',', $this->util->hexToRGB($colorMainText)); - $colorTextMaxcontrast = $this->util->lighten($colorMainText, 33); + // Color that still provides enough contrast for text, so we need a ratio of 4.5:1 on main background AND hover + $colorTextMaxcontrast = '#6b6b6b'; // 4.5 : 1 for hover background and background dark $colorMainBackground = '#ffffff'; $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); $colorBoxShadow = $this->util->darken($colorMainBackground, 70); $colorBoxShadowRGB = join(',', $this->util->hexToRGB($colorBoxShadow)); + $colorError = '#DB0606'; + $colorWarning = '#A37200'; + $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%', @@ -132,61 +123,96 @@ class DefaultTheme implements ITheme { '--color-text-maxcontrast' => $colorTextMaxcontrast, '--color-text-maxcontrast-default' => $colorTextMaxcontrast, '--color-text-maxcontrast-background-blur' => $this->util->darken($colorTextMaxcontrast, 7), - '--color-text-light' => $colorMainText, - '--color-text-lighter' => $this->util->lighten($colorMainText, 33), - - '--color-scrollbar' => 'rgba(' . $colorMainTextRgb . ', .15)', - - // info/warning/success feedback colours - '--color-error' => '#e9322d', - '--color-error-rgb' => join(',', $this->util->hexToRGB('#e9322d')), - '--color-error-hover' => $this->util->mix('#e9322d', $colorMainBackground, 60), - '--color-warning' => '#eca700', - '--color-warning-rgb' => join(',', $this->util->hexToRGB('#eca700')), - '--color-warning-hover' => $this->util->mix('#eca700', $colorMainBackground, 60), - '--color-success' => '#46ba61', - '--color-success-rgb' => join(',', $this->util->hexToRGB('#46ba61')), - '--color-success-hover' => $this->util->mix('#46ba61', $colorMainBackground, 60), + '--color-text-light' => 'var(--color-main-text)', // deprecated + '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated + + '--color-scrollbar' => 'var(--color-border-maxcontrast) transparent', + + // error/warning/success/info feedback colours + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->mix($colorError, $colorMainBackground, 75), + '--color-error-text' => $this->util->darken($colorError, 5), + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->darken($colorWarning, 5), + '--color-warning-text' => $this->util->darken($colorWarning, 7), + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->mix($colorSuccess, $colorMainBackground, 80), + '--color-success-text' => $this->util->darken($colorSuccess, 4), + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->mix($colorInfo, $colorMainBackground, 80), + '--color-info-text' => $this->util->darken($colorInfo, 4), + '--color-favorite' => '#A37200', // used for the icon loading animation '--color-loading-light' => '#cccccc', '--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), - '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 42), + '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 51), - '--font-face' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, sans-serif, 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + '--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', '--animation-slow' => '300ms', // Default variables -------------------------------------------- - '--border-radius' => '3px', - '--border-radius-large' => '10px', - // pill-style button, value is large so big buttons also have correct roundness + // Border width for input elements such as text fields and selects + '--border-width-input' => '1px', + '--border-width-input-focused' => '2px', + + // 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-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 2629ac588c6..2448de7b3c8 100644 --- a/apps/theming/lib/Themes/DyslexiaFont.php +++ b/apps/theming/lib/Themes/DyslexiaFont.php @@ -1,26 +1,9 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Themes; @@ -60,32 +43,23 @@ 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 4bb77b8c961..5b51114a32f 100644 --- a/apps/theming/lib/Themes/HighContrastTheme.php +++ b/apps/theming/lib/Themes/HighContrastTheme.php @@ -1,26 +1,9 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Themes; @@ -32,10 +15,6 @@ class HighContrastTheme extends DefaultTheme implements ITheme { return 'light-highcontrast'; } - public function getMediaQuery(): string { - return '(prefers-contrast: more)'; - } - public function getTitle(): string { return $this->l->t('High contrast mode'); } @@ -48,6 +27,10 @@ class HighContrastTheme extends DefaultTheme implements ITheme { return $this->l->t('A high contrast mode to ease your navigation. Visual quality will be reduced but clarity will be increased.'); } + public function getMediaQuery(): string { + return '(prefers-contrast: more)'; + } + /** * Keep this consistent with other HighContrast Themes */ @@ -56,19 +39,27 @@ class HighContrastTheme extends DefaultTheme implements ITheme { $colorMainText = '#000000'; $colorMainBackground = '#ffffff'; - $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + $colorError = '#D10000'; + $colorWarning = '#995900'; + $colorSuccess = '#207830'; + $colorInfo = '#006DA8'; + + $primaryVariables = $this->generatePrimaryVariables($colorMainBackground, $colorMainText, true); return array_merge( $defaultVariables, - $this->generatePrimaryVariables($colorMainBackground, $colorMainText), + $primaryVariables, [ + '--color-primary-element-text-dark' => $primaryVariables['--color-primary-element-text'], + '--color-main-background' => $colorMainBackground, '--color-main-background-rgb' => $colorMainBackgroundRGB, '--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), 1)', '--color-main-text' => $colorMainText, - '--color-background-dark' => $this->util->darken($colorMainBackground, 30), - '--color-background-darker' => $this->util->darken($colorMainBackground, 30), + '--color-background-dark' => $this->util->darken($colorMainBackground, 20), + '--color-background-darker' => $this->util->darken($colorMainBackground, 20), '--color-main-background-blur' => $colorMainBackground, '--filter-background-blur' => 'none', @@ -81,7 +72,29 @@ class HighContrastTheme extends DefaultTheme implements ITheme { '--color-text-light' => $colorMainText, '--color-text-lighter' => $colorMainText, - '--color-scrollbar' => $this->util->darken($colorMainBackground, 25), + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->darken($colorError, 8), + '--color-error-text' => $this->util->darken($colorError, 17), + + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->darken($colorWarning, 7), + '--color-warning-text' => $this->util->darken($colorWarning, 13), + + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->darken($colorInfo, 7), + '--color-info-text' => $this->util->darken($colorInfo, 15), + + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->darken($colorSuccess, 7), + '--color-success-text' => $this->util->darken($colorSuccess, 14), + + '--color-favorite' => '#936B06', + + '--color-scrollbar' => 'auto transparent', // used for the icon loading animation '--color-loading-light' => '#dddddd', @@ -92,6 +105,10 @@ 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/Themes/LightTheme.php b/apps/theming/lib/Themes/LightTheme.php index e988f612226..714156d4721 100644 --- a/apps/theming/lib/Themes/LightTheme.php +++ b/apps/theming/lib/Themes/LightTheme.php @@ -1,37 +1,13 @@ <?php + declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Themes; -use OCA\Theming\ImageManager; -use OCA\Theming\ThemingDefaults; -use OCA\Theming\Util; use OCA\Theming\ITheme; -use OCA\Theming\Themes\DefaultTheme; -use OCP\IConfig; -use OCP\IL10N; -use OCP\IURLGenerator; class LightTheme extends DefaultTheme implements ITheme { @@ -39,10 +15,6 @@ class LightTheme extends DefaultTheme implements ITheme { return 'light'; } - public function getType(): int { - return ITheme::TYPE_THEME; - } - public function getTitle(): string { return $this->l->t('Light theme'); } @@ -58,4 +30,12 @@ class LightTheme extends DefaultTheme implements ITheme { public function getMediaQuery(): string { return '(prefers-color-scheme: light)'; } + + public function getMeta(): array { + // https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme + return [[ + 'name' => 'color-scheme', + 'content' => 'light', + ]]; + } } diff --git a/apps/theming/lib/ThemingDefaults.php b/apps/theming/lib/ThemingDefaults.php index 6cb204d1b1b..04f56895fa3 100644 --- a/apps/theming/lib/ThemingDefaults.php +++ b/apps/theming/lib/ThemingDefaults.php @@ -1,42 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Bjoern Schiessle <bjoern@schiessle.org> - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Guillaume COMPAGNON <gcompagnon@outlook.com> - * @author Jan-Christoph Borchardt <hey@jancborchardt.net> - * @author Joachim Bauch <bauch@struktur.de> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julien Veyssier <eneiluj@posteo.net> - * @author Julius Haertl <jus@bitgrid.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Weimann <mail@michael-weimann.eu> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Patrik Kernstock <info@pkern.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; @@ -46,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; @@ -55,22 +22,14 @@ use OCP\IUserSession; class ThemingDefaults extends \OC_Defaults { - private IConfig $config; - private IL10N $l; - private ImageManager $imageManager; - private IUserSession $userSession; - private IURLGenerator $urlGenerator; - private ICacheFactory $cacheFactory; - private Util $util; - private IAppManager $appManager; - private INavigationManager $navigationManager; - private string $name; private string $title; private string $entity; private string $productName; private string $url; - private string $color; + private string $backgroundColor; + private string $primaryColor; + private string $docBaseUrl; private string $iTunesAppId; private string $iOSClientUrl; @@ -79,47 +38,34 @@ class ThemingDefaults extends \OC_Defaults { /** * ThemingDefaults constructor. - * - * @param IConfig $config - * @param IL10N $l - * @param ImageManager $imageManager - * @param IUserSession $userSession - * @param IURLGenerator $urlGenerator - * @param ICacheFactory $cacheFactory - * @param Util $util - * @param IAppManager $appManager */ - public function __construct(IConfig $config, - IL10N $l, - IUserSession $userSession, - IURLGenerator $urlGenerator, - ICacheFactory $cacheFactory, - Util $util, - ImageManager $imageManager, - IAppManager $appManager, - INavigationManager $navigationManager + public function __construct( + private IConfig $config, + private IAppConfig $appConfig, + private IL10N $l, + private IUserSession $userSession, + private IURLGenerator $urlGenerator, + private ICacheFactory $cacheFactory, + private Util $util, + private ImageManager $imageManager, + private IAppManager $appManager, + private INavigationManager $navigationManager, + private BackgroundService $backgroundService, ) { parent::__construct(); - $this->config = $config; - $this->l = $l; - $this->imageManager = $imageManager; - $this->userSession = $userSession; - $this->urlGenerator = $urlGenerator; - $this->cacheFactory = $cacheFactory; - $this->util = $util; - $this->appManager = $appManager; - $this->navigationManager = $navigationManager; $this->name = parent::getName(); $this->title = parent::getTitle(); $this->entity = parent::getEntity(); $this->productName = parent::getProductName(); $this->url = parent::getBaseUrl(); - $this->color = parent::getColorPrimary(); + $this->primaryColor = parent::getColorPrimary(); + $this->backgroundColor = parent::getColorBackground(); $this->iTunesAppId = parent::getiTunesAppId(); $this->iOSClientUrl = parent::getiOSClientUrl(); $this->AndroidClientUrl = parent::getAndroidClientUrl(); $this->FDroidClientUrl = parent::getFDroidClientUrl(); + $this->docBaseUrl = parent::getDocBaseUrl(); } public function getName() { @@ -163,14 +109,23 @@ class ThemingDefaults extends \OC_Defaults { return (string)$this->config->getAppValue('theming', 'privacyUrl', ''); } + public function getDocBaseUrl() { + return (string)$this->config->getAppValue('theming', 'docBaseUrl', $this->docBaseUrl); + } + public function getShortFooter() { $slogan = $this->getSlogan(); $baseUrl = $this->getBaseUrl(); - if ($baseUrl !== '') { - $footer = '<a href="' . $baseUrl . '" target="_blank"' . - ' rel="noreferrer noopener" class="entity-name">' . $this->getEntity() . '</a>'; - } else { - $footer = '<span class="entity-name">' .$this->getEntity() . '</span>'; + $entity = $this->getEntity(); + $footer = ''; + + if ($entity !== '') { + if ($baseUrl !== '') { + $footer = '<a href="' . $baseUrl . '" target="_blank"' + . ' rel="noreferrer noopener" class="entity-name">' . $entity . '</a>'; + } else { + $footer = '<span class="entity-name">' . $entity . '</span>'; + } } $footer .= ($slogan !== '' ? ' – ' . $slogan : ''); @@ -200,20 +155,21 @@ 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; } /** - * Color that is used for the header as well as for mail headers + * Color that is used for highlighting elements like important buttons + * If user theming is enabled then the user defined value is returned */ public function getColorPrimary(): string { $user = $this->userSession->getUser(); @@ -227,31 +183,66 @@ class ThemingDefaults extends \OC_Defaults { // user-defined primary color if (!empty($user)) { - $themingBackgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', ''); - // If the user selected a specific colour - if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $themingBackgroundColor)) { - return $themingBackgroundColor; + $userPrimaryColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'primary_color', ''); + if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $userPrimaryColor)) { + return $userPrimaryColor; } } - // If the default color is not valid, return the default background one - if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) { - return BackgroundService::DEFAULT_COLOR; + // Finally, return the system global primary color + return $defaultColor; + } + + /** + * Color that is used for the page background (e.g. the header) + * If user theming is enabled then the user defined value is returned + */ + public function getColorBackground(): string { + $user = $this->userSession->getUser(); + + // admin-defined background color + $defaultColor = $this->getDefaultColorBackground(); + + if ($this->isUserThemingDisabled()) { + return $defaultColor; } - // Finally, return the system global primary color + // user-defined background color + if (!empty($user)) { + $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; + } + } + + // Finally, return the system global background color return $defaultColor; } /** - * Return the default color primary + * Return the default primary color - only taking admin setting into account */ public function getDefaultColorPrimary(): string { - $color = $this->config->getAppValue(Application::APP_ID, 'color', ''); - if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) { - $color = '#0082c9'; + // try admin 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; } - return $color; + + // fall back to default primary color + return $this->primaryColor; + } + + /** + * Default background color only taking admin setting into account + */ + public function getDefaultColorBackground(): string { + $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'background_color'); + if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) { + return $defaultColor; + } + + return $this->backgroundColor; } /** @@ -296,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' : '')); } /** @@ -332,6 +324,7 @@ class ThemingDefaults extends \OC_Defaults { /** * @return array scss variables to overwrite + * @deprecated since Nextcloud 22 - https://github.com/nextcloud/server/issues/9940 */ public function getScssVariables() { $cacheBuster = $this->config->getAppValue('theming', 'cachebuster', '0'); @@ -348,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', '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()); @@ -404,7 +397,7 @@ class ThemingDefaults extends \OC_Defaults { } $route = $this->urlGenerator->linkToRoute('theming.Theming.getManifest', ['app' => $app ]); } - if (strpos($image, 'filetypes/') === 0 && file_exists(\OC::$SERVERROOT . '/core/img/' . $image)) { + if (str_starts_with($image, 'filetypes/') && file_exists(\OC::$SERVERROOT . '/core/img/' . $image)) { $route = $this->urlGenerator->linkToRoute('theming.Icon.getThemedIcon', ['app' => $app, 'image' => $image]); } @@ -448,12 +441,16 @@ 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(); } /** - * Revert settings to the default value + * Revert admin settings to the default value * * @param string $setting setting which should be reverted * @return string default value @@ -473,14 +470,22 @@ class ThemingDefaults extends \OC_Defaults { case 'slogan': $returnValue = $this->getSlogan(); break; - case 'color': - $returnValue = $this->getDefaultColorPrimary(); + case 'primary_color': + $returnValue = BackgroundService::DEFAULT_COLOR; + break; + case 'background_color': + // If a background image is set we revert to the mean image color + if ($this->imageManager->hasImage('background')) { + $file = $this->imageManager->getImage('background'); + $returnValue = $this->backgroundService->setGlobalBackground($file->read()) ?? ''; + } break; case 'logo': case 'logoheader': case 'background': case 'favicon': $this->imageManager->delete($setting); + $this->config->deleteAppValue('theming', $setting . 'Mime'); break; } @@ -488,7 +493,16 @@ class ThemingDefaults extends \OC_Defaults { } /** - * Color of text in the header and primary buttons + * Color of text in the header menu + * + * @return string + */ + public function getTextColorBackground() { + return $this->util->invertTextColor($this->getColorBackground()) ? '#000000' : '#ffffff'; + } + + /** + * Color of text on primary buttons and other elements * * @return string */ @@ -509,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 a85dacd3de2..797456632fc 100644 --- a/apps/theming/lib/Util.php +++ b/apps/theming/lib/Util.php @@ -1,33 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julien Veyssier <eneiluj@posteo.net> - * @author Julius Haertl <jus@bitgrid.net> - * @author Julius Härtl <jus@bitgrid.net> - * @author Michael Weimann <mail@michael-weimann.eu> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; +use Mexitek\PHPColors\Color; use OCP\App\AppPathNotFoundException; use OCP\App\IAppManager; use OCP\Files\IAppData; @@ -35,27 +14,34 @@ use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\IConfig; use OCP\IUserSession; -use Mexitek\PHPColors\Color; +use OCP\Server; +use OCP\ServerVersion; class Util { + public function __construct( + private ServerVersion $serverVersion, + private IConfig $config, + private IAppManager $appManager, + private IAppData $appData, + private ImageManager $imageManager, + ) { + } - 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; + /** + * Should we invert the text on this background color? + * @param string $color rgb color value + * @return bool + */ + public function invertTextColor(string $color): bool { + return $this->colorContrast($color, '#ffffff') < 4.5; } /** + * Is this color too bright ? * @param string $color rgb color value * @return bool */ - public function invertTextColor($color) { + public function isBrightColor(string $color): bool { $l = $this->calculateLuma($color); if ($l > 0.6) { return true; @@ -68,20 +54,43 @@ class Util { * get color for on-page elements: * theme color by default, grey if theme color is to bright * @param string $color - * @param bool $brightBackground + * @param ?bool $brightBackground * @return string */ - public function elementColor($color, bool $brightBackground = true) { + public function elementColor($color, ?bool $brightBackground = null, ?string $backgroundColor = null, bool $highContrast = false) { + if ($backgroundColor !== null) { + $brightBackground = $brightBackground ?? $this->isBrightColor($backgroundColor); + // Minimal amount that is possible to change the luminance + $epsilon = 1.0 / 255.0; + // Current iteration to prevent infinite loops + $iteration = 0; + // We need to keep blurred backgrounds in mind which might be mixed with the background + $blurredBackground = $this->mix($backgroundColor, $brightBackground ? $color : '#ffffff', 66); + $contrast = $this->colorContrast($color, $blurredBackground); + + // Min. element contrast is 3:1 but we need to keep hover states in mind -> min 3.2:1 + $minContrast = $highContrast ? 5.6 : 3.2; + + while ($contrast < $minContrast && $iteration++ < 100) { + $hsl = Color::hexToHsl($color); + $hsl['L'] = max(0, min(1, $hsl['L'] + ($brightBackground ? -$epsilon : $epsilon))); + $color = '#' . Color::hslToHex($hsl); + $contrast = $this->colorContrast($color, $blurredBackground); + } + return $color; + } + + // Fallback for legacy calling $luminance = $this->calculateLuminance($color); - if ($brightBackground && $luminance > 0.8) { - // If the color is too bright in bright mode, we fall back to a darker gray - return '#aaaaaa'; + if ($brightBackground !== false && $luminance > 0.8) { + // If the color is too bright in bright mode, we fall back to a darkened color + return $this->darken($color, 30); } - if (!$brightBackground && $luminance < 0.2) { - // If the color is too dark in dark mode, we fall back to a brighter gray - return '#555555'; + if ($brightBackground !== true && $luminance < 0.2) { + // If the color is too dark in dark mode, we fall back to a brightened color + return $this->lighten($color, 30); } return $color; @@ -129,12 +138,38 @@ class Util { } /** + * Calculate the Luma according to WCAG 2 + * http://www.w3.org/TR/WCAG20/#relativeluminancedef * @param string $color rgb color value * @return float */ public function calculateLuma(string $color): float { - [$red, $green, $blue] = $this->hexToRGB($color); - return (0.2126 * $red + 0.7152 * $green + 0.0722 * $blue) / 255; + $rgb = $this->hexToRGB($color); + + // Normalize the values by converting to float and applying the rules from WCAG2.0 + $rgb = array_map(function (int $color) { + $color = $color / 255.0; + if ($color <= 0.03928) { + return $color / 12.92; + } else { + return pow((($color + 0.055) / 1.055), 2.4); + } + }, $rgb); + + [$red, $green, $blue] = $rgb; + return (0.2126 * $red + 0.7152 * $green + 0.0722 * $blue); + } + + /** + * Calculat the color contrast according to WCAG 2 + * http://www.w3.org/TR/WCAG20/#contrast-ratiodef + * @param string $color1 The first color + * @param string $color2 The second color + */ + public function colorContrast(string $color1, string $color2): float { + $luminance1 = $this->calculateLuma($color1) + 0.05; + $luminance2 = $this->calculateLuma($color2) + 0.05; + return max($luminance1, $luminance2) / min($luminance1, $luminance2); } /** @@ -152,18 +187,18 @@ 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); } /** - * @param $app string app name + * @param string $app app name * @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'; @@ -180,7 +215,7 @@ class Util { if ($this->config->getAppValue('theming', 'logoMime', '') !== '') { $logoFile = null; try { - $folder = $this->appData->getFolder('images'); + $folder = $this->appData->getFolder('global/images'); return $folder->getFile('logo'); } catch (NotFoundException $e) { } @@ -189,14 +224,17 @@ class Util { } /** - * @param $app string app name - * @param $image string relative path to image in app folder + * @param string $app app name + * @param string $image relative path to image in app folder * @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; @@ -236,8 +274,8 @@ class Util { /** * replace default color with a custom one * - * @param $svg string content of a svg file - * @param $color string color to match + * @param string $svg content of a svg file + * @param string $color color to match * @return string */ public function colorizeSvg($svg, $color) { @@ -269,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); } } |