ThemingController.php 14 KB

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