Signed-off-by: Stefan Cherniakov <luka-sama@pm.me>pull/47412/head
parent
6b85a3ae0e
commit
5e4a166365
@ -0,0 +1,36 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCA\Files_Sharing\Migration; |
||||
|
||||
use Closure; |
||||
use OCP\DB\ISchemaWrapper; |
||||
use OCP\DB\Types; |
||||
use OCP\Migration\IOutput; |
||||
use OCP\Migration\SimpleMigrationStep; |
||||
|
||||
class Version31000Date20240821142813 extends SimpleMigrationStep { |
||||
|
||||
/** |
||||
* @param IOutput $output |
||||
* @param Closure(): ISchemaWrapper $schemaClosure |
||||
* @param array $options |
||||
* @return null|ISchemaWrapper |
||||
*/ |
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { |
||||
$schema = $schemaClosure(); |
||||
$table = $schema->getTable('share'); |
||||
$table->addColumn('reminder_sent', Types::BOOLEAN, [ |
||||
'notnull' => false, |
||||
'default' => false, |
||||
]); |
||||
return $schema; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,261 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
namespace OCA\Files_Sharing; |
||||
|
||||
use OCP\AppFramework\Utility\ITimeFactory; |
||||
use OCP\BackgroundJob\TimedJob; |
||||
use OCP\Constants; |
||||
use OCP\DB\Exception; |
||||
use OCP\DB\QueryBuilder\IQueryBuilder; |
||||
use OCP\Defaults; |
||||
use OCP\Files\NotFoundException; |
||||
use OCP\IDBConnection; |
||||
use OCP\IL10N; |
||||
use OCP\IURLGenerator; |
||||
use OCP\IUserManager; |
||||
use OCP\L10N\IFactory; |
||||
use OCP\Mail\IEMailTemplate; |
||||
use OCP\Mail\IMailer; |
||||
use OCP\Share\Exceptions\ShareNotFound; |
||||
use OCP\Share\IManager; |
||||
use OCP\Share\IShare; |
||||
use OCP\Util; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
/** |
||||
* Send a reminder via email to the sharee(s) if the folder is still empty a predefined time before the expiration date |
||||
*/ |
||||
class SharesReminderJob extends TimedJob { |
||||
private const SECONDS_BEFORE_REMINDER = 86400; |
||||
|
||||
public function __construct( |
||||
ITimeFactory $time, |
||||
private readonly IDBConnection $db, |
||||
private readonly IManager $shareManager, |
||||
private readonly IUserManager $userManager, |
||||
private readonly LoggerInterface $logger, |
||||
private readonly IURLGenerator $urlGenerator, |
||||
private readonly IFactory $l10nFactory, |
||||
private readonly IMailer $mailer, |
||||
private readonly Defaults $defaults, |
||||
) { |
||||
parent::__construct($time); |
||||
$this->setInterval(3600); |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Makes the background job do its work |
||||
* |
||||
* @param array $argument unused argument |
||||
* @throws Exception if a database error occurs |
||||
*/ |
||||
public function run(mixed $argument): void { |
||||
$shares = $this->getShares(); |
||||
[$foldersByEmail, $langByEmail] = $this->prepareReminders($shares); |
||||
$this->sendReminders($foldersByEmail, $langByEmail); |
||||
} |
||||
|
||||
/** |
||||
* Finds all folder shares of type user or email with expiration dates within the specified timeframe. |
||||
* This method returns only those shares that have not yet received the reminder. |
||||
* |
||||
* @return array<IShare> |
||||
* @throws Exception if a database error occurs |
||||
*/ |
||||
private function getShares(): array { |
||||
$minDate = new \DateTime(); |
||||
$maxDate = new \DateTime(); |
||||
$maxDate->setTimestamp($maxDate->getTimestamp() + self::SECONDS_BEFORE_REMINDER); |
||||
|
||||
$qb = $this->db->getQueryBuilder(); |
||||
$qb->select('id', 'share_type') |
||||
->from('share') |
||||
->where( |
||||
$qb->expr()->andX( |
||||
$qb->expr()->orX( |
||||
$qb->expr()->eq('share_type', $qb->expr()->literal(IShare::TYPE_USER)), |
||||
$qb->expr()->eq('share_type', $qb->expr()->literal(IShare::TYPE_EMAIL)) |
||||
), |
||||
$qb->expr()->eq('item_type', $qb->expr()->literal('folder')), |
||||
$qb->expr()->gte('expiration', $qb->createNamedParameter($minDate->format('Y-m-d H:i:s'))), |
||||
$qb->expr()->lt('expiration', $qb->createNamedParameter($maxDate->format('Y-m-d H:i:s'))), |
||||
$qb->expr()->eq('reminder_sent', $qb->createNamedParameter( |
||||
false, IQueryBuilder::PARAM_BOOL |
||||
)) |
||||
) |
||||
); |
||||
|
||||
$sharesResult = $qb->executeQuery(); |
||||
$shares = []; |
||||
while ($share = $sharesResult->fetch()) { |
||||
if ((int)$share['share_type'] === IShare::TYPE_EMAIL) { |
||||
$id = "ocMailShare:$share[id]"; |
||||
} else { |
||||
$id = "ocinternal:$share[id]"; |
||||
} |
||||
|
||||
try { |
||||
$shares[] = $this->shareManager->getShareById($id); |
||||
} catch (ShareNotFound) { |
||||
$this->logger->error("Share with ID $id not found."); |
||||
} |
||||
} |
||||
$sharesResult->closeCursor(); |
||||
return $shares; |
||||
} |
||||
|
||||
/** |
||||
* Checks if the user should be reminded about this share. |
||||
* If so, it will retrieve and return all the necessary data for this. |
||||
* It also updates the reminder sent flag for the affected shares (to avoid multiple reminders). |
||||
* |
||||
* @param array<IShare> $shares Shares that were obtained with {@link getShares} |
||||
* @return array<array> A tuple consisting of two dictionaries: folders and languages by email |
||||
* @throws Exception if the reminder sent flag could not be saved |
||||
*/ |
||||
private function prepareReminders(array $shares): array { |
||||
// This dictionary stores email addresses as keys and folder lists as values. |
||||
// It is used to ensure that each user receives no more than one email notification. |
||||
// The email will include the names and links of the folders that the user should be reminded of. |
||||
$foldersByEmail = []; |
||||
// Similar to the previous one, this variable stores the language for each email (if provided) |
||||
$langByEmail = []; |
||||
|
||||
/** @var IShare $share */ |
||||
foreach ($shares as $share) { |
||||
if (!$this->shouldRemindOfThisShare($share)) { |
||||
continue; |
||||
} |
||||
|
||||
$sharedWith = $share->getSharedWith(); |
||||
if ($share->getShareType() == IShare::TYPE_USER) { |
||||
$user = $this->userManager->get($sharedWith); |
||||
$mailTo = $user->getEMailAddress(); |
||||
$lang = $this->l10nFactory->getUserLanguage($user); |
||||
$link = $this->urlGenerator->linkToRouteAbsolute('files.view.index', [ |
||||
'dir' => $share->getTarget() |
||||
]); |
||||
} else { |
||||
$mailTo = $sharedWith; |
||||
$lang = ''; |
||||
$link = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', [ |
||||
'token' => $share->getToken() |
||||
]); |
||||
} |
||||
if (empty($mailTo)) { |
||||
continue; |
||||
} |
||||
|
||||
if (!empty($lang)) { |
||||
$langByEmail[$mailTo] ??= $lang; |
||||
} |
||||
if (!isset($foldersByEmail[$mailTo])) { |
||||
$foldersByEmail[$mailTo] = []; |
||||
} |
||||
$foldersByEmail[$mailTo][] = ['name' => $share->getNode()->getName(), 'link' => $link]; |
||||
|
||||
$share->setReminderSent(true); |
||||
$qb = $this->db->getQueryBuilder(); |
||||
$qb->update('share') |
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($share->getId()))) |
||||
->set('reminder_sent', $qb->createNamedParameter($share->getReminderSent())) |
||||
->execute(); |
||||
} |
||||
|
||||
return [$foldersByEmail, $langByEmail]; |
||||
} |
||||
|
||||
/** |
||||
* Checks if user has write permission and folder is empty |
||||
* |
||||
* @param IShare $share Share to check |
||||
* @return bool |
||||
*/ |
||||
private function shouldRemindOfThisShare(IShare $share): bool { |
||||
try { |
||||
$folder = $share->getNode(); |
||||
$fileCount = count($folder->getDirectoryListing()); |
||||
} catch (NotFoundException) { |
||||
$id = $share->getFullId(); |
||||
$this->logger->debug("File by share ID $id not found."); |
||||
return false; |
||||
} |
||||
$permissions = $share->getPermissions(); |
||||
$hasCreatePermission = ($permissions & Constants::PERMISSION_CREATE) === Constants::PERMISSION_CREATE; |
||||
return ($fileCount == 0 && $hasCreatePermission); |
||||
} |
||||
|
||||
/** |
||||
* This method accepts data obtained by {@link prepareReminders} and sends reminder emails. |
||||
* |
||||
* @param array $foldersByEmail |
||||
* @param array $langByEmail |
||||
* @return void |
||||
*/ |
||||
private function sendReminders(array $foldersByEmail, array $langByEmail): void { |
||||
$instanceName = $this->defaults->getName(); |
||||
$from = [Util::getDefaultEmailAddress($instanceName) => $instanceName]; |
||||
foreach ($foldersByEmail as $email => $folders) { |
||||
$l = $this->l10nFactory->get('files_sharing', $langByEmail[$email] ?? null); |
||||
$emailTemplate = $this->generateEMailTemplate($l, $folders); |
||||
$message = $this->mailer->createMessage(); |
||||
$message->setFrom($from); |
||||
$message->setTo([$email]); |
||||
$message->useTemplate($emailTemplate); |
||||
$errorText = "Sending email with share reminder to $email failed."; |
||||
try { |
||||
$failedRecipients = $this->mailer->send($message); |
||||
if (count($failedRecipients) > 0) { |
||||
$this->logger->error($errorText); |
||||
} |
||||
} catch (\Exception) { |
||||
$this->logger->error($errorText); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Returns the reminder email template |
||||
* |
||||
* @param IL10N $l |
||||
* @param array<array> $folders Folders the user should be reminded of |
||||
* @return IEMailTemplate |
||||
*/ |
||||
private function generateEMailTemplate(IL10N $l, array $folders): IEMailTemplate { |
||||
$emailTemplate = $this->mailer->createEMailTemplate('files_sharing.SharesReminder', [ |
||||
'folders' => $folders, |
||||
]); |
||||
|
||||
$emailTemplate->addHeader(); |
||||
if (count($folders) == 1) { |
||||
$emailTemplate->setSubject( |
||||
$l->t('Remember to upload the files to %s', [$folders[0]['name']]) |
||||
); |
||||
$emailTemplate->addBodyText($l->t( |
||||
'We would like to kindly remind you that you have not yet uploaded any files to the shared folder.' |
||||
)); |
||||
} else { |
||||
$emailTemplate->setSubject( |
||||
$l->t('Remember to upload the files to shared folders') |
||||
); |
||||
$emailTemplate->addBodyText($l->t( |
||||
'We would like to kindly remind you that you have not yet uploaded any files to the shared folders.' |
||||
)); |
||||
} |
||||
|
||||
foreach ($folders as $folder) { |
||||
$emailTemplate->addBodyButton( |
||||
$l->t('Open "%s"', [$folder['name']]), |
||||
$folder['link'] |
||||
); |
||||
} |
||||
$emailTemplate->addFooter(); |
||||
return $emailTemplate; |
||||
} |
||||
} |
||||
@ -0,0 +1,196 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
namespace OCA\Files_Sharing\Tests; |
||||
|
||||
use OC\SystemConfig; |
||||
use OCA\Files_Sharing\SharesReminderJob; |
||||
use OCP\AppFramework\Utility\ITimeFactory; |
||||
use OCP\Constants; |
||||
use OCP\Defaults; |
||||
use OCP\Files\IRootFolder; |
||||
use OCP\IDBConnection; |
||||
use OCP\IURLGenerator; |
||||
use OCP\IUserManager; |
||||
use OCP\L10N\IFactory; |
||||
use OCP\Mail\IMailer; |
||||
use OCP\Mail\IMessage; |
||||
use OCP\Share\IManager; |
||||
use OCP\Share\IShare; |
||||
use PHPUnit\Framework\MockObject\MockObject; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
/** |
||||
* Class SharesReminderJobTest |
||||
* |
||||
* @group DB |
||||
* |
||||
* @package OCA\Files_Sharing\Tests |
||||
*/ |
||||
class SharesReminderJobTest extends \Test\TestCase { |
||||
private SharesReminderJob $job; |
||||
private IDBConnection $db; |
||||
private IManager $shareManager; |
||||
private IUserManager $userManager; |
||||
private IMailer|MockObject $mailer; |
||||
private string $user1; |
||||
private string $user2; |
||||
|
||||
protected function setUp(): void { |
||||
parent::setUp(); |
||||
|
||||
$this->db = \OC::$server->get(IDBConnection::class); |
||||
$this->shareManager = \OC::$server->get(IManager::class); |
||||
$this->userManager = \OC::$server->get(IUserManager::class); |
||||
$this->mailer = $this->createMock(IMailer::class); |
||||
|
||||
// Clear occasional leftover shares from other tests |
||||
$this->db->executeUpdate('DELETE FROM `*PREFIX*share`'); |
||||
|
||||
$this->user1 = $this->getUniqueID('user1_'); |
||||
$this->user2 = $this->getUniqueID('user2_'); |
||||
|
||||
$user1 = $this->userManager->createUser($this->user1, 'longrandompassword'); |
||||
$user2 = $this->userManager->createUser($this->user2, 'longrandompassword'); |
||||
$user1->setSystemEMailAddress('user1@test.com'); |
||||
$user2->setSystemEMailAddress('user2@test.com'); |
||||
|
||||
\OC::registerShareHooks(\OC::$server->get(SystemConfig::class)); |
||||
|
||||
$this->job = new SharesReminderJob( |
||||
\OC::$server->get(ITimeFactory::class), |
||||
$this->db, |
||||
\OC::$server->get(IManager::class), |
||||
$this->userManager, |
||||
\OC::$server->get(LoggerInterface::class), |
||||
\OC::$server->get(IURLGenerator::class), |
||||
\OC::$server->get(IFactory::class), |
||||
$this->mailer, |
||||
\OC::$server->get(Defaults::class), |
||||
); |
||||
} |
||||
|
||||
protected function tearDown(): void { |
||||
$this->db->executeUpdate('DELETE FROM `*PREFIX*share`'); |
||||
|
||||
$userManager = \OC::$server->get(IUserManager::class); |
||||
$user1 = $userManager->get($this->user1); |
||||
if ($user1) { |
||||
$user1->delete(); |
||||
} |
||||
$user2 = $userManager->get($this->user2); |
||||
if ($user2) { |
||||
$user2->delete(); |
||||
} |
||||
|
||||
$this->logout(); |
||||
|
||||
parent::tearDown(); |
||||
} |
||||
|
||||
public function dataSharesReminder() { |
||||
$someMail = 'test@test.com'; |
||||
$noExpirationDate = null; |
||||
$today = new \DateTime(); |
||||
// For expiration dates, the time is always automatically set to zero by ShareAPIController |
||||
$today->setTime(0, 0); |
||||
$nearFuture = new \DateTime(); |
||||
$nearFuture->setTimestamp($today->getTimestamp() + 86400 * 1); |
||||
$farFuture = new \DateTime(); |
||||
$farFuture->setTimestamp($today->getTimestamp() + 86400 * 2); |
||||
$permissionRead = Constants::PERMISSION_READ; |
||||
$permissionCreate = $permissionRead | Constants::PERMISSION_CREATE; |
||||
$permissionUpdate = $permissionRead | Constants::PERMISSION_UPDATE; |
||||
$permissionDelete = $permissionRead | Constants::PERMISSION_DELETE; |
||||
$permissionAll = Constants::PERMISSION_ALL; |
||||
|
||||
return [ |
||||
// No reminders for folders without expiration date |
||||
[$noExpirationDate, '', false, $permissionRead, false], |
||||
[$noExpirationDate, '', false, $permissionCreate, false], |
||||
[$noExpirationDate, '', true, $permissionDelete, false], |
||||
[$noExpirationDate, '', true, $permissionCreate, false], |
||||
[$noExpirationDate, $someMail, false, $permissionUpdate, false], |
||||
[$noExpirationDate, $someMail, false, $permissionCreate, false], |
||||
[$noExpirationDate, $someMail, true, $permissionRead, false], |
||||
[$noExpirationDate, $someMail, true, $permissionAll, false], |
||||
// No reminders for folders with expiration date in the far future |
||||
[$farFuture, '', false, $permissionRead, false], |
||||
[$farFuture, '', false, $permissionCreate, false], |
||||
[$farFuture, '', true, $permissionDelete, false], |
||||
[$farFuture, '', true, $permissionCreate, false], |
||||
[$farFuture, $someMail, false, $permissionUpdate, false], |
||||
[$farFuture, $someMail, false, $permissionCreate, false], |
||||
[$farFuture, $someMail, true, $permissionRead, false], |
||||
[$farFuture, $someMail, true, $permissionAll, false], |
||||
/* Should send reminders for folders with expiration date in the near future |
||||
if the folder is empty and the user has write permission */ |
||||
[$nearFuture, '', false, $permissionRead, false], |
||||
[$nearFuture, '', false, $permissionCreate, false], |
||||
[$nearFuture, '', true, $permissionDelete, false], |
||||
[$nearFuture, '', true, $permissionCreate, true], |
||||
[$nearFuture, $someMail, false, $permissionUpdate, false], |
||||
[$nearFuture, $someMail, false, $permissionCreate, false], |
||||
[$nearFuture, $someMail, true, $permissionRead, false], |
||||
[$nearFuture, $someMail, true, $permissionAll, true], |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* @dataProvider dataSharesReminder |
||||
* |
||||
* @param \DateTime|null $expirationDate Share expiration date |
||||
* @param string $email Share with this email. If empty, the share is of type TYPE_USER and the sharee is user2 |
||||
* @param bool $isEmpty Is share folder empty? |
||||
* @param int $permissions |
||||
* @param bool $shouldBeReminded |
||||
*/ |
||||
public function testSharesReminder( |
||||
\DateTime|null $expirationDate, string $email, bool $isEmpty, int $permissions, bool $shouldBeReminded |
||||
): void { |
||||
$this->loginAsUser($this->user1); |
||||
|
||||
$user1Folder = \OC::$server->get(IRootFolder::class)->getUserFolder($this->user1); |
||||
$testFolder = $user1Folder->newFolder('test'); |
||||
|
||||
if (!$isEmpty) { |
||||
$testFolder->newFile("some_file.txt"); |
||||
} |
||||
|
||||
$share = $this->shareManager->newShare(); |
||||
|
||||
$share->setNode($testFolder) |
||||
->setShareType(($email ? IShare::TYPE_EMAIL : IShare::TYPE_USER)) |
||||
->setPermissions($permissions) |
||||
->setSharedBy($this->user1) |
||||
->setSharedWith(($email ?: $this->user2)) |
||||
->setExpirationDate($expirationDate); |
||||
$share = $this->shareManager->createShare($share); |
||||
|
||||
$this->logout(); |
||||
$messageMock = $this->createMock(IMessage::class); |
||||
$this->mailer->method('createMessage')->willReturn($messageMock); |
||||
$this->mailer |
||||
->expects(($shouldBeReminded ? $this->once() : $this->never())) |
||||
->method('send') |
||||
->with($messageMock); |
||||
$messageMock |
||||
->expects(($shouldBeReminded ? $this->once() : $this->never())) |
||||
->method('setTo') |
||||
->with([$email ?: $this->userManager->get($this->user2)->getSystemEMailAddress()]); |
||||
$this->assertSame(false, $share->getReminderSent()); |
||||
$this->job->run([]); |
||||
$qb = $this->db->getQueryBuilder(); |
||||
$reminderSent = $qb |
||||
->select('reminder_sent') |
||||
->from('share') |
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($share->getId()))) |
||||
->executeQuery() |
||||
->fetch()["reminder_sent"]; |
||||
$this->assertEquals($shouldBeReminded, $reminderSent); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue