Manager.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @copyright Copyright (c) 2016, ownCloud, Inc.
  5. *
  6. * @author Joas Schilling <coding@schilljs.com>
  7. * @author Morris Jobke <hey@morrisjobke.de>
  8. * @author Roeland Jago Douma <roeland@famdouma.nl>
  9. *
  10. * @license AGPL-3.0
  11. *
  12. * This code is free software: you can redistribute it and/or modify
  13. * it under the terms of the GNU Affero General Public License, version 3,
  14. * as published by the Free Software Foundation.
  15. *
  16. * This program is distributed in the hope that it will be useful,
  17. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. * GNU Affero General Public License for more details.
  20. *
  21. * You should have received a copy of the GNU Affero General Public License, version 3,
  22. * along with this program. If not, see <http://www.gnu.org/licenses/>
  23. *
  24. */
  25. namespace OC\Notification;
  26. use OC\AppFramework\Bootstrap\Coordinator;
  27. use OCP\ICache;
  28. use OCP\ICacheFactory;
  29. use OCP\IUserManager;
  30. use OCP\Notification\AlreadyProcessedException;
  31. use OCP\Notification\IApp;
  32. use OCP\Notification\IDeferrableApp;
  33. use OCP\Notification\IDismissableNotifier;
  34. use OCP\Notification\IManager;
  35. use OCP\Notification\IncompleteNotificationException;
  36. use OCP\Notification\IncompleteParsedNotificationException;
  37. use OCP\Notification\INotification;
  38. use OCP\Notification\INotifier;
  39. use OCP\Notification\UnknownNotificationException;
  40. use OCP\RichObjectStrings\IValidator;
  41. use OCP\Support\Subscription\IRegistry;
  42. use Psr\Container\ContainerExceptionInterface;
  43. use Psr\Log\LoggerInterface;
  44. class Manager implements IManager {
  45. /** @var ICache */
  46. protected ICache $cache;
  47. /** @var IApp[] */
  48. protected array $apps;
  49. /** @var string[] */
  50. protected array $appClasses;
  51. /** @var INotifier[] */
  52. protected array $notifiers;
  53. /** @var string[] */
  54. protected array $notifierClasses;
  55. /** @var bool */
  56. protected bool $preparingPushNotification;
  57. /** @var bool */
  58. protected bool $deferPushing;
  59. /** @var bool */
  60. private bool $parsedRegistrationContext;
  61. public function __construct(
  62. protected IValidator $validator,
  63. private IUserManager $userManager,
  64. ICacheFactory $cacheFactory,
  65. protected IRegistry $subscription,
  66. protected LoggerInterface $logger,
  67. private Coordinator $coordinator,
  68. ) {
  69. $this->cache = $cacheFactory->createDistributed('notifications');
  70. $this->apps = [];
  71. $this->notifiers = [];
  72. $this->appClasses = [];
  73. $this->notifierClasses = [];
  74. $this->preparingPushNotification = false;
  75. $this->deferPushing = false;
  76. $this->parsedRegistrationContext = false;
  77. }
  78. /**
  79. * @param string $appClass The service must implement IApp, otherwise a
  80. * \InvalidArgumentException is thrown later
  81. * @since 17.0.0
  82. */
  83. public function registerApp(string $appClass): void {
  84. $this->appClasses[] = $appClass;
  85. }
  86. /**
  87. * @param \Closure $service The service must implement INotifier, otherwise a
  88. * \InvalidArgumentException is thrown later
  89. * @param \Closure $info An array with the keys 'id' and 'name' containing
  90. * the app id and the app name
  91. * @deprecated 17.0.0 use registerNotifierService instead.
  92. * @since 8.2.0 - Parameter $info was added in 9.0.0
  93. */
  94. public function registerNotifier(\Closure $service, \Closure $info): void {
  95. $infoData = $info();
  96. $exception = new \InvalidArgumentException(
  97. 'Notifier ' . $infoData['name'] . ' (id: ' . $infoData['id'] . ') is not considered because it is using the old way to register.'
  98. );
  99. $this->logger->error($exception->getMessage(), ['exception' => $exception]);
  100. }
  101. /**
  102. * @param string $notifierService The service must implement INotifier, otherwise a
  103. * \InvalidArgumentException is thrown later
  104. * @since 17.0.0
  105. */
  106. public function registerNotifierService(string $notifierService): void {
  107. $this->notifierClasses[] = $notifierService;
  108. }
  109. /**
  110. * @return IApp[]
  111. */
  112. protected function getApps(): array {
  113. if (empty($this->appClasses)) {
  114. return $this->apps;
  115. }
  116. foreach ($this->appClasses as $appClass) {
  117. try {
  118. $app = \OC::$server->get($appClass);
  119. } catch (ContainerExceptionInterface $e) {
  120. $this->logger->error('Failed to load notification app class: ' . $appClass, [
  121. 'exception' => $e,
  122. 'app' => 'notifications',
  123. ]);
  124. continue;
  125. }
  126. if (!($app instanceof IApp)) {
  127. $this->logger->error('Notification app class ' . $appClass . ' is not implementing ' . IApp::class, [
  128. 'app' => 'notifications',
  129. ]);
  130. continue;
  131. }
  132. $this->apps[] = $app;
  133. }
  134. $this->appClasses = [];
  135. return $this->apps;
  136. }
  137. /**
  138. * @return INotifier[]
  139. */
  140. public function getNotifiers(): array {
  141. if (!$this->parsedRegistrationContext) {
  142. $notifierServices = $this->coordinator->getRegistrationContext()->getNotifierServices();
  143. foreach ($notifierServices as $notifierService) {
  144. try {
  145. $notifier = \OC::$server->get($notifierService->getService());
  146. } catch (ContainerExceptionInterface $e) {
  147. $this->logger->error('Failed to load notification notifier class: ' . $notifierService->getService(), [
  148. 'exception' => $e,
  149. 'app' => 'notifications',
  150. ]);
  151. continue;
  152. }
  153. if (!($notifier instanceof INotifier)) {
  154. $this->logger->error('Notification notifier class ' . $notifierService->getService() . ' is not implementing ' . INotifier::class, [
  155. 'app' => 'notifications',
  156. ]);
  157. continue;
  158. }
  159. $this->notifiers[] = $notifier;
  160. }
  161. $this->parsedRegistrationContext = true;
  162. }
  163. if (empty($this->notifierClasses)) {
  164. return $this->notifiers;
  165. }
  166. foreach ($this->notifierClasses as $notifierClass) {
  167. try {
  168. $notifier = \OC::$server->get($notifierClass);
  169. } catch (ContainerExceptionInterface $e) {
  170. $this->logger->error('Failed to load notification notifier class: ' . $notifierClass, [
  171. 'exception' => $e,
  172. 'app' => 'notifications',
  173. ]);
  174. continue;
  175. }
  176. if (!($notifier instanceof INotifier)) {
  177. $this->logger->error('Notification notifier class ' . $notifierClass . ' is not implementing ' . INotifier::class, [
  178. 'app' => 'notifications',
  179. ]);
  180. continue;
  181. }
  182. $this->notifiers[] = $notifier;
  183. }
  184. $this->notifierClasses = [];
  185. return $this->notifiers;
  186. }
  187. /**
  188. * @return INotification
  189. * @since 8.2.0
  190. */
  191. public function createNotification(): INotification {
  192. return new Notification($this->validator);
  193. }
  194. /**
  195. * @return bool
  196. * @since 8.2.0
  197. */
  198. public function hasNotifiers(): bool {
  199. return !empty($this->notifiers) || !empty($this->notifierClasses);
  200. }
  201. /**
  202. * @param bool $preparingPushNotification
  203. * @since 14.0.0
  204. */
  205. public function setPreparingPushNotification(bool $preparingPushNotification): void {
  206. $this->preparingPushNotification = $preparingPushNotification;
  207. }
  208. /**
  209. * @return bool
  210. * @since 14.0.0
  211. */
  212. public function isPreparingPushNotification(): bool {
  213. return $this->preparingPushNotification;
  214. }
  215. /**
  216. * The calling app should only "flush" when it got returned true on the defer call
  217. * @return bool
  218. * @since 20.0.0
  219. */
  220. public function defer(): bool {
  221. $alreadyDeferring = $this->deferPushing;
  222. $this->deferPushing = true;
  223. $apps = $this->getApps();
  224. foreach ($apps as $app) {
  225. if ($app instanceof IDeferrableApp) {
  226. $app->defer();
  227. }
  228. }
  229. return !$alreadyDeferring;
  230. }
  231. /**
  232. * @since 20.0.0
  233. */
  234. public function flush(): void {
  235. $apps = $this->getApps();
  236. foreach ($apps as $app) {
  237. if (!$app instanceof IDeferrableApp) {
  238. continue;
  239. }
  240. try {
  241. $app->flush();
  242. } catch (\InvalidArgumentException $e) {
  243. }
  244. }
  245. $this->deferPushing = false;
  246. }
  247. /**
  248. * {@inheritDoc}
  249. */
  250. public function isFairUseOfFreePushService(): bool {
  251. $pushAllowed = $this->cache->get('push_fair_use');
  252. if ($pushAllowed === null) {
  253. /**
  254. * We want to keep offering our push notification service for free, but large
  255. * users overload our infrastructure. For this reason we have to rate-limit the
  256. * use of push notifications. If you need this feature, consider using Nextcloud Enterprise.
  257. */
  258. $isFairUse = $this->subscription->delegateHasValidSubscription() || $this->userManager->countSeenUsers() < 1000;
  259. $pushAllowed = $isFairUse ? 'yes' : 'no';
  260. $this->cache->set('push_fair_use', $pushAllowed, 3600);
  261. }
  262. return $pushAllowed === 'yes';
  263. }
  264. /**
  265. * {@inheritDoc}
  266. */
  267. public function notify(INotification $notification): void {
  268. if (!$notification->isValid()) {
  269. throw new IncompleteNotificationException('The given notification is invalid');
  270. }
  271. $apps = $this->getApps();
  272. foreach ($apps as $app) {
  273. try {
  274. $app->notify($notification);
  275. } catch (IncompleteNotificationException) {
  276. } catch (\InvalidArgumentException $e) {
  277. // todo 33.0.0 Log as warning
  278. // todo 39.0.0 Log as error
  279. $this->logger->debug(get_class($app) . '::notify() threw \InvalidArgumentException which is deprecated. Throw \OCP\Notification\IncompleteNotificationException when the notification is incomplete for your app and otherwise handle all \InvalidArgumentException yourself.');
  280. }
  281. }
  282. }
  283. /**
  284. * Identifier of the notifier, only use [a-z0-9_]
  285. *
  286. * @return string
  287. * @since 17.0.0
  288. */
  289. public function getID(): string {
  290. return 'core';
  291. }
  292. /**
  293. * Human readable name describing the notifier
  294. *
  295. * @return string
  296. * @since 17.0.0
  297. */
  298. public function getName(): string {
  299. return 'core';
  300. }
  301. /**
  302. * {@inheritDoc}
  303. */
  304. public function prepare(INotification $notification, string $languageCode): INotification {
  305. $notifiers = $this->getNotifiers();
  306. foreach ($notifiers as $notifier) {
  307. try {
  308. $notification = $notifier->prepare($notification, $languageCode);
  309. } catch (AlreadyProcessedException $e) {
  310. $this->markProcessed($notification);
  311. throw $e;
  312. } catch (UnknownNotificationException) {
  313. continue;
  314. } catch (\InvalidArgumentException $e) {
  315. // todo 33.0.0 Log as warning
  316. // todo 39.0.0 Log as error
  317. $this->logger->debug(get_class($notifier) . '::prepare() threw \InvalidArgumentException which is deprecated. Throw \OCP\Notification\UnknownNotificationException when the notification is not known to your notifier and otherwise handle all \InvalidArgumentException yourself.');
  318. continue;
  319. }
  320. if (!$notification->isValidParsed()) {
  321. $this->logger->info('Notification was claimed to be parsed, but was not fully parsed by ' . get_class($notifier) . ' [app: ' . $notification->getApp() . ', subject: ' . $notification->getSubject() . ']');
  322. throw new IncompleteParsedNotificationException();
  323. }
  324. }
  325. if (!$notification->isValidParsed()) {
  326. $this->logger->info('Notification was not parsed by any notifier [app: ' . $notification->getApp() . ', subject: ' . $notification->getSubject() . ']');
  327. throw new IncompleteParsedNotificationException();
  328. }
  329. $link = $notification->getLink();
  330. if ($link !== '' && !str_starts_with($link, 'http://') && !str_starts_with($link, 'https://')) {
  331. $this->logger->warning('Link of notification is not an absolute URL and does not work in mobile and desktop clients [app: ' . $notification->getApp() . ', subject: ' . $notification->getSubject() . ']');
  332. }
  333. $icon = $notification->getIcon();
  334. if ($icon !== '' && !str_starts_with($icon, 'http://') && !str_starts_with($icon, 'https://')) {
  335. $this->logger->warning('Icon of notification is not an absolute URL and does not work in mobile and desktop clients [app: ' . $notification->getApp() . ', subject: ' . $notification->getSubject() . ']');
  336. }
  337. foreach ($notification->getParsedActions() as $action) {
  338. $link = $action->getLink();
  339. if ($link !== '' && !str_starts_with($link, 'http://') && !str_starts_with($link, 'https://')) {
  340. $this->logger->warning('Link of action is not an absolute URL and does not work in mobile and desktop clients [app: ' . $notification->getApp() . ', subject: ' . $notification->getSubject() . ']');
  341. }
  342. }
  343. return $notification;
  344. }
  345. /**
  346. * @param INotification $notification
  347. */
  348. public function markProcessed(INotification $notification): void {
  349. $apps = $this->getApps();
  350. foreach ($apps as $app) {
  351. $app->markProcessed($notification);
  352. }
  353. }
  354. /**
  355. * @param INotification $notification
  356. * @return int
  357. */
  358. public function getCount(INotification $notification): int {
  359. $apps = $this->getApps();
  360. $count = 0;
  361. foreach ($apps as $app) {
  362. $count += $app->getCount($notification);
  363. }
  364. return $count;
  365. }
  366. /**
  367. * {@inheritDoc}
  368. */
  369. public function dismissNotification(INotification $notification): void {
  370. $notifiers = $this->getNotifiers();
  371. foreach ($notifiers as $notifier) {
  372. if ($notifier instanceof IDismissableNotifier) {
  373. try {
  374. $notifier->dismissNotification($notification);
  375. } catch (UnknownNotificationException) {
  376. continue;
  377. } catch (\InvalidArgumentException $e) {
  378. // todo 33.0.0 Log as warning
  379. // todo 39.0.0 Log as error
  380. $this->logger->debug(get_class($notifier) . '::dismissNotification() threw \InvalidArgumentException which is deprecated. Throw \OCP\Notification\UnknownNotificationException when the notification is not known to your notifier and otherwise handle all \InvalidArgumentException yourself.');
  381. continue;
  382. }
  383. }
  384. }
  385. }
  386. }