SharesReminderJob.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\Files_Sharing;
  8. use OCP\AppFramework\Utility\ITimeFactory;
  9. use OCP\BackgroundJob\TimedJob;
  10. use OCP\Constants;
  11. use OCP\DB\Exception;
  12. use OCP\DB\QueryBuilder\IQueryBuilder;
  13. use OCP\Defaults;
  14. use OCP\Files\Cache\ICacheEntry;
  15. use OCP\Files\IMimeTypeLoader;
  16. use OCP\Files\NotFoundException;
  17. use OCP\IDBConnection;
  18. use OCP\IL10N;
  19. use OCP\IURLGenerator;
  20. use OCP\IUserManager;
  21. use OCP\L10N\IFactory;
  22. use OCP\Mail\IEMailTemplate;
  23. use OCP\Mail\IMailer;
  24. use OCP\Share\Exceptions\ShareNotFound;
  25. use OCP\Share\IManager;
  26. use OCP\Share\IShare;
  27. use OCP\Util;
  28. use Psr\Log\LoggerInterface;
  29. /**
  30. * Send a reminder via email to the sharee(s) if the folder is still empty a predefined time before the expiration date
  31. */
  32. class SharesReminderJob extends TimedJob {
  33. private const SECONDS_BEFORE_REMINDER = 24 * 60 * 60;
  34. private const CHUNK_SIZE = 1000;
  35. private int $folderMimeTypeId;
  36. public function __construct(
  37. ITimeFactory $time,
  38. private readonly IDBConnection $db,
  39. private readonly IManager $shareManager,
  40. private readonly IUserManager $userManager,
  41. private readonly LoggerInterface $logger,
  42. private readonly IURLGenerator $urlGenerator,
  43. private readonly IFactory $l10nFactory,
  44. private readonly IMailer $mailer,
  45. private readonly Defaults $defaults,
  46. IMimeTypeLoader $mimeTypeLoader,
  47. ) {
  48. parent::__construct($time);
  49. $this->setInterval(60 * 60);
  50. $this->folderMimeTypeId = $mimeTypeLoader->getId(ICacheEntry::DIRECTORY_MIMETYPE);
  51. }
  52. /**
  53. * Makes the background job do its work
  54. *
  55. * @param array $argument unused argument
  56. * @throws Exception if a database error occurs
  57. */
  58. public function run(mixed $argument): void {
  59. foreach ($this->getShares() as $share) {
  60. $reminderInfo = $this->prepareReminder($share);
  61. if ($reminderInfo !== null) {
  62. $this->sendReminder($reminderInfo);
  63. }
  64. }
  65. }
  66. /**
  67. * Finds all shares of empty folders, for which the user has write permissions.
  68. * The returned shares are of type user or email only, have expiration dates within the specified time frame
  69. * and have not yet received a reminder.
  70. *
  71. * @return array<IShare>|\Iterator
  72. * @throws Exception if a database error occurs
  73. */
  74. private function getShares(): array|\Iterator {
  75. if ($this->db->getShardDefinition('filecache')) {
  76. $sharesResult = $this->getSharesDataSharded();
  77. } else {
  78. $sharesResult = $this->getSharesData();
  79. }
  80. foreach ($sharesResult as $share) {
  81. if ($share['share_type'] === IShare::TYPE_EMAIL) {
  82. $id = "ocMailShare:$share[id]";
  83. } else {
  84. $id = "ocinternal:$share[id]";
  85. }
  86. try {
  87. yield $this->shareManager->getShareById($id);
  88. } catch (ShareNotFound) {
  89. $this->logger->error("Share with ID $id not found.");
  90. }
  91. }
  92. }
  93. /**
  94. * @return list<array{id: int, share_type: int}>
  95. */
  96. private function getSharesData(): array {
  97. $minDate = new \DateTime();
  98. $maxDate = new \DateTime();
  99. $maxDate->setTimestamp($maxDate->getTimestamp() + self::SECONDS_BEFORE_REMINDER);
  100. $qb = $this->db->getQueryBuilder();
  101. $qb->select('s.id', 's.share_type')
  102. ->from('share', 's')
  103. ->leftJoin('s', 'filecache', 'f', $qb->expr()->eq('f.parent', 's.file_source'))
  104. ->where(
  105. $qb->expr()->andX(
  106. $qb->expr()->orX(
  107. $qb->expr()->eq('s.share_type', $qb->expr()->literal(IShare::TYPE_USER)),
  108. $qb->expr()->eq('s.share_type', $qb->expr()->literal(IShare::TYPE_EMAIL))
  109. ),
  110. $qb->expr()->eq('s.item_type', $qb->expr()->literal('folder')),
  111. $qb->expr()->gte('s.expiration', $qb->createNamedParameter($minDate, IQueryBuilder::PARAM_DATE)),
  112. $qb->expr()->lte('s.expiration', $qb->createNamedParameter($maxDate, IQueryBuilder::PARAM_DATE)),
  113. $qb->expr()->eq('s.reminder_sent', $qb->createNamedParameter(
  114. false, IQueryBuilder::PARAM_BOOL
  115. )),
  116. $qb->expr()->eq(
  117. $qb->expr()->bitwiseAnd('s.permissions', Constants::PERMISSION_CREATE),
  118. $qb->createNamedParameter(Constants::PERMISSION_CREATE, IQueryBuilder::PARAM_INT)
  119. ),
  120. $qb->expr()->isNull('f.fileid')
  121. )
  122. )
  123. ->setMaxResults(SharesReminderJob::CHUNK_SIZE);
  124. $shares = $qb->executeQuery()->fetchAll();
  125. return array_values(array_map(fn ($share): array => [
  126. 'id' => (int)$share['id'],
  127. 'share_type' => (int)$share['share_type'],
  128. ], $shares));
  129. }
  130. /**
  131. * Sharding compatible version of getSharesData
  132. *
  133. * @return list<array{id: int, share_type: int, file_source: int}>
  134. */
  135. private function getSharesDataSharded(): array|\Iterator {
  136. $minDate = new \DateTime();
  137. $maxDate = new \DateTime();
  138. $maxDate->setTimestamp($maxDate->getTimestamp() + self::SECONDS_BEFORE_REMINDER);
  139. $qb = $this->db->getQueryBuilder();
  140. $qb->select('s.id', 's.share_type', 's.file_source')
  141. ->from('share', 's')
  142. ->where(
  143. $qb->expr()->andX(
  144. $qb->expr()->orX(
  145. $qb->expr()->eq('s.share_type', $qb->expr()->literal(IShare::TYPE_USER)),
  146. $qb->expr()->eq('s.share_type', $qb->expr()->literal(IShare::TYPE_EMAIL))
  147. ),
  148. $qb->expr()->eq('s.item_type', $qb->expr()->literal('folder')),
  149. $qb->expr()->gte('s.expiration', $qb->createNamedParameter($minDate, IQueryBuilder::PARAM_DATE)),
  150. $qb->expr()->lte('s.expiration', $qb->createNamedParameter($maxDate, IQueryBuilder::PARAM_DATE)),
  151. $qb->expr()->eq('s.reminder_sent', $qb->createNamedParameter(
  152. false, IQueryBuilder::PARAM_BOOL
  153. )),
  154. $qb->expr()->eq(
  155. $qb->expr()->bitwiseAnd('s.permissions', Constants::PERMISSION_CREATE),
  156. $qb->createNamedParameter(Constants::PERMISSION_CREATE, IQueryBuilder::PARAM_INT)
  157. ),
  158. )
  159. );
  160. $shares = $qb->executeQuery()->fetchAll();
  161. $shares = array_values(array_map(fn ($share): array => [
  162. 'id' => (int)$share['id'],
  163. 'share_type' => (int)$share['share_type'],
  164. 'file_source' => (int)$share['file_source'],
  165. ], $shares));
  166. return $this->filterSharesWithEmptyFolders($shares, self::CHUNK_SIZE);
  167. }
  168. /**
  169. * Check which of the supplied file ids is an empty folder until there are `$maxResults` folders
  170. * @param list<array{id: int, share_type: int, file_source: int}> $shares
  171. * @return list<array{id: int, share_type: int, file_source: int}>
  172. */
  173. private function filterSharesWithEmptyFolders(array $shares, int $maxResults): array {
  174. $query = $this->db->getQueryBuilder();
  175. $query->select('fileid')
  176. ->from('filecache')
  177. ->where($query->expr()->eq('size', $query->createNamedParameter(0), IQueryBuilder::PARAM_INT_ARRAY))
  178. ->andWhere($query->expr()->eq('mimetype', $query->createNamedParameter($this->folderMimeTypeId, IQueryBuilder::PARAM_INT)))
  179. ->andWhere($query->expr()->in('fileid', $query->createParameter('fileids')));
  180. $chunks = array_chunk($shares, SharesReminderJob::CHUNK_SIZE);
  181. $results = [];
  182. foreach ($chunks as $chunk) {
  183. $chunkFileIds = array_map(fn ($share): int => $share['file_source'], $chunk);
  184. $chunkByFileId = array_combine($chunkFileIds, $chunk);
  185. $query->setParameter('fileids', $chunkFileIds, IQueryBuilder::PARAM_INT_ARRAY);
  186. $chunkResults = $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
  187. foreach ($chunkResults as $folderId) {
  188. $results[] = $chunkByFileId[$folderId];
  189. }
  190. if (count($results) >= $maxResults) {
  191. break;
  192. }
  193. }
  194. return $results;
  195. }
  196. /**
  197. * Retrieves and returns all the necessary data before sending a reminder.
  198. * It also updates the reminder sent flag for the affected shares (to avoid multiple reminders).
  199. *
  200. * @param IShare $share Share that was obtained with {@link getShares}
  201. * @return array|null Info needed to send a reminder
  202. */
  203. private function prepareReminder(IShare $share): ?array {
  204. $sharedWith = $share->getSharedWith();
  205. $reminderInfo = [];
  206. if ($share->getShareType() == IShare::TYPE_USER) {
  207. $user = $this->userManager->get($sharedWith);
  208. if ($user === null) {
  209. return null;
  210. }
  211. $reminderInfo['email'] = $user->getEMailAddress();
  212. $reminderInfo['userLang'] = $this->l10nFactory->getUserLanguage($user);
  213. $reminderInfo['folderLink'] = $this->urlGenerator->linkToRouteAbsolute('files.view.index', [
  214. 'dir' => $share->getTarget()
  215. ]);
  216. } else {
  217. $reminderInfo['email'] = $sharedWith;
  218. $reminderInfo['folderLink'] = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', [
  219. 'token' => $share->getToken()
  220. ]);
  221. }
  222. if (empty($reminderInfo['email'])) {
  223. return null;
  224. }
  225. try {
  226. $reminderInfo['folderName'] = $share->getNode()->getName();
  227. } catch (NotFoundException) {
  228. $id = $share->getFullId();
  229. $this->logger->error("File by share ID $id not found.");
  230. }
  231. $share->setReminderSent(true);
  232. $this->shareManager->updateShare($share);
  233. return $reminderInfo;
  234. }
  235. /**
  236. * This method accepts data obtained by {@link prepareReminder} and sends reminder email.
  237. *
  238. * @param array $reminderInfo
  239. * @return void
  240. */
  241. private function sendReminder(array $reminderInfo): void {
  242. $instanceName = $this->defaults->getName();
  243. $from = [Util::getDefaultEmailAddress($instanceName) => $instanceName];
  244. $l = $this->l10nFactory->get('files_sharing', $reminderInfo['userLang'] ?? null);
  245. $emailTemplate = $this->generateEMailTemplate($l, [
  246. 'link' => $reminderInfo['folderLink'], 'name' => $reminderInfo['folderName']
  247. ]);
  248. $message = $this->mailer->createMessage();
  249. $message->setFrom($from);
  250. $message->setTo([$reminderInfo['email']]);
  251. $message->useTemplate($emailTemplate);
  252. $errorText = "Sending email with share reminder to $reminderInfo[email] failed.";
  253. try {
  254. $failedRecipients = $this->mailer->send($message);
  255. if (count($failedRecipients) > 0) {
  256. $this->logger->error($errorText);
  257. }
  258. } catch (\Exception) {
  259. $this->logger->error($errorText);
  260. }
  261. }
  262. /**
  263. * Returns the reminder email template
  264. *
  265. * @param IL10N $l
  266. * @param array $folder Folder the user should be reminded of
  267. * @return IEMailTemplate
  268. */
  269. private function generateEMailTemplate(IL10N $l, array $folder): IEMailTemplate {
  270. $emailTemplate = $this->mailer->createEMailTemplate('files_sharing.SharesReminder', [
  271. 'folder' => $folder,
  272. ]);
  273. $emailTemplate->addHeader();
  274. $emailTemplate->setSubject(
  275. $l->t('Remember to upload the files to %s', [$folder['name']])
  276. );
  277. $emailTemplate->addBodyText($l->t(
  278. 'We would like to kindly remind you that you have not yet uploaded any files to the shared folder.'
  279. ));
  280. $emailTemplate->addBodyButton(
  281. $l->t('Open "%s"', [$folder['name']]),
  282. $folder['link']
  283. );
  284. $emailTemplate->addFooter();
  285. return $emailTemplate;
  286. }
  287. }