diff --git a/bin/console b/bin/console
index 3a0904d2b1..a275593dbf 100755
--- a/bin/console
+++ b/bin/console
@@ -14,6 +14,7 @@ namespace KonsoleKommander;
use Alchemy\Phrasea\CLI;
use Alchemy\Phrasea\Command\ApplyRightsCommand;
use Alchemy\Phrasea\Command\BuildMissingSubdefs;
+use Alchemy\Phrasea\Command\ExpiringRights\AlertExpiringRightsCommand;
use Alchemy\Phrasea\Command\Record\BuildPermalinks;
use Alchemy\Phrasea\Command\Record\BuildSubdefs;
use Alchemy\Phrasea\Command\CheckConfig;
@@ -194,6 +195,8 @@ $cli->command(new SendValidationRemindersCommand());
$cli->command(new NetworkProxiesTestCommand('network-proxies:test'));
+$cli->command(new AlertExpiringRightsCommand());
+
$cli->loadPlugins();
$cli->run();
diff --git a/config/configuration.sample.yml b/config/configuration.sample.yml
index e89fdd5062..f3290f093e 100644
--- a/config/configuration.sample.yml
+++ b/config/configuration.sample.yml
@@ -489,3 +489,42 @@ translator:
# end of job : change coll status
set_status: 10xxxx
set_collection: online
+expiring-rights:
+ version: 3
+ jobs:
+ # "I want to alert owners that records have expired"
+ - rights-expired-owners:
+ active: false
+ target: "owners"
+ databox: "db_with_rights"
+ collection: [ "Promo", "Selections" ]
+ expire_field: ExpireDate
+ prior_notice: -60
+ set_status: 01xxxx
+ alerts:
+ - method: webhook
+ recipient: ["bob@a.fr", "joe@b.com"]
+
+ # "I want to alert users who have downloaded that a document rights will expire in 60 days"
+ - rights-60-downloaders:
+ active: false
+ target: "downloaders"
+ databox: "db_with_rights"
+ collection: [ "Promo", "Selections" ]
+ downloaded: [ "document", "preview" ]
+ expire_field: "ExpirationDate"
+ prior_notice: -60
+ alerts:
+ - method: "webhook"
+
+ # "I want to alert users who have downloaded that a document rights has expired"
+ - rights-expired-dowloaders:
+ active: false
+ target: "downloaders"
+ databox: "db_with_rights"
+ collection: [ "Promo", "Selections" ]
+ downloaded: [ "document", "preview" ]
+ expire_field: "ExpirationDate"
+ prior_notice: 0
+ alerts:
+ - method: "webhook"
diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php
index c67ab8613d..d1ef5d8ac1 100644
--- a/lib/Alchemy/Phrasea/Application.php
+++ b/lib/Alchemy/Phrasea/Application.php
@@ -20,6 +20,7 @@
use Alchemy\Phrasea\Authorization\AuthorizationServiceProvider;
use Alchemy\Phrasea\Core\Event\Subscriber\BasketSubscriber;
use Alchemy\Phrasea\Core\Event\Subscriber\BridgeSubscriber;
+use Alchemy\Phrasea\Core\Event\Subscriber\ExpiringRightsSubscriber;
use Alchemy\Phrasea\Core\Event\Subscriber\ExportSubscriber;
use Alchemy\Phrasea\Core\Event\Subscriber\FeedEntrySubscriber;
use Alchemy\Phrasea\Core\Event\Subscriber\LazaretSubscriber;
@@ -27,6 +28,7 @@
use Alchemy\Phrasea\Core\Event\Subscriber\RegistrationSubscriber;
use Alchemy\Phrasea\Core\Event\Subscriber\ValidationSubscriber;
use Alchemy\Phrasea\Core\Event\Subscriber\WebhookUserEventSubscriber;
+use Alchemy\Phrasea\Core\LazyLocator;
use Alchemy\Phrasea\Core\MetaProvider\DatabaseMetaProvider;
use Alchemy\Phrasea\Core\MetaProvider\HttpStackMetaProvider;
use Alchemy\Phrasea\Core\MetaProvider\MediaUtilitiesMetaServiceProvider;
@@ -767,6 +769,8 @@ private function setupEventDispatcher()
$dispatcher->addSubscriber(new WebhookUserEventSubscriber($app));
}
+ $dispatcher->addSubscriber(new ExpiringRightsSubscriber($app, new LazyLocator($app, 'phraseanet.appbox')));
+
return $dispatcher;
})
);
diff --git a/lib/Alchemy/Phrasea/Command/ExpiringRights/AlertExpiringRightsCommand.php b/lib/Alchemy/Phrasea/Command/ExpiringRights/AlertExpiringRightsCommand.php
new file mode 100644
index 0000000000..f354aee59d
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/ExpiringRights/AlertExpiringRightsCommand.php
@@ -0,0 +1,591 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Alchemy\Phrasea\Command\ExpiringRights;
+
+
+use Alchemy\Phrasea\Command\Command;
+use Alchemy\Phrasea\Model\Manipulator\WebhookEventManipulator;
+use appbox;
+use collection;
+use databox;
+use Doctrine\DBAL\DBALException;
+use PDO;
+use Symfony\Component\Console\Formatter\OutputFormatterStyle;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use function igorw\get_in;
+
+
+class AlertExpiringRightsCommand extends Command
+{
+ const RIGHT_SHORTENED = 'shortened';
+ const RIGHT_EXTENDED = 'extended';
+ const RIGHT_EXPIRING = 'expiring';
+
+ /** @var InputInterface $input */
+ private $input;
+ /** @var OutputInterface $output */
+ private $output;
+ /** @var appbox $appbox */
+ private $appbox;
+ /** @var array $databoxes */
+ private $databoxes;
+
+ private $now = null;
+
+ public function configure()
+ {
+ $this->setName("workflow:expiring:run")
+ ->setDescription('alert owners and users of expiring records')
+ ->addOption('dry', null, InputOption::VALUE_NONE, "Dry run (list alerts but don't insert in webhooks).")
+ ->addOption('show-sql', null, InputOption::VALUE_NONE, "Show the selection sql.")
+ ->addOption('dump-webhooks', null, InputOption::VALUE_NONE, "Show the webhooks data.")
+ ->addOption('now', null, InputOption::VALUE_REQUIRED, "for testing : fake the 'today' date (format=YYYYMMDD).")
+ // ->setHelp('')
+ ;
+ }
+
+ /**
+ * @param InputInterface $input
+ * @param OutputInterface $output
+ * @return int
+ */
+ protected function doExecute(InputInterface $input, OutputInterface $output)
+ {
+ $this->input = $input;
+ // add cool styles
+ $style = new OutputFormatterStyle('black', 'yellow'); // , array('bold'));
+ $output->getFormatter()->setStyle('warning', $style);
+ $this->output = $output;
+
+ // sanitize parameters
+ if(($now = $input->getOption('now')) !== null) {
+ if(preg_match("/^[0-9]{8}$/", $now) === 1) {
+ $this->now = $now;
+ }
+ else {
+ $this->output->writeln(sprintf("bad format for 'now' (%s) option (must be YYYYMMDD)", $now));
+ return -1;
+ }
+ }
+
+ $this->appbox = $this->container['phraseanet.appbox'];
+
+ // list databoxes and collections to access by id or by name
+ $this->databoxes = [];
+ foreach ($this->appbox->get_databoxes() as $databox) {
+ $sbas_id = $databox->get_sbas_id();
+ $sbas_name = $databox->get_dbname();
+ $this->databoxes[$sbas_id] = [
+ 'dbox' => $databox,
+ 'collections' => []
+ ];
+ $this->databoxes[$sbas_name] = &$this->databoxes[$sbas_id];
+ // list all collections
+ foreach ($databox->get_collections() as $collection) {
+ $coll_id = $collection->get_coll_id();
+ $coll_name = $collection->get_name();
+ $this->databoxes[$sbas_id]['collections'][$coll_id] = $collection;
+ $this->databoxes[$sbas_id]['collections'][$coll_name] = &$this->databoxes[$sbas_id]['collections'][$coll_id];
+ }
+ }
+
+ // play jobs
+ $ret = 0;
+ foreach ($this->container['conf']->get(['expiring-rights', 'jobs'], []) as $jobname => &$job) {
+ if($job['active']) {
+ if (!$this->playJob($jobname, $job)) {
+ $this->output->writeln(sprintf("job skipped"));
+ $ret = -1;
+ }
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param $jobname
+ * @param $job
+ * @return bool
+ * @throws DBALException
+ */
+ private function playJob($jobname, $job)
+ {
+ $this->output->writeln(sprintf("\n\n======== Playing job \"%s\" ========\n", $jobname));
+
+ // ensure that the job syntax is ok
+ if (!$this->sanitizeJob($job)) {
+ return false;
+ }
+
+ switch ($job['target']) {
+ case "owners":
+ return $this->playJobOwners($jobname, $job);
+ break;
+ case "downloaders":
+ return $this->playJobDownloaders($jobname, $job);
+ break;
+ default:
+ $this->output->writeln(sprintf("alert>bad target \"%s\" (should be \"owners\" or \"downloaders\")\n", $job['target']));
+ break;
+ }
+ return false;
+ }
+
+ private function playJobOwners($jobname, $job)
+ {
+ // ensure that the job syntax is ok
+ if (!$this->sanitizeJobOwners($job)) {
+ return false;
+ }
+
+ if (get_in($job, ['active'], false) === false) {
+ return true;
+ }
+
+ // build sql where clause
+ $wheres = [];
+
+ // clause on databox ?
+ $d = $job['databox'];
+ if (!is_string($d) && !is_int($d)) {
+ $this->output->writeln(sprintf("bad databox clause"));
+ return false;
+ }
+ if (!array_key_exists($d, $this->databoxes)) {
+ $this->output->writeln(sprintf("unknown databox (%s)", $d));
+ return false;
+ }
+
+ // find the sbas_id for the databox of this job
+ /** @var Databox $dbox */
+ $dbox = $this->databoxes[$d]['dbox'];
+ $sbas_id = $dbox->get_sbas_id();
+
+ // filter on collections ?
+ $collList = [];
+ foreach (get_in($job, ['collection'], []) as $c) {
+ /** @var collection $coll */
+ if (($coll = get_in($this->databoxes[$sbas_id], ['collections', $c])) !== null) {
+ $collList[] = $dbox->get_connection()->quote($coll->get_coll_id());
+ }
+ }
+ if (!empty($collList)) {
+ if(count($collList) === 1) {
+ $wheres[] = "r.`coll_id`=" . $collList[0];
+ }
+ else {
+ $wheres[] = "r.`coll_id` IN(" . join(',', $collList) . ")";
+ }
+ }
+
+ // clause on sb (negated)
+ $mask = get_in($job, ['set_status']);
+ if ($mask === null) {
+ $this->output->writeln(sprintf("missing 'set_status' clause"));
+ return false;
+ }
+ $m = preg_replace('/[^0-1]/', 'x', trim($mask));
+ if (strlen($m) > 32) {
+ $this->output->writeln(sprintf("status mask (%s) too long", $mask));
+ return false;
+ }
+ $mask_xor = str_replace(' ', '0', ltrim(str_replace(array('0', 'x'), array(' ', ' '), $m)));
+ $mask_and = str_replace(' ', '0', ltrim(str_replace(array('x', '0'), array(' ', '1'), $m)));
+ if ($mask_xor && $mask_and) {
+ $wheres[] = '((r.`status` ^ 0b' . $mask_xor . ') & 0b' . $mask_and . ') != 0';
+ } elseif ($mask_xor) {
+ $wheres[] = '(r.`status` ^ 0b' . $mask_xor . ') != 0';
+ } elseif ($mask_and) {
+ $wheres[] = '(r.`status` & 0b' . $mask_and . ') != 0';
+ } else {
+ $this->output->writeln(sprintf("empty status mask"));
+ return false;
+ }
+ // set status
+ $set_status = "`status`";
+ $set_or = str_replace(' ', '0', ltrim(str_replace(array('0', 'x'), array(' ', ' '), $m)));
+ $set_nand = str_replace(' ', '0', ltrim(str_replace(array('x', '1', '0'), array(' ', ' ', '1'), $m)));
+ if($set_or) {
+ $set_status = "(" . $set_status . " | 0b" . $set_or . ")";
+ }
+ if($set_nand) {
+ $set_status = "(" . $set_status . " & ~0b" . $set_nand . ")";
+ }
+
+ // clause on expiration date
+ // the NOW() can be faked for testing
+ $expire_field_id = null;
+ foreach ($dbox->get_meta_structure() as $dbf) {
+ if ($dbf->get_name() === $job['expire_field']) {
+ $expire_field_id = $dbf->get_id();
+ break;
+ }
+ }
+ if ($expire_field_id === null) {
+ $this->output->writeln(sprintf("unknown field (%s)", $job['expire_field']));
+ return false;
+ }
+ $now = $this->now === null ? "NOW()" : $this->appbox->get_connection()->quote($this->now);
+ $delta = (int)$job['prior_notice'];
+ if ($delta > 0) {
+ $value = "(`expire`+INTERVAL " . $delta . " DAY)";
+ }
+ elseif ($delta < 0) {
+ $value = "(`expire`-INTERVAL " . -$delta . " DAY)";
+ }
+ else {
+ $value = "`expire`";
+ }
+
+ $sql_where = count($wheres) > 0 ? " WHERE " . join(" AND ", $wheres) : "";
+ $sql = "SELECT t.*, DATEDIFF(`expire`, " . $now . ") AS 'expire_in' FROM (\n"
+ . " SELECT r.`record_id`, CAST(CAST(m.`value` AS DATE) AS DATETIME) AS `expire`\n"
+ . " FROM `record` AS r INNER JOIN `metadatas` AS m\n"
+ . " ON m.`record_id`=r.`record_id` AND m.`meta_struct_id`=" . $dbox->get_connection()->quote($expire_field_id, PDO::PARAM_INT) . "\n"
+ . $sql_where
+ . ") AS t WHERE " . $now . ">=" . $value;
+
+ if ($this->input->getOption('show-sql')) {
+ $this->output->writeln(sprintf("sql: %s", $sql));
+ }
+
+ // play sql
+ $records = [];
+ $stmt = $dbox->get_connection()->prepare($sql);
+ $stmt->execute();
+
+ if ($this->input->getOption('dry')) {
+ $this->output->writeln(sprintf("dry mode: updates on %d records NOT executed", $stmt->rowCount()));
+ }
+
+ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ $record = $dbox->get_record($row['record_id']);
+ $row['collection'] = $record->getCollection()->get_name();
+ $row['title'] = $record->get_title();
+ $records[] = $row;
+
+ $sql = "UPDATE `record` SET `status`=" . $set_status . " WHERE record_id=" . $dbox->get_connection()->quote($row['record_id']);
+ if ($this->input->getOption('show-sql')) {
+ $this->output->writeln(sprintf("sql: %s", $sql));
+ }
+ if (!$this->input->getOption('dry')) {
+ $dbox->get_connection()->exec($sql);
+ }
+ }
+ $stmt->closeCursor();
+
+ $n_records = count($records);
+ if($n_records === 0) {
+ return true;
+ }
+ foreach ($job['alerts'] as $alert) {
+ $method = get_in($alert, ['method']);
+ switch($method) {
+ case "webhook":
+ $payload = [
+ 'job' => $jobname,
+ 'delta' => $delta,
+ 'email' => $alert['recipient'],
+ 'sbas_id' => $dbox->get_sbas_id(),
+ 'base' => $dbox->get_viewname(),
+ 'records' => $records
+ ];
+ if ($this->input->getOption('dry')) {
+ $this->output->writeln(
+ sprintf(
+ "dry run : webhook about %d record(s) to [%s] NOT inserted",
+ $n_records, join(',', $alert['recipient'])
+ )
+ );
+ }
+ else {
+ $this->output->writeln(
+ sprintf(
+ "Inserting webhook about %d record(s) to [%s]",
+ $n_records, join(',', $alert['recipient'])
+ )
+ );
+ /** @var WebhookEventManipulator $manipulator */
+ $webhookManipulator = $this->container['manipulator.webhook-event'];
+ $webhookManipulator->create(
+ "Expiring.Rights.Records",
+ "Expiring.Rights",
+ $payload
+ );
+ }
+ if($this->input->getOption("dump-webhooks")) {
+ $this->output->writeln("webhook: \"Expiring.Rights.Records\", \"Expiring.Rights\"\npayload=");
+ $this->output->writeln(json_encode($payload, JSON_PRETTY_PRINT));
+ }
+ break;
+ default:
+ $this->output->writeln(sprintf("bad or undefined alert method (%s), ignored", $method));
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ private function playJobDownloaders($jobname, $job)
+ {
+ // ensure that the job syntax is ok
+ if (!$this->sanitizeJobDownloaders($job)) {
+ return false;
+ }
+
+ if (get_in($job, ['active'], false) === false) {
+ return true;
+ }
+
+ // build sql where clause
+ $wheres = [
+ '`job` = ' . $this->appbox->get_connection()->quote($jobname)
+ ];
+
+ // clause on databox
+ $databox = $job['databox'];
+ if(!is_string($databox) && !is_int($databox)) {
+ $this->output->writeln(sprintf("bad databox clause"));
+ return false;
+ }
+ if (!array_key_exists($databox, $this->databoxes)) {
+ $this->output->writeln(sprintf("unknown databox (%s)", $job['databox']));
+ return false;
+ }
+ // find the sbas_id for the databox of this job
+ /** @var Databox $dbox */
+ $dbox = $this->databoxes[$databox]['dbox'];
+ $sbas_id = $dbox->get_sbas_id();
+ $wheres[] = "(`sbas_id`=" . $dbox->get_connection()->quote($sbas_id) . ")";
+
+ $wheres[] = "(ISNULL(`alerted`) OR !ISNULL(`new_expire`))";
+
+ // clause on expiration date
+ // the NOW() can be faked for testing
+ $now = $this->now === null ? "NOW()" : $this->appbox->get_connection()->quote($this->now);
+ $delta = (int)$job['prior_notice'];
+ if ($delta > 0) {
+ $value = "(real_expire+INTERVAL " . $delta . " DAY)";
+ } elseif ($delta < 0) {
+ $value = "(real_expire-INTERVAL " . -$delta . " DAY)";
+ } else {
+ $value = "real_expire";
+ }
+
+ // build SELECT sql
+ $sql_where = join("\n AND", $wheres);
+ $sql = "SELECT t.*, DATEDIFF(real_expire, " . $now . ") AS 'expire_in' FROM (\n"
+ . " SELECT *, COALESCE(`expire`, `new_expire`) AS `real_expire` FROM `ExpiringRights`\n"
+ . " WHERE " . $sql_where . "\n"
+ . ") AS t\n"
+ . "WHERE !ISNULL(real_expire) AND " . $now . ">=" . $value . "\n"
+ . "ORDER BY `user_id`";
+
+ if($this->input->getOption('show-sql')) {
+ $this->output->writeln(sprintf("%s", $sql));
+ }
+
+ // play sql
+ $usersById = [];
+
+ $n_records = [
+ "all" => 0,
+ self::RIGHT_EXPIRING => 0,
+ self::RIGHT_EXTENDED => 0,
+ self::RIGHT_SHORTENED => 0
+ ];
+ $stmt = $this->appbox->get_connection()->prepare($sql);
+ $stmt->execute();
+ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ $type = null;
+ if($row['new_expire'] !== null) {
+ if($row['expire'] === null || $row['alerted'] === null) {
+ // same thing as new expire
+ $type = self::RIGHT_EXPIRING;
+ }
+ elseif($row['new_expire'] < $row['expire']) {
+ // shortened
+ $type = self::RIGHT_SHORTENED;
+ }
+ elseif($row['new_expire'] > $row['expire']) {
+ // extended
+ $type = self::RIGHT_EXTENDED;
+ }
+ else {
+ // same date (should not happen ?)
+ $type = self::RIGHT_EXPIRING;
+ }
+ }
+ else {
+ // new_expire === null
+ if($row['alerted'] === null) {
+ $type = self::RIGHT_EXPIRING;
+ }
+ else {
+ // nothing to do (should not happen)
+ }
+ }
+
+ if($type !== null) {
+ $user_id = $row['user_id'];
+ if(!array_key_exists($user_id, $usersById)) {
+ $usersById[$user_id] = [
+ 'user_id' => $user_id,
+ 'email' => $row['email'],
+ 'records' => []
+ ];
+ }
+ unset($row['id'], $row['job'], $row['user_id'], $row['email'], $row['sbas_id'], $row['base']);
+ $row['type'] = $type;
+ $usersById[$user_id]['records'][] = $row;
+
+ $n_records[$type]++;
+ }
+ $n_records['all']++;
+ }
+ $stmt->closeCursor();
+
+ $payload = [
+ 'job' => $jobname,
+ 'sbas_id' => $sbas_id,
+ 'base' => $dbox->get_viewname(),
+ 'delta' => $delta,
+ 'users' => array_values($usersById)
+ ];
+ unset($usersById);
+
+ if($n_records['all'] > 0 && !$this->input->getOption('dry')) {
+ // build UPDATE sql
+ $sql = "UPDATE `ExpiringRights` SET expire=COALESCE(new_expire, expire), new_expire=NULL, alerted=" . $now . "\n"
+ . " WHERE " . $sql_where;
+ $stmt = $this->appbox->get_connection()->prepare($sql);
+ $stmt->execute([]);
+ $this->appbox->get_connection()->exec($sql);
+ $stmt->closeCursor();
+ }
+
+ $this->output->writeln(
+ sprintf(
+ "%d records selected (%s expiring, %s shortened, %s extended)",
+ $n_records['all'],
+ $n_records[self::RIGHT_EXPIRING],
+ $n_records[self::RIGHT_SHORTENED],
+ $n_records[self::RIGHT_EXTENDED]
+ )
+ );
+
+ if ($n_records['all'] > 0 ) {
+ foreach ($job['alerts'] as $alert) {
+ switch ($alert['method']) {
+ case 'webhook':
+ if ($this->input->getOption('dry')) {
+ $this->output->writeln(
+ sprintf(
+ "dry run : webhook about %d record(s) NOT inserted",
+ $n_records['all']
+ )
+ );
+ }
+ else {
+ $this->output->writeln(
+ sprintf(
+ "webhook about %d record(s) inserted",
+ $n_records['all']
+ )
+ );
+ /** @var WebhookEventManipulator $manipulator */
+ $webhookManipulator = $this->container['manipulator.webhook-event'];
+ $webhookManipulator->create(
+ "Expiring.Rights.Downloaded",
+ "Expiring.Rights",
+ $payload
+ );
+ }
+ if($this->input->getOption("dump-webhooks")) {
+ $this->output->writeln("webhook: \"Expiring.Rights.Downloaded\", \"Expiring.Rights\"\npayload=");
+ $this->output->writeln(json_encode($payload, JSON_PRETTY_PRINT));
+ }
+ break;
+ default :
+ $this->output->writeln(sprintf("unknown alert method \"%s\"", $alert['method']));
+ break;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ // ================================================================================================
+
+ /**
+ * check that a yaml->php block is ok against rules
+ *
+ * @param array $object
+ * @param array $rules
+ * @return bool
+ */
+ private function sanitize(array $object, array $rules)
+ {
+ $object_ok = true;
+
+ foreach ($rules as $key => $fsanitize) {
+ if (!array_key_exists($key, $object) || !($fsanitize($object[$key]))) {
+ $this->output->writeln(sprintf("missing or bad format setting \"%s\"", $key));
+ $object_ok = false;
+ }
+ }
+
+ return $object_ok;
+ }
+
+ /**
+ * check that a job (first level block) is ok
+ *
+ * @param $job
+ * @return bool
+ */
+ private function sanitizeJob($job)
+ {
+ return $this->sanitize(
+ $job,
+ [
+ 'active' => "is_bool",
+ 'target' => function($v) {return in_array($v, ['owners', 'downloaders']);},
+ 'databox' => "is_string",
+ 'prior_notice' => 'is_int',
+ 'expire_field' => 'is_string',
+ 'alerts' => 'is_array'
+ ]
+ );
+ }
+
+ private function sanitizeJobOwners($job)
+ {
+ return $this->sanitize(
+ $job,
+ [
+ 'set_status' => "is_string",
+ ]
+ );
+ }
+
+ private function sanitizeJobDownloaders($job)
+ {
+ return true; // sanitizeJob is enough
+ }
+
+}
diff --git a/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php b/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php
index 075cecb66b..7fa80cfc72 100644
--- a/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php
+++ b/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php
@@ -67,6 +67,12 @@ public function checkDownload(Request $request)
$list['include_report'] = $request->request->get('include_report') === 'INCLUDE_REPORT';
$list['include_businessfields'] = (bool)$request->request->get('businessfields');
+ $lst = [];
+ foreach ($list['files'] as $file) {
+ $lst[] = $this->getApplicationBox()->get_collection($file['base_id'])->get_databox()->get_sbas_id() . '_' . $file['record_id'];
+ }
+ $lst = join(';', $lst);
+
$token = $this->getTokenManipulator()->createDownloadToken($this->getAuthenticatedUser(), serialize($list));
$this->getDispatcher()->dispatch(PhraseaEvents::EXPORT_CREATE, new ExportEvent(
@@ -136,6 +142,12 @@ public function listDownloadAsync(Request $request)
$records[sprintf('%s_%s', $sbasId, $file['record_id'])] = $record;
}
+ $lst = [];
+ foreach ($list['files'] as $file) {
+ $lst[] = $this->getApplicationBox()->get_collection($file['base_id'])->get_databox()->get_sbas_id() . '_' . $file['record_id'];
+ }
+ $lst = join(';', $lst);
+
$token = $this->getTokenManipulator()->createDownloadToken($this->getAuthenticatedUser(), serialize($list));
$pusher_auth_key =$this->getConf()->get(['download_async', 'enabled'], false) ? $this->getConf()->get(['externalservice', 'pusher', 'auth_key'], '') : null;
diff --git a/lib/Alchemy/Phrasea/Core/Event/Subscriber/ExpiringRightsSubscriber.php b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ExpiringRightsSubscriber.php
new file mode 100644
index 0000000000..3aa41cb0bf
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ExpiringRightsSubscriber.php
@@ -0,0 +1,223 @@
+app = $app;
+ $this->appboxLocator = $appboxLocator;
+ }
+
+ private function getJobsByDb($sbas_id)
+ {
+ static $jobsByDb = [];
+ static $jobsByName = null;
+
+ if($jobsByName === null) {
+ $jobsByName = [];
+ foreach ($this->app['conf']->get(['expiring-rights', 'jobs'], []) as $jobname => &$job) {
+ // nb: we must include inactive jobs so every download is recorded
+ $job['_c'] = $job['collection'];
+ unset($job['collection']);
+ $jobsByName[$jobname] = $job;
+ }
+ }
+
+ $sbas_id = (int)$sbas_id;
+ if(!array_key_exists($sbas_id, $jobsByDb)) {
+ $jobsByDb[$sbas_id] = [];
+ /** @var appbox $abox */
+ $abox = $this->app['phraseanet.appbox'];
+ $dbox = $abox->get_databox($sbas_id);
+ foreach($jobsByName as $jobname => &$job) {
+ if($job['databox'] === $sbas_id || $job['databox'] === $dbox->get_dbname()) {
+ // patch the collections filter to have id's and names
+ if(!array_key_exists('collection', $job)) {
+ $job['collection'] = [];
+ foreach ($dbox->get_collections() as $coll) {
+ if(empty($job['_c']) ||
+ in_array($coll->get_coll_id(), $job['_c'], true) ||
+ in_array($coll->get_name(), $job['_c'], true))
+ {
+ $job['collection'][] = $coll->get_coll_id();
+ $job['collection'][] = $coll->get_name();
+ }
+ }
+ }
+ $jobsByDb[$sbas_id][$jobname] = &$jobsByName[$jobname];
+ }
+ }
+ }
+
+ return array_key_exists($sbas_id, $jobsByDb) ? $jobsByDb[$sbas_id] : [];
+ }
+
+ /**
+ * when someone downloads, if the base has a declared "expire_field"
+ * insert an entry in our table
+ *
+ * @param ExportEvent $event
+ * @param string $eventName
+ */
+ public function onExportCreate(ExportEvent $event, $eventName)
+ {
+ $user_id = $event->getUser()->getId();
+ $email = $event->getUser()->getEmail();
+ /** @var appbox $abox */
+ $abox = $this->getApplicationBox();
+ $stmt = null;
+
+ foreach(explode(';', $event->getList()) as $sbid_rid) {
+ list($sbas_id, $record_id) = explode('_', $sbid_rid);
+
+ try {
+ /** @var databox $dbox */
+ $dbox = $abox->get_databox($sbas_id);
+ }
+ catch (\Exception $e) {
+ continue;
+ }
+
+ foreach($this->getJobsByDb($sbas_id) as $jobname => $job) {
+ if($job['target'] !== "downloaders") {
+ continue;
+ }
+
+ $record = $dbox->get_record($record_id);
+
+ // get the expire_field unique value
+ $expire_value = null;
+ $expire_field = $job['expire_field'];
+ $fields = $record->getCaption([$expire_field]);
+ if (array_key_exists($expire_field, $fields) && count($fields[$expire_field]) === 1) {
+ try {
+ $expire_value = (new \DateTime($fields[$expire_field][0]))->format('Y-m-d'); // drop hour, minutes...
+ }
+ catch (\Exception $e) {
+ // bad date format ? set null
+ }
+ }
+
+ try {
+ // insert
+ if(!$stmt) {
+ // first sql
+ $sql = "INSERT INTO `ExpiringRights` (job, downloaded, user_id, email, sbas_id, base, collection, record_id, title, expire)\n"
+ . " VALUES (:job, NOW(), :user_id, :email, :sbas_id, :base, :collection, :record_id, :title, :expire)";
+ $stmt = $abox->get_connection()->prepare($sql);
+ }
+ try {
+ $stmt->execute([
+ ':job' => $jobname,
+ ':user_id' => $user_id,
+ ':email' => $email,
+ ':sbas_id' => $sbas_id,
+ ':base' => $dbox->get_viewname(), // fallback of get_label() because we may not know the current language
+ ':collection' => $record->getCollection()->get_name(),
+ ':record_id' => $record_id,
+ ':title' => $record->get_title(),
+ ':expire' => $expire_value
+ ]);
+ }
+ catch (\Exception $e) {
+ // duplicate (same job+user+sbas_id+record_id) ?
+ }
+ }
+ catch (\Exception $e) {
+ // no-op
+ }
+
+ }
+ }
+ if($stmt) {
+ $stmt->closeCursor();
+ }
+ }
+
+ /**
+ * when the "expire_field" is edited, update the new value for every downloaded record
+ *
+ * @param RecordEdit $event
+ * @param string $eventName
+ * @throws DBALException
+ */
+ public function onRecordEdit(RecordEdit $event, $eventName)
+ {
+ $record = $event->getRecord();
+ $sbas_id = $record->getDataboxId();
+
+ // get settings for this databox
+ foreach($this->getJobsByDb($sbas_id) as $job) {
+ if($job['target'] !== "downloaders") {
+ continue;
+ }
+ $expire_field = $job['expire_field'];
+ $expire_value = null;
+ $record_id = $record->getRecordId();
+ $fields = $record->getCaption([$expire_field]);
+ if (array_key_exists($expire_field, $fields) && count($fields[$expire_field]) > 0) {
+ $expire_value = $fields[$expire_field][0];
+ }
+
+ /** @var appbox $abox */
+ $abox = $this->getApplicationBox();
+ $sql = "UPDATE `ExpiringRights` SET new_expire = :new_expire\n"
+ . " WHERE sbas_id = :sbas_id AND record_id = :record_id AND (IFNULL(expire, 0) != IFNULL(:expire, 0) OR !ISNULL(new_expire))";
+ $stmt = $abox->get_connection()->prepare($sql);
+ $stmt->execute($p = [
+ ':new_expire' => $expire_value,
+ ':sbas_id' => $sbas_id,
+ ':record_id' => $record_id,
+ ':expire' => $expire_value
+ ]);
+ $stmt->closeCursor();
+ }
+ }
+
+
+ public static function getSubscribedEvents()
+ {
+ return [
+ /** @uses onRecordEdit */
+ PhraseaEvents::RECORD_EDIT => 'onRecordEdit',
+ /** @uses onExportCreate */
+ PhraseaEvents::EXPORT_CREATE => 'onExportCreate',
+ ];
+ }
+
+
+ /**
+ * @return appbox
+ */
+ private function getApplicationBox()
+ {
+ static $appbox = null;
+ if($appbox === null) {
+ $callable = $this->appboxLocator;
+ $appbox = $callable();
+ }
+ return $appbox;
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Core/Version.php b/lib/Alchemy/Phrasea/Core/Version.php
index 17121523f9..ad0a782b48 100644
--- a/lib/Alchemy/Phrasea/Core/Version.php
+++ b/lib/Alchemy/Phrasea/Core/Version.php
@@ -17,7 +17,7 @@ class Version
* @var string
*/
- private $number = '4.1.8';
+ private $number = '4.1.9';
/**
* @var string
diff --git a/lib/classes/patch/419PHRAS4078.php b/lib/classes/patch/419PHRAS4078.php
new file mode 100644
index 0000000000..bb97c092e7
--- /dev/null
+++ b/lib/classes/patch/419PHRAS4078.php
@@ -0,0 +1,268 @@
+release;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDoctrineMigrations()
+ {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function require_all_upgrades()
+ {
+ return false;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function concern()
+ {
+ return $this->concern;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(base $base, Application $app)
+ {
+ $this->app = $app;
+ $this->logger = $app['logger'];
+
+ if ($base->get_base_type() === base::DATA_BOX) {
+ $this->patch_databox($base, $app);
+ } elseif ($base->get_base_type() === base::APPLICATION_BOX) {
+ $this->patch_appbox($base, $app);
+ }
+
+ return true;
+ }
+
+ private function patch_databox(databox $databox, Application $app)
+ {
+ }
+
+ private function patch_appbox(base $appbox, Application $app)
+ {
+ $this->migrateConfig();
+ $this->migrateTable($appbox);
+ }
+
+ const TABLENAME = "_ExpiringRights";
+ const TABLENAME3 = "ExpiringRights";
+ const CONFIG_DIR = "/config/plugins/expirating-rights-plugin/";
+ const CONFIG_FILE = "configuration.yml";
+ /** @var Application $app */
+ private $app;
+ /** @var LoggerInterface $logger */
+ private $logger;
+ private $config;
+
+ private function migrateConfig()
+ {
+ /** @var PropertyAccess $conf */
+ $phrconfig = $this->app['conf'];
+
+ if ($phrconfig->has(['expiring-rights'])) {
+ return;
+ }
+
+ // locate the config for the ExpiringRights plugin (v1 or v3)
+ $config_dir = $this->app['root.path'] . self::CONFIG_DIR;
+ $config_file = $config_dir . self::CONFIG_FILE;
+ $piconfig = ['jobs' => []];
+ if (file_exists($config_file)) {
+ try {
+ $piconfig = Yaml::parse(file_get_contents($config_file));
+ }
+ catch (\Exception $e) {
+ $piconfig = ['jobs' => []];
+ }
+
+ if (array_key_exists('databoxes', $piconfig)) {
+ // migrate the job settings to v3
+ $jobs3 = [];
+ foreach ($piconfig['jobs'] as $job) {
+ $jobname = $job['job'];
+
+ // find databox
+ $found = 0;
+ foreach ($piconfig['databoxes'] as $db) {
+ if ($db['databox'] === $job['databox']) {
+ unset($job['job']);
+ $jobs3[$jobname] = $job;
+ $jobs3[$jobname]['target'] = "downloader";
+ $jobs3[$jobname]['collection'] = array_key_exists('collection', $db) ? $db['collection'] : [];
+ $jobs3[$jobname]['downloaded'] = array_key_exists('downloaded', $db) ? $db['downloaded'] : [];
+ $jobs3[$jobname]['expire_field'] = $db['expire_field'];
+ $found++;
+ }
+ }
+
+ if ($found != 1) {
+ $msg = sprintf("error migrating job \"%s\": databox not found or not unique", $jobname);
+ $this->logger->error(sprintf("%s", $msg));
+ }
+ }
+
+ $piconfig = [
+ 'version' => 3,
+ 'jobs' => $jobs3
+ ];
+ }
+
+ rename($config_file, $config_file . "_bkp");
+ }
+
+ $phrconfig->set(['plugins', 'expirating-rights-plugin', 'enabled'], false);
+ $phrconfig->set(['expiring-rights'], $piconfig);
+ }
+
+
+ private function migrateTable(appbox $appbox)
+ {
+ // create the table
+ $sql = "CREATE TABLE `ExpiringRights` (\n"
+ . "`id` int(11) unsigned NOT NULL AUTO_INCREMENT,\n"
+ . "`job` char(128) DEFAULT NULL,\n"
+ . "`downloaded` datetime DEFAULT NULL,\n"
+ . "`user_id` int(11) unsigned DEFAULT NULL,\n"
+ . "`email` char(128) DEFAULT NULL,\n"
+ . "`sbas_id` int(11) unsigned DEFAULT NULL,\n"
+ . "`base` char(50) DEFAULT NULL,\n"
+ . "`collection` char(50) DEFAULT NULL,\n"
+ . "`record_id` int(11) unsigned DEFAULT NULL,\n"
+ . "`title` char(200) DEFAULT NULL,\n"
+ . "`expire` datetime DEFAULT NULL,\n"
+ . "`new_expire` datetime DEFAULT NULL,\n"
+ . "`alerted` datetime DEFAULT NULL,\n"
+ . "PRIMARY KEY (`id`),\n"
+ . "KEY `job` (`job`),\n"
+ . "KEY `sbas_id` (`sbas_id`),\n"
+ . "KEY `expire` (`expire`),\n"
+ . "KEY `new_expire` (`new_expire`),\n"
+ . "KEY `alerted` (`alerted`),\n"
+ . "UNIQUE KEY `unique` (job,user_id,sbas_id,record_id)"
+ . ") ENGINE=InnoDB CHARSET=utf8;";
+
+ $stmt = $appbox->get_connection()->prepare($sql);
+ try {
+ $stmt->execute();
+ }
+ catch(TableExistsException $e) {
+ // table v3 already exists, skip migration
+ return;
+ }
+ catch(\Exception $e) {
+ // fatal
+ return;
+ }
+ $stmt->closeCursor();
+
+ // migrate data from v-1 ?
+ try {
+ $appbox->get_connection()->exec("SELECT `id` FROM `_ExpiringRights` LIMIT 1");
+ }
+ catch (\Exception $e) {
+ // failed : no table to copy ?
+ return;
+ }
+
+ /** @var PropertyAccess $conf */
+ $phrconfig = $this->app['conf'];
+
+ if (!$phrconfig->has(['expiring-rights'])) {
+ return;
+ }
+
+ // table v3 was just created, insert from table v-1
+ $sql = "INSERT INTO `ExpiringRights` (\n"
+ . " `job`, \n"
+ . " `alerted`,\n"
+ . " `base`,\n"
+ . " `collection`,\n"
+ . " `downloaded`,\n"
+ . " `email`,\n"
+ . " `expire`,\n"
+ . " `new_expire`,\n"
+ . " `record_id`,\n"
+ . " `sbas_id`,\n"
+ . " `title`,\n"
+ . " `user_id`)\n"
+ . "SELECT\n"
+ . " :job AS `job`,\n"
+ . " MAX(`alerted`),\n"
+ . " `base`,\n"
+ . " `collection`,\n"
+ . " MAX(`downloaded`),\n"
+ . " `email`,\n"
+ . " MAX(`expire`),\n"
+ . " MAX(`new_expire`),\n"
+ . " `record_id`,\n"
+ . " `sbas_id`,\n"
+ . " `title`,\n"
+ . " `user_id`\n"
+ . "FROM `_ExpiringRights` GROUP BY `user_id`, `sbas_id`, `record_id` ORDER BY `id` ASC";
+ $stmtCopy = $appbox->get_connection()->prepare($sql);
+
+ $config = $phrconfig->get(['expiring-rights']);
+ foreach ($config['jobs'] as $jobname => $job) {
+
+ // copy v-1 rows to v3 (add job)
+ $stmtCopy->execute([':job' => $jobname]);
+
+ // fix alerted too early
+ $delta = $job['prior_notice'];
+ if ($delta > 0) {
+ $value = "(`expire`+INTERVAL " . $delta . " DAY)";
+ } elseif ($delta < 0) {
+ $value = "(`expire`-INTERVAL " . -$delta . " DAY)";
+ } else {
+ $value = "`expire`";
+ }
+ $sql = "UPDATE `ExpiringRights` SET `alerted`=NULL WHERE `job` = :job AND `alerted` < " . $value;
+ $stmtEarly = $appbox->get_connection()->prepare($sql);
+ $stmtEarly->execute([':job' => $jobname]);
+ $stmtEarly->closeCursor();
+ }
+ $stmtCopy->closeCursor();
+
+ // fix bad date
+ $sql = "UPDATE `ExpiringRights` SET `expire`=NULL WHERE `expire`='0000-00-00'";
+ $stmt = $appbox->get_connection()->prepare($sql);
+ $stmt->execute([]);
+ $stmt->closeCursor();
+
+ // fix useless new_expire
+ $sql = "UPDATE `ExpiringRights` SET `new_expire`=NULL WHERE `new_expire` = `expire`";
+ $stmt = $appbox->get_connection()->prepare($sql);
+ $stmt->execute([]);
+ $stmt->closeCursor();
+ }
+
+}