Skip to content

Commit

Permalink
Afform - support file uploads
Browse files Browse the repository at this point in the history
  • Loading branch information
colemanw committed Aug 16, 2021
1 parent bb5569e commit 2a004e8
Show file tree
Hide file tree
Showing 14 changed files with 470 additions and 44 deletions.
3 changes: 2 additions & 1 deletion CRM/Core/BAO/CustomValueTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ public static function create($customParams, $parentOperation = NULL) {

case 'File':
if (!$field['file_id']) {
throw new CRM_Core_Exception('Missing parameter file_id');
$value = 'null';
break;
}

// need to add/update civicrm_entity_file
Expand Down
3 changes: 0 additions & 3 deletions Civi/API/Subscriber/DynamicFKAuthorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,6 @@ public function onApiAuthorize(\Civi\API\Event\AuthorizeEvent $event) {
}

if (isset($apiRequest['params']['entity_table'])) {
if (!\CRM_Core_DAO_AllCoreTables::isCoreTable($apiRequest['params']['entity_table'])) {
throw new \API_Exception("Unrecognized target entity table {$apiRequest['params']['entity_table']}");
}
$this->authorizeDelegate(
$apiRequest['action'],
$apiRequest['params']['entity_table'],
Expand Down
3 changes: 3 additions & 0 deletions ext/afform/admin/ang/afGuiEditor/inputType/File.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="form-inline">
<input type="file" disabled>
</div>
19 changes: 16 additions & 3 deletions ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use Civi\Afform\FormDataModel;
use Civi\Api4\Action\Afform\Submit;
use Civi\Api4\Utils\CoreUtil;

/**
* Handle submission of an "<af-form>" entity (or set of entities in the case of `<af-repeat>`).
Expand Down Expand Up @@ -96,12 +97,24 @@ public function getSecureApi4() {
}

/**
* @param $index
* @param $entityId
* @param int $index
* @param int|string $entityId
* @return $this
*/
public function setEntityId($index, $entityId) {
$this->entityIds[$this->entityName][$index]['id'] = $entityId;
$idField = CoreUtil::getIdFieldName($this->entityName);
$this->entityIds[$this->entityName][$index][$idField] = $entityId;
return $this;
}

/**
* @param int $index
* @param string $joinEntity
* @param array $joinIds
* @return $this
*/
public function setJoinIds($index, $joinEntity, $joinIds) {
$this->entityIds[$this->entityName][$index]['joins'][$joinEntity] = $joinIds;
return $this;
}

Expand Down
5 changes: 3 additions & 2 deletions ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Civi\Afform\FormDataModel;
use Civi\Api4\Generic\Result;
use Civi\Api4\Utils\CoreUtil;

/**
* Shared functionality for form submission pre & post processing.
Expand Down Expand Up @@ -64,7 +65,7 @@ public function _run(Result $result) {
/**
* Load all entities
*/
private function loadEntities() {
protected function loadEntities() {
foreach ($this->_formDataModel->getEntities() as $entityName => $entity) {
$this->_entityIds[$entityName] = [];
if (!empty($entity['actions']['update'])) {
Expand Down Expand Up @@ -149,7 +150,7 @@ protected static function getJoinWhereClause($mainEntityName, $joinEntityName, $
if (self::getEntityField($joinEntityName, 'entity_id')) {
$params[] = ['entity_id', '=', $mainEntityId];
if (self::getEntityField($joinEntityName, 'entity_table')) {
$params[] = ['entity_table', '=', 'civicrm_' . \CRM_Core_DAO_AllCoreTables::convertEntityNameToLower($mainEntityName)];
$params[] = ['entity_table', '=', CoreUtil::getTableName($mainEntityName)];
}
}
else {
Expand Down
74 changes: 59 additions & 15 deletions ext/afform/core/Civi/Api4/Action/Afform/Submit.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Civi\Api4\Action\Afform;

use Civi\Afform\Event\AfformSubmitEvent;
use Civi\Api4\Utils\CoreUtil;

/**
* Class Submit
Expand Down Expand Up @@ -59,8 +60,12 @@ protected function processForm() {
\Civi::dispatcher()->dispatch('civi.afform.submit', $event);
}

// What should I return?
return [];
// Return ids and a token for uploading files
return [
[
'token' => $this->generatePostSubmitToken(),
],
];
}

/**
Expand Down Expand Up @@ -136,7 +141,7 @@ public static function processGenericEntity(AfformSubmitEvent $event) {
try {
$saved = $api4($event->getEntityType(), 'save', ['records' => [$record['fields']]])->first();
$event->setEntityId($index, $saved['id']);
self::saveJoins($event->getEntityType(), $saved['id'], $record['joins'] ?? []);
self::saveJoins($event, $index, $saved['id'], $record['joins'] ?? []);
}
catch (\API_Exception $e) {
// What to do here? Sometimes we should silently ignore errors, e.g. an optional entity
Expand All @@ -148,51 +153,69 @@ public static function processGenericEntity(AfformSubmitEvent $event) {
/**
* This saves joins (sub-entities) such as Email, Address, Phone, etc.
*
* @param $mainEntityName
* @param $entityId
* @param $joins
* @param \Civi\Afform\Event\AfformSubmitEvent $event
* @param int $index
* @param int|string $entityId
* @param array $joins
* @throws \API_Exception
* @throws \Civi\API\Exception\NotImplementedException
*/
protected static function saveJoins($mainEntityName, $entityId, $joins) {
protected static function saveJoins(AfformSubmitEvent $event, $index, $entityId, $joins) {
foreach ($joins as $joinEntityName => $join) {
$values = self::filterEmptyJoins($joinEntityName, $join);
// TODO: REPLACE works for creating or updating contacts, but different logic would be needed if
// the contact was being auto-updated via a dedupe rule; in that case we would not want to
// delete any existing records.
if ($values) {
civicrm_api4($joinEntityName, 'replace', [
$result = civicrm_api4($joinEntityName, 'replace', [
// Disable permission checks because the main entity has already been vetted
'checkPermissions' => FALSE,
'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
'where' => self::getJoinWhereClause($event->getEntityType(), $joinEntityName, $entityId),
'records' => $values,
]);
], ['id']);
$indexedResult = array_combine(array_keys($values), (array) $result);
$event->setJoinIds($index, $joinEntityName, $indexedResult);
}
// REPLACE doesn't work if there are no records, have to use DELETE
else {
try {
civicrm_api4($joinEntityName, 'delete', [
// Disable permission checks because the main entity has already been vetted
'checkPermissions' => FALSE,
'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
'where' => self::getJoinWhereClause($event->getEntityType(), $joinEntityName, $entityId),
]);
}
catch (\API_Exception $e) {
// No records to delete
}
$event->setJoinIds($index, $joinEntityName, []);
}
}
}

/**
* Filter out joins that have been left blank on the form
* Filter out join entities that have been left blank on the form
*
* @param $entity
* @param $join
* @return array
*/
private static function filterEmptyJoins($entity, $join) {
return array_filter($join, function($item) use($entity) {
$idField = CoreUtil::getIdFieldName($entity);
$fileFields = (array) civicrm_api4($entity, 'getFields', [
'checkPermissions' => FALSE,
'where' => [['fk_entity', '=', 'File']],
], ['name']);
// Files will be uploaded later, fill with empty values for now
// TODO: Somehow check if a file has actually been selected for upload
foreach ($join as &$item) {
if (empty($item[$idField]) && $fileFields) {
$item += array_fill_keys($fileFields, '');
}
}
return array_filter($join, function($item) use($entity, $idField, $fileFields) {
if (!empty($item[$idField]) || $fileFields) {
return TRUE;
}
switch ($entity) {
case 'Email':
return !empty($item['email']);
Expand All @@ -207,7 +230,7 @@ private static function filterEmptyJoins($entity, $join) {
return !empty($item['url']);

default:
\CRM_Utils_Array::remove($item, 'id', 'is_primary', 'location_type_id', 'entity_id', 'contact_id', 'entity_table');
\CRM_Utils_Array::remove($item, 'is_primary', 'location_type_id', 'entity_id', 'contact_id', 'entity_table');
return (bool) array_filter($item);
}
});
Expand Down Expand Up @@ -241,4 +264,25 @@ private function fillIdFields(array &$records, string $entityName): void {
}
}

/**
* Generates token returned from submit action
*
* @return string
* @throws \Civi\Crypto\Exception\CryptoException
*/
private function generatePostSubmitToken(): string {
// 1 hour should be more than sufficient to upload files
$expires = \CRM_Utils_Time::time() + (60 * 60);

/** @var \Civi\Crypto\CryptoJwt $jwt */
$jwt = \Civi::service('crypto.jwt');

return $jwt->encode([
'exp' => $expires,
// Note: Scope is not the same as "authx" scope. "Authx" tokens are user-login tokens. This one is a more limited access token.
'scope' => 'afformPostSubmit',
'civiAfformSubmission' => ['name' => $this->name, 'data' => $this->_entityIds],
]);
}

}
140 changes: 140 additions & 0 deletions ext/afform/core/Civi/Api4/Action/Afform/SubmitFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

namespace Civi\Api4\Action\Afform;

use Civi\API\Exception\UnauthorizedException;
use Civi\Api4\Utils\CoreUtil;

/**
* Special-purpose API for uploading files as part of a form submission.
*
* This API is meant to be called with a multipart POST ajax request which includes the uploaded file.
*
* @method $this setToken(string $token)
* @method $this setFieldName(string $fieldName)
* @method $this setEntityName(string $entityName)
* @method $this setJoinEntity(string $joinEntity)
* @method $this setEntityIndex(int $entityIndex)
* @method $this setJoinIndex(int $joinIndex)
* @package Civi\Api4\Action\Afform
*/
class SubmitFile extends AbstractProcessor {

/**
* Submission token
* @var string
* @required
*/
protected $token;

/**
* @var string
* @required
*/
protected $entityName;

/**
* @var string
* @required
*/
protected $fieldName;

/**
* @var string
*/
protected $joinEntity;

/**
* @var string|int
*/
protected $entityIndex;

/**
* @var string|int
*/
protected $joinIndex;

protected function processForm() {
if (empty($_FILES['file'])) {
throw new \API_Exception('File upload required');
}
$afformEntity = $this->_formDataModel->getEntity($this->entityName);
$apiEntity = $this->joinEntity ?: $afformEntity['type'];
$entityIndex = (int) $this->entityIndex;
$joinIndex = (int) $this->joinIndex;
if ($this->joinEntity) {
$entityId = $this->_entityIds[$this->entityName][$entityIndex]['joins'][$this->joinEntity][$joinIndex] ?? NULL;
}
else {
$entityId = $this->_entityIds[$this->entityName][$entityIndex]['id'] ?? NULL;
}

if (!$entityId) {
throw new \API_Exception('Entity not found');
}

$attachmentParams = [
'entity_id' => $entityId,
'mime_type' => $_FILES['file']['type'],
'name' => $_FILES['file']['name'],
'options' => [
'move-file' => $_FILES['file']['tmp_name'],
],
];

if (strpos($this->fieldName, '.')) {
$attachmentParams['field_name'] = $this->convertFieldNameToApi3($apiEntity, $this->fieldName);
}
else {
$attachmentParams['entity_table'] = CoreUtil::getTableName($apiEntity);
}

$file = civicrm_api3('Attachment', 'create', $attachmentParams);

// Update multi-record custom field with value
if (strpos($apiEntity, 'Custom_') === 0) {
civicrm_api4($apiEntity, 'update', [
'values' => [
'id' => $entityId,
$this->fieldName => $file['id'],
],
]);
}

return [];
}

/**
* Load entityIds from web token
*/
protected function loadEntities() {
/** @var \Civi\Crypto\CryptoJwt $jwt */
$jwt = \Civi::service('crypto.jwt');

// Double-decode is needed to convert PHP objects to arrays
$info = json_decode(json_encode($jwt->decode($this->token)), TRUE);

if ($info['civiAfformSubmission']['name'] !== $this->getName()) {
throw new UnauthorizedException('Name mismatch');
}

$this->_entityIds = $info['civiAfformSubmission']['data'];
}

/**
* @param string $apiEntity
* @param string $fieldName
* @return string
*/
private function convertFieldNameToApi3($apiEntity, $fieldName) {
if (strpos($fieldName, '.')) {
$fields = civicrm_api4($apiEntity, 'getFields', [
'checkPermissions' => FALSE,
'where' => [['name', '=', $fieldName]],
]);
return 'custom_' . $fields[0]['custom_field_id'];
}
return $fieldName;
}

}
9 changes: 9 additions & 0 deletions ext/afform/core/Civi/Api4/Afform.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ public static function submit($checkPermissions = TRUE) {
->setCheckPermissions($checkPermissions);
}

/**
* @param bool $checkPermissions
* @return Action\Afform\SubmitFile
*/
public static function submitFile($checkPermissions = TRUE) {
return (new Action\Afform\SubmitFile('Afform', __FUNCTION__))
->setCheckPermissions($checkPermissions);
}

/**
* @param bool $checkPermissions
* @return Generic\BasicBatchAction
Expand Down
Loading

0 comments on commit 2a004e8

Please sign in to comment.