Commit 87274153 authored by Rich's avatar Rich

Basic funcionality working, with tests

parent 1505cb60
......@@ -9,9 +9,16 @@ class CRM_Actionlinks_BAO_ActionLink extends CRM_Actionlinks_DAO_ActionLink {
* @param array $params key-value pairs
* @return CRM_Actionlinks_DAO_ActionLink|NULL
*
*/
public static function create($params) {
$className = 'CRM_Actionlinks_DAO_ActionLink';
$entityName = 'ActionLink';
// If we do not have a hash, set it now.
if (empty($params['hash'])) {
$params['hash'] = substr(sha1(uniqid((string) rand(), TRUE) . serialize($params)), 0, 10);
}
$hook = empty($params['id']) ? 'create' : 'edit';
CRM_Utils_Hook::pre($hook, $entityName, CRM_Utils_Array::value('id', $params), $params);
......@@ -21,6 +28,163 @@ class CRM_Actionlinks_BAO_ActionLink extends CRM_Actionlinks_DAO_ActionLink {
CRM_Utils_Hook::post($hook, $entityName, $instance->id, $instance);
return $instance;
} */
}
/**
* @param array $query
*
* @return array
*/
public static function handleRequest($query) {
// Parse link.
$link_id = (int) ($query['alid'] ?? 0);
if (!($link_id > 0)) {
// Die with 400 error
return ['status' => 400, 'redirect' => NULL];
}
$linkBAO = new static();
$linkBAO->id = $link_id;
if (!$linkBAO->find(TRUE)) {
// Die with 404 error
return ['status' => 404, 'redirect' => NULL];
}
// Found link.
// Prepare the denied response.
$denied_response = ['status' => 400, 'redirect' => $linkBAO->denied_url];
if (!$linkBAO->is_active) {
return $denied_response;
}
if ($linkBAO->use_limit && ((int) $linkBAO->use_count) >= ((int) $linkBAO->use_limit)) {
// Link has reached its limit.
return $denied_response;
}
if ($linkBAO->use_by && date('Y-m-d H:i:s') > $linkBAO->use_by) {
// Link has expired
return $denied_response;
}
// Now we need the contact.
$contact_id = (int) ($query['cid'] ?? 0);
if (!($contact_id) > 0) {
// Invalid link.
return $denied_response;
}
// Load the contact. Q. do we care if it is deleted?
$contactCount = \Civi\Api4\Contact::get()
->selectRowCount()
->addWhere('id', '=', $contact_id)
->setCheckPermissions(FALSE)
->execute()
->count();
if ($contactCount !== 1) {
// We don't know this contact (may have been deleted). Deny!
return $denied_response;
}
// Check checksum.
if (!$linkBAO->validChecksum($contact_id, $query['cs'] ?? '')) {
// Invalid checksum.
return $denied_response;
}
// Ok! Let's do this!
$linkBAO->use_count += 1;
$linkBAO->save();
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];
}
public function redirectToDeny() {
if ($this->denied_url) {
CRM_Utils_System::redirect($this->denied_url);
CRM_Utils_System::civiExit();
}
else {
// No url to redirect to.
CRM_Utils_System::civiExit(404);
}
}
/**
* Generate external links for given contact Ids.
*
* @param array $contactIDs
*
* @return array
* Keyed by contactID.
*/
public function generateContactLinks($contactIDs) {
if (!$this->id) {
throw new \Exception("generateContactLinks called before ActionLink was saved.");
}
if (!$this->hash) {
throw new \Exception("generateContactLinks called before ActionLink had a hash (probably not saved?).");
}
$base_url = \CRM_Utils_System::url('civicrm/actionlink', ['alid' => $this->id], TRUE, NULL, TRUE);
$result = [];
foreach ($contactIDs as $contact_id) {
$contact_id = (int) $contact_id;
if (!($contact_id > 0)) {
throw new \Exception("generateContactLinks called with invalid ContactID");
}
// Generate a hash that matches this Action Link, for this contact.
$contact_hash = $this->generateChecksum($contact_id);
$result[$contact_id] = "$base_url&cid=$contact_id&cs=$contact_hash";
}
return $result;
}
/**
* Generate Contact checksum
*/
public function generateChecksum($contact_id, $ts=NULL) {
return \CRM_Contact_BAO_Contact_Utils::generateChecksum($contact_id, $ts, 'inf', $this->hash);
}
/**
* Validate Contact checksum
*
* @param int $contact_id
* @param string $cs
*/
public function validChecksum($contactID, $cs) {
$parts = explode('_', $cs);
if (count($parts) !== 3) {
return FALSE;
}
$ts = (int) $parts[1];
$check = CRM_Contact_BAO_Contact_Utils::generateChecksum($contactID, $ts, 'inf', $this->hash);
if (!hash_equals($check, (string) $cs)) {
return FALSE;
}
// no life limit for checksum
if ($parts[2] === 'inf') {
return TRUE;
}
// checksum matches so now check timestamp
$now = time();
$lifespan = (int) $parts[2];
return ($ts + ($lifespan * 60 * 60) >= $now);
}
}
......@@ -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:3954e8566f98ee0ff680eebfef7fb50f)
* (GenCodeChecksum:85b2afc9f8813c0f47f6a05e665d8686)
*/
/**
......@@ -110,6 +110,13 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
*/
public $use_by;
/**
* Hash unique to this link
*
* @var string
*/
public $hash;
/**
* Class constructor.
*/
......@@ -273,6 +280,19 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
],
'hash' => [
'name' => 'hash',
'type' => CRM_Utils_Type::T_STRING,
'title' => CRM_Actionlinks_ExtensionUtil::ts('Hash'),
'description' => CRM_Actionlinks_ExtensionUtil::ts('Hash unique to this link'),
'maxlength' => 10,
'size' => CRM_Utils_Type::TWELVE,
'where' => 'civicrm_action_link.hash',
'table_name' => 'civicrm_action_link',
'entity' => 'ActionLink',
'bao' => 'CRM_Actionlinks_DAO_ActionLink',
'localizable' => 0,
],
];
CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']);
}
......@@ -342,7 +362,17 @@ class CRM_Actionlinks_DAO_ActionLink extends CRM_Core_DAO {
* @return array
*/
public static function indices($localize = TRUE) {
$indices = [];
$indices = [
'index_hash' => [
'name' => 'index_hash',
'field' => [
0 => 'hash',
],
'localizable' => FALSE,
'unique' => TRUE,
'sig' => 'civicrm_action_link::1::hash',
],
];
return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices;
}
......
<?php
use CRM_Actionlinks_ExtensionUtil as E;
class CRM_Actionlinks_Page_ActionLink extends CRM_Core_Page {
public function run() {
$result = CRM_Actionlinks_BAO_ActionLink::handleRequest($_GET ?? []);
if (!$result['redirect']) {
$this->civiExit($result['status']);
}
CRM_Utils_System::redirect($result['redirect']);
}
/**
* Exit with given HTTP response.
*
* This is mostly a copy of CRM_Utils_System::civiExit() except that
* we emit the given http response code instead of 500.
*/
public function civiExit($http_response_code) {
http_response_code($http_response_code);
// move things to CiviCRM cache as needed
CRM_Core_Session::storeSessionObjects();
if (Civi\Core\Container::isContainerBooted()) {
Civi::dispatcher()->dispatch('civi.core.exit');
}
$userSystem = CRM_Core_Config::singleton()->userSystem;
if (is_callable([$userSystem, 'onCiviExit'])) {
$userSystem->onCiviExit();
}
exit($http_response_code);
}
}
<?php
namespace Civi\Api4\Action\ActionLink;
use Civi\Api4\Generic\Result;
class GetLink extends \Civi\Api4\Generic\AbstractAction {
/**
* The action link ID
*
* @var int
*/
protected $actionLinkID;
/**
* Array of contact IDs to generate links for
*
* @var array
*/
protected $contactIDs = [];
public function _run(Result $result) {
if (!$this->actionLinkID) {
throw new \API_Exception("Required actionLinkID parameter missing");
}
// First, fetch the Link.
$linkBAO = new \CRM_Actionlinks_BAO_ActionLink();
$linkBAO->id = $this->actionLinkID;
if (!$linkBAO->find(TRUE)) {
throw new \API_Exception("Given actionLinkID not found.");
}
// Generate an absolute, front end link.
$links = $linkBAO->generateContactLinks($this->contactIDs);
foreach ($links as $contactID => $link) {
$result[] = ['contactID' => $contactID, 'link' => $link];
}
return $result;
}
/**
* Declare ad-hoc field list for this action.
*
* Some actions return entirely different data to the entity's "regular" fields.
*
* This is a convenient alternative to adding special logic to our GetFields function to handle this action.
*
* @return array
*/
public static function fields() {
return [
['name' => 'actionLinkID', 'data_type' => 'int', 'description' => 'Action Link ID'],
['name' => 'contactIDs', 'data_type' => 'int', 'description' => 'Contact IDs to generate the links for'],
];
}
}
<?php
namespace Civi\Api4;
/**
* ActionLink entity.
*
......@@ -9,5 +8,4 @@ namespace Civi\Api4;
* @package Civi\Api4
*/
class ActionLink extends Generic\DAOEntity {
}
......@@ -65,7 +65,18 @@
};
$scope.deleteRow = function deleteRow(row) {
if (confirm("Delete " + row.name + "? This will break any links to this item that have been published.")) {
console.warn("deleteRow @todo");
return crmStatus(
// Status messages. For defaults, just use "{}"
{start: ts('Deleting...'), success: ts('Deleted')},
crmApi4('ActionLink', 'delete', {where: [["id", '=', row.id]]})
.then(r => {
console.log("delete result", r);
var i = $scope.actionLinks.map(l => l.id).indexOf(row.id);
$scope.actionLinks.splice(i, 1);
$scope.cancelEdit();
}
));
}
};
$scope.cancelEdit = function cancelEdit() {
......@@ -74,7 +85,6 @@
};
$scope.saveRow = function saveRow() {
console.warn("saveRow @todo");
var apiParams = Object.assign({}, $scope.editData);
if (!apiParams.id) {
delete apiParams.id;
......@@ -91,11 +101,11 @@
console.log("save result", r);
if (apiParams.id) {
var i = $scope.actionLinks.map(l => l.id).indexOf(apiParams.id);
Object.assign($scope.actionLinks[i], r[0].the_link);
Object.assign($scope.actionLinks[i], r[0].the_link[0]);
}
else {
// We just added a new thing.
$scope.actionLinks.push(Object.assign({}, r[0].the_link));
$scope.actionLinks.push(Object.assign({}, r[0].the_link[0]));
// Re-sort.
$scope.actionLinks.sort( (a, b) => {
if (a.is_active < b.is_active) {
......@@ -113,8 +123,7 @@
return 0;
});
}
$scope.view = 'list';
resetEditData();
$scope.cancelEdit();
}));
};
......
<?php
use CRM_Actionlinks_ExtensionUtil as E;
/**
* ActionLink.create API specification (optional).
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_action_link_create_spec(&$spec) {
// $spec['some_parameter']['api.required'] = 1;
}
/**
* ActionLink.create API.
*
* @param array $params
*
* @return array
* API result descriptor
*
* @throws API_Exception
*/
function civicrm_api3_action_link_create($params) {
return _civicrm_api3_basic_create(_civicrm_api3_get_BAO(__FUNCTION__), $params, ActionLink);
}
/**
* ActionLink.delete API.
*
* @param array $params
*
* @return array
* API result descriptor
*
* @throws API_Exception
*/
function civicrm_api3_action_link_delete($params) {
return _civicrm_api3_basic_delete(_civicrm_api3_get_BAO(__FUNCTION__), $params);
}
/**
* ActionLink.get API.
*
* @param array $params
*
* @return array
* API result descriptor
*
* @throws API_Exception
*/
function civicrm_api3_action_link_get($params) {
return _civicrm_api3_basic_get(_civicrm_api3_get_BAO(__FUNCTION__), $params, FALSE, ActionLink);
}
......@@ -22,6 +22,9 @@
<ver>5.24</ver>
</compatibility>
<comments></comments>
<classloader>
<psr4 prefix="Civi\" path="Civi" />
</classloader>
<civix>
<namespace>CRM/Actionlinks</namespace>
</civix>
......
<?xml version="1.0"?>
<phpunit backupGlobals="false" backupStaticAttributes="false" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" syntaxCheck="false" bootstrap="tests/phpunit/bootstrap.php">
<testsuites>
<testsuite name="My Test Suite">
<directory>./tests/phpunit</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">./</directory>
</whitelist>
</filter>
<listeners>
<listener class="Civi\Test\CiviTestListener">
<arguments/>
</listener>
</listeners>
</phpunit>
-- +--------------------------------------------------------------------+
-- | Copyright CiviCRM LLC. All rights reserved. |
-- | |
-- | This work is published under the GNU AGPLv3 license with some |
-- | permitted exceptions and without any warranty. For full license |
-- | and copyright information, see https://civicrm.org/licensing |
-- +--------------------------------------------------------------------+
--
-- Generated from schema.tpl
-- DO NOT EDIT. Generated by CRM_Core_CodeGen
--
-- +--------------------------------------------------------------------+
-- | Copyright CiviCRM LLC. All rights reserved. |
-- | |
-- | This work is published under the GNU AGPLv3 license with some |
-- | permitted exceptions and without any warranty. For full license |
-- | and copyright information, see https://civicrm.org/licensing |
-- +--------------------------------------------------------------------+
--
-- Generated from drop.tpl
-- DO NOT EDIT. Generated by CRM_Core_CodeGen
--
-- /*******************************************************
-- *
-- * Clean up the exisiting tables
-- *
-- *******************************************************/
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `civicrm_action_link`;
SET FOREIGN_KEY_CHECKS=1;
-- /*******************************************************
-- *
-- * Create new tables
-- *
-- *******************************************************/
-- /*******************************************************
-- *
-- * civicrm_action_link
-- *
-- * Holds action links from the Action Links Extension
-- *
-- *******************************************************/
CREATE TABLE `civicrm_action_link` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ActionLink ID',
`name` varchar(255) COMMENT 'Administrative name',
`description` text NULL COMMENT 'Description',
`is_active` tinyint DEFAULT 0 COMMENT 'Whether this link is active.',
`allowed_url` varchar(255) COMMENT 'The URL you would normally redirect to',
`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_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',
`use_by` datetime NULL COMMENT 'Access denied after this',
`hash` varchar(10) COMMENT 'Hash unique to this link'
,
PRIMARY KEY (`id`)
, UNIQUE INDEX `index_hash`(
hash
)
) ;
\ No newline at end of file
-- +--------------------------------------------------------------------+
-- | Copyright CiviCRM LLC. All rights reserved. |
-- | |
-- | This work is published under the GNU AGPLv3 license with some |
-- | permitted exceptions and without any warranty. For full license |
-- | and copyright information, see https://civicrm.org/licensing |
-- +--------------------------------------------------------------------+
--
-- Generated from drop.tpl
-- DO NOT EDIT. Generated by CRM_Core_CodeGen
--
-- /*******************************************************
-- *
-- * Clean up the exisiting tables
-- *
-- *******************************************************/
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `civicrm_action_link`;
SET FOREIGN_KEY_CHECKS=1;
\ No newline at end of file
<h3>This new page is generated by CRM/Actionlinks/Page/ActionLink.php</h3>
{* Example: Display a variable directly *}
<p>The current time is {$currentTime}</p>
{* Example: Display a translated string -- which happens to include a variable *}
<p>{ts 1=$currentTime}(In your native language) The current time is %1.{/ts}</p>
<?php
use CRM_Actionlinks_ExtensionUtil as E;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* FIXME - Add test description.
*
* Tips:
* - With HookInterface, you may implement CiviCRM hooks directly in the test class.
* Simply create corresponding functions (e.g. "hook_civicrm_post(...)" or similar).
* - With TransactionalInterface, any data changes made by setUp() or test****() functions will
* rollback automatically -- as long as you don't manipulate schema or truncate tables.
* If this test needs to manipulate schema or truncate tables, then either:
* a. Do all that using setupHeadless() and Civi\Test.
* b. Disable TransactionalInterface, and handle all setup/teardown yourself.
*
* @group headless
*/
class CRM_ActionLinksTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
/** @var int */
public $testContactID;
public function setUpHeadless() {
// Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
// See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
return \Civi\Test::headless()
->installMe(__DIR__)
->apply();
}
public function setUp() {
parent::setUp();
// Create a test contact.
$this->testContactID = \Civi\Api4\Contact::create()
->addValue('contact_type', 'Individual')
->addValue('display_name', 'Wilma')
->setCheckPermissions(FALSE)
->execute()[0]['id'];
}
public function tearDown() {
parent::tearDown();
}
/**
* When we create a link, it must have a hash added.
*/
public function testHashIsGenerated() {
$results = \Civi\Api4\ActionLink::create()
->addValue('name', 'test')
->execute();
$this->assertArrayHasKey(0, $results);
$this->assertArrayHasKey('hash', $results[0]);
$this->assertInternalType('string', $results[0]['hash']);
$this->assertRegExp('/^[a-f0-9]{10}$/', $results[0]['hash']);
}
/**
* Basic success test.
*/
public function testValidLink() {
$linkBAO = $this->createReturnBAO([]);
$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);
}
/**
* Test that disabled links are not allowed.
*/
public function testInactiveLink() {
$linkBAO = $this->createReturnBAO(['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);
}