aboutsummaryrefslogtreecommitdiffstats
path: root/apps/theming/lib/Controller
diff options
context:
space:
mode:
Diffstat (limited to 'apps/theming/lib/Controller')
-rw-r--r--apps/theming/lib/Controller/IconController.php131
-rw-r--r--apps/theming/lib/Controller/ThemingController.php305
-rw-r--r--apps/theming/lib/Controller/UserThemeController.php229
3 files changed, 431 insertions, 234 deletions
diff --git a/apps/theming/lib/Controller/IconController.php b/apps/theming/lib/Controller/IconController.php
index 173b5210394..e82faf78a79 100644
--- a/apps/theming/lib/Controller/IconController.php
+++ b/apps/theming/lib/Controller/IconController.php
@@ -1,29 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Julius Haertl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Michael Weimann <mail@michael-weimann.eu>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Controller;
@@ -31,8 +10,12 @@ use OC\IntegrityCheck\Helpers\FileAccessHelper;
use OCA\Theming\IconBuilder;
use OCA\Theming\ImageManager;
use OCA\Theming\ThemingDefaults;
+use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\NotFoundResponse;
@@ -41,61 +24,53 @@ use OCP\Files\NotFoundException;
use OCP\IRequest;
class IconController extends Controller {
- /** @var ThemingDefaults */
- private $themingDefaults;
- /** @var IconBuilder */
- private $iconBuilder;
- /** @var ImageManager */
- private $imageManager;
/** @var FileAccessHelper */
private $fileAccessHelper;
- /**
- * IconController constructor.
- *
- * @param string $appName
- * @param IRequest $request
- * @param ThemingDefaults $themingDefaults
- * @param IconBuilder $iconBuilder
- * @param ImageManager $imageManager
- * @param FileAccessHelper $fileAccessHelper
- */
public function __construct(
$appName,
IRequest $request,
- ThemingDefaults $themingDefaults,
- IconBuilder $iconBuilder,
- ImageManager $imageManager,
- FileAccessHelper $fileAccessHelper
+ private ThemingDefaults $themingDefaults,
+ private IconBuilder $iconBuilder,
+ private ImageManager $imageManager,
+ FileAccessHelper $fileAccessHelper,
+ private IAppManager $appManager,
) {
parent::__construct($appName, $request);
-
- $this->themingDefaults = $themingDefaults;
- $this->iconBuilder = $iconBuilder;
- $this->imageManager = $imageManager;
$this->fileAccessHelper = $fileAccessHelper;
}
/**
- * @PublicPage
- * @NoCSRFRequired
+ * Get a themed icon
*
- * @param $app string app name
- * @param $image string image file name (svg required)
- * @return FileDisplayResponse|NotFoundResponse
+ * @param string $app ID of the app
+ * @param string $image image file name (svg required)
+ * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/svg+xml'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws \Exception
+ *
+ * 200: Themed icon returned
+ * 404: Themed icon not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getThemedIcon(string $app, string $image): Response {
+ if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) {
+ $app = 'core';
+ $image = 'favicon.png';
+ }
+
+ $color = $this->themingDefaults->getColorPrimary();
try {
- $iconFile = $this->imageManager->getCachedImage('icon-' . $app . '-' . str_replace('/', '_',$image));
+ $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();
}
- $iconFile = $this->imageManager->setCachedImage('icon-' . $app . '-' . str_replace('/', '_',$image), $icon);
+ $iconFileName = $this->imageManager->setCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image), $icon);
}
- $response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/svg+xml']);
+ $response = new FileDisplayResponse($iconFileName, Http::STATUS_OK, ['Content-Type' => 'image/svg+xml']);
$response->cacheFor(86400, false, true);
return $response;
}
@@ -103,14 +78,21 @@ class IconController extends Controller {
/**
* Return a 32x32 favicon as png
*
- * @PublicPage
- * @NoCSRFRequired
- *
- * @param $app string app name
- * @return FileDisplayResponse|DataDisplayResponse|NotFoundResponse
+ * @param string $app ID of the app
+ * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws \Exception
+ *
+ * 200: Favicon returned
+ * 404: Favicon not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getFavicon(string $app = 'core'): Response {
+ if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) {
+ $app = 'core';
+ }
+
$response = null;
$iconFile = null;
try {
@@ -119,14 +101,15 @@ class IconController extends Controller {
} catch (NotFoundException $e) {
}
if ($iconFile === null && $this->imageManager->shouldReplaceIcons()) {
+ $color = $this->themingDefaults->getColorPrimary();
try {
- $iconFile = $this->imageManager->getCachedImage('favIcon-' . $app);
+ $iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color);
} catch (NotFoundException $exception) {
$icon = $this->iconBuilder->getFavicon($app);
if ($icon === false || $icon === '') {
return new NotFoundResponse();
}
- $iconFile = $this->imageManager->setCachedImage('favIcon-' . $app, $icon);
+ $iconFile = $this->imageManager->setCachedImage('favIcon-' . $app . $color, $icon);
}
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
}
@@ -141,14 +124,21 @@ class IconController extends Controller {
/**
* Return a 512x512 icon for touch devices
*
- * @PublicPage
- * @NoCSRFRequired
- *
- * @param $app string app name
- * @return DataDisplayResponse|FileDisplayResponse|NotFoundResponse
+ * @param string $app ID of the app
+ * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'|'image/png'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws \Exception
+ *
+ * 200: Touch icon returned
+ * 404: Touch icon not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getTouchIcon(string $app = 'core'): Response {
+ if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) {
+ $app = 'core';
+ }
+
$response = null;
try {
$iconFile = $this->imageManager->getImage('favicon');
@@ -156,14 +146,15 @@ class IconController extends Controller {
} catch (NotFoundException $e) {
}
if ($this->imageManager->shouldReplaceIcons()) {
+ $color = $this->themingDefaults->getColorPrimary();
try {
- $iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app);
+ $iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color);
} catch (NotFoundException $exception) {
$icon = $this->iconBuilder->getTouchIcon($app);
if ($icon === false || $icon === '') {
return new NotFoundResponse();
}
- $iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app, $icon);
+ $iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app . $color, $icon);
}
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']);
}
diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php
index 94deb2e7376..e5cee254fe8 100644
--- a/apps/theming/lib/Controller/ThemingController.php
+++ b/apps/theming/lib/Controller/ThemingController.php
@@ -1,61 +1,38 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Bjoern Schiessle <bjoern@schiessle.org>
- * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author Bjoern Schiessle <bjoern@schiessle.org>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Jan-Christoph Borchardt <hey@jancborchardt.net>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Kyle Fazzari <kyrofa@ubuntu.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author nhirokinet <nhirokinet@nhiroki.net>
- * @author rakekniven <mark.ziegler@rakekniven.de>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Citharel <nextcloud@tcit.fr>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Controller;
+use InvalidArgumentException;
use OCA\Theming\ImageManager;
use OCA\Theming\Service\ThemesService;
+use OCA\Theming\Settings\Admin;
use OCA\Theming\ThemingDefaults;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\AppFramework\Http\Attribute\BruteForceProtection;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
+use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\NotFoundResponse;
-use OCP\Files\IAppData;
+use OCP\AppFramework\Services\IAppConfig;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IL10N;
+use OCP\INavigationManager;
use OCP\IRequest;
-use OCP\ITempManager;
use OCP\IURLGenerator;
-use ScssPhp\ScssPhp\Compiler;
/**
* Class ThemingController
@@ -65,52 +42,35 @@ use ScssPhp\ScssPhp\Compiler;
* @package OCA\Theming\Controller
*/
class ThemingController extends Controller {
- private ThemingDefaults $themingDefaults;
- private IL10N $l10n;
- private IConfig $config;
- private ITempManager $tempManager;
- private IAppData $appData;
- private IURLGenerator $urlGenerator;
- private IAppManager $appManager;
- private ImageManager $imageManager;
- private ThemesService $themesService;
+ public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon'];
public function __construct(
- $appName,
+ string $appName,
IRequest $request,
- IConfig $config,
- ThemingDefaults $themingDefaults,
- IL10N $l,
- ITempManager $tempManager,
- IAppData $appData,
- IURLGenerator $urlGenerator,
- IAppManager $appManager,
- ImageManager $imageManager,
- ThemesService $themesService
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ private ThemingDefaults $themingDefaults,
+ private IL10N $l10n,
+ private IURLGenerator $urlGenerator,
+ private IAppManager $appManager,
+ private ImageManager $imageManager,
+ private ThemesService $themesService,
+ private INavigationManager $navigationManager,
) {
parent::__construct($appName, $request);
-
- $this->themingDefaults = $themingDefaults;
- $this->l10n = $l;
- $this->config = $config;
- $this->tempManager = $tempManager;
- $this->appData = $appData;
- $this->urlGenerator = $urlGenerator;
- $this->appManager = $appManager;
- $this->imageManager = $imageManager;
- $this->themesService = $themesService;
}
/**
- * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin)
* @param string $setting
* @param string $value
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function updateStylesheet($setting, $value) {
$value = trim($value);
$error = null;
+ $saved = false;
switch ($setting) {
case 'name':
if (strlen($value) > 250) {
@@ -146,9 +106,28 @@ class ThemingController extends Controller {
$error = $this->l10n->t('The given slogan is too long');
}
break;
- case 'color':
+ case 'primary_color':
+ if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
+ $error = $this->l10n->t('The given color is invalid');
+ } else {
+ $this->appConfig->setAppValueString('primary_color', $value);
+ $saved = true;
+ }
+ break;
+ case 'background_color':
if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
$error = $this->l10n->t('The given color is invalid');
+ } else {
+ $this->appConfig->setAppValueString('background_color', $value);
+ $saved = true;
+ }
+ break;
+ case 'disable-user-theming':
+ if (!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;
}
@@ -161,7 +140,9 @@ class ThemingController extends Controller {
], Http::STATUS_BAD_REQUEST);
}
- $this->themingDefaults->set($setting, $value);
+ if (!$saved) {
+ $this->themingDefaults->set($setting, $value);
+ }
return new DataResponse([
'data' => [
@@ -172,20 +153,74 @@ class ThemingController extends Controller {
}
/**
- * Check that a string is a valid http/https url
+ * @param string $setting
+ * @param mixed $value
+ * @return DataResponse
+ * @throws NotPermittedException
+ */
+ #[AuthorizedAdminSetting(settings: Admin::class)]
+ public function updateAppMenu($setting, $value) {
+ $error = null;
+ switch ($setting) {
+ case 'defaultApps':
+ if (is_array($value)) {
+ try {
+ $this->navigationManager->setDefaultEntryIds($value);
+ } catch (InvalidArgumentException $e) {
+ $error = $this->l10n->t('Invalid app given');
+ }
+ } else {
+ $error = $this->l10n->t('Invalid type for setting "defaultApp" given');
+ }
+ break;
+ default:
+ $error = $this->l10n->t('Invalid setting key');
+ }
+ if ($error !== null) {
+ return new DataResponse([
+ 'data' => [
+ 'message' => $error,
+ ],
+ 'status' => 'error'
+ ], Http::STATUS_BAD_REQUEST);
+ }
+
+ return new DataResponse([
+ 'data' => [
+ 'message' => $this->l10n->t('Saved'),
+ ],
+ 'status' => 'success'
+ ]);
+ }
+
+ /**
+ * Check that a string is a valid http/https url.
+ * Also validates that there is no way for XSS through HTML
*/
private function isValidUrl(string $url): bool {
- return ((strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0) &&
- filter_var($url, FILTER_VALIDATE_URL) !== false);
+ return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://'))
+ && filter_var($url, FILTER_VALIDATE_URL) !== false)
+ && !str_contains($url, '"');
}
/**
- * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin)
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function uploadImage(): DataResponse {
$key = $this->request->getParam('key');
+ if (!in_array($key, self::VALID_UPLOAD_KEYS, true)) {
+ return new DataResponse(
+ [
+ 'data' => [
+ 'message' => 'Invalid key'
+ ],
+ 'status' => 'failure',
+ ],
+ Http::STATUS_BAD_REQUEST
+ );
+ }
$image = $this->request->getUploadedFile('image');
$error = null;
$phpFileUploadErrors = [
@@ -236,8 +271,8 @@ class ThemingController extends Controller {
return new DataResponse(
[
- 'data' =>
- [
+ 'data'
+ => [
'name' => $name,
'url' => $this->imageManager->getImageUrl($key),
'message' => $this->l10n->t('Saved'),
@@ -249,19 +284,19 @@ class ThemingController extends Controller {
/**
* Revert setting to default value
- * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin)
*
* @param string $setting setting which should be reverted
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function undo(string $setting): DataResponse {
$value = $this->themingDefaults->undo($setting);
return new DataResponse(
[
- 'data' =>
- [
+ 'data'
+ => [
'value' => $value,
'message' => $this->l10n->t('Saved'),
],
@@ -271,15 +306,43 @@ class ThemingController extends Controller {
}
/**
- * @PublicPage
- * @NoCSRFRequired
+ * 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
*
- * @param string $key
- * @param bool $useSvg
- * @return FileDisplayResponse|NotFoundResponse
+ * Get an image
+ *
+ * @param string $key Key of the image
+ * @param bool $useSvg Return image as SVG
+ * @return FileDisplayResponse<Http::STATUS_OK, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws NotPermittedException
+ *
+ * 200: Image returned
+ * 404: Image not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getImage(string $key, bool $useSvg = true) {
try {
$file = $this->imageManager->getImage($key, $useSvg);
@@ -288,7 +351,7 @@ class ThemingController extends Controller {
}
$response = new FileDisplayResponse($file);
- $csp = new Http\ContentSecurityPolicy();
+ $csp = new ContentSecurityPolicy();
$csp->allowInlineStyle();
$response->setContentSecurityPolicy($csp);
$response->cacheFor(3600);
@@ -303,13 +366,22 @@ class ThemingController extends Controller {
}
/**
- * @NoCSRFRequired
- * @PublicPage
* @NoSameSiteCookieRequired
* @NoTwoFactorRequired
*
- * @return DataDisplayResponse|NotFoundResponse
+ * Get the CSS stylesheet for a theme
+ *
+ * @param string $themeId ID of the theme
+ * @param bool $plain Let the browser decide the CSS priority
+ * @param bool $withCustomCss Include custom CSS
+ * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'text/css'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
+ *
+ * 200: Stylesheet returned
+ * 404: Theme not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) {
$themes = $this->themesService->getThemes();
if (!in_array($themeId, array_keys($themes))) {
@@ -317,7 +389,7 @@ class ThemingController extends Controller {
}
$theme = $themes[$themeId];
- $customCss = $theme->getCustomCss();
+ $customCss = $theme->getCustomCss();
// Generate variables
$variables = '';
@@ -328,11 +400,19 @@ class ThemingController extends Controller {
// If plain is set, the browser decides of the css priority
if ($plain) {
$css = ":root { $variables } " . $customCss;
- } else {
+ } else {
// If not set, we'll rely on the body class
- $compiler = new Compiler();
- $compiledCss = $compiler->compileString("body[data-theme-$themeId] { $variables $customCss }");
- $css = $compiledCss->getCss();;
+ // We need to separate @-rules from normal selectors, as they can't be nested
+ // This is a replacement for the SCSS compiler that did this automatically before f1448fcf0777db7d4254cb0a3ef94d63be9f7a24
+ // We need a better way to handle this, but for now we just remove comments and split the at-rules
+ // from the rest of the CSS.
+ $customCssWithoutComments = preg_replace('!/\*.*?\*/!s', '', $customCss);
+ $customCssWithoutComments = preg_replace('!//.*!', '', $customCssWithoutComments);
+ preg_match_all('/(@[^{]+{(?:[^{}]*|(?R))*})/', $customCssWithoutComments, $atRules);
+ $atRulesCss = implode('', $atRules[0]);
+ $scopedCss = preg_replace('/(@[^{]+{(?:[^{}]*|(?R))*})/', '', $customCssWithoutComments);
+
+ $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss }";
}
try {
@@ -345,12 +425,20 @@ class ThemingController extends Controller {
}
/**
- * @NoCSRFRequired
- * @PublicPage
+ * Get the manifest for an app
*
- * @return Http\JSONResponse
+ * @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
*/
- public function getManifest($app) {
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[BruteForceProtection(action: 'manifest')]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+ public function getManifest(string $app): JSONResponse {
$cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0');
if ($app === 'core' || $app === 'settings') {
$name = $this->themingDefaults->getName();
@@ -358,16 +446,26 @@ class ThemingController extends Controller {
$startUrl = $this->urlGenerator->getBaseUrl();
$description = $this->themingDefaults->getSlogan();
} else {
+ if (!$this->appManager->isEnabledForUser($app)) {
+ $response = new JSONResponse([], Http::STATUS_NOT_FOUND);
+ $response->throttle(['action' => 'manifest', 'app' => $app]);
+ return $response;
+ }
+
$info = $this->appManager->getAppInfo($app, false, $this->l10n->getLanguageCode());
$name = $info['name'] . ' - ' . $this->themingDefaults->getName();
$shortName = $info['name'];
- if (strpos($this->request->getRequestUri(), '/index.php/') !== false) {
+ if (str_contains($this->request->getRequestUri(), '/index.php/')) {
$startUrl = $this->urlGenerator->getBaseUrl() . '/index.php/apps/' . $app . '/';
} else {
$startUrl = $this->urlGenerator->getBaseUrl() . '/apps/' . $app . '/';
}
$description = $info['summary'] ?? '';
}
+ /**
+ * @var string $description
+ * @var string $shortName
+ */
$responseJS = [
'name' => $name,
'short_name' => $shortName,
@@ -375,24 +473,25 @@ class ThemingController extends Controller {
'theme_color' => $this->themingDefaults->getColorPrimary(),
'background_color' => $this->themingDefaults->getColorPrimary(),
'description' => $description,
- 'icons' =>
- [
+ 'icons'
+ => [
[
'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon',
- ['app' => $app]) . '?v=' . $cacheBusterValue,
+ ['app' => $app]) . '?v=' . $cacheBusterValue,
'type' => 'image/png',
'sizes' => '512x512'
],
[
'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon',
- ['app' => $app]) . '?v=' . $cacheBusterValue,
+ ['app' => $app]) . '?v=' . $cacheBusterValue,
'type' => 'image/svg+xml',
'sizes' => '16x16'
]
],
- 'display' => 'standalone'
+ 'display_override' => [$this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'minimal-ui' : ''],
+ 'display' => $this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'standalone' : 'browser'
];
- $response = new Http\JSONResponse($responseJS);
+ $response = new JSONResponse($responseJS);
$response->cacheFor(3600);
return $response;
}
diff --git a/apps/theming/lib/Controller/UserThemeController.php b/apps/theming/lib/Controller/UserThemeController.php
index ec379d2e6fa..770f2ca922f 100644
--- a/apps/theming/lib/Controller/UserThemeController.php
+++ b/apps/theming/lib/Controller/UserThemeController.php
@@ -3,98 +3,107 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2018 John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
- * @copyright Copyright (c) 2019 Janis Köhr <janiskoehr@icloud.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Janis Köhr <janis.koehr@novatec-gmbh.de>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Controller;
+use OCA\Theming\AppInfo\Application;
+use OCA\Theming\ITheme;
+use OCA\Theming\ResponseDefinitions;
+use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\ThemesService;
+use OCA\Theming\ThemingDefaults;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\FileDisplayResponse;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\NotFoundResponse;
+use OCP\AppFramework\Http\Response;
use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCSController;
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;
- private IConfig $config;
- private IUserSession $userSession;
- private ThemesService $themesService;
+ protected ?string $userId = null;
- /**
- * Config constructor.
- */
- public function __construct(string $appName,
- IRequest $request,
- IConfig $config,
- IUserSession $userSession,
- ThemesService $themesService) {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IConfig $config,
+ IUserSession $userSession,
+ private ThemesService $themesService,
+ private ThemingDefaults $themingDefaults,
+ private BackgroundService $backgroundService,
+ ) {
parent::__construct($appName, $request);
- $this->config = $config;
- $this->userSession = $userSession;
- $this->themesService = $themesService;
- $this->userId = $userSession->getUser()->getUID();
+
+ $user = $userSession->getUser();
+ if ($user !== null) {
+ $this->userId = $user->getUID();
+ }
}
/**
- * @NoAdminRequired
- *
* Enable theme
*
* @param string $themeId the theme ID
- * @return DataResponse
- * @throws OCSBadRequestException|PreConditionNotMetException
+ * @return DataResponse<Http::STATUS_OK, list<empty>, array{}>
+ * @throws OCSBadRequestException Enabling theme is not possible
+ * @throws PreConditionNotMetException
+ *
+ * 200: Theme enabled successfully
*/
+ #[NoAdminRequired]
public function enableTheme(string $themeId): DataResponse {
- if ($themeId === '' || !$themeId) {
- throw new OCSBadRequestException('Invalid theme id: ' . $themeId);
- }
+ $theme = $this->validateTheme($themeId);
- $themes = $this->themesService->getThemes();
- if (!isset($themes[$themeId])) {
- throw new OCSBadRequestException('Invalid theme id: ' . $themeId);
- }
-
// Enable selected theme
- $this->themesService->enableTheme($themes[$themeId]);
+ $this->themesService->enableTheme($theme);
return new DataResponse();
}
/**
- * @NoAdminRequired
- *
* Disable theme
*
* @param string $themeId the theme ID
- * @return DataResponse
- * @throws OCSBadRequestException|PreConditionNotMetException
+ * @return DataResponse<Http::STATUS_OK, list<empty>, array{}>
+ * @throws OCSBadRequestException Disabling theme is not possible
+ * @throws PreConditionNotMetException
+ *
+ * 200: Theme disabled successfully
*/
+ #[NoAdminRequired]
public function disableTheme(string $themeId): DataResponse {
+ $theme = $this->validateTheme($themeId);
+
+ // 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);
}
@@ -103,9 +112,107 @@ class UserThemeController extends OCSController {
if (!isset($themes[$themeId])) {
throw new OCSBadRequestException('Invalid theme id: ' . $themeId);
}
-
- // Enable selected theme
- $this->themesService->disableTheme($themes[$themeId]);
- return new DataResponse();
+
+ // 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,
+ ]);
}
}