ThemingController.php 14 KB

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