aboutsummaryrefslogtreecommitdiffstats
path: root/apps/theming/lib/Controller/ThemingController.php
diff options
context:
space:
mode:
Diffstat (limited to 'apps/theming/lib/Controller/ThemingController.php')
-rw-r--r--apps/theming/lib/Controller/ThemingController.php402
1 files changed, 250 insertions, 152 deletions
diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php
index 5e21ae7b2ed..e5cee254fe8 100644
--- a/apps/theming/lib/Controller/ThemingController.php
+++ b/apps/theming/lib/Controller/ThemingController.php
@@ -1,59 +1,37 @@
<?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 OC\Template\SCSSCacher;
+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;
/**
@@ -64,64 +42,22 @@ use OCP\IURLGenerator;
* @package OCA\Theming\Controller
*/
class ThemingController extends Controller {
- /** @var ThemingDefaults */
- private $themingDefaults;
- /** @var IL10N */
- private $l10n;
- /** @var IConfig */
- private $config;
- /** @var ITempManager */
- private $tempManager;
- /** @var IAppData */
- private $appData;
- /** @var SCSSCacher */
- private $scssCacher;
- /** @var IURLGenerator */
- private $urlGenerator;
- /** @var IAppManager */
- private $appManager;
- /** @var ImageManager */
- private $imageManager;
+ public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon'];
- /**
- * ThemingController constructor.
- *
- * @param string $appName
- * @param IRequest $request
- * @param IConfig $config
- * @param ThemingDefaults $themingDefaults
- * @param IL10N $l
- * @param ITempManager $tempManager
- * @param IAppData $appData
- * @param SCSSCacher $scssCacher
- * @param IURLGenerator $urlGenerator
- * @param IAppManager $appManager
- * @param ImageManager $imageManager
- */
public function __construct(
- $appName,
+ string $appName,
IRequest $request,
- IConfig $config,
- ThemingDefaults $themingDefaults,
- IL10N $l,
- ITempManager $tempManager,
- IAppData $appData,
- SCSSCacher $scssCacher,
- IURLGenerator $urlGenerator,
- IAppManager $appManager,
- ImageManager $imageManager
+ 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->scssCacher = $scssCacher;
- $this->urlGenerator = $urlGenerator;
- $this->appManager = $appManager;
- $this->imageManager = $imageManager;
}
/**
@@ -130,9 +66,11 @@ class ThemingController extends Controller {
* @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) {
@@ -168,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;
}
@@ -183,37 +140,87 @@ class ThemingController extends Controller {
], Http::STATUS_BAD_REQUEST);
}
- $this->themingDefaults->set($setting, $value);
+ if (!$saved) {
+ $this->themingDefaults->set($setting, $value);
+ }
- // reprocess server scss for preview
- $cssCached = $this->scssCacher->process(\OC::$SERVERROOT, 'core/css/css-variables.scss', 'core');
+ return new DataResponse([
+ 'data' => [
+ 'message' => $this->l10n->t('Saved'),
+ ],
+ 'status' => 'success'
+ ]);
+ }
- return new DataResponse(
- [
- 'data' =>
- [
- 'message' => $this->l10n->t('Saved'),
- 'serverCssUrl' => $this->urlGenerator->linkTo('', $this->scssCacher->getCachedSCSS('core', '/core/css/css-variables.scss'))
- ],
- '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
+ * 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, '"');
}
/**
* @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 = [
@@ -261,16 +268,14 @@ class ThemingController extends Controller {
}
$name = $image['name'];
- $cssCached = $this->scssCacher->process(\OC::$SERVERROOT, 'core/css/css-variables.scss', 'core');
return new DataResponse(
[
- 'data' =>
- [
+ 'data'
+ => [
'name' => $name,
'url' => $this->imageManager->getImageUrl($key),
'message' => $this->l10n->t('Saved'),
- 'serverCssUrl' => $this->urlGenerator->linkTo('', $this->scssCacher->getCachedSCSS('core', '/core/css/css-variables.scss'))
],
'status' => 'success'
]
@@ -284,18 +289,16 @@ class ThemingController extends Controller {
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function undo(string $setting): DataResponse {
$value = $this->themingDefaults->undo($setting);
- // reprocess server scss for preview
- $cssCached = $this->scssCacher->process(\OC::$SERVERROOT, 'core/css/css-variables.scss', 'core');
return new DataResponse(
[
- 'data' =>
- [
+ 'data'
+ => [
'value' => $value,
'message' => $this->l10n->t('Saved'),
- 'serverCssUrl' => $this->urlGenerator->linkTo('', $this->scssCacher->getCachedSCSS('core', '/core/css/css-variables.scss'))
],
'status' => 'success'
]
@@ -303,14 +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
+ *
+ * Get an image
*
- * @param string $key
- * @param bool $useSvg
- * @return FileDisplayResponse|NotFoundResponse
+ * @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);
@@ -319,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);
@@ -334,30 +366,57 @@ class ThemingController extends Controller {
}
/**
- * @NoCSRFRequired
- * @PublicPage
* @NoSameSiteCookieRequired
+ * @NoTwoFactorRequired
*
- * @return FileDisplayResponse|NotFoundResponse
- * @throws NotPermittedException
- * @throws \Exception
- * @throws \OCP\App\AppPathNotFoundException
+ * 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
*/
- public function getStylesheet() {
- $appPath = $this->appManager->getAppPath('theming');
-
- /* SCSSCacher is required here
- * We cannot rely on automatic caching done by \OC_Util::addStyle,
- * since we need to add the cacheBuster value to the url
- */
- $cssCached = $this->scssCacher->process($appPath, 'css/theming.scss', 'theming');
- if (!$cssCached) {
+ #[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 {
- $cssFile = $this->scssCacher->getCachedCSS('theming', 'theming.css');
- $response = new FileDisplayResponse($cssFile, Http::STATUS_OK, ['Content-Type' => 'text/css']);
+ $response = new DataDisplayResponse($css, Http::STATUS_OK, ['Content-Type' => 'text/css']);
$response->cacheFor(86400);
return $response;
} catch (NotFoundException $e) {
@@ -366,34 +425,73 @@ class ThemingController extends Controller {
}
/**
- * @NoCSRFRequired
- * @PublicPage
+ * Get the manifest for an app
+ *
+ * @param string $app ID of the app
+ * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type
+ * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
*
- * @return Http\JSONResponse
+ * 200: Manifest returned
+ * 404: App not found
*/
- public function getManifest($app) {
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[BruteForceProtection(action: 'manifest')]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+ public function getManifest(string $app): JSONResponse {
$cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0');
+ if ($app === 'core' || $app === 'settings') {
+ $name = $this->themingDefaults->getName();
+ $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' => $this->themingDefaults->getName(),
- 'start_url' => $this->urlGenerator->getBaseUrl(),
- 'icons' =>
- [
+ '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,
+ ['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;
}