DeclarativeManager.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\Settings;
  8. use Exception;
  9. use OC\AppFramework\Bootstrap\Coordinator;
  10. use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
  11. use OCP\EventDispatcher\IEventDispatcher;
  12. use OCP\IAppConfig;
  13. use OCP\IConfig;
  14. use OCP\IGroupManager;
  15. use OCP\IUser;
  16. use OCP\Server;
  17. use OCP\Settings\DeclarativeSettingsTypes;
  18. use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
  19. use OCP\Settings\Events\DeclarativeSettingsRegisterFormEvent;
  20. use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
  21. use OCP\Settings\IDeclarativeManager;
  22. use OCP\Settings\IDeclarativeSettingsForm;
  23. use Psr\Log\LoggerInterface;
  24. /**
  25. * @psalm-import-type DeclarativeSettingsValueTypes from IDeclarativeSettingsForm
  26. * @psalm-import-type DeclarativeSettingsStorageType from IDeclarativeSettingsForm
  27. * @psalm-import-type DeclarativeSettingsSectionType from IDeclarativeSettingsForm
  28. * @psalm-import-type DeclarativeSettingsFormSchemaWithValues from IDeclarativeSettingsForm
  29. * @psalm-import-type DeclarativeSettingsFormSchemaWithoutValues from IDeclarativeSettingsForm
  30. */
  31. class DeclarativeManager implements IDeclarativeManager {
  32. public function __construct(
  33. private IEventDispatcher $eventDispatcher,
  34. private IGroupManager $groupManager,
  35. private Coordinator $coordinator,
  36. private IConfig $config,
  37. private IAppConfig $appConfig,
  38. private LoggerInterface $logger,
  39. ) {
  40. }
  41. /**
  42. * @var array<string, list<DeclarativeSettingsFormSchemaWithoutValues>>
  43. */
  44. private array $appSchemas = [];
  45. /**
  46. * @inheritdoc
  47. */
  48. public function registerSchema(string $app, array $schema): void {
  49. $this->appSchemas[$app] ??= [];
  50. if (!$this->validateSchema($app, $schema)) {
  51. throw new Exception('Invalid schema. Please check the logs for more details.');
  52. }
  53. foreach ($this->appSchemas[$app] as $otherSchema) {
  54. if ($otherSchema['id'] === $schema['id']) {
  55. throw new Exception('Duplicate form IDs detected: ' . $schema['id']);
  56. }
  57. }
  58. $fieldIDs = array_map(fn ($field) => $field['id'], $schema['fields']);
  59. $otherFieldIDs = array_merge(...array_map(fn ($schema) => array_map(fn ($field) => $field['id'], $schema['fields']), $this->appSchemas[$app]));
  60. $intersectionFieldIDs = array_intersect($fieldIDs, $otherFieldIDs);
  61. if (count($intersectionFieldIDs) > 0) {
  62. throw new Exception('Non unique field IDs detected: ' . join(', ', $intersectionFieldIDs));
  63. }
  64. $this->appSchemas[$app][] = $schema;
  65. }
  66. /**
  67. * @inheritdoc
  68. */
  69. public function loadSchemas(): void {
  70. $declarativeSettings = $this->coordinator->getRegistrationContext()->getDeclarativeSettings();
  71. foreach ($declarativeSettings as $declarativeSetting) {
  72. /** @var IDeclarativeSettingsForm $declarativeSettingObject */
  73. $declarativeSettingObject = Server::get($declarativeSetting->getService());
  74. $this->registerSchema($declarativeSetting->getAppId(), $declarativeSettingObject->getSchema());
  75. }
  76. $this->eventDispatcher->dispatchTyped(new DeclarativeSettingsRegisterFormEvent($this));
  77. }
  78. /**
  79. * @inheritdoc
  80. */
  81. public function getFormIDs(IUser $user, string $type, string $section): array {
  82. $isAdmin = $this->groupManager->isAdmin($user->getUID());
  83. /** @var array<string, list<string>> $formIds */
  84. $formIds = [];
  85. foreach ($this->appSchemas as $app => $schemas) {
  86. $ids = [];
  87. usort($schemas, [$this, 'sortSchemasByPriorityCallback']);
  88. foreach ($schemas as $schema) {
  89. if ($schema['section_type'] === DeclarativeSettingsTypes::SECTION_TYPE_ADMIN && !$isAdmin) {
  90. continue;
  91. }
  92. if ($schema['section_type'] === $type && $schema['section_id'] === $section) {
  93. $ids[] = $schema['id'];
  94. }
  95. }
  96. if (!empty($ids)) {
  97. $formIds[$app] = array_merge($formIds[$app] ?? [], $ids);
  98. }
  99. }
  100. return $formIds;
  101. }
  102. /**
  103. * @inheritdoc
  104. * @throws Exception
  105. */
  106. public function getFormsWithValues(IUser $user, ?string $type, ?string $section): array {
  107. $isAdmin = $this->groupManager->isAdmin($user->getUID());
  108. $forms = [];
  109. foreach ($this->appSchemas as $app => $schemas) {
  110. foreach ($schemas as $schema) {
  111. if ($type !== null && $schema['section_type'] !== $type) {
  112. continue;
  113. }
  114. if ($section !== null && $schema['section_id'] !== $section) {
  115. continue;
  116. }
  117. // If listing all fields skip the admin fields which a non-admin user has no access to
  118. if ($type === null && $schema['section_type'] === 'admin' && !$isAdmin) {
  119. continue;
  120. }
  121. $s = $schema;
  122. $s['app'] = $app;
  123. foreach ($s['fields'] as &$field) {
  124. $field['value'] = $this->getValue($user, $app, $schema['id'], $field['id']);
  125. }
  126. unset($field);
  127. /** @var DeclarativeSettingsFormSchemaWithValues $s */
  128. $forms[] = $s;
  129. }
  130. }
  131. usort($forms, [$this, 'sortSchemasByPriorityCallback']);
  132. return $forms;
  133. }
  134. private function sortSchemasByPriorityCallback(mixed $a, mixed $b): int {
  135. if ($a['priority'] === $b['priority']) {
  136. return 0;
  137. }
  138. return $a['priority'] > $b['priority'] ? -1 : 1;
  139. }
  140. /**
  141. * @return DeclarativeSettingsStorageType
  142. */
  143. private function getStorageType(string $app, string $fieldId): string {
  144. if (array_key_exists($app, $this->appSchemas)) {
  145. foreach ($this->appSchemas[$app] as $schema) {
  146. foreach ($schema['fields'] as $field) {
  147. if ($field['id'] == $fieldId) {
  148. if (array_key_exists('storage_type', $field)) {
  149. return $field['storage_type'];
  150. }
  151. }
  152. }
  153. if (array_key_exists('storage_type', $schema)) {
  154. return $schema['storage_type'];
  155. }
  156. }
  157. }
  158. return DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL;
  159. }
  160. /**
  161. * @return DeclarativeSettingsSectionType
  162. * @throws Exception
  163. */
  164. private function getSectionType(string $app, string $fieldId): string {
  165. if (array_key_exists($app, $this->appSchemas)) {
  166. foreach ($this->appSchemas[$app] as $schema) {
  167. foreach ($schema['fields'] as $field) {
  168. if ($field['id'] == $fieldId) {
  169. return $schema['section_type'];
  170. }
  171. }
  172. }
  173. }
  174. throw new Exception('Unknown fieldId "' . $fieldId . '"');
  175. }
  176. /**
  177. * @psalm-param DeclarativeSettingsSectionType $sectionType
  178. * @throws NotAdminException
  179. */
  180. private function assertAuthorized(IUser $user, string $sectionType): void {
  181. if ($sectionType === 'admin' && !$this->groupManager->isAdmin($user->getUID())) {
  182. throw new NotAdminException('Logged in user does not have permission to access these settings.');
  183. }
  184. }
  185. /**
  186. * @return DeclarativeSettingsValueTypes
  187. * @throws Exception
  188. * @throws NotAdminException
  189. */
  190. private function getValue(IUser $user, string $app, string $formId, string $fieldId): mixed {
  191. $sectionType = $this->getSectionType($app, $fieldId);
  192. $this->assertAuthorized($user, $sectionType);
  193. $storageType = $this->getStorageType($app, $fieldId);
  194. switch ($storageType) {
  195. case DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL:
  196. $event = new DeclarativeSettingsGetValueEvent($user, $app, $formId, $fieldId);
  197. $this->eventDispatcher->dispatchTyped($event);
  198. return $event->getValue();
  199. case DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL:
  200. return $this->getInternalValue($user, $app, $formId, $fieldId);
  201. default:
  202. throw new Exception('Unknown storage type "' . $storageType . '"');
  203. }
  204. }
  205. /**
  206. * @inheritdoc
  207. */
  208. public function setValue(IUser $user, string $app, string $formId, string $fieldId, mixed $value): void {
  209. $sectionType = $this->getSectionType($app, $fieldId);
  210. $this->assertAuthorized($user, $sectionType);
  211. $storageType = $this->getStorageType($app, $fieldId);
  212. switch ($storageType) {
  213. case DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL:
  214. $this->eventDispatcher->dispatchTyped(new DeclarativeSettingsSetValueEvent($user, $app, $formId, $fieldId, $value));
  215. break;
  216. case DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL:
  217. $this->saveInternalValue($user, $app, $fieldId, $value);
  218. break;
  219. default:
  220. throw new Exception('Unknown storage type "' . $storageType . '"');
  221. }
  222. }
  223. private function getInternalValue(IUser $user, string $app, string $formId, string $fieldId): mixed {
  224. $sectionType = $this->getSectionType($app, $fieldId);
  225. $defaultValue = $this->getDefaultValue($app, $formId, $fieldId);
  226. switch ($sectionType) {
  227. case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
  228. return $this->config->getAppValue($app, $fieldId, $defaultValue);
  229. case DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL:
  230. return $this->config->getUserValue($user->getUID(), $app, $fieldId, $defaultValue);
  231. default:
  232. throw new Exception('Unknown section type "' . $sectionType . '"');
  233. }
  234. }
  235. private function saveInternalValue(IUser $user, string $app, string $fieldId, mixed $value): void {
  236. $sectionType = $this->getSectionType($app, $fieldId);
  237. switch ($sectionType) {
  238. case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
  239. $this->appConfig->setValueString($app, $fieldId, $value);
  240. break;
  241. case DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL:
  242. $this->config->setUserValue($user->getUID(), $app, $fieldId, $value);
  243. break;
  244. default:
  245. throw new Exception('Unknown section type "' . $sectionType . '"');
  246. }
  247. }
  248. private function getDefaultValue(string $app, string $formId, string $fieldId): mixed {
  249. foreach ($this->appSchemas[$app] as $schema) {
  250. if ($schema['id'] === $formId) {
  251. foreach ($schema['fields'] as $field) {
  252. if ($field['id'] === $fieldId) {
  253. if (isset($field['default'])) {
  254. if (is_array($field['default'])) {
  255. return json_encode($field['default']);
  256. }
  257. return $field['default'];
  258. }
  259. }
  260. }
  261. }
  262. }
  263. return null;
  264. }
  265. private function validateSchema(string $appId, array $schema): bool {
  266. if (!isset($schema['id'])) {
  267. $this->logger->warning('Attempt to register a declarative settings schema with no id', ['app' => $appId]);
  268. return false;
  269. }
  270. $formId = $schema['id'];
  271. if (!isset($schema['section_type'])) {
  272. $this->logger->warning('Declarative settings: missing section_type', ['app' => $appId, 'form_id' => $formId]);
  273. return false;
  274. }
  275. if (!in_array($schema['section_type'], [DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL])) {
  276. $this->logger->warning('Declarative settings: invalid section_type', ['app' => $appId, 'form_id' => $formId, 'section_type' => $schema['section_type']]);
  277. return false;
  278. }
  279. if (!isset($schema['section_id'])) {
  280. $this->logger->warning('Declarative settings: missing section_id', ['app' => $appId, 'form_id' => $formId]);
  281. return false;
  282. }
  283. if (!isset($schema['storage_type'])) {
  284. $this->logger->warning('Declarative settings: missing storage_type', ['app' => $appId, 'form_id' => $formId]);
  285. return false;
  286. }
  287. if (!in_array($schema['storage_type'], [DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL, DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL])) {
  288. $this->logger->warning('Declarative settings: invalid storage_type', ['app' => $appId, 'form_id' => $formId, 'storage_type' => $schema['storage_type']]);
  289. return false;
  290. }
  291. if (!isset($schema['title'])) {
  292. $this->logger->warning('Declarative settings: missing title', ['app' => $appId, 'form_id' => $formId]);
  293. return false;
  294. }
  295. if (!isset($schema['fields']) || !is_array($schema['fields'])) {
  296. $this->logger->warning('Declarative settings: missing or invalid fields', ['app' => $appId, 'form_id' => $formId]);
  297. return false;
  298. }
  299. foreach ($schema['fields'] as $field) {
  300. if (!isset($field['id'])) {
  301. $this->logger->warning('Declarative settings: missing field id', ['app' => $appId, 'form_id' => $formId, 'field' => $field]);
  302. return false;
  303. }
  304. $fieldId = $field['id'];
  305. if (!isset($field['title'])) {
  306. $this->logger->warning('Declarative settings: missing field title', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
  307. return false;
  308. }
  309. if (!isset($field['type'])) {
  310. $this->logger->warning('Declarative settings: missing field type', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
  311. return false;
  312. }
  313. if (!in_array($field['type'], [
  314. DeclarativeSettingsTypes::MULTI_SELECT, DeclarativeSettingsTypes::MULTI_CHECKBOX, DeclarativeSettingsTypes::RADIO,
  315. DeclarativeSettingsTypes::SELECT, DeclarativeSettingsTypes::CHECKBOX,
  316. DeclarativeSettingsTypes::URL, DeclarativeSettingsTypes::EMAIL, DeclarativeSettingsTypes::NUMBER,
  317. DeclarativeSettingsTypes::TEL, DeclarativeSettingsTypes::TEXT, DeclarativeSettingsTypes::PASSWORD,
  318. ])) {
  319. $this->logger->warning('Declarative settings: invalid field type', [
  320. 'app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId, 'type' => $field['type'],
  321. ]);
  322. return false;
  323. }
  324. if (!$this->validateField($appId, $formId, $field)) {
  325. return false;
  326. }
  327. }
  328. return true;
  329. }
  330. private function validateField(string $appId, string $formId, array $field): bool {
  331. $fieldId = $field['id'];
  332. if (in_array($field['type'], [
  333. DeclarativeSettingsTypes::MULTI_SELECT, DeclarativeSettingsTypes::MULTI_CHECKBOX, DeclarativeSettingsTypes::RADIO,
  334. DeclarativeSettingsTypes::SELECT
  335. ])) {
  336. if (!isset($field['options'])) {
  337. $this->logger->warning('Declarative settings: missing field options', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
  338. return false;
  339. }
  340. if (!is_array($field['options'])) {
  341. $this->logger->warning('Declarative settings: field options should be an array', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
  342. return false;
  343. }
  344. }
  345. return true;
  346. }
  347. }