Skip to content

Commit

Permalink
Implements possibility to unsubscribe from reports (#13214)
Browse files Browse the repository at this point in the history
* Implements possibility to unsubscribe from reports

* Use a nonce for better security

* post event if someone unsubscribes

* various improvements and tests

* store information about unsubscribed reports until they are resubscribed

* code improvements
  • Loading branch information
sgiehl authored and diosmosis committed Aug 6, 2018
1 parent 87525b0 commit 70ca09f
Show file tree
Hide file tree
Showing 16 changed files with 644 additions and 3 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -12,8 +12,10 @@ The Product Changelog at **[matomo.org/changelog](https://matomo.org/changelog)*

### New APIs

* Reports send by mail now contain unsubscribe-links, which lets every recipient unsubscribe from a specific report, even without access to Matomo
* Added new event `API.addGlossaryItems` which lets you add items to the glossary.
* Added new event `Tracker.detectReferrerSocialNetwork` which lets you add custom social network detections
* Added new event `Report.unsubscribe` which is triggered whenever someone unsubscribe from a report
* A new role has introduced called "write" which has less permissions than an admin but more than a view only user.
* Added new API method `UsersManager.getAvailableRoles` to fetch a list of all available roles that can be granted to a user.
* Added new API method `UsersManager.getAvailableCapabilities` to fetch a list of all available capabilities that can be granted to a user.
Expand Down
51 changes: 51 additions & 0 deletions core/Updates/3.6.0-b3.php
@@ -0,0 +1,51 @@
<?php
/**
* Piwik - free/libre analytics platform
*
* @link http://piwik.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*
*/

namespace Piwik\Updates;

use Piwik\Common;
use Piwik\Updater\Migration\Factory as MigrationFactory;
use Piwik\Updater;
use Piwik\Updates;

/**
* Update for version 3.6.0-b3.
*/
class Updates_3_6_0_b3 extends Updates
{
/**
* @var MigrationFactory
*/
private $migration;

public function __construct(MigrationFactory $factory)
{
$this->migration = $factory;
}

public function getMigrations(Updater $updater)
{
$columns = array(
'idreport' => 'INT(11) NOT NULL',
'token' => ' VARCHAR(100) NULL',
'email' => 'VARCHAR(100) NOT NULL',
'ts_subscribed' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
'ts_unsubscribed' => 'TIMESTAMP NULL',
);
return array(
$this->migration->db->createTable('report_subscriptions', $columns, ['idreport', 'email']),
$this->migration->db->addUniqueKey('report_subscriptions', 'token', 'unique_token')
);
}

public function doUpdate(Updater $updater)
{
$updater->executeMigrations(__FILE__, $this->getMigrations($updater));
}
}
2 changes: 1 addition & 1 deletion core/Version.php
Expand Up @@ -20,7 +20,7 @@ final class Version
* The current Matomo version.
* @var string
*/
const VERSION = '3.6.0-b1';
const VERSION = '3.6.0-b3';

public function isStableVersion($version)
{
Expand Down
Expand Up @@ -50,7 +50,7 @@

{% if displaySegment %}
<p style="{{styleParagraph}}{{fontStyle}}">
{{ 'ScheduledReports_CustomVisitorSegment' }} {{ segmentName }}
{{ 'ScheduledReports_CustomVisitorSegment'|translate }} {{ segmentName }}
</p>
{% endif %}

Expand Down
48 changes: 48 additions & 0 deletions plugins/ScheduledReports/Controller.php
Expand Up @@ -8,8 +8,11 @@
*/
namespace Piwik\Plugins\ScheduledReports;

use Piwik\Access;
use Piwik\API\Request;
use Piwik\Common;
use Piwik\Date;
use Piwik\Nonce;
use Piwik\Piwik;
use Piwik\Plugins\LanguagesManager\LanguagesManager;
use Piwik\Plugins\SegmentEditor\API as APISegmentEditor;
Expand Down Expand Up @@ -103,4 +106,49 @@ public function index()

return $view->render();
}

public function unsubscribe()
{
$view = new View('@ScheduledReports/unsubscribe');
$this->setBasicVariablesView($view);
$view->linkTitle = Piwik::getRandomTitle();

$token = Common::getRequestVar('token', '', 'string');

if (empty($token)) {
$view->error = Piwik::translate('ScheduledReports_NoTokenProvided');
return $view->render();
}

$subscriptionModel = new SubscriptionModel();
$subscription = $subscriptionModel->getSubscription($token);

$report = Access::doAsSuperUser(function() use ($subscription) {
$reports = Request::processRequest('ScheduledReports.getReports', array(
'idReport' => $subscription['idreport'],
));
return reset($reports);
});

if (empty($subscription)) {
$view->error = Piwik::translate('ScheduledReports_NoSubscriptionFound');
return $view->render();
}

$confirm = Common::getRequestVar('confirm', '', 'string');

$view->reportName = $report['description'];

$nonce = Common::getRequestVar('nonce', '', 'string');

if (!empty($confirm) && Nonce::verifyNonce('Report.Unsubscribe', $nonce)) {
Nonce::discardNonce('Report.Unsubscribe');
$subscriptionModel->unsubscribe($token);
$view->success = true;
} else {
$view->nonce = Nonce::getNonce('Report.Unsubscribe');
}

return $view->render();
}
}
25 changes: 25 additions & 0 deletions plugins/ScheduledReports/ScheduledReports.php
Expand Up @@ -20,6 +20,7 @@
use Piwik\Plugins\UsersManager\API as APIUsersManager;
use Piwik\ReportRenderer;
use Piwik\Scheduler\Schedule\Schedule;
use Piwik\SettingsPiwik;
use Piwik\Tracker;
use Piwik\View;

Expand Down Expand Up @@ -343,12 +344,35 @@ public function sendReport($reportType, $report, $contents, $filename, $prettyDa
$this->markReportAsSent($report, $period);
}

$subscriptionModel = new SubscriptionModel();
$subscriptionModel->updateReportSubscriptions($report['idreport'], $emails);
$subscriptions = $subscriptionModel->getReportSubscriptions($report['idreport']);

$tokens = array_column($subscriptions, 'token', 'email');

$textContent = $mail->getBodyText();
$htmlContent = $mail->getBodyHtml();
if ($htmlContent instanceof \Zend_Mime_Part) {
$htmlContent = $htmlContent->getRawContent();
}

foreach ($emails as $email) {
if (empty($email)) {
continue;
}
$mail->addTo($email);

// add unsubscribe links to content
if ($htmlContent) {
$link = SettingsPiwik::getPiwikUrl() . 'index.php?module=ScheduledReports&action=unsubscribe&token=' . $tokens[$email];
$mail->setBodyHtml($htmlContent . '<br /><br /><hr /><br />'.Piwik::translate('ScheduledReports_UnsubscribeFooter', [' <a href="' . $link . '">' . $link . '</a>']));
}

if ($textContent) {
$link = SettingsPiwik::getPiwikUrl() . 'index.php?module=ScheduledReports&action=unsubscribe&token=' . $tokens[$email];
$mail->setBodyText($textContent . "\n\n".Piwik::translate('ScheduledReports_UnsubscribeFooter', [$link]));
}

try {
$mail->send();
} catch (Exception $e) {
Expand Down Expand Up @@ -529,6 +553,7 @@ private function getModel()
public function install()
{
Model::install();
SubscriptionModel::install();
}

private static function checkAdditionalEmails($additionalEmails)
Expand Down
190 changes: 190 additions & 0 deletions plugins/ScheduledReports/SubscriptionModel.php
@@ -0,0 +1,190 @@
<?php
/**
* Piwik - free/libre analytics platform
*
* @link http://piwik.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*
*/
namespace Piwik\Plugins\ScheduledReports;

use Piwik\Access;
use Piwik\API\Request;
use Piwik\Common;
use Piwik\Db;
use Piwik\DbHelper;
use Piwik\Piwik;

class SubscriptionModel
{
private static $rawPrefix = 'report_subscriptions';
private $table;

public function __construct()
{
$this->table = Common::prefixTable(self::$rawPrefix);
}

public function unsubscribe($token)
{
$details = $this->getSubscription($token);

if (empty($details)) {
return false;
}

$email = $details['email'];

$report = Access::doAsSuperUser(function() use ($details) {
$reports = Request::processRequest('ScheduledReports.getReports', array(
'idReport' => $details['idreport'],
));
return reset($reports);
});

if (empty($report)) {
// if the report isn't found, remove subscription as it isn't active anymore
$this->removeSubscription($token);
return false;
}

$reportParameters = $report['parameters'];

$emailFound = false;

if (!empty($reportParameters['additionalEmails'])) {
$additionalEmails = $reportParameters['additionalEmails'];
$filteredEmails = [];
foreach ($additionalEmails as $additionalEmail) {
if ($additionalEmail == $email) {
$emailFound = true;
continue;
}
$filteredEmails[] = $additionalEmail;
}
if ($emailFound) {
$report['parameters']['additionalEmails'] = $filteredEmails;
}
}

if ($reportParameters['emailMe']) {
$login = $report['login'];

$userModel = new \Piwik\Plugins\UsersManager\Model();
$userData = $userModel->getUser($login);

if ($userData['email'] == $email) {
$emailFound = true;
$report['parameters']['emailMe'] = false;
}
}

if ($emailFound) {
Access::doAsSuperUser(function() use ($report) {
Request::processRequest('ScheduledReports.updateReport', [
'idReport' => $report['idreport'],
'idSite' => $report['idsite'],
'description' => $report['description'],
'period' => $report['period'],
'hour' => $report['hour'],
'reportType' => $report['type'],
'reportFormat' => $report['format'],
'reports' => $report['reports'],
'parameters' => $report['parameters'],
'idSegment' => $report['idsegment'],
]);
});

Piwik::postEvent('Report.unsubscribe', [$report['idreport'], $email]);

$this->removeSubscription($token);
}

return $emailFound;
}

public function getReportSubscriptions($idReport, $includeUnsubscribed = false)
{
$query = 'SELECT * FROM ' . $this->table . ' WHERE idreport = ?';

if (!$includeUnsubscribed) {
$query .= ' AND ts_unsubscribed IS NULL';
}

return $this->getDb()->fetchAll($query, [$idReport]);
}

public function getSubscription($token)
{
return $this->getDb()->fetchRow('SELECT * FROM ' . $this->table . ' WHERE token = ?', [$token]);
}

public function updateReportSubscriptions($idReport, $emails)
{
$availableSubscriptions = $this->getReportSubscriptions($idReport);
$availableEmails = array_column($availableSubscriptions, 'email');

// remove available subscriptions that aren't present anymore
foreach ($availableSubscriptions as $availableSubscription) {
if (!in_array($availableSubscription['email'], $emails) && !empty($availableSubscription['token'])) {
$this->removeSubscription($availableSubscription['token']);
}
}

$emails = array_unique($emails);

// add new subscriptions
foreach ($emails as $email) {
while($token = $this->generateToken($email)) {
if (!$this->tokenExists($token)) {
break;
}
}

if (!in_array($email, $availableEmails)) {
$subscription = [
'idreport' => $idReport,
'token' => $token,
'email' => $email
];
// remove possible "unsubscribe" entry
$this->getDb()->query('DELETE FROM ' . $this->table . ' WHERE idreport = ? AND email = ?', [$idReport, $email]);
$this->getDb()->insert($this->table, $subscription);
}
}

}

private function removeSubscription($token)
{
$this->getDb()->query('UPDATE ' . $this->table . ' SET token = "", ts_unsubscribed = NOW() WHERE token = ?', [$token]);
}

private function generateToken($email)
{
return substr(Common::hash($email . time() . Common::getRandomString(5)), 0, 100);
}

private function tokenExists($token)
{
return !!$this->getDb()->fetchOne('SELECT token FROM ' . $this->table . ' WHERE token = ?', [$token]);
}

private function getDb()
{
return Db::get();
}

public static function install()
{
$reportTable = "`idreport` INT(11) NOT NULL,
`token` VARCHAR(100) NULL,
`email` VARCHAR(100) NOT NULL,
`ts_subscribed` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`ts_unsubscribed` TIMESTAMP NULL,
PRIMARY KEY (`idreport`, `email`),
UNIQUE INDEX `unique_token` (`token`)";

DbHelper::createTable(self::$rawPrefix, $reportTable);
}
}

0 comments on commit 70ca09f

Please sign in to comment.