ThemingController.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-or-later
  5. */
  6. namespace OCA\Theming\Controller;
  7. use InvalidArgumentException;
  8. use OCA\Theming\ImageManager;
  9. use OCA\Theming\Service\ThemesService;
  10. use OCA\Theming\Settings\Admin;
  11. use OCA\Theming\ThemingDefaults;
  12. use OCP\App\IAppManager;
  13. use OCP\AppFramework\Controller;
  14. use OCP\AppFramework\Http;
  15. use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
  16. use OCP\AppFramework\Http\Attribute\BruteForceProtection;
  17. use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
  18. use OCP\AppFramework\Http\Attribute\PublicPage;
  19. use OCP\AppFramework\Http\DataDisplayResponse;
  20. use OCP\AppFramework\Http\DataResponse;
  21. use OCP\AppFramework\Http\FileDisplayResponse;
  22. use OCP\AppFramework\Http\JSONResponse;
  23. use OCP\AppFramework\Http\NotFoundResponse;
  24. use OCP\AppFramework\Services\IAppConfig;
  25. use OCP\Files\NotFoundException;
  26. use OCP\Files\NotPermittedException;
  27. use OCP\IConfig;
  28. use OCP\IL10N;
  29. use OCP\INavigationManager;
  30. use OCP\IRequest;
  31. use OCP\IURLGenerator;
  32. use ScssPhp\ScssPhp\Compiler;
  33. /**
  34. * Class ThemingController
  35. *
  36. * handle ajax requests to update the theme
  37. *
  38. * @package OCA\Theming\Controller
  39. */
  40. class ThemingController extends Controller {
  41. public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon'];
  42. public function __construct(
  43. $appName,
  44. IRequest $request,
  45. private IConfig $config,
  46. private IAppConfig $appConfig,
  47. private ThemingDefaults $themingDefaults,
  48. private IL10N $l10n,
  49. private IURLGenerator $urlGenerator,
  50. private IAppManager $appManager,
  51. private ImageManager $imageManager,
  52. private ThemesService $themesService,
  53. private INavigationManager $navigationManager,
  54. ) {
  55. parent::__construct($appName, $request);
  56. }
  57. /**
  58. * @param string $setting
  59. * @param string $value
  60. * @return DataResponse
  61. * @throws NotPermittedException
  62. */
  63. #[AuthorizedAdminSetting(settings: Admin::class)]
  64. public function updateStylesheet($setting, $value) {
  65. $value = trim($value);
  66. $error = null;
  67. $saved = false;
  68. switch ($setting) {
  69. case 'name':
  70. if (strlen($value) > 250) {
  71. $error = $this->l10n->t('The given name is too long');
  72. }
  73. break;
  74. case 'url':
  75. if (strlen($value) > 500) {
  76. $error = $this->l10n->t('The given web address is too long');
  77. }
  78. if (!$this->isValidUrl($value)) {
  79. $error = $this->l10n->t('The given web address is not a valid URL');
  80. }
  81. break;
  82. case 'imprintUrl':
  83. if (strlen($value) > 500) {
  84. $error = $this->l10n->t('The given legal notice address is too long');
  85. }
  86. if (!$this->isValidUrl($value)) {
  87. $error = $this->l10n->t('The given legal notice address is not a valid URL');
  88. }
  89. break;
  90. case 'privacyUrl':
  91. if (strlen($value) > 500) {
  92. $error = $this->l10n->t('The given privacy policy address is too long');
  93. }
  94. if (!$this->isValidUrl($value)) {
  95. $error = $this->l10n->t('The given privacy policy address is not a valid URL');
  96. }
  97. break;
  98. case 'slogan':
  99. if (strlen($value) > 500) {
  100. $error = $this->l10n->t('The given slogan is too long');
  101. }
  102. break;
  103. case 'primary_color':
  104. if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
  105. $error = $this->l10n->t('The given color is invalid');
  106. } else {
  107. $this->appConfig->setAppValueString('primary_color', $value);
  108. $saved = true;
  109. }
  110. break;
  111. case 'background_color':
  112. if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
  113. $error = $this->l10n->t('The given color is invalid');
  114. } else {
  115. $this->appConfig->setAppValueString('background_color', $value);
  116. $saved = true;
  117. }
  118. break;
  119. case 'disable-user-theming':
  120. if (!in_array($value, ['yes', 'true', 'no', 'false'])) {
  121. $error = $this->l10n->t('Disable-user-theming should be true or false');
  122. } else {
  123. $this->appConfig->setAppValueBool('disable-user-theming', $value === 'yes' || $value === 'true');
  124. $saved = true;
  125. }
  126. break;
  127. }
  128. if ($error !== null) {
  129. return new DataResponse([
  130. 'data' => [
  131. 'message' => $error,
  132. ],
  133. 'status' => 'error'
  134. ], Http::STATUS_BAD_REQUEST);
  135. }
  136. if (!$saved) {
  137. $this->themingDefaults->set($setting, $value);
  138. }
  139. return new DataResponse([
  140. 'data' => [
  141. 'message' => $this->l10n->t('Saved'),
  142. ],
  143. 'status' => 'success'
  144. ]);
  145. }
  146. /**
  147. * @param string $setting
  148. * @param mixed $value
  149. * @return DataResponse
  150. * @throws NotPermittedException
  151. */
  152. #[AuthorizedAdminSetting(settings: Admin::class)]
  153. public function updateAppMenu($setting, $value) {
  154. $error = null;
  155. switch ($setting) {
  156. case 'defaultApps':
  157. if (is_array($value)) {
  158. try {
  159. $this->navigationManager->setDefaultEntryIds($value);
  160. } catch (InvalidArgumentException $e) {
  161. $error = $this->l10n->t('Invalid app given');
  162. }
  163. } else {
  164. $error = $this->l10n->t('Invalid type for setting "defaultApp" given');
  165. }
  166. break;
  167. default:
  168. $error = $this->l10n->t('Invalid setting key');
  169. }
  170. if ($error !== null) {
  171. return new DataResponse([
  172. 'data' => [
  173. 'message' => $error,
  174. ],
  175. 'status' => 'error'
  176. ], Http::STATUS_BAD_REQUEST);
  177. }
  178. return new DataResponse([
  179. 'data' => [
  180. 'message' => $this->l10n->t('Saved'),
  181. ],
  182. 'status' => 'success'
  183. ]);
  184. }
  185. /**
  186. * Check that a string is a valid http/https url
  187. */
  188. private function isValidUrl(string $url): bool {
  189. return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) &&
  190. filter_var($url, FILTER_VALIDATE_URL) !== false);
  191. }
  192. /**
  193. * @return DataResponse
  194. * @throws NotPermittedException
  195. */
  196. #[AuthorizedAdminSetting(settings: Admin::class)]
  197. public function uploadImage(): DataResponse {
  198. $key = $this->request->getParam('key');
  199. if (!in_array($key, self::VALID_UPLOAD_KEYS, true)) {
  200. return new DataResponse(
  201. [
  202. 'data' => [
  203. 'message' => 'Invalid key'
  204. ],
  205. 'status' => 'failure',
  206. ],
  207. Http::STATUS_BAD_REQUEST
  208. );
  209. }
  210. $image = $this->request->getUploadedFile('image');
  211. $error = null;
  212. $phpFileUploadErrors = [
  213. UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
  214. UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
  215. UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
  216. UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
  217. UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
  218. UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
  219. UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
  220. UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
  221. ];
  222. if (empty($image)) {
  223. $error = $this->l10n->t('No file uploaded');
  224. }
  225. if (!empty($image) && array_key_exists('error', $image) && $image['error'] !== UPLOAD_ERR_OK) {
  226. $error = $phpFileUploadErrors[$image['error']];
  227. }
  228. if ($error !== null) {
  229. return new DataResponse(
  230. [
  231. 'data' => [
  232. 'message' => $error
  233. ],
  234. 'status' => 'failure',
  235. ],
  236. Http::STATUS_UNPROCESSABLE_ENTITY
  237. );
  238. }
  239. try {
  240. $mime = $this->imageManager->updateImage($key, $image['tmp_name']);
  241. $this->themingDefaults->set($key . 'Mime', $mime);
  242. } catch (\Exception $e) {
  243. return new DataResponse(
  244. [
  245. 'data' => [
  246. 'message' => $e->getMessage()
  247. ],
  248. 'status' => 'failure',
  249. ],
  250. Http::STATUS_UNPROCESSABLE_ENTITY
  251. );
  252. }
  253. $name = $image['name'];
  254. return new DataResponse(
  255. [
  256. 'data' =>
  257. [
  258. 'name' => $name,
  259. 'url' => $this->imageManager->getImageUrl($key),
  260. 'message' => $this->l10n->t('Saved'),
  261. ],
  262. 'status' => 'success'
  263. ]
  264. );
  265. }
  266. /**
  267. * Revert setting to default value
  268. *
  269. * @param string $setting setting which should be reverted
  270. * @return DataResponse
  271. * @throws NotPermittedException
  272. */
  273. #[AuthorizedAdminSetting(settings: Admin::class)]
  274. public function undo(string $setting): DataResponse {
  275. $value = $this->themingDefaults->undo($setting);
  276. return new DataResponse(
  277. [
  278. 'data' =>
  279. [
  280. 'value' => $value,
  281. 'message' => $this->l10n->t('Saved'),
  282. ],
  283. 'status' => 'success'
  284. ]
  285. );
  286. }
  287. /**
  288. * Revert all theming settings to their default values
  289. *
  290. * @return DataResponse
  291. * @throws NotPermittedException
  292. */
  293. #[AuthorizedAdminSetting(settings: Admin::class)]
  294. public function undoAll(): DataResponse {
  295. $this->themingDefaults->undoAll();
  296. $this->navigationManager->setDefaultEntryIds([]);
  297. return new DataResponse(
  298. [
  299. 'data' =>
  300. [
  301. 'message' => $this->l10n->t('Saved'),
  302. ],
  303. 'status' => 'success'
  304. ]
  305. );
  306. }
  307. /**
  308. * @NoSameSiteCookieRequired
  309. *
  310. * Get an image
  311. *
  312. * @param string $key Key of the image
  313. * @param bool $useSvg Return image as SVG
  314. * @return FileDisplayResponse<Http::STATUS_OK, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
  315. * @throws NotPermittedException
  316. *
  317. * 200: Image returned
  318. * 404: Image not found
  319. */
  320. #[PublicPage]
  321. #[NoCSRFRequired]
  322. public function getImage(string $key, bool $useSvg = true) {
  323. try {
  324. $file = $this->imageManager->getImage($key, $useSvg);
  325. } catch (NotFoundException $e) {
  326. return new NotFoundResponse();
  327. }
  328. $response = new FileDisplayResponse($file);
  329. $csp = new Http\ContentSecurityPolicy();
  330. $csp->allowInlineStyle();
  331. $response->setContentSecurityPolicy($csp);
  332. $response->cacheFor(3600);
  333. $response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', ''));
  334. $response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"');
  335. if (!$useSvg) {
  336. $response->addHeader('Content-Type', 'image/png');
  337. } else {
  338. $response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', ''));
  339. }
  340. return $response;
  341. }
  342. /**
  343. * @NoSameSiteCookieRequired
  344. * @NoTwoFactorRequired
  345. *
  346. * Get the CSS stylesheet for a theme
  347. *
  348. * @param string $themeId ID of the theme
  349. * @param bool $plain Let the browser decide the CSS priority
  350. * @param bool $withCustomCss Include custom CSS
  351. * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'text/css'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
  352. *
  353. * 200: Stylesheet returned
  354. * 404: Theme not found
  355. */
  356. #[PublicPage]
  357. #[NoCSRFRequired]
  358. public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) {
  359. $themes = $this->themesService->getThemes();
  360. if (!in_array($themeId, array_keys($themes))) {
  361. return new NotFoundResponse();
  362. }
  363. $theme = $themes[$themeId];
  364. $customCss = $theme->getCustomCss();
  365. // Generate variables
  366. $variables = '';
  367. foreach ($theme->getCSSVariables() as $variable => $value) {
  368. $variables .= "$variable:$value; ";
  369. };
  370. // If plain is set, the browser decides of the css priority
  371. if ($plain) {
  372. $css = ":root { $variables } " . $customCss;
  373. } else {
  374. // If not set, we'll rely on the body class
  375. $compiler = new Compiler();
  376. $compiledCss = $compiler->compileString("[data-theme-$themeId] { $variables $customCss }");
  377. $css = $compiledCss->getCss();
  378. ;
  379. }
  380. try {
  381. $response = new DataDisplayResponse($css, Http::STATUS_OK, ['Content-Type' => 'text/css']);
  382. $response->cacheFor(86400);
  383. return $response;
  384. } catch (NotFoundException $e) {
  385. return new NotFoundResponse();
  386. }
  387. }
  388. /**
  389. * Get the manifest for an app
  390. *
  391. * @param string $app ID of the app
  392. * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type
  393. * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: array{src: non-empty-string, type: string, sizes: string}[], display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
  394. *
  395. * 200: Manifest returned
  396. * 404: App not found
  397. */
  398. #[PublicPage]
  399. #[NoCSRFRequired]
  400. #[BruteForceProtection(action: 'manifest')]
  401. public function getManifest(string $app): JSONResponse {
  402. $cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0');
  403. if ($app === 'core' || $app === 'settings') {
  404. $name = $this->themingDefaults->getName();
  405. $shortName = $this->themingDefaults->getName();
  406. $startUrl = $this->urlGenerator->getBaseUrl();
  407. $description = $this->themingDefaults->getSlogan();
  408. } else {
  409. if (!$this->appManager->isEnabledForUser($app)) {
  410. $response = new JSONResponse([], Http::STATUS_NOT_FOUND);
  411. $response->throttle(['action' => 'manifest', 'app' => $app]);
  412. return $response;
  413. }
  414. $info = $this->appManager->getAppInfo($app, false, $this->l10n->getLanguageCode());
  415. $name = $info['name'] . ' - ' . $this->themingDefaults->getName();
  416. $shortName = $info['name'];
  417. if (str_contains($this->request->getRequestUri(), '/index.php/')) {
  418. $startUrl = $this->urlGenerator->getBaseUrl() . '/index.php/apps/' . $app . '/';
  419. } else {
  420. $startUrl = $this->urlGenerator->getBaseUrl() . '/apps/' . $app . '/';
  421. }
  422. $description = $info['summary'] ?? '';
  423. }
  424. /**
  425. * @var string $description
  426. * @var string $shortName
  427. */
  428. $responseJS = [
  429. 'name' => $name,
  430. 'short_name' => $shortName,
  431. 'start_url' => $startUrl,
  432. 'theme_color' => $this->themingDefaults->getColorPrimary(),
  433. 'background_color' => $this->themingDefaults->getColorPrimary(),
  434. 'description' => $description,
  435. 'icons' =>
  436. [
  437. [
  438. 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon',
  439. ['app' => $app]) . '?v=' . $cacheBusterValue,
  440. 'type' => 'image/png',
  441. 'sizes' => '512x512'
  442. ],
  443. [
  444. 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon',
  445. ['app' => $app]) . '?v=' . $cacheBusterValue,
  446. 'type' => 'image/svg+xml',
  447. 'sizes' => '16x16'
  448. ]
  449. ],
  450. 'display' => 'standalone'
  451. ];
  452. $response = new JSONResponse($responseJS);
  453. $response->cacheFor(3600);
  454. return $response;
  455. }
  456. }