diff options
Diffstat (limited to 'apps/theming/lib')
34 files changed, 5478 insertions, 0 deletions
diff --git a/apps/theming/lib/AppInfo/Application.php b/apps/theming/lib/AppInfo/Application.php new file mode 100644 index 00000000000..d08a1903265 --- /dev/null +++ b/apps/theming/lib/AppInfo/Application.php @@ -0,0 +1,40 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\AppInfo; + +use OCA\Theming\Capabilities; +use OCA\Theming\Listener\BeforePreferenceListener; +use OCA\Theming\Listener\BeforeTemplateRenderedListener; +use OCA\Theming\SetupChecks\PhpImagickModule; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\Config\BeforePreferenceDeletedEvent; +use OCP\Config\BeforePreferenceSetEvent; + +class Application extends App implements IBootstrap { + public const APP_ID = 'theming'; + + public function __construct() { + parent::__construct(self::APP_ID); + } + + public function register(IRegistrationContext $context): void { + $context->registerCapability(Capabilities::class); + $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(BeforeLoginTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(BeforePreferenceSetEvent::class, BeforePreferenceListener::class); + $context->registerEventListener(BeforePreferenceDeletedEvent::class, BeforePreferenceListener::class); + $context->registerSetupCheck(PhpImagickModule::class); + } + + public function boot(IBootContext $context): void { + } +} diff --git a/apps/theming/lib/Capabilities.php b/apps/theming/lib/Capabilities.php new file mode 100644 index 00000000000..d5d6e415e75 --- /dev/null +++ b/apps/theming/lib/Capabilities.php @@ -0,0 +1,117 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming; + +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Service\BackgroundService; +use OCP\Capabilities\IPublicCapability; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; + +/** + * Class Capabilities + * + * @package OCA\Theming + */ +class Capabilities implements IPublicCapability { + + /** + * @param ThemingDefaults $theming + * @param Util $util + * @param IURLGenerator $url + * @param IConfig $config + */ + public function __construct( + protected ThemingDefaults $theming, + protected Util $util, + protected IURLGenerator $url, + protected IConfig $config, + protected IUserSession $userSession, + ) { + } + + /** + * Return this classes capabilities + * + * @return array{ + * theming: array{ + * name: string, + * productName: string, + * url: string, + * slogan: string, + * color: string, + * color-text: string, + * color-element: string, + * color-element-bright: string, + * color-element-dark: string, + * logo: string, + * background: string, + * background-text: string, + * background-plain: bool, + * background-default: bool, + * logoheader: string, + * favicon: string, + * }, + * } + */ + public function getCapabilities() { + $color = $this->theming->getDefaultColorPrimary(); + $colorText = $this->util->invertTextColor($color) ? '#000000' : '#ffffff'; + + $backgroundLogo = $this->config->getAppValue('theming', 'backgroundMime', ''); + $backgroundColor = $this->theming->getColorBackground(); + $backgroundText = $this->theming->getTextColorBackground(); + $backgroundPlain = $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $backgroundColor !== BackgroundService::DEFAULT_COLOR); + $background = $backgroundPlain ? $backgroundColor : $this->url->getAbsoluteURL($this->theming->getBackground()); + + $user = $this->userSession->getUser(); + if ($user instanceof IUser) { + /** + * Mimics the logic of generateUserBackgroundVariables() that generates the CSS variables. + * Also needs to be updated if the logic changes. + * @see \OCA\Theming\Themes\CommonThemeTrait::generateUserBackgroundVariables() + */ + $color = $this->theming->getColorPrimary(); + $colorText = $this->theming->getTextColorPrimary(); + + $backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT); + if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) { + $backgroundPlain = false; + $background = $this->url->linkToRouteAbsolute('theming.userTheme.getBackground'); + } elseif (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) { + $backgroundPlain = false; + $background = $this->url->linkTo(Application::APP_ID, "img/background/$backgroundImage"); + } elseif ($backgroundImage !== BackgroundService::BACKGROUND_DEFAULT) { + $backgroundPlain = true; + $background = $backgroundColor; + } + } + + return [ + 'theming' => [ + 'name' => $this->theming->getName(), + 'productName' => $this->theming->getProductName(), + 'url' => $this->theming->getBaseUrl(), + 'slogan' => $this->theming->getSlogan(), + 'color' => $color, + 'color-text' => $colorText, + 'color-element' => $this->util->elementColor($color), + 'color-element-bright' => $this->util->elementColor($color), + 'color-element-dark' => $this->util->elementColor($color, false), + 'logo' => $this->url->getAbsoluteURL($this->theming->getLogo()), + 'background' => $background, + 'background-text' => $backgroundText, + 'background-plain' => $backgroundPlain, + 'background-default' => !$this->util->isBackgroundThemed(), + 'logoheader' => $this->url->getAbsoluteURL($this->theming->getLogo()), + 'favicon' => $this->url->getAbsoluteURL($this->theming->getLogo()), + ], + ]; + } +} diff --git a/apps/theming/lib/Command/UpdateConfig.php b/apps/theming/lib/Command/UpdateConfig.php new file mode 100644 index 00000000000..6236f866445 --- /dev/null +++ b/apps/theming/lib/Command/UpdateConfig.php @@ -0,0 +1,127 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Command; + +use OCA\Theming\ImageManager; +use OCA\Theming\ThemingDefaults; +use OCP\IConfig; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class UpdateConfig extends Command { + public const SUPPORTED_KEYS = [ + 'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'primary_color', 'background_color', 'disable-user-theming' + ]; + + public function __construct( + private ThemingDefaults $themingDefaults, + private ImageManager $imageManager, + private IConfig $config, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('theming:config') + ->setDescription('Set theming app config values') + ->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) + ) + ->addArgument( + 'value', + InputArgument::OPTIONAL, + 'Value to set (leave empty to obtain the current value)' + ) + ->addOption( + 'reset', + 'r', + InputOption::VALUE_NONE, + 'Reset the given config key to default' + ); + } + + + protected function execute(InputInterface $input, OutputInterface $output): int { + $key = $input->getArgument('key'); + $value = $input->getArgument('value'); + assert(is_string($value) || $value === null, 'At most one value should be provided.'); + + if ($key === null) { + $output->writeln('Current theming config:'); + foreach (self::SUPPORTED_KEYS as $key) { + $value = $this->config->getAppValue('theming', $key, ''); + $output->writeln('- ' . $key . ': ' . $value . ''); + } + foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $key) { + $value = $this->config->getAppValue('theming', $key . 'Mime', ''); + $output->writeln('- ' . $key . ': ' . $value . ''); + } + return 0; + } + + if (!in_array($key, self::SUPPORTED_KEYS, true) && !in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) { + $output->writeln('<error>Invalid config key provided</error>'); + return 1; + } + + if ($input->getOption('reset')) { + $defaultValue = $this->themingDefaults->undo($key); + $output->writeln('<info>Reset ' . $key . ' to ' . $defaultValue . '</info>'); + return 0; + } + + if ($value === null) { + $value = $this->config->getAppValue('theming', $key, ''); + if ($value !== '') { + $output->writeln('<info>' . $key . ' is currently set to ' . $value . '</info>'); + } else { + $output->writeln('<info>' . $key . ' is currently not set</info>'); + } + return 0; + } + + if ($key === 'background' && $value === 'backgroundColor') { + $this->themingDefaults->undo($key); + $key = $key . 'Mime'; + } + + if (in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) { + if (!str_starts_with($value, '/')) { + $output->writeln('<error>The image file needs to be provided as an absolute path: ' . $value . '.</error>'); + return 1; + } + if (!file_exists($value)) { + $output->writeln('<error>File could not be found: ' . $value . '.</error>'); + return 1; + } + $value = $this->imageManager->updateImage($key, $value); + $key = $key . 'Mime'; + } + + 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; + } + + $this->themingDefaults->set($key, $value); + $output->writeln('<info>Updated ' . $key . ' to ' . $value . '</info>'); + + return 0; + } +} diff --git a/apps/theming/lib/Controller/IconController.php b/apps/theming/lib/Controller/IconController.php new file mode 100644 index 00000000000..e82faf78a79 --- /dev/null +++ b/apps/theming/lib/Controller/IconController.php @@ -0,0 +1,168 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Controller; + +use OC\IntegrityCheck\Helpers\FileAccessHelper; +use OCA\Theming\IconBuilder; +use OCA\Theming\ImageManager; +use OCA\Theming\ThemingDefaults; +use OCP\App\IAppManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataDisplayResponse; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\Response; +use OCP\Files\NotFoundException; +use OCP\IRequest; + +class IconController extends Controller { + /** @var FileAccessHelper */ + private $fileAccessHelper; + + public function __construct( + $appName, + IRequest $request, + private ThemingDefaults $themingDefaults, + private IconBuilder $iconBuilder, + private ImageManager $imageManager, + FileAccessHelper $fileAccessHelper, + private IAppManager $appManager, + ) { + parent::__construct($appName, $request); + $this->fileAccessHelper = $fileAccessHelper; + } + + /** + * Get a themed icon + * + * @param string $app ID of the app + * @param string $image image file name (svg required) + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/svg+xml'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> + * @throws \Exception + * + * 200: Themed icon returned + * 404: Themed icon not found + */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getThemedIcon(string $app, string $image): Response { + if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { + $app = 'core'; + $image = 'favicon.png'; + } + + $color = $this->themingDefaults->getColorPrimary(); + try { + $iconFileName = $this->imageManager->getCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image)); + } catch (NotFoundException $exception) { + $icon = $this->iconBuilder->colorSvg($app, $image); + if ($icon === false || $icon === '') { + return new NotFoundResponse(); + } + $iconFileName = $this->imageManager->setCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image), $icon); + } + $response = new FileDisplayResponse($iconFileName, Http::STATUS_OK, ['Content-Type' => 'image/svg+xml']); + $response->cacheFor(86400, false, true); + return $response; + } + + /** + * Return a 32x32 favicon as png + * + * @param string $app ID of the app + * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> + * @throws \Exception + * + * 200: Favicon returned + * 404: Favicon not found + */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getFavicon(string $app = 'core'): Response { + if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { + $app = 'core'; + } + + $response = null; + $iconFile = null; + try { + $iconFile = $this->imageManager->getImage('favicon', false); + $response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']); + } catch (NotFoundException $e) { + } + if ($iconFile === null && $this->imageManager->shouldReplaceIcons()) { + $color = $this->themingDefaults->getColorPrimary(); + try { + $iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color); + } catch (NotFoundException $exception) { + $icon = $this->iconBuilder->getFavicon($app); + if ($icon === false || $icon === '') { + return new NotFoundResponse(); + } + $iconFile = $this->imageManager->setCachedImage('favIcon-' . $app . $color, $icon); + } + $response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']); + } + if ($response === null) { + $fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon.png'; + $response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/x-icon']); + } + $response->cacheFor(86400); + return $response; + } + + /** + * Return a 512x512 icon for touch devices + * + * @param string $app ID of the app + * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'|'image/png'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> + * @throws \Exception + * + * 200: Touch icon returned + * 404: Touch icon not found + */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getTouchIcon(string $app = 'core'): Response { + if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) { + $app = 'core'; + } + + $response = null; + try { + $iconFile = $this->imageManager->getImage('favicon'); + $response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']); + } catch (NotFoundException $e) { + } + if ($this->imageManager->shouldReplaceIcons()) { + $color = $this->themingDefaults->getColorPrimary(); + try { + $iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color); + } catch (NotFoundException $exception) { + $icon = $this->iconBuilder->getTouchIcon($app); + if ($icon === false || $icon === '') { + return new NotFoundResponse(); + } + $iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app . $color, $icon); + } + $response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']); + } + if ($response === null) { + $fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png'; + $response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']); + } + $response->cacheFor(86400); + return $response; + } +} diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php new file mode 100644 index 00000000000..e5cee254fe8 --- /dev/null +++ b/apps/theming/lib/Controller/ThemingController.php @@ -0,0 +1,498 @@ +<?php + +/** + * 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\AppFramework\Services\IAppConfig; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IConfig; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IURLGenerator; + +/** + * Class ThemingController + * + * handle ajax requests to update the theme + * + * @package OCA\Theming\Controller + */ +class ThemingController extends Controller { + public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon']; + + public function __construct( + string $appName, + IRequest $request, + 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); + } + + /** + * @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) { + $error = $this->l10n->t('The given name is too long'); + } + break; + case 'url': + if (strlen($value) > 500) { + $error = $this->l10n->t('The given web address is too long'); + } + if (!$this->isValidUrl($value)) { + $error = $this->l10n->t('The given web address is not a valid URL'); + } + break; + case 'imprintUrl': + if (strlen($value) > 500) { + $error = $this->l10n->t('The given legal notice address is too long'); + } + if (!$this->isValidUrl($value)) { + $error = $this->l10n->t('The given legal notice address is not a valid URL'); + } + break; + case 'privacyUrl': + if (strlen($value) > 500) { + $error = $this->l10n->t('The given privacy policy address is too long'); + } + if (!$this->isValidUrl($value)) { + $error = $this->l10n->t('The given privacy policy address is not a valid URL'); + } + break; + case 'slogan': + if (strlen($value) > 500) { + $error = $this->l10n->t('The given slogan is too long'); + } + break; + 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 (!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; + } + if ($error !== null) { + return new DataResponse([ + 'data' => [ + 'message' => $error, + ], + 'status' => 'error' + ], Http::STATUS_BAD_REQUEST); + } + + if (!$saved) { + $this->themingDefaults->set($setting, $value); + } + + return new DataResponse([ + 'data' => [ + 'message' => $this->l10n->t('Saved'), + ], + 'status' => 'success' + ]); + } + + /** + * @param string $setting + * @param mixed $value + * @return DataResponse + * @throws NotPermittedException + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + public function updateAppMenu($setting, $value) { + $error = null; + switch ($setting) { + case 'defaultApps': + if (is_array($value)) { + try { + $this->navigationManager->setDefaultEntryIds($value); + } catch (InvalidArgumentException $e) { + $error = $this->l10n->t('Invalid app given'); + } + } else { + $error = $this->l10n->t('Invalid type for setting "defaultApp" given'); + } + break; + default: + $error = $this->l10n->t('Invalid setting key'); + } + if ($error !== null) { + return new DataResponse([ + 'data' => [ + 'message' => $error, + ], + 'status' => 'error' + ], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'data' => [ + 'message' => $this->l10n->t('Saved'), + ], + 'status' => 'success' + ]); + } + + /** + * Check that a string is a valid http/https url. + * Also validates that there is no way for XSS through HTML + */ + private function isValidUrl(string $url): bool { + return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) + && filter_var($url, FILTER_VALIDATE_URL) !== false) + && !str_contains($url, '"'); + } + + /** + * @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)) { + return new DataResponse( + [ + 'data' => [ + 'message' => 'Invalid key' + ], + 'status' => 'failure', + ], + Http::STATUS_BAD_REQUEST + ); + } + $image = $this->request->getUploadedFile('image'); + $error = null; + $phpFileUploadErrors = [ + UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), + UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), + UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), + UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), + UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), + UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), + UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), + UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), + ]; + if (empty($image)) { + $error = $this->l10n->t('No file uploaded'); + } + if (!empty($image) && array_key_exists('error', $image) && $image['error'] !== UPLOAD_ERR_OK) { + $error = $phpFileUploadErrors[$image['error']]; + } + + if ($error !== null) { + return new DataResponse( + [ + 'data' => [ + 'message' => $error + ], + 'status' => 'failure', + ], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + + try { + $mime = $this->imageManager->updateImage($key, $image['tmp_name']); + $this->themingDefaults->set($key . 'Mime', $mime); + } catch (\Exception $e) { + return new DataResponse( + [ + 'data' => [ + 'message' => $e->getMessage() + ], + 'status' => 'failure', + ], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + + $name = $image['name']; + + return new DataResponse( + [ + 'data' + => [ + 'name' => $name, + 'url' => $this->imageManager->getImageUrl($key), + 'message' => $this->l10n->t('Saved'), + ], + 'status' => 'success' + ] + ); + } + + /** + * Revert setting to default value + * + * @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' + => [ + 'value' => $value, + 'message' => $this->l10n->t('Saved'), + ], + 'status' => 'success' + ] + ); + } + + /** + * Revert all theming settings to their default values + * + * @return DataResponse + * @throws NotPermittedException + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + public function undoAll(): DataResponse { + $this->themingDefaults->undoAll(); + $this->navigationManager->setDefaultEntryIds([]); + + return new DataResponse( + [ + 'data' + => [ + 'message' => $this->l10n->t('Saved'), + ], + 'status' => 'success' + ] + ); + } + + /** + * @NoSameSiteCookieRequired + * + * Get an image + * + * @param string $key Key of the image + * @param bool $useSvg Return image as SVG + * @return FileDisplayResponse<Http::STATUS_OK, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> + * @throws NotPermittedException + * + * 200: Image returned + * 404: Image not found + */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getImage(string $key, bool $useSvg = true) { + try { + $file = $this->imageManager->getImage($key, $useSvg); + } catch (NotFoundException $e) { + return new NotFoundResponse(); + } + + $response = new FileDisplayResponse($file); + $csp = new ContentSecurityPolicy(); + $csp->allowInlineStyle(); + $response->setContentSecurityPolicy($csp); + $response->cacheFor(3600); + $response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', '')); + $response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"'); + if (!$useSvg) { + $response->addHeader('Content-Type', 'image/png'); + } else { + $response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', '')); + } + return $response; + } + + /** + * @NoSameSiteCookieRequired + * @NoTwoFactorRequired + * + * Get the CSS stylesheet for a theme + * + * @param string $themeId ID of the theme + * @param bool $plain Let the browser decide the CSS priority + * @param bool $withCustomCss Include custom CSS + * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'text/css'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> + * + * 200: Stylesheet returned + * 404: Theme not found + */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) { + $themes = $this->themesService->getThemes(); + if (!in_array($themeId, array_keys($themes))) { + return new NotFoundResponse(); + } + + $theme = $themes[$themeId]; + $customCss = $theme->getCustomCss(); + + // Generate variables + $variables = ''; + foreach ($theme->getCSSVariables() as $variable => $value) { + $variables .= "$variable:$value; "; + }; + + // If plain is set, the browser decides of the css priority + if ($plain) { + $css = ":root { $variables } " . $customCss; + } else { + // If not set, we'll rely on the body class + // 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 { + $response = new DataDisplayResponse($css, Http::STATUS_OK, ['Content-Type' => 'text/css']); + $response->cacheFor(86400); + return $response; + } catch (NotFoundException $e) { + return new NotFoundResponse(); + } + } + + /** + * Get the manifest for an app + * + * @param string $app ID of the app + * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type + * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}> + * + * 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') { + $name = $this->themingDefaults->getName(); + $shortName = $this->themingDefaults->getName(); + $startUrl = $this->urlGenerator->getBaseUrl(); + $description = $this->themingDefaults->getSlogan(); + } else { + if (!$this->appManager->isEnabledForUser($app)) { + $response = new JSONResponse([], Http::STATUS_NOT_FOUND); + $response->throttle(['action' => 'manifest', 'app' => $app]); + return $response; + } + + $info = $this->appManager->getAppInfo($app, false, $this->l10n->getLanguageCode()); + $name = $info['name'] . ' - ' . $this->themingDefaults->getName(); + $shortName = $info['name']; + if (str_contains($this->request->getRequestUri(), '/index.php/')) { + $startUrl = $this->urlGenerator->getBaseUrl() . '/index.php/apps/' . $app . '/'; + } else { + $startUrl = $this->urlGenerator->getBaseUrl() . '/apps/' . $app . '/'; + } + $description = $info['summary'] ?? ''; + } + /** + * @var string $description + * @var string $shortName + */ + $responseJS = [ + 'name' => $name, + 'short_name' => $shortName, + 'start_url' => $startUrl, + 'theme_color' => $this->themingDefaults->getColorPrimary(), + 'background_color' => $this->themingDefaults->getColorPrimary(), + 'description' => $description, + 'icons' + => [ + [ + 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', + ['app' => $app]) . '?v=' . $cacheBusterValue, + 'type' => 'image/png', + 'sizes' => '512x512' + ], + [ + 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', + ['app' => $app]) . '?v=' . $cacheBusterValue, + 'type' => 'image/svg+xml', + 'sizes' => '16x16' + ] + ], + '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); + return $response; + } +} diff --git a/apps/theming/lib/Controller/UserThemeController.php b/apps/theming/lib/Controller/UserThemeController.php new file mode 100644 index 00000000000..770f2ca922f --- /dev/null +++ b/apps/theming/lib/Controller/UserThemeController.php @@ -0,0 +1,218 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Controller; + +use OCA\Theming\AppInfo\Application; +use OCA\Theming\ITheme; +use OCA\Theming\ResponseDefinitions; +use OCA\Theming\Service\BackgroundService; +use OCA\Theming\Service\ThemesService; +use OCA\Theming\ThemingDefaults; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCSController; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\PreConditionNotMetException; + +/** + * @psalm-import-type ThemingBackground from ResponseDefinitions + */ +class UserThemeController extends OCSController { + + protected ?string $userId = null; + + public function __construct( + string $appName, + IRequest $request, + private IConfig $config, + IUserSession $userSession, + private ThemesService $themesService, + private ThemingDefaults $themingDefaults, + private BackgroundService $backgroundService, + ) { + parent::__construct($appName, $request); + + $user = $userSession->getUser(); + if ($user !== null) { + $this->userId = $user->getUID(); + } + } + + /** + * Enable theme + * + * @param string $themeId the theme ID + * @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); + + // Enable selected theme + $this->themesService->enableTheme($theme); + return new DataResponse(); + } + + /** + * Disable theme + * + * @param string $themeId the theme ID + * @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); + + // Enable selected theme + $this->themesService->disableTheme($theme); + return new DataResponse(); + } + + /** + * Validate and return the matching ITheme + * + * Disable theme + * + * @param string $themeId the theme ID + * @return ITheme + * @throws OCSBadRequestException + * @throws PreConditionNotMetException + */ + private function validateTheme(string $themeId): ITheme { + if ($themeId === '' || !$themeId) { + throw new OCSBadRequestException('Invalid theme id: ' . $themeId); + } + + $themes = $this->themesService->getThemes(); + if (!isset($themes[$themeId])) { + throw new OCSBadRequestException('Invalid theme id: ' . $themeId); + } + + // If trying to toggle another theme but this is enforced + if ($this->config->getSystemValueString('enforce_theme', '') !== '' + && $themes[$themeId]->getType() === ITheme::TYPE_THEME) { + throw new OCSForbiddenException('Theme switching is disabled'); + } + + return $themes[$themeId]; + } + + /** + * 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 + */ + #[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()]); + $response->cacheFor(24 * 60 * 60, false, true); + return $response; + } + return new NotFoundResponse(); + } + + /** + * 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->getColorBackground(), + 'primaryColor' => $this->themingDefaults->getColorPrimary(), + 'version' => $currentVersion, + ]); + } + + /** + * Set the background + * + * @param string $type Type of background + * @param string $value Path of the background image + * @param string|null $color Color for the background + * @return JSONResponse<Http::STATUS_OK, ThemingBackground, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}> + * + * 200: Background set successfully + * 400: Setting background is not possible + */ + #[NoAdminRequired] + public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', ?string $color = null): JSONResponse { + $currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0'); + + // Set color if provided + if ($color) { + $this->backgroundService->setColorBackground($color); + } + + // Set background image if provided + try { + switch ($type) { + case BackgroundService::BACKGROUND_SHIPPED: + $this->backgroundService->setShippedBackground($value); + break; + case BackgroundService::BACKGROUND_CUSTOM: + $this->backgroundService->setFileBackground($value); + break; + case BackgroundService::BACKGROUND_DEFAULT: + // Delete both background and color keys + $this->backgroundService->setDefaultBackground(); + break; + default: + if (!$color) { + return new JSONResponse(['error' => 'Invalid type provided'], Http::STATUS_BAD_REQUEST); + } + } + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $currentVersion++; + $this->config->setUserValue($this->userId, Application::APP_ID, 'userCacheBuster', (string)$currentVersion); + + return new JSONResponse([ + 'backgroundImage' => $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT), + 'backgroundColor' => $this->themingDefaults->getColorBackground(), + 'primaryColor' => $this->themingDefaults->getColorPrimary(), + 'version' => $currentVersion, + ]); + } +} diff --git a/apps/theming/lib/ITheme.php b/apps/theming/lib/ITheme.php new file mode 100644 index 00000000000..2e40e8e489b --- /dev/null +++ b/apps/theming/lib/ITheme.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming; + +/** + * Interface ITheme + * + * @since 25.0.0 + */ +interface ITheme { + + public const TYPE_THEME = 1; + public const TYPE_FONT = 2; + + /** + * Unique theme id + * Will be used to search for ID.png in the img folder + * + * @since 25.0.0 + */ + public function getId(): string; + + /** + * Theme type + * TYPE_THEME or TYPE_FONT + * + * @since 25.0.0 + */ + public function getType(): int; + + /** + * The theme translated title + * + * @since 25.0.0 + */ + public function getTitle(): string; + + /** + * The theme enable checkbox translated label + * + * @since 25.0.0 + */ + public function getEnableLabel(): string; + + /** + * The theme translated description + * + * @since 25.0.0 + */ + public function getDescription(): string; + + /** + * Get the meta attribute matching the theme + * e.g. https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme + * @return array{name?: string, content?: string}[] + * @since 29.0.0 + */ + public function getMeta(): array; + + /** + * Get the media query triggering this theme + * Optional, ignored if falsy + * + * @return string + * @since 25.0.0 + */ + public function getMediaQuery(): string; + + /** + * Return the list of changed css variables + * + * @return array + * @since 25.0.0 + */ + public function getCSSVariables(): array; + + /** + * Return the custom css necessary for that app + * ⚠️ Warning, should be used slightly. + * Theoretically, editing the variables should be enough. + * + * @return string + * @since 25.0.0 + */ + public function getCustomCss(): string; +} diff --git a/apps/theming/lib/IconBuilder.php b/apps/theming/lib/IconBuilder.php new file mode 100644 index 00000000000..63f4559970d --- /dev/null +++ b/apps/theming/lib/IconBuilder.php @@ -0,0 +1,208 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming; + +use Imagick; +use ImagickPixel; +use OCP\Files\SimpleFS\ISimpleFile; + +class IconBuilder { + /** + * IconBuilder constructor. + * + * @param ThemingDefaults $themingDefaults + * @param Util $util + * @param ImageManager $imageManager + */ + public function __construct( + private ThemingDefaults $themingDefaults, + private Util $util, + private ImageManager $imageManager, + ) { + } + + /** + * @param $app string app name + * @return string|false image blob + */ + public function getFavicon($app) { + if (!$this->imageManager->shouldReplaceIcons()) { + return false; + } + try { + $favicon = new Imagick(); + $favicon->setFormat('ico'); + $icon = $this->renderAppIcon($app, 128); + if ($icon === false) { + return false; + } + $icon->setImageFormat('png32'); + + $clone = clone $icon; + $clone->scaleImage(16, 0); + $favicon->addImage($clone); + + $clone = clone $icon; + $clone->scaleImage(32, 0); + $favicon->addImage($clone); + + $clone = clone $icon; + $clone->scaleImage(64, 0); + $favicon->addImage($clone); + + $clone = clone $icon; + $clone->scaleImage(128, 0); + $favicon->addImage($clone); + + $data = $favicon->getImagesBlob(); + $favicon->destroy(); + $icon->destroy(); + $clone->destroy(); + return $data; + } catch (\ImagickException $e) { + return false; + } + } + + /** + * @param $app string app name + * @return string|false image blob + */ + public function getTouchIcon($app) { + try { + $icon = $this->renderAppIcon($app, 512); + if ($icon === false) { + return false; + } + $icon->setImageFormat('png32'); + $data = $icon->getImageBlob(); + $icon->destroy(); + return $data; + } catch (\ImagickException $e) { + return false; + } + } + + /** + * Render app icon on themed background color + * fallback to logo + * + * @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 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 === '') { + return false; + } + + $color = $this->themingDefaults->getColorPrimary(); + + // generate background image with rounded corners + $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; + } else { + $svg = $appIconContent; + } + $tmp = new Imagick(); + $tmp->setBackgroundColor(new ImagickPixel('transparent')); + $tmp->setResolution(72, 72); + $tmp->readImageBlob($svg); + $x = $tmp->getImageWidth(); + $y = $tmp->getImageHeight(); + $tmp->destroy(); + + // convert svg to resized image + $appIconFile = new Imagick(); + $resX = (int)(72 * $size / $x); + $resY = (int)(72 * $size / $y); + $appIconFile->setResolution($resX, $resY); + $appIconFile->setBackgroundColor(new ImagickPixel('transparent')); + $appIconFile->readImageBlob($svg); + + /** + * invert app icons for bright primary colors + * the default nextcloud logo will not be inverted to black + */ + if ($this->util->isBrightColor($color) + && !$appIcon instanceof ISimpleFile + && $app !== 'core' + ) { + $appIconFile->negateImage(false); + } + } else { + $appIconFile = new Imagick(); + $appIconFile->setBackgroundColor(new ImagickPixel('transparent')); + $appIconFile->readImageBlob($appIconContent); + } + // offset for icon positioning + $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)($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->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h); + $finalIconFile->setImageFormat('png24'); + if (defined('Imagick::INTERPOLATE_BICUBIC') === true) { + $filter = Imagick::INTERPOLATE_BICUBIC; + } else { + $filter = Imagick::FILTER_LANCZOS; + } + $finalIconFile->resizeImage($size, $size, $filter, 1, false); + + $appIconFile->destroy(); + return $finalIconFile; + } + + /** + * @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 === '' || !file_exists($imageFile)) { + return false; + } + $svg = file_get_contents($imageFile); + if ($svg !== false && $svg !== '') { + $color = $this->util->elementColor($this->themingDefaults->getColorPrimary()); + $svg = $this->util->colorizeSvg($svg, $color); + return $svg; + } else { + return false; + } + } +} diff --git a/apps/theming/lib/ImageManager.php b/apps/theming/lib/ImageManager.php new file mode 100644 index 00000000000..309bf192bc3 --- /dev/null +++ b/apps/theming/lib/ImageManager.php @@ -0,0 +1,388 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming; + +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Service\BackgroundService; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\ITempManager; +use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; + +class ImageManager { + public const SUPPORTED_IMAGE_KEYS = ['background', 'logo', 'logoheader', 'favicon']; + + public function __construct( + private IConfig $config, + private IAppData $appData, + private IURLGenerator $urlGenerator, + private ICacheFactory $cacheFactory, + private LoggerInterface $logger, + private ITempManager $tempManager, + private BackgroundService $backgroundService, + ) { + } + + /** + * Get a globally defined image (admin theming settings) + * + * @param string $key the image key + * @return string the image url + */ + public function getImageUrl(string $key): string { + $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) { + case 'logo': + case 'logoheader': + case 'favicon': + return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter; + case 'backgroundDark': + case 'background': + // Removing the background defines its mime as 'backgroundColor' + $mimeSetting = $this->config->getAppValue('theming', 'backgroundMime', ''); + if ($mimeSetting !== 'backgroundColor') { + $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 ''; + } + + /** + * Get the absolute url. See getImageUrl + */ + public function getImageUrlAbsolute(string $key): string { + return $this->urlGenerator->getAbsoluteURL($this->getImageUrl($key)); + } + + /** + * @param string $key + * @param bool $useSvg + * @return ISimpleFile + * @throws NotFoundException + * @throws NotPermittedException + */ + public function getImage(string $key, bool $useSvg = true): ISimpleFile { + $mime = $this->config->getAppValue('theming', $key . 'Mime', ''); + $folder = $this->getRootFolder()->getFolder('images'); + + if ($mime === '' || !$folder->fileExists($key)) { + throw new NotFoundException(); + } + + if (!$useSvg && $this->shouldReplaceIcons()) { + if (!$folder->fileExists($key . '.png')) { + try { + $finalIconFile = new \Imagick(); + $finalIconFile->setBackgroundColor('none'); + $finalIconFile->readImageBlob($folder->getFile($key)->getContent()); + $finalIconFile->setImageFormat('png32'); + $pngFile = $folder->newFile($key . '.png'); + $pngFile->putContent($finalIconFile->getImageBlob()); + return $pngFile; + } catch (\ImagickException $e) { + $this->logger->info('The image was requested to be no SVG file, but converting it to PNG failed: ' . $e->getMessage()); + } + } else { + return $folder->getFile($key . '.png'); + } + } + + return $folder->getFile($key); + } + + public function hasImage(string $key): bool { + $mimeSetting = $this->config->getAppValue('theming', $key . 'Mime', ''); + // Removing the background defines its mime as 'backgroundColor' + return $mimeSetting !== '' && $mimeSetting !== 'backgroundColor'; + } + + /** + * @return array<string, array{mime: string, url: string}> + */ + public function getCustomImages(): array { + $images = []; + foreach (self::SUPPORTED_IMAGE_KEYS as $key) { + $images[$key] = [ + 'mime' => $this->config->getAppValue('theming', $key . 'Mime', ''), + 'url' => $this->getImageUrl($key), + ]; + } + return $images; + } + + /** + * Get folder for current theming files + * + * @return ISimpleFolder + * @throws NotPermittedException + */ + public function getCacheFolder(): ISimpleFolder { + $cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0'); + try { + $folder = $this->getRootFolder()->getFolder($cacheBusterValue); + } catch (NotFoundException $e) { + $folder = $this->getRootFolder()->newFolder($cacheBusterValue); + $this->cleanup(); + } + return $folder; + } + + /** + * Get a file from AppData + * + * @param string $filename + * @throws NotFoundException + * @return ISimpleFile + * @throws NotPermittedException + */ + public function getCachedImage(string $filename): ISimpleFile { + $currentFolder = $this->getCacheFolder(); + return $currentFolder->getFile($filename); + } + + /** + * Store a file for theming in AppData + * + * @param string $filename + * @param string $data + * @return ISimpleFile + * @throws NotFoundException + * @throws NotPermittedException + */ + public function setCachedImage(string $filename, string $data): ISimpleFile { + $currentFolder = $this->getCacheFolder(); + if ($currentFolder->fileExists($filename)) { + $file = $currentFolder->getFile($filename); + } else { + $file = $currentFolder->newFile($filename); + } + $file->putContent($data); + return $file; + } + + public function delete(string $key): void { + /* ignore exceptions, since we don't want to fail hard if something goes wrong during cleanup */ + try { + $file = $this->getRootFolder()->getFolder('images')->getFile($key); + $file->delete(); + } catch (NotFoundException $e) { + } catch (NotPermittedException $e) { + } + try { + $file = $this->getRootFolder()->getFolder('images')->getFile($key . '.png'); + $file->delete(); + } catch (NotFoundException $e) { + } catch (NotPermittedException $e) { + } + + if ($key === 'logo') { + $this->config->deleteAppValue('theming', 'logoDimensions'); + } + } + + public function updateImage(string $key, string $tmpFile): string { + $this->delete($key); + + try { + $folder = $this->getRootFolder()->getFolder('images'); + } catch (NotFoundException $e) { + $folder = $this->getRootFolder()->newFolder('images'); + } + + $target = $folder->newFile($key); + $supportedFormats = $this->getSupportedUploadImageFormats($key); + $detectedMimeType = mime_content_type($tmpFile); + if (!in_array($detectedMimeType, $supportedFormats, true)) { + throw new \Exception('Unsupported image type: ' . $detectedMimeType); + } + + if ($key === 'background') { + if ($this->shouldOptimizeBackgroundImage($detectedMimeType, filesize($tmpFile))) { + try { + // Optimize the image since some people may upload images that will be + // either to big or are not progressive rendering. + $newImage = @imagecreatefromstring(file_get_contents($tmpFile)); + if ($newImage === false) { + throw new \Exception('Could not read background image, possibly corrupted.'); + } + + // Preserve transparency + imagesavealpha($newImage, true); + imagealphablending($newImage, true); + + $imageWidth = imagesx($newImage); + $imageHeight = imagesy($newImage); + + /** @var int */ + $newWidth = min(4096, $imageWidth); + $newHeight = intval($imageHeight / ($imageWidth / $newWidth)); + $outputImage = imagescale($newImage, $newWidth, $newHeight); + if ($outputImage === false) { + throw new \Exception('Could not scale uploaded background image.'); + } + + $newTmpFile = $this->tempManager->getTemporaryFile(); + imageinterlace($outputImage, true); + // Keep jpeg images encoded as jpeg + if (str_contains($detectedMimeType, 'image/jpeg')) { + if (!imagejpeg($outputImage, $newTmpFile, 90)) { + throw new \Exception('Could not recompress background image as JPEG'); + } + } else { + if (!imagepng($outputImage, $newTmpFile, 8)) { + throw new \Exception('Could not recompress background image as PNG'); + } + } + $tmpFile = $newTmpFile; + imagedestroy($outputImage); + } catch (\Exception $e) { + if (isset($outputImage) && is_resource($outputImage) || $outputImage instanceof \GdImage) { + imagedestroy($outputImage); + } + + $this->logger->debug($e->getMessage()); + } + } + + // 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; + } + + /** + * Decide whether an image benefits from shrinking and reconverting + * + * @param string $mimeType the mime type of the image + * @param int $contentSize size of the image file + * @return bool + */ + private function shouldOptimizeBackgroundImage(string $mimeType, int $contentSize): bool { + // Do not touch SVGs + if (str_contains($mimeType, 'image/svg')) { + return false; + } + // GIF does not benefit from converting + if (str_contains($mimeType, 'image/gif')) { + return false; + } + // WebP also does not benefit from converting + // We could possibly try to convert to progressive image, but normally webP images are quite small + if (str_contains($mimeType, 'image/webp')) { + return false; + } + // As a rule of thumb background images should be max. 150-300 KiB, small images do not benefit from converting + return $contentSize > 150000; + } + + /** + * Returns a list of supported mime types for image uploads. + * "favicon" images are only allowed to be SVG when imagemagick with SVG support is available. + * + * @param string $key The image key, e.g. "favicon" + * @return string[] + */ + public function getSupportedUploadImageFormats(string $key): array { + $supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + + if ($key !== 'favicon' || $this->shouldReplaceIcons() === true) { + $supportedFormats[] = 'image/svg+xml'; + $supportedFormats[] = 'image/svg'; + } + + if ($key === 'favicon') { + $supportedFormats[] = 'image/x-icon'; + $supportedFormats[] = 'image/vnd.microsoft.icon'; + } + + return $supportedFormats; + } + + /** + * remove cached files that are not required any longer + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function cleanup() { + $currentFolder = $this->getCacheFolder(); + $folders = $this->getRootFolder()->getDirectoryListing(); + foreach ($folders as $folder) { + if ($folder->getName() !== 'images' && $folder->getName() !== $currentFolder->getName()) { + $folder->delete(); + } + } + } + + /** + * Check if Imagemagick is enabled and if SVG is supported + * otherwise we can't render custom icons + * + * @return bool + */ + public function shouldReplaceIcons() { + $cache = $this->cacheFactory->createDistributed('theming-' . $this->urlGenerator->getBaseUrl()); + if ($value = $cache->get('shouldReplaceIcons')) { + return (bool)$value; + } + $value = false; + if (extension_loaded('imagick')) { + if (count(\Imagick::queryFormats('SVG')) >= 1) { + $value = true; + } + } + $cache->set('shouldReplaceIcons', $value); + return $value; + } + + private function getRootFolder(): ISimpleFolder { + try { + return $this->appData->getFolder('global'); + } catch (NotFoundException $e) { + return $this->appData->newFolder('global'); + } + } +} diff --git a/apps/theming/lib/Jobs/MigrateBackgroundImages.php b/apps/theming/lib/Jobs/MigrateBackgroundImages.php new file mode 100644 index 00000000000..62e58f5e722 --- /dev/null +++ b/apps/theming/lib/Jobs/MigrateBackgroundImages.php @@ -0,0 +1,199 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming\Jobs; + +use OCA\Theming\AppInfo\Application; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +class MigrateBackgroundImages extends QueuedJob { + public const TIME_SENSITIVE = 0; + + public const STAGE_PREPARE = 'prepare'; + public const STAGE_EXECUTE = 'execute'; + // will be saved in appdata/theming/global/ + protected const STATE_FILE_NAME = '25_dashboard_to_theming_migration_users.json'; + + public function __construct( + ITimeFactory $time, + private IAppDataFactory $appDataFactory, + private IJobList $jobList, + private IDBConnection $dbc, + private IAppData $appData, + private LoggerInterface $logger, + ) { + 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 { + $selector = $this->dbc->getQueryBuilder(); + $result = $selector->select('userid') + ->from('preferences') + ->where($selector->expr()->eq('appid', $selector->createNamedParameter('theming'))) + ->andWhere($selector->expr()->eq('configkey', $selector->createNamedParameter('background'))) + ->andWhere($selector->expr()->eq('configvalue', $selector->createNamedParameter('custom', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->executeQuery(); + + $userIds = $result->fetchAll(\PDO::FETCH_COLUMN); + $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) > 5000; + $dashboardData = $this->appDataFactory->get('dashboard'); + + $userIds = $notSoFastMode ? array_slice($allUserIds, 0, 5000) : $allUserIds; + foreach ($userIds as $userId) { + try { + // migration + $file = $dashboardData->getFolder($userId)->getFile('background.jpg'); + $targetDir = $this->getUserFolder($userId); + + if (!$targetDir->fileExists('background.jpg')) { + $targetDir->newFile('background.jpg', $file->getContent()); + } + $file->delete(); + } catch (NotFoundException|NotPermittedException $e) { + } + } + + if ($notSoFastMode) { + $remainingUserIds = array_slice($allUserIds, 5000); + $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, + ] + ); + } + } + } + + /** + * Get the root location for users theming data + */ + protected function getUserFolder(string $userId): ISimpleFolder { + $themingData = $this->appDataFactory->get(Application::APP_ID); + + try { + $rootFolder = $themingData->getFolder('users'); + } catch (NotFoundException $e) { + $rootFolder = $themingData->newFolder('users'); + } + + try { + return $rootFolder->getFolder($userId); + } catch (NotFoundException $e) { + return $rootFolder->newFolder($userId); + } + } +} 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 new file mode 100644 index 00000000000..048deae50ce --- /dev/null +++ b/apps/theming/lib/Listener/BeforePreferenceListener.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Listener; + +use OCA\Theming\AppInfo\Application; +use OCP\App\IAppManager; +use OCP\Config\BeforePreferenceDeletedEvent; +use OCP\Config\BeforePreferenceSetEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** @template-implements IEventListener<BeforePreferenceDeletedEvent|BeforePreferenceSetEvent> */ +class BeforePreferenceListener implements IEventListener { + + /** + * @var string[] + */ + private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color']; + + public function __construct( + private IAppManager $appManager, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof BeforePreferenceSetEvent + && !$event instanceof BeforePreferenceDeletedEvent) { + // Invalid event type + return; + } + + switch ($event->getAppId()) { + case Application::APP_ID: $this->handleThemingValues($event); + break; + case 'core': $this->handleCoreValues($event); + break; + } + } + + private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { + if (!in_array($event->getConfigKey(), self::ALLOWED_KEYS)) { + // Not allowed config key + return; + } + + if ($event instanceof BeforePreferenceSetEvent) { + switch ($event->getConfigKey()) { + case 'force_enable_blur_filter': + $event->setValid($event->getConfigValue() === 'yes' || $event->getConfigValue() === 'no'); + break; + case 'shortcuts_disabled': + $event->setValid($event->getConfigValue() === 'yes'); + break; + case 'primary_color': + $event->setValid(preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $event->getConfigValue()) === 1); + break; + default: + $event->setValid(false); + } + return; + } + + $event->setValid(true); + } + + private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { + if ($event->getConfigKey() !== 'apporder') { + // Not allowed config key + return; + } + + if ($event instanceof BeforePreferenceDeletedEvent) { + $event->setValid(true); + return; + } + + $value = json_decode($event->getConfigValue(), true, flags:JSON_THROW_ON_ERROR); + if (!is_array(($value))) { + // Must be an array + return; + } + + foreach ($value as $id => $info) { + // required format: [ navigation_id: string => [ order: int, app?: string ] ] + if (!is_string($id) || !is_array($info) || empty($info) || !isset($info['order']) || !is_numeric($info['order']) || (isset($info['app']) && !$this->appManager->isEnabledForUser($info['app']))) { + // Invalid config value, refuse the change + return; + } + } + $event->setValid(true); + } +} diff --git a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php new file mode 100644 index 00000000000..18ab9392b97 --- /dev/null +++ b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/** + * 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\JSDataService; +use OCA\Theming\Service\ThemeInjectionService; +use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +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 { + + public function __construct( + private IInitialState $initialState, + private ContainerInterface $container, + private ThemeInjectionService $themeInjectionService, + private IUserSession $userSession, + private IConfig $config, + ) { + } + + public function handle(Event $event): void { + $this->initialState->provideLazyInitialState( + 'data', + fn () => $this->container->get(JSDataService::class), + ); + + /** @var BeforeTemplateRenderedEvent|BeforeLoginTemplateRenderedEvent $event */ + if ($event->getResponse()->getRenderAs() === TemplateResponse::RENDER_AS_USER) { + $this->initialState->provideLazyInitialState('shortcutsDisabled', function () { + if ($this->userSession->getUser()) { + $uid = $this->userSession->getUser()->getUID(); + return $this->config->getUserValue($uid, Application::APP_ID, 'shortcuts_disabled', 'no') === 'yes'; + } + return false; + }); + } + + $this->themeInjectionService->injectHeaders(); + + // Making sure to inject just after core + Util::addScript('theming', 'theming', 'core'); + } +} diff --git a/apps/theming/lib/Migration/InitBackgroundImagesMigration.php b/apps/theming/lib/Migration/InitBackgroundImagesMigration.php new file mode 100644 index 00000000000..dea1bb3aa83 --- /dev/null +++ b/apps/theming/lib/Migration/InitBackgroundImagesMigration.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming\Migration; + +use OCA\Theming\Jobs\MigrateBackgroundImages; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class InitBackgroundImagesMigration implements IRepairStep { + + public function __construct( + private IJobList $jobList, + ) { + } + + public function getName() { + return 'Initialize migration of background images from dashboard to theming app'; + } + + public function run(IOutput $output) { + $this->jobList->add(MigrateBackgroundImages::class, ['stage' => MigrateBackgroundImages::STAGE_PREPARE]); + } +} diff --git a/apps/theming/lib/Migration/Version2006Date20240905111627.php b/apps/theming/lib/Migration/Version2006Date20240905111627.php new file mode 100644 index 00000000000..8f4130cba46 --- /dev/null +++ b/apps/theming/lib/Migration/Version2006Date20240905111627.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming\Migration; + +use Closure; +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Jobs\RestoreBackgroundImageColor; +use OCP\BackgroundJob\IJobList; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\Migration\IMigrationStep; +use OCP\Migration\IOutput; + +// This can only be executed once because `background_color` is again used with Nextcloud 30, +// so this part only works when updating -> Nextcloud 29 -> 30 +class Version2006Date20240905111627 implements IMigrationStep { + + public function __construct( + private IJobList $jobList, + private IAppConfig $appConfig, + private IDBConnection $connection, + ) { + } + + public function name(): string { + return 'Restore custom primary color'; + } + + public function description(): string { + return 'Restore custom primary color after separating primary color from background color'; + } + + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + // nop + } + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + $this->restoreSystemColors($output); + + $userThemingEnabled = $this->appConfig->getValueBool('theming', 'disable-user-theming') === false; + if ($userThemingEnabled) { + $this->restoreUserColors($output); + } + + return null; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $output->info('Initialize restoring of background colors for custom background images'); + // This is done in a background job as this can take a lot of time for large instances + $this->jobList->add(RestoreBackgroundImageColor::class, ['stage' => RestoreBackgroundImageColor::STAGE_PREPARE]); + } + + private function restoreSystemColors(IOutput $output): void { + $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'color', ''); + if ($defaultColor === '') { + $output->info('No custom system color configured - skipping'); + } else { + // Restore legacy value into new field + $this->appConfig->setValueString(Application::APP_ID, 'background_color', $defaultColor); + $this->appConfig->setValueString(Application::APP_ID, 'primary_color', $defaultColor); + // Delete legacy field + $this->appConfig->deleteKey(Application::APP_ID, 'color'); + // give some feedback + $output->info('Global primary color restored'); + } + } + + private function restoreUserColors(IOutput $output): void { + $output->info('Restoring user primary color'); + // For performance let the DB handle this + $qb = $this->connection->getQueryBuilder(); + // Rename the `background_color` config to `primary_color` as this was the behavior on Nextcloud 29 and older + // with Nextcloud 30 `background_color` is a new option to define the background color independent of the primary color. + $qb->update('preferences') + ->set('configkey', $qb->createNamedParameter('primary_color')) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('background_color'))); + + try { + $qb->executeStatement(); + } catch (\Exception) { + $output->debug('Some users already configured the background color'); + $this->restoreUserColorsFallback($output); + } + + $output->info('Primary color of users restored'); + } + + /** + * Similar to restoreUserColors but also works if some users already setup a new value. + * This is only called if the first approach fails as this takes much longer on the DB. + */ + private function restoreUserColorsFallback(IOutput $output): void { + $qb = $this->connection->getQueryBuilder(); + $qb2 = $this->connection->getQueryBuilder(); + + $qb2->select('userid') + ->from('preferences') + ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('primary_color'))); + + // MySQL does not update on select of the same table, so this is a workaround: + if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_MYSQL) { + $subquery = 'SELECT * from ( ' . $qb2->getSQL() . ' ) preferences_alias'; + } else { + $subquery = $qb2->getSQL(); + } + + $qb->update('preferences') + ->set('configkey', $qb->createNamedParameter('primary_color')) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) + ->andWhere( + $qb->expr()->eq('configkey', $qb->createNamedParameter('background_color')), + $qb->expr()->notIn('userid', $qb->createFunction($subquery)), + ); + + $qb->executeStatement(); + } +} diff --git a/apps/theming/lib/ResponseDefinitions.php b/apps/theming/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..948fc792133 --- /dev/null +++ b/apps/theming/lib/ResponseDefinitions.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Theming; + +/** + * @psalm-type ThemingBackground = array{ + * backgroundImage: ?string, + * backgroundColor: string, + * primaryColor: string, + * version: int, + * } + */ +class ResponseDefinitions { +} diff --git a/apps/theming/lib/Service/BackgroundService.php b/apps/theming/lib/Service/BackgroundService.php new file mode 100644 index 00000000000..ee9466c3a36 --- /dev/null +++ b/apps/theming/lib/Service/BackgroundService.php @@ -0,0 +1,414 @@ +<?php + +declare(strict_types=1); + +/** + * 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 OCP\Files\File; +use OCP\Files\IAppData; +use OCP\Files\IRootFolder; +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 { + 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'; + /** + * Just a background color is used + */ + public const BACKGROUND_COLOR = 'color'; + + public const DEFAULT_BACKGROUND_IMAGE = 'jenna-kim-the-globe.webp'; + + /** + * 'attribution': Name, artist and license + * 'description': Alternative text + * 'attribution_url': URL for attribution + * 'background_color': Cached mean color of the top part to calculate app menu colors and use as fallback + * 'primary_color': Recommended primary color for this theme / image + */ + public const SHIPPED_BACKGROUNDS = [ + 'jenna-kim-the-globe.webp' => [ + 'attribution' => 'Globe (Jenna Kim - Nextcloud GmbH, CC-BY-SA-4.0)', + 'description' => 'Background picture of white clouds on in front of a blue sky', + 'attribution_url' => 'https://nextcloud.com/trademarks/', + 'dark_variant' => 'jenna-kim-the-globe-dark.webp', + 'background_color' => self::DEFAULT_BACKGROUND_COLOR, + 'primary_color' => self::DEFAULT_COLOR, + ], + 'kamil-porembinski-clouds.jpg' => [ + 'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)', + 'description' => 'Background picture of white clouds on in front of a blue sky', + 'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/', + 'background_color' => self::DEFAULT_BACKGROUND_COLOR, + 'primary_color' => self::DEFAULT_COLOR, + ], + 'hannah-maclean-soft-floral.jpg' => [ + 'attribution' => 'Soft floral (Hannah MacLean, CC0)', + 'description' => 'Abstract background picture in yellow and white color whith a flower on it', + 'attribution_url' => 'https://stocksnap.io/photo/soft-floral-XOYWCCW5PA', + 'background_color' => '#e4d2c1', + 'primary_color' => '#9f652f', + ], + 'ted-moravec-morning-fog.jpg' => [ + 'attribution' => 'Morning fog (Ted Moravec, Public Domain)', + 'description' => 'Background picture of a forest shrouded in fog', + 'attribution_url' => 'https://flickr.com/photos/tmoravec/52392410261', + 'background_color' => '#f6f7f6', + 'primary_color' => '#114c3b', + ], + 'stefanus-martanto-setyo-husodo-underwater-ocean.jpg' => [ + 'attribution' => 'Underwater ocean (Stefanus Martanto Setyo Husodo, CC0)', + 'description' => 'Background picture of an underwater ocean', + 'attribution_url' => 'https://stocksnap.io/photo/underwater-ocean-TJA9LBH4WS', + 'background_color' => '#003351', + 'primary_color' => '#04577e', + ], + 'zoltan-voros-rhythm-and-blues.jpg' => [ + 'attribution' => 'Rhythm and blues (Zoltán Vörös, CC BY)', + 'description' => 'Abstract background picture of sand dunes during night', + 'attribution_url' => 'https://flickr.com/photos/v923z/51634409289/', + 'background_color' => '#1c2437', + 'primary_color' => '#1c243c', + ], + 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' => [ + 'attribution' => 'Butterfly wing scale (Anatoly Mikhaltsov, CC BY-SA)', + 'description' => 'Background picture of a red-ish butterfly wing under microscope', + 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:%D0%A7%D0%B5%D1%88%D1%83%D0%B9%D0%BA%D0%B8_%D0%BA%D1%80%D1%8B%D0%BB%D0%B0_%D0%B1%D0%B0%D0%B1%D0%BE%D1%87%D0%BA%D0%B8.jpg', + 'background_color' => '#652e11', + 'primary_color' => '#a53c17', + ], + 'bernie-cetonia-aurata-take-off-composition.jpg' => [ + 'attribution' => 'Cetonia aurata take off composition (Bernie, Public Domain)', + 'description' => 'Montage of a cetonia aurata bug that takes off with white background', + 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Cetonia_aurata_take_off_composition_05172009.jpg', + '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', + ], + '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/', + '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', + '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', + 'background_color' => '#c78a19', + 'primary_color' => '#7f5700', + ], + ]; + + public function __construct( + private IRootFolder $rootFolder, + private IAppData $appData, + private IAppConfig $appConfig, + private IConfig $config, + private ?string $userId, + ) { + } + + 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'); + } + + /** + * @param $path + * @throws NotFoundException + * @throws NotPermittedException + * @throws LockedException + * @throws PreConditionNotMetException + * @throws NoUserException + */ + 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); + $handle = $file->fopen('r'); + if ($handle === false) { + throw new InvalidArgumentException('Invalid image file'); + } + $this->getAppDataFolder()->newFile('background.jpg', $handle); + + $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'); + } + + $meanColor = $this->calculateMeanColor($image); + if ($meanColor !== false) { + $this->setColorBackground($meanColor); + } + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM); + } + + /** + * Set background of user to a shipped background identified by the filename + * @param string $filename The shipped background filename + * @param null|string $userId The user to set - defaults to currently logged in user + * @throws RuntimeException If neither $userId is specified nor a user is logged in + * @throws InvalidArgumentException If the specified filename does not match any shipped background + */ + public function setShippedBackground(string $filename, ?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + + if (!array_key_exists($filename, self::SHIPPED_BACKGROUNDS)) { + throw new InvalidArgumentException('The given file name is invalid'); + } + $this->setColorBackground(self::SHIPPED_BACKGROUNDS[$filename]['background_color'], $userId); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', $filename); + $this->config->setUserValue($userId, Application::APP_ID, 'primary_color', self::SHIPPED_BACKGROUNDS[$filename]['primary_color']); + } + + /** + * Set the background to color only + * @param string|null $userId The user to set the color - default to current logged-in user + */ + public function setColorBackground(string $color, ?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($userId, Application::APP_ID, 'background_color', $color); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR); + } + + public function deleteBackgroundImage(?string $userId = null): void { + $userId = $userId ?? $this->getUserId(); + $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR); + } + + public function getBackground(?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) { + return null; + } + } + return null; + } + + /** + * 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 + * + * @param string|null $userId The user to get the folder - default to current user + * @throws NotPermittedException + */ + private function getAppDataFolder(?string $userId = null): ISimpleFolder { + $userId = $userId ?? $this->getUserId(); + + try { + $rootFolder = $this->appData->getFolder('users'); + } catch (NotFoundException) { + $rootFolder = $this->appData->newFolder('users'); + } + try { + 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 new file mode 100644 index 00000000000..81198f8b3f5 --- /dev/null +++ b/apps/theming/lib/Service/JSDataService.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * 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; + +class JSDataService implements \JsonSerializable { + + public function __construct( + private ThemingDefaults $themingDefaults, + private Util $util, + private ThemesService $themesService, + ) { + $this->themingDefaults = $themingDefaults; + $this->util = $util; + $this->themesService = $themesService; + } + + public function jsonSerialize(): array { + return [ + 'name' => $this->themingDefaults->getName(), + 'slogan' => $this->themingDefaults->getSlogan(), + + '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 new file mode 100644 index 00000000000..873d388081c --- /dev/null +++ b/apps/theming/lib/Service/ThemeInjectionService.php @@ -0,0 +1,116 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Service; + +use OCA\Theming\ITheme; +use OCA\Theming\Themes\DefaultTheme; +use OCA\Theming\Util; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\IUserSession; + +class ThemeInjectionService { + + private ?string $userId; + + 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 { + $this->userId = null; + } + } + + public function injectHeaders(): void { + $themes = $this->themesService->getThemes(); + $defaultTheme = $themes[$this->defaultTheme->getId()]; + $mediaThemes = array_filter($themes, function ($theme) { + // Check if the theme provides a media query + return (bool)$theme->getMediaQuery(); + }); + + // Default theme fallback + $this->addThemeHeaders($defaultTheme); + + // Themes applied by media queries + foreach ($mediaThemes as $theme) { + $this->addThemeHeaders($theme, true, $theme->getMediaQuery()); + } + + // Themes + foreach ($this->themesService->getThemes() as $theme) { + // Ignore default theme as already processed first + if ($theme->getId() === $this->defaultTheme->getId()) { + continue; + } + $this->addThemeHeaders($theme, false); + } + + // Meta headers + $this->addThemeMetaHeaders($themes); + } + + /** + * Inject theme header into rendered page + * + * @param ITheme $theme the theme + * @param bool $plain request the :root syntax + * @param string $media media query to use in the <link> element + */ + private function addThemeHeaders(ITheme $theme, bool $plain = true, ?string $media = null): void { + $linkToCSS = $this->urlGenerator->linkToRoute('theming.Theming.getThemeStylesheet', [ + 'themeId' => $theme->getId(), + 'plain' => $plain, + 'v' => $this->util->getCacheBuster(), + ]); + \OCP\Util::addHeader('link', [ + 'rel' => 'stylesheet', + 'media' => $media, + 'href' => $linkToCSS, + 'class' => 'theme' + ]); + } + + /** + * Inject meta headers into rendered page + * + * @param ITheme[] $themes the theme + */ + private function addThemeMetaHeaders(array $themes): void { + $metaHeaders = []; + + // Meta headers + foreach ($this->themesService->getThemes() as $theme) { + if (!empty($theme->getMeta())) { + foreach ($theme->getMeta() as $meta) { + if (!isset($meta['name']) || !isset($meta['content'])) { + continue; + } + + if (!isset($metaHeaders[$meta['name']])) { + $metaHeaders[$meta['name']] = []; + } + $metaHeaders[$meta['name']][] = $meta['content']; + } + } + } + + foreach ($metaHeaders as $name => $content) { + \OCP\Util::addHeader('meta', [ + 'name' => $name, + 'content' => join(' ', array_unique($content)), + ]); + } + } +} diff --git a/apps/theming/lib/Service/ThemesService.php b/apps/theming/lib/Service/ThemesService.php new file mode 100644 index 00000000000..f49524cb62c --- /dev/null +++ b/apps/theming/lib/Service/ThemesService.php @@ -0,0 +1,188 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Service; + +use OCA\Theming\AppInfo\Application; +use OCA\Theming\ITheme; +use OCA\Theming\Themes\DarkHighContrastTheme; +use OCA\Theming\Themes\DarkTheme; +use OCA\Theming\Themes\DefaultTheme; +use OCA\Theming\Themes\DyslexiaFont; +use OCA\Theming\Themes\HighContrastTheme; +use OCA\Theming\Themes\LightTheme; +use OCP\IConfig; +use OCP\IUser; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +class ThemesService { + /** @var ITheme[] */ + private array $themesProviders; + + public function __construct( + private IUserSession $userSession, + private IConfig $config, + private LoggerInterface $logger, + private DefaultTheme $defaultTheme, + LightTheme $lightTheme, + private DarkTheme $darkTheme, + HighContrastTheme $highContrastTheme, + DarkHighContrastTheme $darkHighContrastTheme, + DyslexiaFont $dyslexiaFont, + ) { + + // Register themes + $this->themesProviders = [ + $defaultTheme->getId() => $defaultTheme, + $lightTheme->getId() => $lightTheme, + $darkTheme->getId() => $darkTheme, + $highContrastTheme->getId() => $highContrastTheme, + $darkHighContrastTheme->getId() => $darkHighContrastTheme, + $dyslexiaFont->getId() => $dyslexiaFont, + ]; + } + + /** + * Get the list of all registered themes + * + * @return ITheme[] + */ + public function getThemes(): array { + // Enforced theme if configured + $enforcedTheme = $this->config->getSystemValueString('enforce_theme', ''); + if ($enforcedTheme !== '') { + if (!isset($this->themesProviders[$enforcedTheme])) { + $this->logger->error('Enforced theme not found', ['theme' => $enforcedTheme]); + return $this->themesProviders; + } + + $defaultTheme = $this->themesProviders[$this->defaultTheme->getId()]; + $darkTheme = $this->themesProviders[$this->darkTheme->getId()]; + $theme = $this->themesProviders[$enforcedTheme]; + return [ + // Leave the default theme as a fallback + $defaultTheme->getId() => $defaultTheme, + // Make sure we also have the dark theme to allow apps + // to scope sections of their UI to the dark theme + $darkTheme->getId() => $darkTheme, + // Finally, the enforced theme + $theme->getId() => $theme, + ]; + } + + return $this->themesProviders; + } + + /** + * Enable a theme for the logged-in user + * + * @param ITheme $theme the theme to enable + * @return string[] the enabled themes + */ + public function enableTheme(ITheme $theme): array { + $themesIds = $this->getEnabledThemes(); + + // If already enabled, ignore + if (in_array($theme->getId(), $themesIds)) { + return $themesIds; + } + + /** @var ITheme[] */ + $themes = array_filter(array_map(function ($themeId) { + return $this->getThemes()[$themeId]; + }, $themesIds)); + + // Filtering all themes with the same type + $filteredThemes = array_filter($themes, function (ITheme $t) use ($theme) { + return $theme->getType() === $t->getType(); + }); + + // Retrieve IDs only + /** @var string[] */ + $filteredThemesIds = array_map(function (ITheme $t) { + return $t->getId(); + }, array_values($filteredThemes)); + + $enabledThemes = array_merge(array_diff($themesIds, $filteredThemesIds), [$theme->getId()]); + $this->setEnabledThemes($enabledThemes); + + return $enabledThemes; + } + + /** + * Disable a theme for the logged-in user + * + * @param ITheme $theme the theme to disable + * @return string[] the enabled themes + */ + public function disableTheme(ITheme $theme): array { + $themesIds = $this->getEnabledThemes(); + + // If enabled, removing it + if (in_array($theme->getId(), $themesIds)) { + $enabledThemes = array_diff($themesIds, [$theme->getId()]); + $this->setEnabledThemes($enabledThemes); + return $enabledThemes; + } + + return $themesIds; + } + + /** + * Check whether a theme is enabled or not + * for the logged-in user + * + * @return bool + */ + public function isEnabled(ITheme $theme): bool { + $user = $this->userSession->getUser(); + if ($user instanceof IUser) { + // Using keys as it's faster + $themes = $this->getEnabledThemes(); + return in_array($theme->getId(), $themes); + } + return false; + } + + /** + * 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 []; + } + + $enabledThemes = json_decode($this->config->getUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', '["default"]')); + if ($enforcedTheme !== '') { + return array_merge([$enforcedTheme], $enabledThemes); + } + + try { + return $enabledThemes; + } catch (\Exception $e) { + return []; + } + } + + /** + * Set the list of enabled themes + * for the logged-in user + * + * @param string[] $themes the list of enabled themes IDs + */ + private function setEnabledThemes(array $themes): void { + $user = $this->userSession->getUser(); + $this->config->setUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', json_encode(array_values(array_unique($themes)))); + } +} diff --git a/apps/theming/lib/Settings/Admin.php b/apps/theming/lib/Settings/Admin.php new file mode 100644 index 00000000000..9fa0f2bb0e7 --- /dev/null +++ b/apps/theming/lib/Settings/Admin.php @@ -0,0 +1,111 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Settings; + +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Controller\ThemingController; +use OCA\Theming\ImageManager; +use OCA\Theming\Service\BackgroundService; +use OCA\Theming\ThemingDefaults; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\IConfig; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IURLGenerator; +use OCP\Settings\IDelegatedSettings; +use OCP\Util; + +class Admin implements IDelegatedSettings { + + public function __construct( + private string $appName, + private IConfig $config, + private IL10N $l, + private ThemingDefaults $themingDefaults, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, + private ImageManager $imageManager, + private INavigationManager $navigationManager, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm(): TemplateResponse { + $themable = true; + $errorMessage = ''; + $theme = $this->config->getSystemValue('theme', ''); + if ($theme !== '') { + $themable = false; + $errorMessage = $this->l->t('You are already using a custom theme. Theming app settings might be overwritten by that.'); + } + + $allowedMimeTypes = array_reduce(ThemingController::VALID_UPLOAD_KEYS, function ($carry, $key) { + $carry[$key] = $this->imageManager->getSupportedUploadImageFormats($key); + return $carry; + }, []); + + $this->initialState->provideInitialState('adminThemingParameters', [ + 'isThemable' => $themable, + 'notThemableErrorMessage' => $errorMessage, + 'name' => $this->themingDefaults->getEntity(), + 'url' => $this->themingDefaults->getBaseUrl(), + 'slogan' => $this->themingDefaults->getSlogan(), + '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', ''), + 'legalNoticeUrl' => $this->themingDefaults->getImprintUrl(), + 'privacyPolicyUrl' => $this->themingDefaults->getPrivacyUrl(), + 'docUrl' => $this->urlGenerator->linkToDocs('admin-theming'), + 'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'), + 'canThemeIcons' => $this->imageManager->shouldReplaceIcons(), + 'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(), + 'defaultApps' => $this->navigationManager->getDefaultEntryIds(), + ]); + + Util::addScript($this->appName, 'admin-theming'); + + return new TemplateResponse($this->appName, 'settings-admin'); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection(): string { + return $this->appName; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 5; + } + + public function getName(): ?string { + return null; // Only one setting in this section + } + + public function getAuthorizedAppConfig(): array { + return [ + $this->appName => '/.*/', + ]; + } +} diff --git a/apps/theming/lib/Settings/AdminSection.php b/apps/theming/lib/Settings/AdminSection.php new file mode 100644 index 00000000000..a1ea568d9f2 --- /dev/null +++ b/apps/theming/lib/Settings/AdminSection.php @@ -0,0 +1,58 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Settings; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class AdminSection implements IIconSection { + public function __construct( + private string $appName, + private IURLGenerator $url, + private IL10N $l, + ) { + } + + /** + * returns the ID of the section. It is supposed to be a lower case string, + * e.g. 'ldap' + * + * @returns string + */ + public function getID() { + return $this->appName; + } + + /** + * returns the translated name as it should be displayed, e.g. 'LDAP / AD + * integration'. Use the L10N service to translate it. + * + * @return string + */ + public function getName() { + return $this->l->t('Theming'); + } + + /** + * @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. + * + * E.g.: 70 + */ + public function getPriority() { + return 30; + } + + /** + * {@inheritdoc} + */ + public function getIcon() { + return $this->url->imagePath($this->appName, 'app-dark.svg'); + } +} diff --git a/apps/theming/lib/Settings/Personal.php b/apps/theming/lib/Settings/Personal.php new file mode 100644 index 00000000000..f14deeb35f0 --- /dev/null +++ b/apps/theming/lib/Settings/Personal.php @@ -0,0 +1,109 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Settings; + +use OCA\Theming\ITheme; +use OCA\Theming\Service\BackgroundService; +use OCA\Theming\Service\ThemesService; +use OCA\Theming\ThemingDefaults; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\IConfig; +use OCP\INavigationManager; +use OCP\Settings\ISettings; +use OCP\Util; + +class Personal implements ISettings { + + public function __construct( + protected string $appName, + private string $userId, + private IConfig $config, + private ThemesService $themesService, + private IInitialState $initialStateService, + private ThemingDefaults $themingDefaults, + private INavigationManager $navigationManager, + ) { + } + + public function getForm(): TemplateResponse { + $enforcedTheme = $this->config->getSystemValueString('enforce_theme', ''); + + $themes = array_map(function ($theme) { + return [ + 'id' => $theme->getId(), + 'type' => $theme->getType(), + 'title' => $theme->getTitle(), + 'enableLabel' => $theme->getEnableLabel(), + 'description' => $theme->getDescription(), + 'enabled' => $this->themesService->isEnabled($theme), + ]; + }, $this->themesService->getThemes()); + + if ($enforcedTheme !== '') { + $themes = array_filter($themes, function ($theme) use ($enforcedTheme) { + return $theme['type'] !== ITheme::TYPE_THEME || $theme['id'] === $enforcedTheme; + }); + } + + // Get the default entry enforced by admin + $forcedDefaultEntry = $this->navigationManager->getDefaultEntryIdForUser(null, false); + + /** List of all shipped backgrounds */ + $this->initialStateService->provideInitialState('shippedBackgrounds', BackgroundService::SHIPPED_BACKGROUNDS); + + /** + * Admin theming + */ + $this->initialStateService->provideInitialState('themingDefaults', [ + /** URL of admin configured background image */ + 'backgroundImage' => $this->themingDefaults->getBackground(), + /** `backgroundColor` if disabled, mime type if defined and empty by default */ + 'backgroundMime' => $this->config->getAppValue('theming', 'backgroundMime', ''), + /** Admin configured background color */ + 'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(), + /** Admin configured primary color */ + 'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(), + /** Nextcloud default background image */ + 'defaultShippedBackground' => BackgroundService::DEFAULT_BACKGROUND_IMAGE, + ]); + + $this->initialStateService->provideInitialState('userBackgroundImage', $this->config->getUserValue($this->userId, 'theming', 'background_image', BackgroundService::BACKGROUND_DEFAULT)); + $this->initialStateService->provideInitialState('themes', array_values($themes)); + $this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme); + $this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled()); + $this->initialStateService->provideInitialState('enableBlurFilter', $this->config->getUserValue($this->userId, 'theming', 'force_enable_blur_filter', '')); + $this->initialStateService->provideInitialState('navigationBar', [ + 'userAppOrder' => json_decode($this->config->getUserValue($this->userId, 'core', 'apporder', '[]'), true, flags:JSON_THROW_ON_ERROR), + 'enforcedDefaultApp' => $forcedDefaultEntry + ]); + + Util::addScript($this->appName, 'personal-theming'); + + return new TemplateResponse($this->appName, 'settings-personal'); + } + + /** + * @return string the section ID, e.g. 'sharing' + * @since 9.1 + */ + public function getSection(): string { + return $this->appName; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + * @since 9.1 + */ + public function getPriority(): int { + return 40; + } +} diff --git a/apps/theming/lib/Settings/PersonalSection.php b/apps/theming/lib/Settings/PersonalSection.php new file mode 100644 index 00000000000..0a9361d5533 --- /dev/null +++ b/apps/theming/lib/Settings/PersonalSection.php @@ -0,0 +1,73 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Settings; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class PersonalSection implements IIconSection { + + /** + * Personal Section constructor. + * + * @param string $appName + * @param IURLGenerator $urlGenerator + * @param IL10N $l + */ + public function __construct( + protected string $appName, + private IURLGenerator $urlGenerator, + private IL10N $l, + ) { + } + + /** + * returns the relative path to an 16*16 icon describing the section. + * e.g. '/core/img/places/files.svg' + * + * @returns string + * @since 13.0.0 + */ + public function getIcon() { + return $this->urlGenerator->imagePath($this->appName, 'accessibility-dark.svg'); + } + + /** + * returns the ID of the section. It is supposed to be a lower case string, + * e.g. 'ldap' + * + * @returns string + * @since 9.1 + */ + public function getID() { + return $this->appName; + } + + /** + * returns the translated name as it should be displayed, e.g. 'LDAP / AD + * integration'. Use the L10N service to translate it. + * + * @return string + * @since 9.1 + */ + public function getName() { + return $this->l->t('Appearance and accessibility'); + } + + /** + * @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. + * + * E.g.: 70 + * @since 9.1 + */ + public function getPriority() { + return 15; + } +} diff --git a/apps/theming/lib/SetupChecks/PhpImagickModule.php b/apps/theming/lib/SetupChecks/PhpImagickModule.php new file mode 100644 index 00000000000..cf9e51eee33 --- /dev/null +++ b/apps/theming/lib/SetupChecks/PhpImagickModule.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\SetupChecks; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class PhpImagickModule implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + ) { + } + + public function getName(): string { + return $this->l10n->t('PHP Imagick module'); + } + + public function getCategory(): string { + return 'php'; + } + + public function run(): SetupResult { + if (!extension_loaded('imagick')) { + return SetupResult::info( + $this->l10n->t('The PHP module "imagick" is not enabled although the theming app is. For favicon generation to work correctly, you need to install and enable this module.'), + $this->urlGenerator->linkToDocs('admin-php-modules') + ); + } elseif (count(\Imagick::queryFormats('SVG')) === 0) { + return SetupResult::info( + $this->l10n->t('The PHP module "imagick" in this instance has no SVG support. For better compatibility it is recommended to install it.'), + $this->urlGenerator->linkToDocs('admin-php-modules') + ); + } else { + return SetupResult::success(); + } + } +} diff --git a/apps/theming/lib/Themes/CommonThemeTrait.php b/apps/theming/lib/Themes/CommonThemeTrait.php new file mode 100644 index 00000000000..74979770b70 --- /dev/null +++ b/apps/theming/lib/Themes/CommonThemeTrait.php @@ -0,0 +1,156 @@ +<?php + +declare(strict_types=1); +/** + * 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 + * This is shared between multiple themes because colorMainBackground and colorMainText + * will change in between. + */ + protected function generatePrimaryVariables(string $colorMainBackground, string $colorMainText, bool $highContrast = false): array { + $isBrightColor = $this->util->isBrightColor($colorMainBackground); + $colorPrimaryElement = $this->util->elementColor($this->primaryColor, $isBrightColor, $colorMainBackground, $highContrast); + $colorPrimaryLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80); + $colorPrimaryElementLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80); + $invertPrimaryTextColor = $this->util->invertTextColor($colorPrimaryElement); + + // primary related colours + return [ + // invert filter if primary is too bright + // to be used for legacy reasons only. Use inline + // svg with proper css variable instead or material + // design icons. + // ⚠️ Using 'no' as a value to make sure we specify an + // invalid one with no fallback. 'unset' could here fallback to some + // other theme with media queries + '--primary-invert-if-bright' => $this->util->invertTextColor($colorPrimaryElement) ? 'invert(100%)' : 'no', + '--primary-invert-if-dark' => $this->util->invertTextColor($colorPrimaryElement) ? 'no' : 'invert(100%)', + + '--color-primary' => $this->primaryColor, + '--color-primary-text' => $this->util->invertTextColor($this->primaryColor) ? '#000000' : '#ffffff', + '--color-primary-hover' => $this->util->mix($this->primaryColor, $colorMainBackground, 60), + '--color-primary-light' => $colorPrimaryLight, + '--color-primary-light-text' => $this->util->mix($this->primaryColor, $this->util->invertTextColor($colorPrimaryLight) ? '#000000' : '#ffffff', -20), + '--color-primary-light-hover' => $this->util->mix($colorPrimaryLight, $colorMainText, 90), + + // used for buttons, inputs... + '--color-primary-element' => $colorPrimaryElement, + '--color-primary-element-hover' => $invertPrimaryTextColor ? $this->util->lighten($colorPrimaryElement, 4) : $this->util->darken($colorPrimaryElement, 4), + '--color-primary-element-text' => $invertPrimaryTextColor ? '#000000' : '#ffffff', + // mostly used for disabled states + '--color-primary-element-text-dark' => $invertPrimaryTextColor ? $this->util->lighten('#000000', 4) : $this->util->darken('#ffffff', 4), + + // used for hover/focus states + '--color-primary-element-light' => $colorPrimaryElementLight, + '--color-primary-element-light-hover' => $this->util->mix($colorPrimaryElementLight, $colorMainText, 90), + '--color-primary-element-light-text' => $this->util->mix($colorPrimaryElement, $this->util->invertTextColor($colorPrimaryElementLight) ? '#000000' : '#ffffff', -20), + + // to use like this: background-image: var(--gradient-primary-background); + '--gradient-primary-background' => 'linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)', + ]; + } + + /** + * Generate admin theming background-related variables + */ + protected function generateGlobalBackgroundVariables(): array { + $backgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor'; + $hasCustomLogoHeader = $this->util->isLogoThemed(); + $backgroundColor = $this->themingDefaults->getColorBackground(); + + // Default last fallback values + $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); + $variables["--image-$image"] = "url('" . $imageUrl . "')"; + } elseif ($image === 'background') { + // Apply default background if nothing is configured + $variables['--image-background'] = "url('" . $this->themingDefaults->getBackground($this->isDarkVariant) . "')"; + } + } + + // If a background has been requested let's not define the background image + if ($backgroundDeleted) { + $variables['--image-background'] = 'none'; + } + + if ($hasCustomLogoHeader) { + // prevent inverting the logo on bright colors if customized + $variables['--image-logoheader-custom'] = 'true'; + } + + return $variables; + } + + /** + * Generate user theming background-related variables + */ + protected function generateUserBackgroundVariables(): array { + $user = $this->userSession->getUser(); + if ($user !== null + && !$this->themingDefaults->isUserThemingDisabled() + && $this->appManager->isEnabledForUser(Application::APP_ID)) { + $backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT); + $backgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', $this->themingDefaults->getColorBackground()); + + $currentVersion = (int)$this->config->getUserValue($user->getUID(), Application::APP_ID, 'userCacheBuster', '0'); + $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); + $variables['--image-background'] = "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')"; + } + + // The user picked a shipped background + if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) { + $shippedBackground = BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage]; + if ($this->isDarkVariant && isset($shippedBackground['dark_variant'])) { + $backgroundImage = $shippedBackground['dark_variant']; + } + $variables['--image-background'] = "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')"; + } + + return $variables; + } + + return []; + } +} diff --git a/apps/theming/lib/Themes/DarkHighContrastTheme.php b/apps/theming/lib/Themes/DarkHighContrastTheme.php new file mode 100644 index 00000000000..0c8b436d660 --- /dev/null +++ b/apps/theming/lib/Themes/DarkHighContrastTheme.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Themes; + +use OCA\Theming\ITheme; + +class DarkHighContrastTheme extends DarkTheme implements ITheme { + + public function getId(): string { + return 'dark-highcontrast'; + } + + public function getTitle(): string { + return $this->l->t('Dark theme with high contrast mode'); + } + + public function getEnableLabel(): string { + return $this->l->t('Enable dark high contrast mode'); + } + + public function getDescription(): string { + return $this->l->t('Similar to the high contrast mode, but with dark colours.'); + } + + public function getMediaQuery(): string { + return '(prefers-color-scheme: dark) and (prefers-contrast: more)'; + } + + /** + * Keep this consistent with other HighContrast Themes + */ + public function getCSSVariables(): array { + $defaultVariables = parent::getCSSVariables(); + + $colorMainText = '#ffffff'; + $colorMainBackground = '#000000'; + $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + + $colorError = '#ff5252'; + $colorWarning = '#ffcc00'; + $colorSuccess = '#42a942'; + $colorInfo = '#38c0ff'; + + return array_merge( + $defaultVariables, + $this->generatePrimaryVariables($colorMainBackground, $colorMainText, true), + [ + '--color-main-background' => $colorMainBackground, + '--color-main-background-rgb' => $colorMainBackgroundRGB, + '--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), 1)', + '--color-main-text' => $colorMainText, + + '--color-background-dark' => $this->util->lighten($colorMainBackground, 25), + '--color-background-darker' => $this->util->lighten($colorMainBackground, 25), + + '--color-main-background-blur' => $colorMainBackground, + '--filter-background-blur' => 'none', + + '--color-placeholder-light' => $this->util->lighten($colorMainBackground, 30), + '--color-placeholder-dark' => $this->util->lighten($colorMainBackground, 45), + + '--color-text-maxcontrast' => $colorMainText, + '--color-text-maxcontrast-background-blur' => $colorMainText, + '--color-text-light' => $colorMainText, + '--color-text-lighter' => $colorMainText, + + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->lighten($colorError, 10), + '--color-error-text' => $this->util->lighten($colorError, 25), + + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->lighten($colorWarning, 10), + '--color-warning-text' => $this->util->lighten($colorWarning, 10), + + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->lighten($colorSuccess, 10), + '--color-success-text' => $this->util->lighten($colorSuccess, 35), + + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->lighten($colorInfo, 10), + '--color-info-text' => $this->util->lighten($colorInfo, 20), + + '--color-scrollbar' => 'auto transparent', + + // used for the icon loading animation + '--color-loading-light' => '#000000', + '--color-loading-dark' => '#dddddd', + + '--color-box-shadow-rgb' => $colorMainText, + '--color-box-shadow' => $colorMainText, + + '--color-border' => $this->util->lighten($colorMainBackground, 50), + '--color-border-dark' => $this->util->lighten($colorMainBackground, 50), + '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 55), + ] + ); + } + + public function getCustomCss(): string { + return " + [class^='icon-'], [class*=' icon-'], + .action, + #appmenu li a, + .menutoggle { + opacity: 1 !important; + } + #app-navigation { + border-right: 1px solid var(--color-border); + } + div.crumb { + filter: brightness(150%); + } + "; + } +} diff --git a/apps/theming/lib/Themes/DarkTheme.php b/apps/theming/lib/Themes/DarkTheme.php new file mode 100644 index 00000000000..fd273d4697d --- /dev/null +++ b/apps/theming/lib/Themes/DarkTheme.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Themes; + +use OCA\Theming\ITheme; + +class DarkTheme extends DefaultTheme implements ITheme { + + protected bool $isDarkVariant = true; + + public function getId(): string { + return 'dark'; + } + + public function getTitle(): string { + return $this->l->t('Dark theme'); + } + + public function getEnableLabel(): string { + return $this->l->t('Enable dark theme'); + } + + public function getDescription(): string { + return $this->l->t('A dark theme to ease your eyes by reducing the overall luminosity and brightness.'); + } + + public function getMediaQuery(): string { + return '(prefers-color-scheme: dark)'; + } + + public function getMeta(): array { + // https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme + return [[ + 'name' => 'color-scheme', + 'content' => 'dark', + ]]; + } + + public function getCSSVariables(): array { + $defaultVariables = parent::getCSSVariables(); + + $colorMainText = '#EBEBEB'; + $colorMainBackground = '#171717'; + $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + $colorTextMaxcontrast = $this->util->darken($colorMainText, 32); + + $colorBoxShadow = $this->util->darken($colorMainBackground, 70); + $colorBoxShadowRGB = join(',', $this->util->hexToRGB($colorBoxShadow)); + + $colorError = '#FF3333'; + $colorWarning = '#FFCC00'; + $colorSuccess = '#3B973B'; + $colorInfo = '#00AEFF'; + + return array_merge( + $defaultVariables, + $this->generatePrimaryVariables($colorMainBackground, $colorMainText), + [ + '--color-main-text' => $colorMainText, + '--color-main-background' => $colorMainBackground, + '--color-main-background-rgb' => $colorMainBackgroundRGB, + '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .85)', + + '--color-background-hover' => $this->util->lighten($colorMainBackground, 4), + '--color-background-dark' => $this->util->lighten($colorMainBackground, 7), + '--color-background-darker' => $this->util->lighten($colorMainBackground, 14), + + '--color-placeholder-light' => $this->util->lighten($colorMainBackground, 10), + '--color-placeholder-dark' => $this->util->lighten($colorMainBackground, 20), + + '--color-text-maxcontrast' => $colorTextMaxcontrast, + '--color-text-maxcontrast-default' => $colorTextMaxcontrast, + '--color-text-maxcontrast-background-blur' => $this->util->lighten($colorTextMaxcontrast, 6), + '--color-text-light' => 'var(--color-main-text)', // deprecated + '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated + + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->lighten($colorError, 10), + '--color-error-text' => $this->util->lighten($colorError, 15), + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->lighten($colorWarning, 10), + '--color-warning-text' => $colorWarning, + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->lighten($colorSuccess, 10), + '--color-success-text' => $this->util->lighten($colorSuccess, 15), + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->lighten($colorInfo, 10), + '--color-info-text' => $colorInfo, + '--color-favorite' => '#ffde00', + + // used for the icon loading animation + '--color-loading-light' => '#777', + '--color-loading-dark' => '#CCC', + + '--color-box-shadow' => $colorBoxShadow, + '--color-box-shadow-rgb' => $colorBoxShadowRGB, + + '--color-border' => $this->util->lighten($colorMainBackground, 7), + '--color-border-dark' => $this->util->lighten($colorMainBackground, 14), + '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 40), + + '--background-invert-if-dark' => 'invert(100%)', + '--background-invert-if-bright' => 'no', + ] + ); + } +} diff --git a/apps/theming/lib/Themes/DefaultTheme.php b/apps/theming/lib/Themes/DefaultTheme.php new file mode 100644 index 00000000000..bdd3048a498 --- /dev/null +++ b/apps/theming/lib/Themes/DefaultTheme.php @@ -0,0 +1,233 @@ +<?php + +declare(strict_types=1); +/** + * 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\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 string $defaultPrimaryColor; + public string $primaryColor; + + 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(); + } + + public function getId(): string { + return 'default'; + } + + public function getType(): int { + return ITheme::TYPE_THEME; + } + + public function getTitle(): string { + return $this->l->t('System default theme'); + } + + public function getEnableLabel(): string { + return $this->l->t('Enable the system default'); + } + + public function getDescription(): string { + return $this->l->t('Using the default system appearance.'); + } + + public function getMediaQuery(): string { + return ''; + } + + public function getMeta(): array { + return []; + } + + public function getCSSVariables(): array { + $colorMainText = '#222222'; + $colorMainTextRgb = join(',', $this->util->hexToRGB($colorMainText)); + // Color that still provides enough contrast for text, so we need a ratio of 4.5:1 on main background AND hover + $colorTextMaxcontrast = '#6b6b6b'; // 4.5 : 1 for hover background and background dark + $colorMainBackground = '#ffffff'; + $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + $colorBoxShadow = $this->util->darken($colorMainBackground, 70); + $colorBoxShadowRGB = join(',', $this->util->hexToRGB($colorBoxShadow)); + + $colorError = '#DB0606'; + $colorWarning = '#A37200'; + $colorSuccess = '#2d7b41'; + $colorInfo = '#0071ad'; + + $user = $this->userSession->getUser(); + // Chromium based browsers currently (2024) have huge performance issues with blur filters + $isChromium = $this->request !== null && $this->request->isUserAgent([Request::USER_AGENT_CHROME, Request::USER_AGENT_MS_EDGE]); + // Ignore MacOS because they always have hardware accelartion + $isChromium = $isChromium && !$this->request->isUserAgent(['/Macintosh/']); + // Allow to force the blur filter + $forceEnableBlur = $user === null ? false : $this->config->getUserValue( + $user->getUID(), + 'theming', + 'force_enable_blur_filter', + ); + $workingBlur = match($forceEnableBlur) { + 'yes' => true, + 'no' => false, + default => !$isChromium + }; + + $variables = [ + '--color-main-background' => $colorMainBackground, + '--color-main-background-rgb' => $colorMainBackgroundRGB, + '--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), .97)', + '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .8)', + '--filter-background-blur' => $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%', + + // used for different active/hover/focus/disabled states + '--color-background-hover' => $this->util->darken($colorMainBackground, 4), + '--color-background-dark' => $this->util->darken($colorMainBackground, 7), + '--color-background-darker' => $this->util->darken($colorMainBackground, 14), + + '--color-placeholder-light' => $this->util->darken($colorMainBackground, 10), + '--color-placeholder-dark' => $this->util->darken($colorMainBackground, 20), + + // max contrast for WCAG compliance + '--color-main-text' => $colorMainText, + '--color-text-maxcontrast' => $colorTextMaxcontrast, + '--color-text-maxcontrast-default' => $colorTextMaxcontrast, + '--color-text-maxcontrast-background-blur' => $this->util->darken($colorTextMaxcontrast, 7), + '--color-text-light' => 'var(--color-main-text)', // deprecated + '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated + + '--color-scrollbar' => 'var(--color-border-maxcontrast) transparent', + + // error/warning/success/info feedback colours + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->mix($colorError, $colorMainBackground, 75), + '--color-error-text' => $this->util->darken($colorError, 5), + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->darken($colorWarning, 5), + '--color-warning-text' => $this->util->darken($colorWarning, 7), + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->mix($colorSuccess, $colorMainBackground, 80), + '--color-success-text' => $this->util->darken($colorSuccess, 4), + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->mix($colorInfo, $colorMainBackground, 80), + '--color-info-text' => $this->util->darken($colorInfo, 4), + '--color-favorite' => '#A37200', + + // used for the icon loading animation + '--color-loading-light' => '#cccccc', + '--color-loading-dark' => '#444444', + + '--color-box-shadow-rgb' => $colorBoxShadowRGB, + '--color-box-shadow' => 'rgba(var(--color-box-shadow-rgb), 0.5)', + + '--color-border' => $this->util->darken($colorMainBackground, 7), + '--color-border-dark' => $this->util->darken($colorMainBackground, 14), + '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 51), + + '--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 width for input elements such as text fields and selects + '--border-width-input' => '1px', + '--border-width-input-focused' => '2px', + + // Border radii (new values) + '--border-radius-small' => '4px', // For smaller elements + '--border-radius-element' => '8px', // For interactive elements such as buttons, input, navigation and list items + '--border-radius-container' => '12px', // For smaller containers like action menus + '--border-radius-container-large' => '16px', // For bigger containers like body or modals + + // Border radii (deprecated) + '--border-radius' => 'var(--border-radius-small)', + '--border-radius-large' => 'var(--border-radius-element)', + '--border-radius-rounded' => '28px', + '--border-radius-pill' => '100px', + + '--default-clickable-area' => '34px', + '--clickable-area-large' => '48px', + '--clickable-area-small' => '24px', + + '--default-grid-baseline' => '4px', + + // 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', + + // 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%)', + '--background-image-invert-if-bright' => 'no', + ]; + + // Primary variables + $variables = array_merge($variables, $this->generatePrimaryVariables($colorMainBackground, $colorMainText)); + $variables = array_merge($variables, $this->generateGlobalBackgroundVariables()); + $variables = array_merge($variables, $this->generateUserBackgroundVariables()); + + return $variables; + } + + public function getCustomCss(): string { + return ''; + } +} diff --git a/apps/theming/lib/Themes/DyslexiaFont.php b/apps/theming/lib/Themes/DyslexiaFont.php new file mode 100644 index 00000000000..2448de7b3c8 --- /dev/null +++ b/apps/theming/lib/Themes/DyslexiaFont.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Themes; + +use OCA\Theming\ITheme; + +class DyslexiaFont extends DefaultTheme implements ITheme { + + public function getId(): string { + return 'opendyslexic'; + } + + public function getType(): int { + return ITheme::TYPE_FONT; + } + + public function getTitle(): string { + return $this->l->t('Dyslexia font'); + } + + public function getEnableLabel(): string { + return $this->l->t('Enable dyslexia font'); + } + + public function getDescription(): string { + return $this->l->t('OpenDyslexic is a free typeface/font designed to mitigate some of the common reading errors caused by dyslexia.'); + } + + public function getCSSVariables(): array { + $variables = parent::getCSSVariables(); + $originalFontFace = $variables['--font-face']; + + $variables = [ + '--font-face' => 'OpenDyslexic, ' . $originalFontFace + ]; + + return $variables; + } + + public function getCustomCss(): string { + $fontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.otf'); + $boldFontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.otf'); + + return " + @font-face { + font-family: 'OpenDyslexic'; + font-style: normal; + font-weight: 400; + src: url('$fontPathOtf') format('opentype'); + } + + @font-face { + font-family: 'OpenDyslexic'; + font-style: normal; + font-weight: 700; + src: url('$boldFontPathOtf') format('opentype'); + } + "; + } +} diff --git a/apps/theming/lib/Themes/HighContrastTheme.php b/apps/theming/lib/Themes/HighContrastTheme.php new file mode 100644 index 00000000000..5b51114a32f --- /dev/null +++ b/apps/theming/lib/Themes/HighContrastTheme.php @@ -0,0 +1,129 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Themes; + +use OCA\Theming\ITheme; + +class HighContrastTheme extends DefaultTheme implements ITheme { + + public function getId(): string { + return 'light-highcontrast'; + } + + public function getTitle(): string { + return $this->l->t('High contrast mode'); + } + + public function getEnableLabel(): string { + return $this->l->t('Enable high contrast mode'); + } + + public function getDescription(): string { + return $this->l->t('A high contrast mode to ease your navigation. Visual quality will be reduced but clarity will be increased.'); + } + + public function getMediaQuery(): string { + return '(prefers-contrast: more)'; + } + + /** + * Keep this consistent with other HighContrast Themes + */ + public function getCSSVariables(): array { + $defaultVariables = parent::getCSSVariables(); + + $colorMainText = '#000000'; + $colorMainBackground = '#ffffff'; + $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground)); + + $colorError = '#D10000'; + $colorWarning = '#995900'; + $colorSuccess = '#207830'; + $colorInfo = '#006DA8'; + + $primaryVariables = $this->generatePrimaryVariables($colorMainBackground, $colorMainText, true); + return array_merge( + $defaultVariables, + $primaryVariables, + [ + '--color-primary-element-text-dark' => $primaryVariables['--color-primary-element-text'], + + '--color-main-background' => $colorMainBackground, + '--color-main-background-rgb' => $colorMainBackgroundRGB, + '--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), 1)', + '--color-main-text' => $colorMainText, + + '--color-background-dark' => $this->util->darken($colorMainBackground, 20), + '--color-background-darker' => $this->util->darken($colorMainBackground, 20), + + '--color-main-background-blur' => $colorMainBackground, + '--filter-background-blur' => 'none', + + '--color-placeholder-light' => $this->util->darken($colorMainBackground, 30), + '--color-placeholder-dark' => $this->util->darken($colorMainBackground, 45), + + '--color-text-maxcontrast' => $colorMainText, + '--color-text-maxcontrast-background-blur' => $colorMainText, + '--color-text-light' => $colorMainText, + '--color-text-lighter' => $colorMainText, + + '--color-error' => $colorError, + '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)), + '--color-error-hover' => $this->util->darken($colorError, 8), + '--color-error-text' => $this->util->darken($colorError, 17), + + '--color-warning' => $colorWarning, + '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)), + '--color-warning-hover' => $this->util->darken($colorWarning, 7), + '--color-warning-text' => $this->util->darken($colorWarning, 13), + + '--color-info' => $colorInfo, + '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)), + '--color-info-hover' => $this->util->darken($colorInfo, 7), + '--color-info-text' => $this->util->darken($colorInfo, 15), + + '--color-success' => $colorSuccess, + '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)), + '--color-success-hover' => $this->util->darken($colorSuccess, 7), + '--color-success-text' => $this->util->darken($colorSuccess, 14), + + '--color-favorite' => '#936B06', + + '--color-scrollbar' => 'auto transparent', + + // used for the icon loading animation + '--color-loading-light' => '#dddddd', + '--color-loading-dark' => '#000000', + + '--color-box-shadow-rgb' => $colorMainText, + '--color-box-shadow' => $colorMainText, + + '--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', + ] + ); + } + + public function getCustomCss(): string { + return " + [class^='icon-'], [class*=' icon-'], + .action, + #appmenu li a, + .menutoggle { + opacity: 1 !important; + } + #app-navigation { + border-right: 1px solid var(--color-border); + } + "; + } +} diff --git a/apps/theming/lib/Themes/LightTheme.php b/apps/theming/lib/Themes/LightTheme.php new file mode 100644 index 00000000000..714156d4721 --- /dev/null +++ b/apps/theming/lib/Themes/LightTheme.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming\Themes; + +use OCA\Theming\ITheme; + +class LightTheme extends DefaultTheme implements ITheme { + + public function getId(): string { + return 'light'; + } + + public function getTitle(): string { + return $this->l->t('Light theme'); + } + + public function getEnableLabel(): string { + return $this->l->t('Enable the default light theme'); + } + + public function getDescription(): string { + return $this->l->t('The default light appearance.'); + } + + public function getMediaQuery(): string { + return '(prefers-color-scheme: light)'; + } + + public function getMeta(): array { + // https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme + return [[ + 'name' => 'color-scheme', + 'content' => 'light', + ]]; + } +} diff --git a/apps/theming/lib/ThemingDefaults.php b/apps/theming/lib/ThemingDefaults.php new file mode 100644 index 00000000000..04f56895fa3 --- /dev/null +++ b/apps/theming/lib/ThemingDefaults.php @@ -0,0 +1,528 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming; + +use OCA\Theming\AppInfo\Application; +use OCA\Theming\Service\BackgroundService; +use OCP\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; +use OCP\INavigationManager; +use OCP\IURLGenerator; +use OCP\IUserSession; + +class ThemingDefaults extends \OC_Defaults { + + private string $name; + private string $title; + private string $entity; + private string $productName; + private string $url; + private string $backgroundColor; + private string $primaryColor; + private string $docBaseUrl; + + private string $iTunesAppId; + private string $iOSClientUrl; + private string $AndroidClientUrl; + private string $FDroidClientUrl; + + /** + * ThemingDefaults constructor. + */ + 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->name = parent::getName(); + $this->title = parent::getTitle(); + $this->entity = parent::getEntity(); + $this->productName = parent::getProductName(); + $this->url = parent::getBaseUrl(); + $this->primaryColor = parent::getColorPrimary(); + $this->backgroundColor = parent::getColorBackground(); + $this->iTunesAppId = parent::getiTunesAppId(); + $this->iOSClientUrl = parent::getiOSClientUrl(); + $this->AndroidClientUrl = parent::getAndroidClientUrl(); + $this->FDroidClientUrl = parent::getFDroidClientUrl(); + $this->docBaseUrl = parent::getDocBaseUrl(); + } + + public function getName() { + return strip_tags($this->config->getAppValue('theming', 'name', $this->name)); + } + + public function getHTMLName() { + return $this->config->getAppValue('theming', 'name', $this->name); + } + + public function getTitle() { + return strip_tags($this->config->getAppValue('theming', 'name', $this->title)); + } + + public function getEntity() { + return strip_tags($this->config->getAppValue('theming', 'name', $this->entity)); + } + + public function getProductName() { + return strip_tags($this->config->getAppValue('theming', 'productName', $this->productName)); + } + + public function getBaseUrl() { + return $this->config->getAppValue('theming', 'url', $this->url); + } + + /** + * We pass a string and sanitizeHTML will return a string too in that case + * @psalm-suppress InvalidReturnStatement + * @psalm-suppress InvalidReturnType + */ + public function getSlogan(?string $lang = null) { + return \OCP\Util::sanitizeHTML($this->config->getAppValue('theming', 'slogan', parent::getSlogan($lang))); + } + + public function getImprintUrl() { + return (string)$this->config->getAppValue('theming', 'imprintUrl', ''); + } + + public function getPrivacyUrl() { + return (string)$this->config->getAppValue('theming', 'privacyUrl', ''); + } + + public function getDocBaseUrl() { + return (string)$this->config->getAppValue('theming', 'docBaseUrl', $this->docBaseUrl); + } + + public function getShortFooter() { + $slogan = $this->getSlogan(); + $baseUrl = $this->getBaseUrl(); + $entity = $this->getEntity(); + $footer = ''; + + if ($entity !== '') { + if ($baseUrl !== '') { + $footer = '<a href="' . $baseUrl . '" target="_blank"' + . ' rel="noreferrer noopener" class="entity-name">' . $entity . '</a>'; + } else { + $footer = '<span class="entity-name">' . $entity . '</span>'; + } + } + $footer .= ($slogan !== '' ? ' – ' . $slogan : ''); + + $links = [ + [ + 'text' => $this->l->t('Legal notice'), + 'url' => (string)$this->getImprintUrl() + ], + [ + 'text' => $this->l->t('Privacy policy'), + 'url' => (string)$this->getPrivacyUrl() + ], + ]; + + $navigation = $this->navigationManager->getAll(INavigationManager::TYPE_GUEST); + $guestNavigation = array_map(function ($nav) { + return [ + 'text' => $nav['name'], + 'url' => $nav['href'] + ]; + }, $navigation); + $links = array_merge($links, $guestNavigation); + + $legalLinks = ''; + $divider = ''; + foreach ($links as $link) { + 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>'; + $divider = ' · '; + } + } + if ($legalLinks !== '') { + $footer .= '<br/><span class="footer__legal-links">' . $legalLinks . '</span>'; + } + + return $footer; + } + + /** + * 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(); + + // admin-defined primary color + $defaultColor = $this->getDefaultColorPrimary(); + + if ($this->isUserThemingDisabled()) { + return $defaultColor; + } + + // user-defined primary color + if (!empty($user)) { + $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; + } + } + + // 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; + } + + // 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 primary color - only taking admin setting into account + */ + public function getDefaultColorPrimary(): string { + // 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 $this->backgroundColor; + } + + /** + * Themed logo url + * + * @param bool $useSvg Whether to point to the SVG image or a fallback + * @return string + */ + public function getLogo($useSvg = true): string { + $logo = $this->config->getAppValue('theming', 'logoMime', ''); + + // short cut to avoid setting up the filesystem just to check if the logo is there + // + // explanation: if an SVG is requested and the app config value for logoMime is set then the logo is there. + // otherwise we need to check it and maybe also generate a PNG from the SVG (that's done in getImage() which + // needs to be called then) + if ($useSvg === true && $logo !== false) { + $logoExists = true; + } else { + try { + $this->imageManager->getImage('logo', $useSvg); + $logoExists = true; + } catch (\Exception $e) { + $logoExists = false; + } + } + + $cacheBusterCounter = $this->config->getAppValue('theming', 'cachebuster', '0'); + + if (!$logo || !$logoExists) { + if ($useSvg) { + $logo = $this->urlGenerator->imagePath('core', 'logo/logo.svg'); + } else { + $logo = $this->urlGenerator->imagePath('core', 'logo/logo.png'); + } + return $logo . '?v=' . $cacheBusterCounter; + } + + return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => 'logo', 'useSvg' => $useSvg, 'v' => $cacheBusterCounter ]); + } + + /** + * Themed background image url + * + * @param bool $darkVariant if the dark variant (if available) of the background should be used + * @return string + */ + public function getBackground(bool $darkVariant = false): string { + return $this->imageManager->getImageUrl('background' . ($darkVariant ? 'Dark' : '')); + } + + /** + * @return string + */ + public function getiTunesAppId() { + return $this->config->getAppValue('theming', 'iTunesAppId', $this->iTunesAppId); + } + + /** + * @return string + */ + public function getiOSClientUrl() { + return $this->config->getAppValue('theming', 'iOSClientUrl', $this->iOSClientUrl); + } + + /** + * @return string + */ + public function getAndroidClientUrl() { + return $this->config->getAppValue('theming', 'AndroidClientUrl', $this->AndroidClientUrl); + } + + /** + * @return string + */ + public function getFDroidClientUrl() { + return $this->config->getAppValue('theming', 'FDroidClientUrl', $this->FDroidClientUrl); + } + + /** + * @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'); + $cache = $this->cacheFactory->createDistributed('theming-' . $cacheBuster . '-' . $this->urlGenerator->getBaseUrl()); + if ($value = $cache->get('getScssVariables')) { + return $value; + } + + $variables = [ + 'theming-cachebuster' => "'" . $cacheBuster . "'", + 'theming-logo-mime' => "'" . $this->config->getAppValue('theming', 'logoMime') . "'", + 'theming-background-mime' => "'" . $this->config->getAppValue('theming', 'backgroundMime') . "'", + 'theming-logoheader-mime' => "'" . $this->config->getAppValue('theming', 'logoheaderMime') . "'", + '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-login-plain'] = 'false'; + + 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()); + } + + if ($this->config->getAppValue('theming', 'backgroundMime', '') === 'backgroundColor') { + $variables['image-login-plain'] = 'true'; + } + + $variables['has-legal-links'] = 'false'; + if ($this->getImprintUrl() !== '' || $this->getPrivacyUrl() !== '') { + $variables['has-legal-links'] = 'true'; + } + + $cache->set('getScssVariables', $variables); + return $variables; + } + + /** + * Check if the image should be replaced by the theming app + * and return the new image location then + * + * @param string $app name of the app + * @param string $image filename of the image + * @return bool|string false if image should not replaced, otherwise the location of the image + */ + public function replaceImagePath($app, $image) { + if ($app === '' || $app === 'files_sharing') { + $app = 'core'; + } + $cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0'); + + $route = false; + if ($image === 'favicon.ico' && ($this->imageManager->shouldReplaceIcons() || $this->getCustomFavicon() !== null)) { + $route = $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', ['app' => $app]); + } + if (($image === 'favicon-touch.png' || $image === 'favicon-fb.png') && ($this->imageManager->shouldReplaceIcons() || $this->getCustomFavicon() !== null)) { + $route = $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => $app]); + } + if ($image === 'manifest.json') { + try { + $appPath = $this->appManager->getAppPath($app); + if (file_exists($appPath . '/img/manifest.json')) { + return false; + } + } catch (AppPathNotFoundException $e) { + } + $route = $this->urlGenerator->linkToRoute('theming.Theming.getManifest', ['app' => $app ]); + } + if (str_starts_with($image, 'filetypes/') && file_exists(\OC::$SERVERROOT . '/core/img/' . $image)) { + $route = $this->urlGenerator->linkToRoute('theming.Icon.getThemedIcon', ['app' => $app, 'image' => $image]); + } + + if ($route) { + return $route . '?v=' . $this->util->getCacheBuster(); + } + + return false; + } + + protected function getCustomFavicon(): ?ISimpleFile { + try { + return $this->imageManager->getImage('favicon'); + } catch (NotFoundException $e) { + return null; + } + } + + /** + * Increases the cache buster key + */ + public function increaseCacheBuster(): void { + $cacheBusterKey = (int)$this->config->getAppValue('theming', 'cachebuster', '0'); + $this->config->setAppValue('theming', 'cachebuster', (string)($cacheBusterKey + 1)); + $this->cacheFactory->createDistributed('theming-')->clear(); + $this->cacheFactory->createDistributed('imagePath')->clear(); + } + + /** + * Update setting in the database + * + * @param string $setting + * @param string $value + */ + public function set($setting, $value): void { + $this->config->setAppValue('theming', $setting, $value); + $this->increaseCacheBuster(); + } + + /** + * 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 admin settings to the default value + * + * @param string $setting setting which should be reverted + * @return string default value + */ + public function undo($setting): string { + $this->config->deleteAppValue('theming', $setting); + $this->increaseCacheBuster(); + + $returnValue = ''; + switch ($setting) { + case 'name': + $returnValue = $this->getEntity(); + break; + case 'url': + $returnValue = $this->getBaseUrl(); + break; + case 'slogan': + $returnValue = $this->getSlogan(); + break; + case 'primary_color': + $returnValue = BackgroundService::DEFAULT_COLOR; + break; + case 'background_color': + // If a background image is set we revert to the mean image color + if ($this->imageManager->hasImage('background')) { + $file = $this->imageManager->getImage('background'); + $returnValue = $this->backgroundService->setGlobalBackground($file->read()) ?? ''; + } + break; + case 'logo': + case 'logoheader': + case 'background': + case 'favicon': + $this->imageManager->delete($setting); + $this->config->deleteAppValue('theming', $setting . 'Mime'); + break; + } + + return $returnValue; + } + + /** + * 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 + */ + public function getTextColorPrimary() { + return $this->util->invertTextColor($this->getColorPrimary()) ? '#000000' : '#ffffff'; + } + + /** + * Color of text in the header and primary buttons + * + * @return string + */ + public function getDefaultTextColorPrimary() { + return $this->util->invertTextColor($this->getDefaultColorPrimary()) ? '#000000' : '#ffffff'; + } + + /** + * Has the admin disabled user customization + */ + public function isUserThemingDisabled(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, 'disable-user-theming'); + } +} diff --git a/apps/theming/lib/Util.php b/apps/theming/lib/Util.php new file mode 100644 index 00000000000..797456632fc --- /dev/null +++ b/apps/theming/lib/Util.php @@ -0,0 +1,326 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Theming; + +use Mexitek\PHPColors\Color; +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\IConfig; +use OCP\IUserSession; +use OCP\Server; +use OCP\ServerVersion; + +class Util { + public function __construct( + private ServerVersion $serverVersion, + private IConfig $config, + private IAppManager $appManager, + private IAppData $appData, + private ImageManager $imageManager, + ) { + } + + /** + * Should we invert the text on this background color? + * @param string $color rgb color value + * @return bool + */ + public function invertTextColor(string $color): bool { + return $this->colorContrast($color, '#ffffff') < 4.5; + } + + /** + * Is this color too bright ? + * @param string $color rgb color value + * @return bool + */ + public function isBrightColor(string $color): bool { + $l = $this->calculateLuma($color); + if ($l > 0.6) { + return true; + } else { + return false; + } + } + + /** + * get color for on-page elements: + * theme color by default, grey if theme color is to bright + * @param string $color + * @param ?bool $brightBackground + * @return string + */ + public function elementColor($color, ?bool $brightBackground = null, ?string $backgroundColor = null, bool $highContrast = false) { + if ($backgroundColor !== null) { + $brightBackground = $brightBackground ?? $this->isBrightColor($backgroundColor); + // Minimal amount that is possible to change the luminance + $epsilon = 1.0 / 255.0; + // Current iteration to prevent infinite loops + $iteration = 0; + // We need to keep blurred backgrounds in mind which might be mixed with the background + $blurredBackground = $this->mix($backgroundColor, $brightBackground ? $color : '#ffffff', 66); + $contrast = $this->colorContrast($color, $blurredBackground); + + // Min. element contrast is 3:1 but we need to keep hover states in mind -> min 3.2:1 + $minContrast = $highContrast ? 5.6 : 3.2; + + while ($contrast < $minContrast && $iteration++ < 100) { + $hsl = Color::hexToHsl($color); + $hsl['L'] = max(0, min(1, $hsl['L'] + ($brightBackground ? -$epsilon : $epsilon))); + $color = '#' . Color::hslToHex($hsl); + $contrast = $this->colorContrast($color, $blurredBackground); + } + return $color; + } + + // Fallback for legacy calling + $luminance = $this->calculateLuminance($color); + + if ($brightBackground !== false && $luminance > 0.8) { + // If the color is too bright in bright mode, we fall back to a darkened color + return $this->darken($color, 30); + } + + if ($brightBackground !== true && $luminance < 0.2) { + // If the color is too dark in dark mode, we fall back to a brightened color + return $this->lighten($color, 30); + } + + return $color; + } + + public function mix(string $color1, string $color2, int $factor): string { + $color = new Color($color1); + return '#' . $color->mix($color2, $factor); + } + + public function lighten(string $color, int $factor): string { + $color = new Color($color); + return '#' . $color->lighten($factor); + } + + public function darken(string $color, int $factor): string { + $color = new Color($color); + return '#' . $color->darken($factor); + } + + /** + * Convert RGB to HSL + * + * Copied from cssphp, copyright Leaf Corcoran, licensed under MIT + * + * @param int $red + * @param int $green + * @param int $blue + * + * @return float[] + */ + public function toHSL(int $red, int $green, int $blue): array { + $color = new Color(Color::rgbToHex(['R' => $red, 'G' => $green, 'B' => $blue])); + return array_values($color->getHsl()); + } + + /** + * @param string $color rgb color value + * @return float + */ + public function calculateLuminance(string $color): float { + [$red, $green, $blue] = $this->hexToRGB($color); + $hsl = $this->toHSL($red, $green, $blue); + return $hsl[2]; + } + + /** + * Calculate the Luma according to WCAG 2 + * http://www.w3.org/TR/WCAG20/#relativeluminancedef + * @param string $color rgb color value + * @return float + */ + public function calculateLuma(string $color): float { + $rgb = $this->hexToRGB($color); + + // Normalize the values by converting to float and applying the rules from WCAG2.0 + $rgb = array_map(function (int $color) { + $color = $color / 255.0; + if ($color <= 0.03928) { + return $color / 12.92; + } else { + return pow((($color + 0.055) / 1.055), 2.4); + } + }, $rgb); + + [$red, $green, $blue] = $rgb; + return (0.2126 * $red + 0.7152 * $green + 0.0722 * $blue); + } + + /** + * Calculat the color contrast according to WCAG 2 + * http://www.w3.org/TR/WCAG20/#contrast-ratiodef + * @param string $color1 The first color + * @param string $color2 The second color + */ + public function colorContrast(string $color1, string $color2): float { + $luminance1 = $this->calculateLuma($color1) + 0.05; + $luminance2 = $this->calculateLuma($color2) + 0.05; + return max($luminance1, $luminance2) / min($luminance1, $luminance2); + } + + /** + * @param string $color rgb color value + * @return int[] + * @psalm-return array{0: int, 1: int, 2: int} + */ + public function hexToRGB(string $color): array { + $color = new Color($color); + return array_values($color->getRgb()); + } + + /** + * @param $color + * @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>'; + return base64_encode($radioButtonIcon); + } + + + /** + * @param string $app app name + * @return string|ISimpleFile path to app icon / file of logo + */ + public function getAppIcon($app) { + $app = $this->appManager->cleanAppId($app); + try { + $appPath = $this->appManager->getAppPath($app); + $icon = $appPath . '/img/' . $app . '.svg'; + if (file_exists($icon)) { + return $icon; + } + $icon = $appPath . '/img/app.svg'; + if (file_exists($icon)) { + return $icon; + } + } catch (AppPathNotFoundException $e) { + } + + if ($this->config->getAppValue('theming', 'logoMime', '') !== '') { + $logoFile = null; + try { + $folder = $this->appData->getFolder('global/images'); + return $folder->getFile('logo'); + } catch (NotFoundException $e) { + } + } + return \OC::$SERVERROOT . '/core/img/logo/logo.svg'; + } + + /** + * @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 = $this->appManager->cleanAppId($app); + /** + * @psalm-taint-escape file + */ + $image = str_replace(['\0', '\\', '..'], '', $image); + if ($app === 'core') { + $icon = \OC::$SERVERROOT . '/core/img/' . $image; + if (file_exists($icon)) { + return $icon; + } + } + + try { + $appPath = $this->appManager->getAppPath($app); + } catch (AppPathNotFoundException $e) { + return false; + } + + $icon = $appPath . '/img/' . $image; + if (file_exists($icon)) { + return $icon; + } + $icon = $appPath . '/img/' . $image . '.svg'; + if (file_exists($icon)) { + return $icon; + } + $icon = $appPath . '/img/' . $image . '.png'; + if (file_exists($icon)) { + return $icon; + } + $icon = $appPath . '/img/' . $image . '.gif'; + if (file_exists($icon)) { + return $icon; + } + $icon = $appPath . '/img/' . $image . '.jpg'; + if (file_exists($icon)) { + return $icon; + } + + return false; + } + + /** + * replace default color with a custom one + * + * @param string $svg content of a svg file + * @param string $color color to match + * @return string + */ + public function colorizeSvg($svg, $color) { + $svg = preg_replace('/#0082c9/i', $color, $svg); + return $svg; + } + + /** + * Check if a custom theme is set in the server configuration + * + * @return bool + */ + public function isAlreadyThemed() { + $theme = $this->config->getSystemValue('theme', ''); + if ($theme !== '') { + return true; + } + return false; + } + + public function isBackgroundThemed() { + $backgroundLogo = $this->config->getAppValue('theming', 'backgroundMime', ''); + return $backgroundLogo !== '' && $backgroundLogo !== 'backgroundColor'; + } + + public function isLogoThemed() { + return $this->imageManager->hasImage('logo') + || $this->imageManager->hasImage('logoheader'); + } + + public function getCacheBuster(): string { + $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($serverVersion . $themingAppVersion . $userCacheBuster . $systemCacheBuster), 0, 8); + } +} |