Commit e7b4303d authored by Rich's avatar Rich
Browse files

Add limit for running form processor

parent 360aaead
......@@ -3,6 +3,12 @@ use CRM_Actionlinks_ExtensionUtil as E;
class CRM_Actionlinks_BAO_ActionLink extends CRM_Actionlinks_DAO_ActionLink {
/** @var callback For testing only. */
public static $api3callback = 'civicrm_api3';
/** @var null|int cache */
protected $cacheCountUseByContact;
/**
* Create a new ActionLink based on array-data
*
......@@ -114,48 +120,137 @@ class CRM_Actionlinks_BAO_ActionLink extends CRM_Actionlinks_DAO_ActionLink {
}
}
if ($linkBAO->use_per_contact) {
// Limit times a particular contact can use this link.
// Limit times a particular contact can use this link.
if ($linkBAO->use_per_contact
&& $linkBAO->countUseByContact($contact_id) >= $linkBAO->use_per_contact) {
return $denied_response;
}
// Ok! Let's do this!
// Record that this contact has accessed it.
$linkBAO->logUseByContact($contact_id);
$linkBAO->runFormProcessor($contact_id, $query);
return ['status' => 200, 'redirect' => $linkBAO->allowed_url];
}
/**
* Run the Form Processor if configured to.
*
* @var int $contacID
* @var array $query (normally _GET data)
*
* @return NULL|TRUE
* TRUE if processor ran (used by phpunit tests)
*/
public function runFormProcessor($contactID, $query) {
if (!$this->form_processor_name) {
// Not configured.
return;
}
// If we're running live and not in a phpunit test, check the form processor is installed.
// If api3callback is not as expected, we're being called as a test; assume the extension
// is enabled.
if (static::$api3callback === 'civicrm_api3' && CRM_Extension_System::singleton()->getManager()->getStatus('form-processor') !== CRM_Extension_Manager::STATUS_INSTALLED) {
// Form processor not installed.
Civi::log()->warning("Action Link #$this->id is configured to run a Form Processor but Form Processor is not installed/enabled.");
return;
}
// Form Processor is configured for this link, and is installed.
// Is it configured to run every time, or once per contact?
if ($this->form_processor_run === 'once' && $this->countUseByContact($contactID) > 1) {
// Configured to run once, and that's been done.
// Note; because logUseByContact() is called before this method, the count will be 1 for the first time of running.
return;
}
$api3callback = static::$api3callback;
// Get input params for form processor.
$fp = $api3callback('FormProcessorInstance', 'get', [
'name' => $this->form_processor_name,
'is_active' => 1,
'sequential' => 1
])['values'][0] ?? FALSE;
if (!$fp) {
Civi::log()->warning("Action Link #$this->id is configured to run Form Processor '$this->form_processor_name' but that does not exist or is not active.");
return;
}
$configuredParams = json_decode($this->form_processor_params, TRUE);
if (!$configuredParams) {
$configuredParams = [];
}
// Only allow using params specified in the form processor.
$fpParams = [];
foreach ($fp['inputs'] as $_) {
$inputName = $_['name'];
if (!empty($configuredParams[$inputName])) {
// First priority is the configured values.
$fpParams[$inputName] = $configuredParams[$inputName];
}
elseif (!empty($query[$inputName])) {
// Second priority is the query data.
$fpParams[$inputName] = $query[$inputName];
}
}
// Finally, call the FormProcessor.
$api3callback('FormProcessor', $this->form_processor_name, $fpParams);
// Return TRUE to tell phpunit tests we ran.
return TRUE;
}
/**
* Look up how many times this link has been used by this contact.
*
* @return int
*/
public function countUseByContact($contactID, $reset = FALSE) {
if (!$this->id) {
throw new \LogicException('countUseByContact called before record has an ID. This is a coding error since this should not be called on new objects.');
}
if ($reset || $this->cacheCountUseByContact === NULL) {
$stats = CRM_Core_DAO::executeQuery(
'SELECT COUNT(1) used_times
FROM civicrm_action_link_contact
WHERE contact_id = %1 AND action_link_id = %2',
[
1 => [$contact_id, 'Integer'],
2 => [$linkBAO->id, 'Integer'],
1 => [$contactID, 'Integer'],
2 => [$this->id, 'Integer'],
]
);
$stats->fetch();
if ($stats->used_times >= $linkBAO->use_per_contact) {
return $denied_response;
}
$this->cacheCountUseByContact = (int) $stats->used_times;
}
return $this->cacheCountUseByContact;
}
/**
* Log use in civicrm_action_link_contact table
*
* @param int $contactID
*/
public function logUseByContact($contactID) {
// Ok! Let's do this!
$linkBAO->use_count += 1;
$linkBAO->save();
$this->use_count += 1;
$this->save();
// Record that this contact has accessed it.
$sqlParams = [
1 => [$contact_id, 'Integer'],
2 => [$linkBAO->id, 'Integer'],
1 => [$contactID, 'Integer'],
2 => [$this->id, 'Integer'],
];
CRM_Core_DAO::executeQuery(
'INSERT INTO civicrm_action_link_contact (contact_id, action_link_id, use_date) VALUES (%1, %2, NOW());',
$sqlParams);
if ($linkBAO->form_processor_name) {
// @todo
if (CRM_Extension_System::singleton()->getManager()->getStatus('form-processor') === CRM_Extension_Manager::STATUS_INSTALLED) {
// Pass over to the form processor.
$params = $query;
$result = civicrm_api3('FormProcessor', $linkBAO->form_processor_name, $params);
}
}
return ['status' => 200, 'redirect' => $linkBAO->allowed_url];
// Invalidate cache.
$this->cacheCountUseByContact = NULL;
}
public function redirectToDeny() {
if ($this->denied_url) {
......
......@@ -6,7 +6,7 @@
*
* Generated from /buildkit/build/dmaster/sites/default/files/civicrm/ext/actionlinks/xml/schema/CRM/Actionlinks/ActionLink.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
* (GenCodeChecksum:f1ec7c33cf845da13a36cf66ac2f42c0)
* (GenCodeChecksum:fada413e7d6b6fa66b960e0cebcfd323)
*/
/**
......@@ -84,6 +84,13 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
*/
public $form_processor_name;
/**
* every or once - how often to run the form prcessor per contact
*
* @var string
*/
public $form_processor_run;
/**
* JSON parameters (object of simple key:value pairs) for the form processor
*
......@@ -246,6 +253,21 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
],
'form_processor_run' => [
'name' => 'form_processor_run',
'type' => CRM_Utils_Type::T_STRING,
'title' => CRM_Actionlinks_ExtensionUtil::ts('Form Processor Run'),
'description' => CRM_Actionlinks_ExtensionUtil::ts('every or once - how often to run the form prcessor per contact'),
'required' => TRUE,
'maxlength' => 5,
'size' => CRM_Utils_Type::SIX,
'where' => 'civicrm_action_link.form_processor_run',
'default' => 'every',
'table_name' => 'civicrm_action_link',
'entity' => 'ActionLink',
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
],
'form_processor_params' => [
'name' => 'form_processor_params',
'type' => CRM_Utils_Type::T_TEXT,
......
......@@ -60,13 +60,12 @@ class CRM_Actionlinks_Upgrader extends CRM_Actionlinks_Upgrader_Base {
*
* @return TRUE on success
* @throws Exception
*
public function upgrade_4200() {
$this->ctx->log->info('Applying update 4200');
CRM_Core_DAO::executeQuery('UPDATE foo SET bar = "whiz"');
CRM_Core_DAO::executeQuery('DELETE FROM bang WHERE willy = wonka(2)');
*/
public function upgrade_0001() {
$this->ctx->log->info('Applying update 0001: add form_processor_run field');
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_action_link ADD form_processor_run VARCHAR(5) NOT NULL DEFAULT 'every' COMMENT 'every or once - how often to run the form prcessor per contact';");
return TRUE;
} // */
}
/**
......
......@@ -46,23 +46,32 @@ Links**
Here you can add your first action link. The edit screen is shown in the
screnshot above.
- Name: used for the administrative interface only.
- Description: same
- Active: if un-checked users of the link will get the denied/fallback URL
- Link URL: the web address to the page/resource you want to redirect
- *Name*: used for the administrative interface only.
- *Description*: same
- *Active*: if un-checked users of the link will get the denied/fallback URL
- *Link URL*: the web address to the page/resource you want to redirect
people to.
- Fallback URL (if denied): the web address to redirect people to if
- *Fallback URL (if denied)*: the web address to redirect people to if
they're not alowed the link for any reason.
- Form Processor: if you have the Form Processor extension, you can pick
- *Form Processor*: if you have the Form Processor extension, you can pick
a form processor to call. This gives you all the logic that extension
provides, e.g. creating activities, adding to groups. If you choose one,
that form processor's inputs will show up too.
- Max uses: the max times the link can be used by anyone. (probably not
provides, e.g. creating activities, adding to groups. If you choose one
then the following configuration is also available:
- Whether to run the Form Processor every time the link is allowed, or
only once per contact. e.g. you might want to record that a contact
has downloaded a resource, but you don't need to know every time they
downloaded it.
- Values for the Form Processor's inputs. Note that you can
(programmatically) add Form Processor input params to the links
generated. You can't overwrite a configured input, and you can't
provide a parameter that is not an allowed/expected input.
- *Max uses*: the max times the link can be used by anyone. (probably not
that useful)
- Max number of unique contacts who can use this. e.g. "First 10 people to
- *Max number of unique contacts who can use this.* e.g. "First 10 people to
click this get a voucher for a free pie.".
- Number of times each contact is allowed to use this link.
- When to stop allowing access.
- *Number of times each contact is allowed to use this link*.
- *When to stop allowing access*.
### Create links for contacts
......
......@@ -108,17 +108,30 @@
</select>
</div>
<div crm-ui-field="{name: 'editLinkForm.form_processor_run', title: ts('Run Form Processor')}" ng-show="editData.form_processor_name">
<select
crm-ui-id="editLinkForm.form_processor_run"
ng-model="editData.form_processor_run"
name="form_processor_run"
class="crm-form-select"
>
<option value="every" >{{ts('Every time')}}</option>
<option value="once" >{{ts('Once per contact')}}</option>
</select>
if the link is allowed.
</div>
<div ng-show="editData.fpParams.length > 0" class="crm-section">
<div class="label">
Form processor parameters
<label>Form processor parameters</label>
</div>
<div class="content">
<div ng-repeat="fpParam in editData.fpParams">
<label for="{{'fpParam_' + fpParam.name}}" >{{fpParam.name}}</label><br/>
<label for="{{'fpParam_' + fpParam.name}}" ><strong>{{fpParam.title}}</strong> (<code>{{fpParam.name}}</code>)</label><br/>
<input ng-model="fpParam.value" id="{{'fpParam_' + fpParam.name}}" /><br/>
<span><strong>{{fpParam.title}}</strong> {{fpParam.description}}</span>
<span ng-show="!fpParam.value" >This value will need to be added to the link query string, unless it has a default.</span>
<span>{{fpParam.description}}</span>
<p ng-show="!fpParam.value" >This value will need to be added to the link query string, unless it has a default.</p>
</div>
</div>
</div>
......
......@@ -42,6 +42,7 @@
use_by_contacts: '',
denied_url: '',
form_processor_name: '',
form_processor_run: 'every',
form_processor_params: '',
fpParams: []
};
......
......@@ -14,8 +14,8 @@
<url desc="Support">https://lab.civicrm.org/artfulrobot/actionlinks</url>
<url desc="Licensing">http://www.gnu.org/licenses/agpl-3.0.html</url>
</urls>
<releaseDate>2020-02-17</releaseDate>
<version>1.0</version>
<releaseDate>2020-02-20</releaseDate>
<version>1.0.1</version>
<develStage>alpha</develStage>
<compatibility>
<ver>5.0</ver>
......
......@@ -57,6 +57,7 @@ CREATE TABLE `civicrm_action_link` (
`denied_url` varchar(255) COMMENT 'The URL to redirect to if not allowed',
`contact_required` tinyint DEFAULT 1 COMMENT 'Must we be able to identify a valid contact',
`form_processor_name` varchar(255) NULL COMMENT 'The name of the form processor to trigger.',
`form_processor_run` varchar(5) NOT NULL DEFAULT "every" COMMENT 'every or once - how often to run the form prcessor per contact',
`form_processor_params` text NULL COMMENT 'JSON parameters (object of simple key:value pairs) for the form processor',
`use_count` int unsigned DEFAULT 0 ,
`use_limit` int unsigned NULL COMMENT 'Access denied after this many uses',
......
......@@ -21,6 +21,8 @@ use Civi\Test\TransactionalInterface;
*/
class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
/** @var array */
protected $mockApiCalls = [];
/** @var int */
public $testContactID;
......@@ -64,7 +66,7 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
* Basic success test.
*/
public function testValidLink() {
$linkBAO = $this->createReturnBAO([]);
$linkBAO = $this->createTestLink([]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result);
......@@ -73,7 +75,7 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
* Test that disabled links are not allowed.
*/
public function testInactiveLink() {
$linkBAO = $this->createReturnBAO(['is_active' => FALSE]);
$linkBAO = $this->createTestLink(['is_active' => FALSE]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 400, 'redirect' => 'https://denied.com'], $result);
......@@ -82,7 +84,7 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
* Test that invalid hashes are denied.
*/
public function testInvalidHash() {
$linkBAO = $this->createReturnBAO(['is_active' => FALSE]);
$linkBAO = $this->createTestLink(['is_active' => FALSE]);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => '']);
$this->assertEquals(['status' => 400, 'redirect' => 'https://denied.com'], $result);
}
......@@ -97,7 +99,7 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
* Test that missing Contacts issue 400 error
*/
public function testMissingContct() {
$linkBAO = $this->createReturnBAO([]);
$linkBAO = $this->createTestLink([]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
// Delete contact.
civicrm_api3('Contact', 'delete', ['skip_undelete' => TRUE, 'id' => $this->testContactID]);
......@@ -108,7 +110,7 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
* Test use_limit
*/
public function testUseLimit() {
$linkBAO = $this->createReturnBAO(['use_limit' => 1]);
$linkBAO = $this->createTestLink(['use_limit' => 1]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "First use should be allowed");
......@@ -126,7 +128,7 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
->setCheckPermissions(FALSE)
->execute()[0]['id'];
$linkBAO = $this->createReturnBAO(['use_per_contact' => 1]);
$linkBAO = $this->createTestLink(['use_per_contact' => 1]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "First use should be allowed");
......@@ -153,7 +155,7 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
->setCheckPermissions(FALSE)
->execute()[0]['id'];
$linkBAO = $this->createReturnBAO(['use_by_contacts' => 1]);
$linkBAO = $this->createTestLink(['use_by_contacts' => 1]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "First contact should be allowed");
......@@ -170,17 +172,90 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
* Test use_by
*/
public function testUseBy() {
$linkBAO = $this->createReturnBAO(['use_by' => date('Y-m-d H:i:s', strtotime('tomorrow'))]);
$linkBAO = $this->createTestLink(['use_by' => date('Y-m-d H:i:s', strtotime('tomorrow'))]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 200, 'redirect' => 'https://allowed.com'], $result, "Using link before use_by should be allowed.");
$linkBAO = $this->createReturnBAO(['use_by' => date('Y-m-d H:i:s', strtotime('yesterday'))]);
$linkBAO = $this->createTestLink(['use_by' => date('Y-m-d H:i:s', strtotime('yesterday'))]);
$cs = $linkBAO->generateChecksum($this->testContactID, NULL);
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest(['alid' => $linkBAO->id, 'cid' => $this->testContactID, 'cs' => $cs]);
$this->assertEquals(['status' => 400, 'redirect' => 'https://denied.com'], $result, "Using link after use by date should not be allowed");
}
public function createReturnBAO($data) {
/**
* Test FormProcessor config
*/
public function testFormProcessorBasic() {
$this->mockFormProcessorApi();
// Check that if configured with a non-existat form processor, the form processor is not called.
$linkBAO = $this->createTestLink(['form_processor_name' => 'test_fp_does_not_exist']);
$linkBAO->runFormProcessor($this->testContactID, []);
$this->assertCount(1, $this->mockApiCalls, "Fail on test_fp_does_not_exist");
// Reset
$this->mockApiCalls = [];
// Check that if configured with an inactive form processor, the form processor is not called.
$linkBAO = $this->createTestLink(['form_processor_name' => 'test_fp_exists_inactive']);
$linkBAO->runFormProcessor($this->testContactID, []);
$this->assertCount(1, $this->mockApiCalls, "Fail on test_fp_exists_inactive");
// Reset
$this->mockApiCalls = [];
// Check that if configured with an active form processor, the form processor is called.
$linkBAO = $this->createTestLink(['form_processor_name' => 'test_fp_exists_active']);
$this->mockRunProcessor($linkBAO, []);
$this->assertCount(2, $this->mockApiCalls, "Fail on test_fp_exists_active/every/1st call");
$this->mockApiCalls = [];
// Call again. The form processor should be called again as the default is 'every'
$this->mockRunProcessor($linkBAO, []);
$this->assertCount(2, $this->mockApiCalls, "Fail on test_fp_exists_active/every/2nd call");
$this->mockApiCalls = [];
}
/**
* Test FormProcessor is called for 'once' config.
*/
public function testFormProcessorOnce() {
$this->mockFormProcessorApi();
// Check that if configured to run once, it does.
$linkBAO = $this->createTestLink(['form_processor_name' => 'test_fp_exists_active', 'form_processor_run' => 'once']);
$this->mockRunProcessor($linkBAO, []);
$this->assertCount(2, $this->mockApiCalls, "Fail on test_fp_exists_active/once/1st call");
$this->mockApiCalls = [];
// Check that if configured to run once, the 2nd call does not run.
$this->mockRunProcessor($linkBAO, []);
$this->assertCount(0, $this->mockApiCalls, "Fail on test_fp_exists_active/once/2nd call");
$this->mockApiCalls = [];
}
/**
* Test params to FormProcessor
*/
public function testFormProcessorParams() {
$this->mockFormProcessorApi();
$linkBAO = $this->createTestLink(['form_processor_name' => 'test_fp_exists_active', 'form_processor_params' => json_encode(['input_b' => 'configured'])]);
$this->mockRunProcessor($linkBAO, []);
$this->assertCount(2, $this->mockApiCalls);
$last_api_call_params = end($this->mockApiCalls)['params'] ?? NULL;
$this->assertEquals(['input_b' => 'configured'], $last_api_call_params, "Fail call without params");
//
// We expect:
// - input_a which is not configured to be allowed through.
// - something which is not a valid input param for the form processor to be filtered out.
// - input_b which is configured to retain its configured value, not the value from the input.
$this->mockRunProcessor($linkBAO, ['input_a' => 'foo', 'something' => 'naughty', 'input_b' => 'illegal override']);
$this->assertCount(4, $this->mockApiCalls);
$last_api_call_params = end($this->mockApiCalls)['params'] ?? NULL;
$this->assertEquals(['input_a' => 'foo', 'input_b' => 'configured'], $last_api_call_params, "Expected only valid input_a to come through, and 'something' param to be filtered out");
}
protected function createTestLink($data) {
// Add default test data.
$data += [
......@@ -203,4 +278,53 @@ class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements Headles
return $linkBAO;
}
/**
* Fake API responses so we don't actually have to install form processor.
*/
protected function mockFormProcessorApi() {
// Mock api3
CRM_Actionlinks_BAO_ActionLink::$api3callback = function($entity, $action, $params) {
// Record we were called.
$this->mockApiCalls[] = ['entityaction' => "$entity.$action" , 'params' => $params];
if ($entity === 'FormProcessorInstance') {
if ($action === 'get') {
if ($params['name'] === 'test_fp_exists_active') {
// Return a stub instance
return [
'count' => 1,
'values' => [
[
'name' => 'test_fp',
'inputs' => [
['name' => 'input_a'],
['name' => 'input_b'],
]
]
]
];
}
elseif ($params['name'] === 'test_fp_exists_inactive') {
return ['count' => 0, 'values' => []];
}
elseif ($params['name'] === 'test_fp_does_not_exist') {
return ['count' => 0, 'values' => []];
}
}
}
elseif ($entity === 'FormProcessor') {
if ($action === 'test_fp_exists_active') {
// Fine.
return;
}
}
throw new LogicException("Unexpected Mock api call for $entity.$action with params: ". json_encode($params, JSON_PRETTY_PRINT));
};
}
protected function mockRunProcessor($linkBAO, $params) {
$linkBAO->logUseByContact($this->testContactID);
$ran = $linkBAO->runFormProcessor($this->testContactID, $params);
}
}
......@@ -68,6 +68,15 @@
<required>false</required>
</field>
<field>
<name>form_processor_run</name>
<type>varchar</type>
<length>5</length>
<default>"every"</default>
<required>true</required>
<comment>every or once - how often to run the form prcessor per contact</comment>
</field>
<field>
<name>form_processor_params</name>
<type>text</type>
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment