diff options
Diffstat (limited to 'apps/theming/lib')
34 files changed, 1319 insertions, 1396 deletions
diff --git a/apps/theming/lib/AppInfo/Application.php b/apps/theming/lib/AppInfo/Application.php index ec4c2940871..d08a1903265 100644 --- a/apps/theming/lib/AppInfo/Application.php +++ b/apps/theming/lib/AppInfo/Application.php @@ -1,26 +1,8 @@ <?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; diff --git a/apps/theming/lib/Capabilities.php b/apps/theming/lib/Capabilities.php index b0c6a71731b..d5d6e415e75 100644 --- a/apps/theming/lib/Capabilities.php +++ b/apps/theming/lib/Capabilities.php @@ -1,29 +1,8 @@ <?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> - * @author Kate Döen <kate.doeen@nextcloud.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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; @@ -42,32 +21,19 @@ use OCP\IUserSession; */ class Capabilities implements IPublicCapability { - /** @var ThemingDefaults */ - protected $theming; - - /** @var Util */ - protected $util; - - /** @var IURLGenerator */ - protected $url; - - /** @var IConfig */ - protected $config; - - protected IUserSession $userSession; - /** * @param ThemingDefaults $theming * @param Util $util * @param IURLGenerator $url * @param IConfig $config */ - public function __construct(ThemingDefaults $theming, Util $util, IURLGenerator $url, IConfig $config, IUserSession $userSession) { - $this->theming = $theming; - $this->util = $util; - $this->url = $url; - $this->config = $config; - $this->userSession = $userSession; + public function __construct( + protected ThemingDefaults $theming, + protected Util $util, + protected IURLGenerator $url, + protected IConfig $config, + protected IUserSession $userSession, + ) { } /** @@ -76,6 +42,7 @@ class Capabilities implements IPublicCapability { * @return array{ * theming: array{ * name: string, + * productName: string, * url: string, * slogan: string, * color: string, @@ -85,6 +52,7 @@ class Capabilities implements IPublicCapability { * color-element-dark: string, * logo: string, * background: string, + * background-text: string, * background-plain: bool, * background-default: bool, * logoheader: string, @@ -94,15 +62,13 @@ class Capabilities implements IPublicCapability { */ public function getCapabilities() { $color = $this->theming->getDefaultColorPrimary(); - // Same as in DefaultTheme - if ($color === BackgroundService::DEFAULT_COLOR) { - $color = BackgroundService::DEFAULT_ACCESSIBLE_COLOR; - } $colorText = $this->util->invertTextColor($color) ? '#000000' : '#ffffff'; $backgroundLogo = $this->config->getAppValue('theming', 'backgroundMime', ''); - $backgroundPlain = $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $color !== '#0082c9'); - $background = $backgroundPlain ? $color : $this->url->getAbsoluteURL($this->theming->getBackground()); + $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) { @@ -112,10 +78,7 @@ class Capabilities implements IPublicCapability { * @see \OCA\Theming\Themes\CommonThemeTrait::generateUserBackgroundVariables() */ $color = $this->theming->getColorPrimary(); - if ($color === BackgroundService::DEFAULT_COLOR) { - $color = BackgroundService::DEFAULT_ACCESSIBLE_COLOR; - } - $colorText = $this->util->invertTextColor($color) ? '#000000' : '#ffffff'; + $colorText = $this->theming->getTextColorPrimary(); $backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT); if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) { @@ -126,13 +89,14 @@ class Capabilities implements IPublicCapability { $background = $this->url->linkTo(Application::APP_ID, "img/background/$backgroundImage"); } elseif ($backgroundImage !== BackgroundService::BACKGROUND_DEFAULT) { $backgroundPlain = true; - $background = $color; + $background = $backgroundColor; } } return [ 'theming' => [ 'name' => $this->theming->getName(), + 'productName' => $this->theming->getProductName(), 'url' => $this->theming->getBaseUrl(), 'slogan' => $this->theming->getSlogan(), 'color' => $color, @@ -142,6 +106,7 @@ class Capabilities implements IPublicCapability { 'color-element-dark' => $this->util->elementColor($color, false), 'logo' => $this->url->getAbsoluteURL($this->theming->getLogo()), 'background' => $background, + 'background-text' => $backgroundText, 'background-plain' => $backgroundPlain, 'background-default' => !$this->util->isBackgroundThemed(), 'logoheader' => $this->url->getAbsoluteURL($this->theming->getLogo()), diff --git a/apps/theming/lib/Command/UpdateConfig.php b/apps/theming/lib/Command/UpdateConfig.php index de180db6ce9..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', @@ -129,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 216ca88d375..e82faf78a79 100644 --- a/apps/theming/lib/Controller/IconController.php +++ b/apps/theming/lib/Controller/IconController.php @@ -1,30 +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> - * @author Kate Döen <kate.doeen@nextcloud.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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\Controller; @@ -35,6 +13,9 @@ use OCA\Theming\ThemingDefaults; use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\NotFoundResponse; @@ -43,39 +24,23 @@ use OCP\Files\NotFoundException; use OCP\IRequest; class IconController extends Controller { - /** @var ThemingDefaults */ - private $themingDefaults; - /** @var IconBuilder */ - private $iconBuilder; - /** @var ImageManager */ - private $imageManager; /** @var FileAccessHelper */ private $fileAccessHelper; - /** @var IAppManager */ - private $appManager; public function __construct( $appName, IRequest $request, - ThemingDefaults $themingDefaults, - IconBuilder $iconBuilder, - ImageManager $imageManager, + private ThemingDefaults $themingDefaults, + private IconBuilder $iconBuilder, + private ImageManager $imageManager, FileAccessHelper $fileAccessHelper, - IAppManager $appManager + private IAppManager $appManager, ) { parent::__construct($appName, $request); - - $this->themingDefaults = $themingDefaults; - $this->iconBuilder = $iconBuilder; - $this->imageManager = $imageManager; $this->fileAccessHelper = $fileAccessHelper; - $this->appManager = $appManager; } /** - * @PublicPage - * @NoCSRFRequired - * * Get a themed icon * * @param string $app ID of the app @@ -86,6 +51,9 @@ class IconController extends Controller { * 200: Themed icon returned * 404: Themed icon not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getThemedIcon(string $app, string $image): Response { if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { $app = 'core'; @@ -110,9 +78,6 @@ class IconController extends Controller { /** * Return a 32x32 favicon as png * - * @PublicPage - * @NoCSRFRequired - * * @param string $app ID of the app * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * @throws \Exception @@ -120,6 +85,9 @@ class IconController extends Controller { * 200: Favicon returned * 404: Favicon not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getFavicon(string $app = 'core'): Response { if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { $app = 'core'; @@ -156,9 +124,6 @@ class IconController extends Controller { /** * Return a 512x512 icon for touch devices * - * @PublicPage - * @NoCSRFRequired - * * @param string $app ID of the app * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'|'image/png'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * @throws \Exception @@ -166,6 +131,9 @@ class IconController extends Controller { * 200: Touch icon returned * 404: Touch icon not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getTouchIcon(string $app = 'core'): Response { if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { $app = 'core'; diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php index 91012d1e37a..e5cee254fe8 100644 --- a/apps/theming/lib/Controller/ThemingController.php +++ b/apps/theming/lib/Controller/ThemingController.php @@ -1,64 +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> - * @author Kate Döen <kate.doeen@nextcloud.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: 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 @@ -70,52 +44,33 @@ use ScssPhp\ScssPhp\Compiler; class ThemingController extends Controller { public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon']; - private ThemingDefaults $themingDefaults; - private IL10N $l10n; - private IConfig $config; - private ITempManager $tempManager; - private IAppData $appData; - private IURLGenerator $urlGenerator; - private IAppManager $appManager; - private ImageManager $imageManager; - private ThemesService $themesService; - public function __construct( - $appName, + string $appName, IRequest $request, - IConfig $config, - ThemingDefaults $themingDefaults, - IL10N $l, - 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) { @@ -151,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; } @@ -171,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' => [ @@ -182,19 +153,19 @@ class ThemingController extends Controller { } /** - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * @param string $setting * @param mixed $value * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function updateAppMenu($setting, $value) { $error = null; switch ($setting) { case 'defaultApps': if (is_array($value)) { try { - $this->appManager->setDefaultApps($value); + $this->navigationManager->setDefaultEntryIds($value); } catch (InvalidArgumentException $e) { $error = $this->l10n->t('Invalid app given'); } @@ -223,18 +194,20 @@ class ThemingController extends Controller { } /** - * Check that a string is a valid http/https url + * Check that a string is a valid http/https url. + * Also validates that there is no way for XSS through HTML */ private function isValidUrl(string $url): bool { - return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) && - filter_var($url, FILTER_VALIDATE_URL) !== false); + return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) + && filter_var($url, FILTER_VALIDATE_URL) !== false) + && !str_contains($url, '"'); } /** - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function uploadImage(): DataResponse { $key = $this->request->getParam('key'); if (!in_array($key, self::VALID_UPLOAD_KEYS, true)) { @@ -298,8 +271,8 @@ class ThemingController extends Controller { return new DataResponse( [ - 'data' => - [ + 'data' + => [ 'name' => $name, 'url' => $this->imageManager->getImageUrl($key), 'message' => $this->l10n->t('Saved'), @@ -311,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'), ], @@ -334,19 +307,19 @@ class ThemingController extends Controller { /** * Revert all theming settings to their default values - * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) * * @return DataResponse * @throws NotPermittedException */ + #[AuthorizedAdminSetting(settings: Admin::class)] public function undoAll(): DataResponse { $this->themingDefaults->undoAll(); - $this->appManager->setDefaultApps([]); + $this->navigationManager->setDefaultEntryIds([]); return new DataResponse( [ - 'data' => - [ + 'data' + => [ 'message' => $this->l10n->t('Saved'), ], 'status' => 'success' @@ -355,8 +328,6 @@ class ThemingController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired * @NoSameSiteCookieRequired * * Get an image @@ -369,6 +340,9 @@ class ThemingController extends Controller { * 200: Image returned * 404: Image not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getImage(string $key, bool $useSvg = true) { try { $file = $this->imageManager->getImage($key, $useSvg); @@ -377,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); @@ -392,8 +366,6 @@ class ThemingController extends Controller { } /** - * @NoCSRFRequired - * @PublicPage * @NoSameSiteCookieRequired * @NoTwoFactorRequired * @@ -407,6 +379,9 @@ class ThemingController extends Controller { * 200: Stylesheet returned * 404: Theme not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) { $themes = $this->themesService->getThemes(); if (!in_array($themeId, array_keys($themes))) { @@ -427,10 +402,17 @@ class ThemingController extends Controller { $css = ":root { $variables } " . $customCss; } else { // If not set, we'll rely on the body class - $compiler = new Compiler(); - $compiledCss = $compiler->compileString("[data-theme-$themeId] { $variables $customCss }"); - $css = $compiledCss->getCss(); - ; + // We need to separate @-rules from normal selectors, as they can't be nested + // This is a replacement for the SCSS compiler that did this automatically before f1448fcf0777db7d4254cb0a3ef94d63be9f7a24 + // We need a better way to handle this, but for now we just remove comments and split the at-rules + // from the rest of the CSS. + $customCssWithoutComments = preg_replace('!/\*.*?\*/!s', '', $customCss); + $customCssWithoutComments = preg_replace('!//.*!', '', $customCssWithoutComments); + preg_match_all('/(@[^{]+{(?:[^{}]*|(?R))*})/', $customCssWithoutComments, $atRules); + $atRulesCss = implode('', $atRules[0]); + $scopedCss = preg_replace('/(@[^{]+{(?:[^{}]*|(?R))*})/', '', $customCssWithoutComments); + + $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss }"; } try { @@ -443,19 +425,19 @@ class ThemingController extends Controller { } /** - * @NoCSRFRequired - * @PublicPage - * @BruteForceProtection(action=manifest) - * * Get the manifest for an app * * @param string $app ID of the app * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type - * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: array{src: non-empty-string, type: string, sizes: string}[], display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}> + * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}> * * 200: Manifest returned * 404: App not found */ + #[PublicPage] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'manifest')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getManifest(string $app): JSONResponse { $cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0'); if ($app === 'core' || $app === 'settings') { @@ -491,8 +473,8 @@ class ThemingController extends Controller { 'theme_color' => $this->themingDefaults->getColorPrimary(), 'background_color' => $this->themingDefaults->getColorPrimary(), 'description' => $description, - 'icons' => - [ + 'icons' + => [ [ 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => $app]) . '?v=' . $cacheBusterValue, @@ -506,7 +488,8 @@ class ThemingController extends Controller { 'sizes' => '16x16' ] ], - 'display' => 'standalone' + 'display_override' => [$this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'minimal-ui' : ''], + 'display' => $this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'standalone' : 'browser' ]; $response = new JSONResponse($responseJS); $response->cacheFor(3600); diff --git a/apps/theming/lib/Controller/UserThemeController.php b/apps/theming/lib/Controller/UserThemeController.php index 657c20036d3..770f2ca922f 100644 --- a/apps/theming/lib/Controller/UserThemeController.php +++ b/apps/theming/lib/Controller/UserThemeController.php @@ -3,31 +3,8 @@ 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> - * @author Kate Döen <kate.doeen@nextcloud.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\Controller; @@ -38,10 +15,14 @@ use OCA\Theming\Service\BackgroundService; use OCA\Theming\Service\ThemesService; use OCA\Theming\ThemingDefaults; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCSController; @@ -57,25 +38,16 @@ 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, + public function __construct( + string $appName, IRequest $request, - IConfig $config, + private IConfig $config, IUserSession $userSession, - ThemesService $themesService, - ThemingDefaults $themingDefaults, - BackgroundService $backgroundService) { + private ThemesService $themesService, + private ThemingDefaults $themingDefaults, + private BackgroundService $backgroundService, + ) { parent::__construct($appName, $request); - $this->config = $config; - $this->userSession = $userSession; - $this->themesService = $themesService; - $this->themingDefaults = $themingDefaults; - $this->backgroundService = $backgroundService; $user = $userSession->getUser(); if ($user !== null) { @@ -84,17 +56,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Enable theme * * @param string $themeId the theme ID - * @return DataResponse<Http::STATUS_OK, array<empty>, array{}> + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> * @throws OCSBadRequestException Enabling theme is not possible * @throws PreConditionNotMetException * * 200: Theme enabled successfully */ + #[NoAdminRequired] public function enableTheme(string $themeId): DataResponse { $theme = $this->validateTheme($themeId); @@ -104,17 +75,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * * Disable theme * * @param string $themeId the theme ID - * @return DataResponse<Http::STATUS_OK, array<empty>, array{}> + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> * @throws OCSBadRequestException Disabling theme is not possible * @throws PreConditionNotMetException * * 200: Theme disabled successfully */ + #[NoAdminRequired] public function disableTheme(string $themeId): DataResponse { $theme = $this->validateTheme($themeId); @@ -153,16 +123,16 @@ class UserThemeController extends OCSController { } /** - * @NoAdminRequired - * @NoCSRFRequired - * * Get the background image * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> * * 200: Background image returned * 404: Background image not found */ - public function getBackground(): Http\Response { + #[NoAdminRequired] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getBackground(): Response { $file = $this->backgroundService->getBackground(); if ($file !== null) { $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]); @@ -173,27 +143,25 @@ 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 @@ -204,6 +172,7 @@ class UserThemeController extends OCSController { * 200: Background set successfully * 400: Setting background is not possible */ + #[NoAdminRequired] public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', ?string $color = null): JSONResponse { $currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0'); @@ -241,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 4ff455005a2..2e40e8e489b 100644 --- a/apps/theming/lib/ITheme.php +++ b/apps/theming/lib/ITheme.php @@ -2,24 +2,8 @@ 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; diff --git a/apps/theming/lib/IconBuilder.php b/apps/theming/lib/IconBuilder.php index 8d6546cdcce..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,12 +35,12 @@ class IconBuilder { } try { $favicon = new Imagick(); - $favicon->setFormat("ico"); + $favicon->setFormat('ico'); $icon = $this->renderAppIcon($app, 128); if ($icon === false) { return false; } - $icon->setImageFormat("png32"); + $icon->setImageFormat('png32'); $clone = clone $icon; $clone->scaleImage(16, 0); @@ -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); @@ -183,35 +148,34 @@ class IconBuilder { */ if ($this->util->isBrightColor($color) && !$appIcon instanceof ISimpleFile - && $app !== "core" + && $app !== 'core' ) { $appIconFile->negateImage(false); } - $appIconFile->scaleImage(512, 512, true); } else { $appIconFile = new Imagick(); $appIconFile->setBackgroundColor(new ImagickPixel('transparent')); $appIconFile->readImageBlob($appIconContent); - $appIconFile->scaleImage(512, 512, true); } // offset for icon positioning - $border_w = (int)($appIconFile->getImageWidth() * 0.05); - $border_h = (int)($appIconFile->getImageHeight() * 0.05); + $padding = 0.15; + $border_w = (int)($appIconFile->getImageWidth() * $padding); + $border_h = (int)($appIconFile->getImageHeight() * $padding); $innerWidth = ($appIconFile->getImageWidth() - $border_w * 2); $innerHeight = ($appIconFile->getImageHeight() - $border_h * 2); $appIconFile->adaptiveResizeImage($innerWidth, $innerHeight); // center icon - $offset_w = (int)(512 / 2 - $innerWidth / 2); - $offset_h = (int)(512 / 2 - $innerHeight / 2); + $offset_w = (int)($size / 2 - $innerWidth / 2); + $offset_h = (int)($size / 2 - $innerHeight / 2); $finalIconFile = new Imagick(); $finalIconFile->setBackgroundColor(new ImagickPixel('transparent')); $finalIconFile->readImageBlob($background); $finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT); - $finalIconFile->setImageArtifact('compose:args', "1,0,-0.5,0.5"); + $finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5'); $finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h); $finalIconFile->setImageFormat('png24'); - if (defined("Imagick::INTERPOLATE_BICUBIC") === true) { + if (defined('Imagick::INTERPOLATE_BICUBIC') === true) { $filter = Imagick::INTERPOLATE_BICUBIC; } else { $filter = Imagick::FILTER_LANCZOS; @@ -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 994e3f35118..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; @@ -56,6 +29,7 @@ class ImageManager { private ICacheFactory $cacheFactory, private LoggerInterface $logger, private ITempManager $tempManager, + private BackgroundService $backgroundService, ) { } @@ -69,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) { @@ -76,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 ''; } @@ -167,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 { @@ -180,7 +166,7 @@ class ImageManager { * * @param string $filename * @param string $data - * @return \OCP\Files\SimpleFS\ISimpleFile + * @return ISimpleFile * @throws NotFoundException * @throws NotPermittedException */ @@ -209,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 { @@ -227,51 +217,79 @@ class ImageManager { throw new \Exception('Unsupported image type: ' . $detectedMimeType); } - if ($key === 'background' && $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.'); - } + 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); + // Preserve transparency + imagesavealpha($newImage, true); + imagealphablending($newImage, true); - $newWidth = (imagesx($newImage) < 4096 ? imagesx($newImage) : 4096); - $newHeight = (int)(imagesy($newImage) / (imagesx($newImage) / $newWidth)); - $outputImage = imagescale($newImage, $newWidth, $newHeight); - if ($outputImage === false) { - throw new \Exception('Could not scale uploaded background image.'); - } + $imageWidth = imagesx($newImage); + $imageHeight = imagesy($newImage); - $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'); + /** @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.'); } - } else { - if (!imagepng($outputImage, $newTmpFile, 8)) { - throw new \Exception('Could not recompress background image as PNG'); + + $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 (is_resource($outputImage) || $outputImage instanceof \GdImage) { + $tmpFile = $newTmpFile; imagedestroy($outputImage); - } + } catch (\Exception $e) { + if (isset($outputImage) && is_resource($outputImage) || $outputImage instanceof \GdImage) { + imagedestroy($outputImage); + } - $this->logger->debug($e->getMessage()); + $this->logger->debug($e->getMessage()); + } } + + // For background images we need to announce it + $this->backgroundService->setGlobalBackground($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; } 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 47b7d3fb6ff..048deae50ce 100644 --- a/apps/theming/lib/Listener/BeforePreferenceListener.php +++ b/apps/theming/lib/Listener/BeforePreferenceListener.php @@ -3,25 +3,8 @@ 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; @@ -34,6 +17,12 @@ use OCP\EventDispatcher\IEventListener; /** @template-implements IEventListener<BeforePreferenceDeletedEvent|BeforePreferenceSetEvent> */ class BeforePreferenceListener implements IEventListener { + + /** + * @var string[] + */ + private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color']; + public function __construct( private IAppManager $appManager, ) { @@ -55,13 +44,25 @@ class BeforePreferenceListener implements IEventListener { } private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { - if ($event->getConfigKey() !== 'shortcuts_disabled') { + 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; } diff --git a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php index d845c9a1091..18ab9392b97 100644 --- a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php +++ b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php @@ -3,30 +3,12 @@ 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; @@ -37,29 +19,19 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IConfig; use OCP\IUserSession; +use OCP\Util; use Psr\Container\ContainerInterface; /** @template-implements IEventListener<BeforeTemplateRenderedEvent|BeforeLoginTemplateRenderedEvent> */ class BeforeTemplateRenderedListener implements IEventListener { - private IInitialState $initialState; - private ContainerInterface $container; - private ThemeInjectionService $themeInjectionService; - private IUserSession $userSession; - private IConfig $config; - public function __construct( - IInitialState $initialState, - ContainerInterface $container, - ThemeInjectionService $themeInjectionService, - IUserSession $userSession, - IConfig $config + private IInitialState $initialState, + private ContainerInterface $container, + private ThemeInjectionService $themeInjectionService, + private IUserSession $userSession, + private IConfig $config, ) { - $this->initialState = $initialState; - $this->container = $container; - $this->themeInjectionService = $themeInjectionService; - $this->userSession = $userSession; - $this->config = $config; } public function handle(Event $event): void { @@ -81,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/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 index f1b0d09790a..948fc792133 100644 --- a/apps/theming/lib/ResponseDefinitions.php +++ b/apps/theming/lib/ResponseDefinitions.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 Kate Döen <kate.doeen@nextcloud.com> - * - * @author Kate Döen <kate.doeen@nextcloud.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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming; @@ -30,6 +13,7 @@ namespace OCA\Theming; * @psalm-type ThemingBackground = array{ * backgroundImage: ?string, * backgroundColor: string, + * primaryColor: string, * version: int, * } */ diff --git a/apps/theming/lib/Service/BackgroundService.php b/apps/theming/lib/Service/BackgroundService.php index 1b948c7300a..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,170 +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 = '#00679e'; + 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', - 'theming' => self::THEMING_MODE_DARK, - 'primary_color' => '#D8A06C', + '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', - 'theming' => self::THEMING_MODE_DARK, - 'primary_color' => '#38A084', + '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, - 'primary_color' => '#869171', + '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)', - 'description' => 'Background picture of white clouds on in front of a blue sky', - '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, - 'primary_color' => '#b17ab4', + '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, - 'primary_color' => '#c074a9', + '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, - 'primary_color' => '#bc8210', + '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'); } /** @@ -212,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; } } @@ -259,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 5dc7b3fd632..81198f8b3f5 100644 --- a/apps/theming/lib/Service/JSDataService.php +++ b/apps/theming/lib/Service/JSDataService.php @@ -3,63 +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\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 c6ef6b9614d..873d388081c 100644 --- a/apps/theming/lib/Service/ThemeInjectionService.php +++ b/apps/theming/lib/Service/ThemeInjectionService.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; @@ -31,25 +15,16 @@ use OCP\IUserSession; class ThemeInjectionService { - private IURLGenerator $urlGenerator; - private ThemesService $themesService; - private DefaultTheme $defaultTheme; - private Util $util; - private IConfig $config; private ?string $userId; - public function __construct(IURLGenerator $urlGenerator, - ThemesService $themesService, - DefaultTheme $defaultTheme, - Util $util, - IConfig $config, - IUserSession $userSession) { - $this->urlGenerator = $urlGenerator; - $this->themesService = $themesService; - $this->defaultTheme = $defaultTheme; - $this->util = $util; - $this->config = $config; - + public function __construct( + private IURLGenerator $urlGenerator, + private ThemesService $themesService, + private DefaultTheme $defaultTheme, + private Util $util, + private IConfig $config, + IUserSession $userSession, + ) { if ($userSession->getUser() !== null) { $this->userId = $userSession->getUser()->getUID(); } else { @@ -69,12 +44,12 @@ class ThemeInjectionService { $this->addThemeHeaders($defaultTheme); // Themes applied by media queries - foreach($mediaThemes as $theme) { + foreach ($mediaThemes as $theme) { $this->addThemeHeaders($theme, true, $theme->getMediaQuery()); } // Themes - foreach($this->themesService->getThemes() as $theme) { + foreach ($this->themesService->getThemes() as $theme) { // Ignore default theme as already processed first if ($theme->getId() === $this->defaultTheme->getId()) { continue; @@ -116,9 +91,9 @@ class ThemeInjectionService { $metaHeaders = []; // Meta headers - foreach($this->themesService->getThemes() as $theme) { + foreach ($this->themesService->getThemes() as $theme) { if (!empty($theme->getMeta())) { - foreach($theme->getMeta() as $meta) { + foreach ($theme->getMeta() as $meta) { if (!isset($meta['name']) || !isset($meta['content'])) { continue; } @@ -131,7 +106,7 @@ class ThemeInjectionService { } } - foreach($metaHeaders as $name => $content) { + foreach ($metaHeaders as $name => $content) { \OCP\Util::addHeader('meta', [ 'name' => $name, 'content' => join(' ', array_unique($content)), diff --git a/apps/theming/lib/Service/ThemesService.php b/apps/theming/lib/Service/ThemesService.php index 0d252e96431..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,24 +17,23 @@ use OCA\Theming\Themes\LightTheme; use OCP\IConfig; use OCP\IUser; use OCP\IUserSession; +use Psr\Log\LoggerInterface; class ThemesService { - private IUserSession $userSession; - private IConfig $config; - /** @var ITheme[] */ private array $themesProviders; - public function __construct(IUserSession $userSession, - IConfig $config, - DefaultTheme $defaultTheme, + public function __construct( + private IUserSession $userSession, + private IConfig $config, + private LoggerInterface $logger, + private DefaultTheme $defaultTheme, LightTheme $lightTheme, - DarkTheme $darkTheme, + private DarkTheme $darkTheme, HighContrastTheme $highContrastTheme, DarkHighContrastTheme $darkHighContrastTheme, - DyslexiaFont $dyslexiaFont) { - $this->userSession = $userSession; - $this->config = $config; + DyslexiaFont $dyslexiaFont, + ) { // Register themes $this->themesProviders = [ @@ -69,6 +52,28 @@ class ThemesService { * @return ITheme[] */ public function getThemes(): array { + // Enforced theme if configured + $enforcedTheme = $this->config->getSystemValueString('enforce_theme', ''); + if ($enforcedTheme !== '') { + if (!isset($this->themesProviders[$enforcedTheme])) { + $this->logger->error('Enforced theme not found', ['theme' => $enforcedTheme]); + return $this->themesProviders; + } + + $defaultTheme = $this->themesProviders[$this->defaultTheme->getId()]; + $darkTheme = $this->themesProviders[$this->darkTheme->getId()]; + $theme = $this->themesProviders[$enforcedTheme]; + return [ + // Leave the default theme as a fallback + $defaultTheme->getId() => $defaultTheme, + // Make sure we also have the dark theme to allow apps + // to scope sections of their UI to the dark theme + $darkTheme->getId() => $darkTheme, + // Finally, the enforced theme + $theme->getId() => $theme, + ]; + } + return $this->themesProviders; } @@ -123,7 +128,7 @@ class ThemesService { $this->setEnabledThemes($enabledThemes); return $enabledThemes; } - + return $themesIds; } @@ -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); } diff --git a/apps/theming/lib/Settings/Admin.php b/apps/theming/lib/Settings/Admin.php index dc5d9a2a6a4..9fa0f2bb0e7 100644 --- a/apps/theming/lib/Settings/Admin.php +++ b/apps/theming/lib/Settings/Admin.php @@ -1,40 +1,21 @@ <?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; @@ -49,6 +30,7 @@ class Admin implements IDelegatedSettings { private IInitialState $initialState, private IURLGenerator $urlGenerator, private ImageManager $imageManager, + private INavigationManager $navigationManager, ) { } @@ -75,9 +57,13 @@ class Admin implements IDelegatedSettings { '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', ''), @@ -87,7 +73,7 @@ class Admin implements IDelegatedSettings { 'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'), 'canThemeIcons' => $this->imageManager->shouldReplaceIcons(), 'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(), - 'defaultApps' => array_filter(explode(',', $this->config->getSystemValueString('defaultapp', ''))), + 'defaultApps' => $this->navigationManager->getDefaultEntryIds(), ]); Util::addScript($this->appName, 'admin-theming'); @@ -104,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 f24aaa2f8f8..f14deeb35f0 100644 --- a/apps/theming/lib/Settings/Personal.php +++ b/apps/theming/lib/Settings/Personal.php @@ -1,37 +1,19 @@ <?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\App\IAppManager; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; +use OCP\INavigationManager; use OCP\Settings\ISettings; use OCP\Util; @@ -44,7 +26,7 @@ class Personal implements ISettings { private ThemesService $themesService, private IInitialState $initialStateService, private ThemingDefaults $themingDefaults, - private IAppManager $appManager, + private INavigationManager $navigationManager, ) { } @@ -68,15 +50,36 @@ class Personal implements ISettings { }); } - // Get the default app enforced by admin - $forcedDefaultApp = $this->appManager->getDefaultAppForUser(null, false); + // Get the default entry enforced by admin + $forcedDefaultEntry = $this->navigationManager->getDefaultEntryIdForUser(null, false); + + /** List of all shipped backgrounds */ + $this->initialStateService->provideInitialState('shippedBackgrounds', BackgroundService::SHIPPED_BACKGROUNDS); + + /** + * 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' => $forcedDefaultApp + 'enforcedDefaultApp' => $forcedDefaultEntry ]); Util::addScript($this->appName, 'personal-theming'); @@ -94,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 bfaa8bcaa32..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 index 7d08aa8e954..cf9e51eee33 100644 --- a/apps/theming/lib/SetupChecks/PhpImagickModule.php +++ b/apps/theming/lib/SetupChecks/PhpImagickModule.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2023 Côme Chilliet <come.chilliet@nextcloud.com> - * - * @author Côme Chilliet <come.chilliet@nextcloud.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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Theming\SetupChecks; diff --git a/apps/theming/lib/Themes/CommonThemeTrait.php b/apps/theming/lib/Themes/CommonThemeTrait.php index dd5b25e1a29..74979770b70 100644 --- a/apps/theming/lib/Themes/CommonThemeTrait.php +++ b/apps/theming/lib/Themes/CommonThemeTrait.php @@ -2,36 +2,22 @@ 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\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 @@ -58,7 +44,6 @@ trait CommonThemeTrait { '--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, @@ -88,34 +73,33 @@ trait CommonThemeTrait { protected function generateGlobalBackgroundVariables(): array { $backgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor'; $hasCustomLogoHeader = $this->util->isLogoThemed(); - $isPrimaryBright = $this->util->invertTextColor($this->primaryColor); - - $variables = []; + $backgroundColor = $this->themingDefaults->getColorBackground(); // Default last fallback values - $variables['--image-background-default'] = "url('" . $this->themingDefaults->getBackground() . "')"; - $variables['--color-background-plain'] = $this->primaryColor; + $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 if logged in $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 primary as background has been request or if we have a custom primary colour - // let's not define the background image + // If a background has been requested let's not define the background image if ($backgroundDeleted) { - $variables['--color-background-plain'] = $this->primaryColor; - $variables['--image-background-plain'] = 'yes'; - $variables['--image-background'] = 'no'; - // If no background image is set, we need to check against the shown primary colour - $variables['--background-image-invert-if-bright'] = $isPrimaryBright ? 'invert(100%)' : 'no'; + $variables['--image-background'] = 'none'; } if ($hasCustomLogoHeader) { + // prevent inverting the logo on bright colors if customized $variables['--image-logoheader-custom'] = 'true'; } @@ -130,48 +114,41 @@ trait CommonThemeTrait { if ($user !== null && !$this->themingDefaults->isUserThemingDisabled() && $this->appManager->isEnabledForUser(Application::APP_ID)) { - $adminBackgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor'; $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 [ - // Might be defined already by admin theming, needs to be overridden - '--image-background' => 'none', - '--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->primaryColor, - ]; - } - - // The user is using the default background and admin removed the background image - if ($backgroundImage === BackgroundService::BACKGROUND_DEFAULT && $adminBackgroundDeleted) { - return [ - // --image-background is not defined in this case - '--color-background-plain' => $this->primaryColor, - '--background-image-invert-if-bright' => $isPrimaryBright ? 'invert(100%)' : 'no', - ]; + $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->primaryColor, - '--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 e6f1da94b4e..0c8b436d660 100644 --- a/apps/theming/lib/Themes/DarkHighContrastTheme.php +++ b/apps/theming/lib/Themes/DarkHighContrastTheme.php @@ -2,26 +2,8 @@ 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; @@ -107,7 +89,7 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme { '--color-info-hover' => $this->util->lighten($colorInfo, 10), '--color-info-text' => $this->util->lighten($colorInfo, 20), - '--color-scrollbar' => $this->util->lighten($colorMainBackground, 35), + '--color-scrollbar' => 'auto transparent', // used for the icon loading animation '--color-loading-light' => '#000000', diff --git a/apps/theming/lib/Themes/DarkTheme.php b/apps/theming/lib/Themes/DarkTheme.php index 8d88de75834..fd273d4697d 100644 --- a/apps/theming/lib/Themes/DarkTheme.php +++ b/apps/theming/lib/Themes/DarkTheme.php @@ -2,26 +2,8 @@ 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; @@ -29,6 +11,8 @@ use OCA\Theming\ITheme; class DarkTheme extends DefaultTheme implements ITheme { + protected bool $isDarkVariant = true; + public function getId(): string { return 'dark'; } @@ -82,8 +66,6 @@ class DarkTheme extends DefaultTheme implements ITheme { '--color-main-background-rgb' => $colorMainBackgroundRGB, '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .85)', - '--color-scrollbar' => $this->util->lighten($colorMainBackground, 15), - '--color-background-hover' => $this->util->lighten($colorMainBackground, 4), '--color-background-dark' => $this->util->lighten($colorMainBackground, 7), '--color-background-darker' => $this->util->lighten($colorMainBackground, 14), diff --git a/apps/theming/lib/Themes/DefaultTheme.php b/apps/theming/lib/Themes/DefaultTheme.php index 68038f053e3..bdd3048a498 100644 --- a/apps/theming/lib/Themes/DefaultTheme.php +++ b/apps/theming/lib/Themes/DefaultTheme.php @@ -2,79 +2,42 @@ 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 primary colors (if set) to improve accessibility - if ($this->primaryColor === BackgroundService::DEFAULT_COLOR) { - $this->primaryColor = BackgroundService::DEFAULT_ACCESSIBLE_COLOR; - } } public function getId(): string { @@ -120,12 +83,29 @@ class DefaultTheme implements ITheme { $colorSuccess = '#2d7b41'; $colorInfo = '#0071ad'; + $user = $this->userSession->getUser(); + // Chromium based browsers currently (2024) have huge performance issues with blur filters + $isChromium = $this->request !== null && $this->request->isUserAgent([Request::USER_AGENT_CHROME, Request::USER_AGENT_MS_EDGE]); + // Ignore MacOS because they always have hardware accelartion + $isChromium = $isChromium && !$this->request->isUserAgent(['/Macintosh/']); + // Allow to force the blur filter + $forceEnableBlur = $user === null ? false : $this->config->getUserValue( + $user->getUID(), + 'theming', + 'force_enable_blur_filter', + ); + $workingBlur = match($forceEnableBlur) { + 'yes' => true, + 'no' => false, + default => !$isChromium + }; + $variables = [ '--color-main-background' => $colorMainBackground, '--color-main-background-rgb' => $colorMainBackgroundRGB, '--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), .97)', '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .8)', - '--filter-background-blur' => 'blur(25px)', + '--filter-background-blur' => $workingBlur ? 'blur(25px)' : 'none', // to use like this: background-image: linear-gradient(0, var('--gradient-main-background)); '--gradient-main-background' => 'var(--color-main-background) 0%, var(--color-main-background-translucent) 85%, transparent 100%', @@ -146,7 +126,7 @@ class DefaultTheme implements ITheme { '--color-text-light' => 'var(--color-main-text)', // deprecated '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated - '--color-scrollbar' => 'rgba(' . $colorMainTextRgb . ', .15)', + '--color-scrollbar' => 'var(--color-border-maxcontrast) transparent', // error/warning/success/info feedback colours '--color-error' => $colorError, @@ -172,7 +152,7 @@ class DefaultTheme implements ITheme { '--color-loading-dark' => '#444444', '--color-box-shadow-rgb' => $colorBoxShadowRGB, - '--color-box-shadow' => "rgba(var(--color-box-shadow-rgb), 0.5)", + '--color-box-shadow' => 'rgba(var(--color-box-shadow-rgb), 0.5)', '--color-border' => $this->util->darken($colorMainBackground, 7), '--color-border-dark' => $this->util->darken($colorMainBackground, 14), @@ -180,33 +160,59 @@ class DefaultTheme implements ITheme { '--font-face' => "system-ui, -apple-system, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'", '--default-font-size' => '15px', + '--font-size-small' => '13px', + // 1.5 * font-size for accessibility + '--default-line-height' => '1.5', // TODO: support "(prefers-reduced-motion)" '--animation-quick' => '100ms', '--animation-slow' => '300ms', // Default variables -------------------------------------------- - '--border-radius' => '3px', - '--border-radius-large' => '10px', + // 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', - // pill-style button, value is large so big buttons also have correct roundness '--border-radius-pill' => '100px', - '--default-clickable-area' => '44px', - '--default-line-height' => '24px', + '--default-clickable-area' => '34px', + '--clickable-area-large' => '48px', + '--clickable-area-small' => '24px', + '--default-grid-baseline' => '4px', - // various structure data + // header / navigation bar '--header-height' => '50px', + '--header-menu-item-height' => '44px', + /* An alpha mask to be applied to all icons on the navigation bar (header menu). + * Icons are have a size of 20px but usually we use MDI which have a content of 16px so 2px padding top bottom, + * for better gradient we must at first begin at those 2px (10% of height) as start and stop positions. + */ + '--header-menu-icon-mask' => 'linear-gradient(var(--color-background-plain-text) 25%, color-mix(in srgb, var(--color-background-plain-text), 55% transparent) 90%) alpha', + + // various structure data '--navigation-width' => '300px', '--sidebar-min-width' => '300px', '--sidebar-max-width' => '500px', - '--list-min-width' => '200px', - '--list-max-width' => '300px', - '--header-menu-item-height' => '44px', - '--header-menu-profile-item-height' => '66px', - // mobile. Keep in sync with core/js/js.js + // Border radius of the body container + '--body-container-radius' => 'var(--border-radius-container-large)', + // Margin of the body container + '--body-container-margin' => 'calc(var(--default-grid-baseline) * 2)', + // Height of the body container to fully fill the view port + '--body-height' => 'calc(100% - env(safe-area-inset-bottom) - var(--header-height) - var(--body-container-margin))', + + // mobile. Keep in sync with core/src/init.js '--breakpoint-mobile' => '1024px', '--background-invert-if-dark' => 'no', '--background-invert-if-bright' => 'invert(100%)', diff --git a/apps/theming/lib/Themes/DyslexiaFont.php b/apps/theming/lib/Themes/DyslexiaFont.php index 3275a005c8d..2448de7b3c8 100644 --- a/apps/theming/lib/Themes/DyslexiaFont.php +++ b/apps/theming/lib/Themes/DyslexiaFont.php @@ -2,26 +2,8 @@ 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; @@ -61,30 +43,22 @@ class DyslexiaFont extends DefaultTheme implements ITheme { } public function getCustomCss(): string { - $fontPathWoff = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.woff'); $fontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.otf'); - $fontPathTtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.ttf'); - $boldFontPathWoff = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.woff'); $boldFontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.otf'); - $boldFontPathTtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.ttf'); return " @font-face { font-family: 'OpenDyslexic'; font-style: normal; font-weight: 400; - src: url('$fontPathWoff') format('woff'), - url('$fontPathOtf') format('opentype'), - url('$fontPathTtf') format('truetype'); + src: url('$fontPathOtf') format('opentype'); } - + @font-face { font-family: 'OpenDyslexic'; font-style: normal; font-weight: 700; - src: url('$boldFontPathWoff') format('woff'), - url('$boldFontPathOtf') format('opentype'), - url('$boldFontPathTtf') format('truetype'); + src: url('$boldFontPathOtf') format('opentype'); } "; } diff --git a/apps/theming/lib/Themes/HighContrastTheme.php b/apps/theming/lib/Themes/HighContrastTheme.php index e21e802fbe3..5b51114a32f 100644 --- a/apps/theming/lib/Themes/HighContrastTheme.php +++ b/apps/theming/lib/Themes/HighContrastTheme.php @@ -2,26 +2,8 @@ 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; @@ -112,7 +94,7 @@ class HighContrastTheme extends DefaultTheme implements ITheme { '--color-favorite' => '#936B06', - '--color-scrollbar' => $this->util->darken($colorMainBackground, 25), + '--color-scrollbar' => 'auto transparent', // used for the icon loading animation '--color-loading-light' => '#dddddd', @@ -124,6 +106,9 @@ class HighContrastTheme extends DefaultTheme implements ITheme { '--color-border' => $this->util->darken($colorMainBackground, 50), '--color-border-dark' => $this->util->darken($colorMainBackground, 50), '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 56), + + // remove the gradient from the app icons + '--header-menu-icon-mask' => 'none', ] ); } diff --git a/apps/theming/lib/Themes/LightTheme.php b/apps/theming/lib/Themes/LightTheme.php index 7e6773992a1..714156d4721 100644 --- a/apps/theming/lib/Themes/LightTheme.php +++ b/apps/theming/lib/Themes/LightTheme.php @@ -2,26 +2,8 @@ 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; diff --git a/apps/theming/lib/ThemingDefaults.php b/apps/theming/lib/ThemingDefaults.php index 210029ae636..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,13 @@ 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; @@ -80,43 +38,29 @@ 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(); @@ -177,10 +121,10 @@ class ThemingDefaults extends \OC_Defaults { if ($entity !== '') { if ($baseUrl !== '') { - $footer = '<a href="' . $baseUrl . '" target="_blank"' . - ' rel="noreferrer noopener" class="entity-name">' . $entity . '</a>'; + $footer = '<a href="' . $baseUrl . '" target="_blank"' + . ' rel="noreferrer noopener" class="entity-name">' . $entity . '</a>'; } else { - $footer = '<span class="entity-name">' .$entity . '</span>'; + $footer = '<span class="entity-name">' . $entity . '</span>'; } } $footer .= ($slogan !== '' ? ' – ' . $slogan : ''); @@ -211,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(); @@ -238,32 +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)) { - return BackgroundService::DEFAULT_COLOR; + // 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; + } + + // 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 $color; + return $this->backgroundColor; } /** @@ -308,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' : '')); } /** @@ -344,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'); @@ -360,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()); @@ -460,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 @@ -485,8 +470,15 @@ 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': @@ -501,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 */ @@ -522,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 71ab0a6dc6c..797456632fc 100644 --- a/apps/theming/lib/Util.php +++ b/apps/theming/lib/Util.php @@ -1,30 +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 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; @@ -36,19 +14,17 @@ use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\IConfig; use OCP\IUserSession; +use OCP\Server; +use OCP\ServerVersion; class Util { - - private IConfig $config; - private IAppManager $appManager; - private IAppData $appData; - private ImageManager $imageManager; - - public function __construct(IConfig $config, IAppManager $appManager, IAppData $appData, ImageManager $imageManager) { - $this->config = $config; - $this->appManager = $appManager; - $this->appData = $appData; - $this->imageManager = $imageManager; + public function __construct( + private ServerVersion $serverVersion, + private IConfig $config, + private IAppManager $appManager, + private IAppData $appData, + private ImageManager $imageManager, + ) { } /** @@ -93,7 +69,7 @@ class Util { $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.5 : 3.2; + $minContrast = $highContrast ? 5.6 : 3.2; while ($contrast < $minContrast && $iteration++ < 100) { $hsl = Color::hexToHsl($color); @@ -211,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'; @@ -248,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; @@ -295,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) { @@ -328,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); } } |