Merge pull request #47412 from Luka-sama/feat/shares-reminder
commit
f9fcc5b170
@ -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,218 @@
|
||||
<?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;
|
||||
private const CHUNK_SIZE = 1000;
|
||||
|
||||
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 {
|
||||
foreach ($this->getShares() as $share) {
|
||||
$reminderInfo = $this->prepareReminder($share);
|
||||
if ($reminderInfo !== null) {
|
||||
$this->sendReminder($reminderInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all shares of empty folders, for which the user has write permissions.
|
||||
* The returned shares are of type user or email only, have expiration dates within the specified time frame
|
||||
* and have not yet received a reminder.
|
||||
*
|
||||
* @return array<IShare>|\Iterator
|
||||
* @throws Exception if a database error occurs
|
||||
*/
|
||||
private function getShares(): array|\Iterator {
|
||||
$minDate = new \DateTime();
|
||||
$maxDate = new \DateTime();
|
||||
$maxDate->setTimestamp($maxDate->getTimestamp() + self::SECONDS_BEFORE_REMINDER);
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('s.id', 's.share_type')
|
||||
->from('share', 's')
|
||||
->leftJoin('s', 'filecache', 'f', $qb->expr()->eq('f.parent', 's.file_source'))
|
||||
->where(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->eq('s.share_type', $qb->expr()->literal(IShare::TYPE_USER)),
|
||||
$qb->expr()->eq('s.share_type', $qb->expr()->literal(IShare::TYPE_EMAIL))
|
||||
),
|
||||
$qb->expr()->eq('s.item_type', $qb->expr()->literal('folder')),
|
||||
$qb->expr()->gte('s.expiration', $qb->createNamedParameter($minDate->format('Y-m-d H:i:s'))),
|
||||
$qb->expr()->lt('s.expiration', $qb->createNamedParameter($maxDate->format('Y-m-d H:i:s'))),
|
||||
$qb->expr()->eq('s.reminder_sent', $qb->createNamedParameter(
|
||||
false, IQueryBuilder::PARAM_BOOL
|
||||
)),
|
||||
$qb->expr()->eq(
|
||||
$qb->expr()->bitwiseAnd('s.permissions', Constants::PERMISSION_CREATE),
|
||||
$qb->createNamedParameter(Constants::PERMISSION_CREATE, IQueryBuilder::PARAM_INT)
|
||||
),
|
||||
$qb->expr()->isNull('f.fileid')
|
||||
)
|
||||
)
|
||||
->setMaxResults(SharesReminderJob::CHUNK_SIZE);
|
||||
|
||||
$sharesResult = $qb->executeQuery();
|
||||
while ($share = $sharesResult->fetch()) {
|
||||
if ((int)$share['share_type'] === IShare::TYPE_EMAIL) {
|
||||
$id = "ocMailShare:$share[id]";
|
||||
} else {
|
||||
$id = "ocinternal:$share[id]";
|
||||
}
|
||||
|
||||
try {
|
||||
yield $this->shareManager->getShareById($id);
|
||||
} catch (ShareNotFound) {
|
||||
$this->logger->error("Share with ID $id not found.");
|
||||
}
|
||||
}
|
||||
$sharesResult->closeCursor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and returns all the necessary data before sending a reminder.
|
||||
* It also updates the reminder sent flag for the affected shares (to avoid multiple reminders).
|
||||
*
|
||||
* @param IShare $share Share that was obtained with {@link getShares}
|
||||
* @return array|null Info needed to send a reminder
|
||||
*/
|
||||
private function prepareReminder(IShare $share): array|null {
|
||||
$sharedWith = $share->getSharedWith();
|
||||
$reminderInfo = [];
|
||||
if ($share->getShareType() == IShare::TYPE_USER) {
|
||||
$user = $this->userManager->get($sharedWith);
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
$reminderInfo['email'] = $user->getEMailAddress();
|
||||
$reminderInfo['userLang'] = $this->l10nFactory->getUserLanguage($user);
|
||||
$reminderInfo['folderLink'] = $this->urlGenerator->linkToRouteAbsolute('files.view.index', [
|
||||
'dir' => $share->getTarget()
|
||||
]);
|
||||
} else {
|
||||
$reminderInfo['email'] = $sharedWith;
|
||||
$reminderInfo['folderLink'] = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', [
|
||||
'token' => $share->getToken()
|
||||
]);
|
||||
}
|
||||
if (empty($reminderInfo['email'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$reminderInfo['folderName'] = $share->getNode()->getName();
|
||||
} catch (NotFoundException) {
|
||||
$id = $share->getFullId();
|
||||
$this->logger->error("File by share ID $id not found.");
|
||||
}
|
||||
$share->setReminderSent(true);
|
||||
$this->shareManager->updateShare($share);
|
||||
return $reminderInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method accepts data obtained by {@link prepareReminder} and sends reminder email.
|
||||
*
|
||||
* @param array $reminderInfo
|
||||
* @return void
|
||||
*/
|
||||
private function sendReminder(array $reminderInfo): void {
|
||||
$instanceName = $this->defaults->getName();
|
||||
$from = [Util::getDefaultEmailAddress($instanceName) => $instanceName];
|
||||
$l = $this->l10nFactory->get('files_sharing', $reminderInfo['userLang'] ?? null);
|
||||
$emailTemplate = $this->generateEMailTemplate($l, [
|
||||
'link' => $reminderInfo['folderLink'], 'name' => $reminderInfo['folderName']
|
||||
]);
|
||||
|
||||
$message = $this->mailer->createMessage();
|
||||
$message->setFrom($from);
|
||||
$message->setTo([$reminderInfo['email']]);
|
||||
$message->useTemplate($emailTemplate);
|
||||
$errorText = "Sending email with share reminder to $reminderInfo[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 $folder Folder the user should be reminded of
|
||||
* @return IEMailTemplate
|
||||
*/
|
||||
private function generateEMailTemplate(IL10N $l, array $folder): IEMailTemplate {
|
||||
$emailTemplate = $this->mailer->createEMailTemplate('files_sharing.SharesReminder', [
|
||||
'folder' => $folder,
|
||||
]);
|
||||
$emailTemplate->addHeader();
|
||||
$emailTemplate->setSubject(
|
||||
$l->t('Remember to upload the files to %s', [$folder['name']])
|
||||
);
|
||||
$emailTemplate->addBodyText($l->t(
|
||||
'We would like to kindly remind you that you have not yet uploaded any files to the shared folder.'
|
||||
));
|
||||
$emailTemplate->addBodyButton(
|
||||
$l->t('Open "%s"', [$folder['name']]),
|
||||
$folder['link']
|
||||
);
|
||||
$emailTemplate->addFooter();
|
||||
return $emailTemplate;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,190 @@
|
||||
<?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([]);
|
||||
$share = $this->shareManager->getShareById($share->getFullId());
|
||||
$this->assertEquals($shouldBeReminded, $share->getReminderSent());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue