Api4 - Enable file attachments to be displayed in SearchKit
authorcolemanw <coleman@civicrm.org>
Thu, 7 Sep 2023 17:32:04 +0000 (13:32 -0400)
committercolemanw <coleman@civicrm.org>
Tue, 12 Sep 2023 01:52:46 +0000 (21:52 -0400)
12 files changed:
CRM/Core/BAO/File.php
CRM/Core/DAO/EntityFile.php
CRM/Upgrade/Incremental/php/FiveSixtySeven.php
CRM/Upgrade/Incremental/sql/5.67.alpha1.mysql.tpl
CRM/Utils/File.php
Civi/Api4/EntityFile.php [new file with mode: 0644]
Civi/Api4/File.php
Civi/Api4/Query/Api4Query.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Service/Spec/Provider/FileGetSpecProvider.php [new file with mode: 0644]
api/v3/Attachment.php
xml/schema/Core/EntityFile.xml

index b1672b4aa090d88a867b616ccebceb67a3d130ea..3db9618489c74a8e64610f008fbef8c88caaa98b 100644 (file)
@@ -834,4 +834,27 @@ HEREDOC;
     return FALSE;
   }
 
+  /**
+   * FIXME: Incomplete pseudoconstant for EntityFile.entity_table
+   *
+   * The `EntityFile` table serves 2 purposes:
+   * 1. As a many-to-many bridge table for entities that support multiple attachments
+   * 2. As a redundant copy of the value of custom fields of type File
+   *
+   * The 2nd use isn't really a bridge entity, and doesn't even make much sense
+   * (what purpose does it serve other than as a dummy value to use in file download links).
+   * Including the 2nd in this function would blow up the possible values for `entity_table`
+   * and make ACL clauses quite slow. So until someone comes up with a better idea,
+   * this only returns values relevant to the 1st.
+   *
+   * @return array
+   */
+  public static function getEntityTables(): array {
+    return [
+      'civicrm_activity' => ts('Activity'),
+      'civicrm_case' => ts('Case'),
+      'civicrm_note' => ts('Note'),
+    ];
+  }
+
 }
index 21383d0788bdf347939700042cc6f2051f9255ae..325a58ffc8ab554b2f79e165ef0ab4c8ba585b8f 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Core/EntityFile.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:abc8d2d096560e9cf5e96f385bb6c4a6)
+ * (GenCodeChecksum:21c0d34d03ae7f8aad9706aab1793656)
  */
 
 /**
@@ -42,7 +42,7 @@ class CRM_Core_DAO_EntityFile extends CRM_Core_DAO {
   /**
    * physical tablename for entity being joined to file, e.g. civicrm_contact
    *
-   * @var string|null
+   * @var string
    *   (SQL type: varchar(64))
    *   Note that values will be retrieved from the database as a string.
    */
@@ -136,6 +136,7 @@ class CRM_Core_DAO_EntityFile extends CRM_Core_DAO {
           'type' => CRM_Utils_Type::T_STRING,
           'title' => ts('Entity Table'),
           'description' => ts('physical tablename for entity being joined to file, e.g. civicrm_contact'),
+          'required' => TRUE,
           'maxlength' => 64,
           'size' => CRM_Utils_Type::BIG,
           'usage' => [
@@ -149,6 +150,9 @@ class CRM_Core_DAO_EntityFile extends CRM_Core_DAO {
           'entity' => 'EntityFile',
           'bao' => 'CRM_Core_DAO_EntityFile',
           'localizable' => 0,
+          'pseudoconstant' => [
+            'callback' => 'CRM_Core_BAO_File::getEntityTables',
+          ],
           'add' => '1.5',
         ],
         'entity_id' => [
index 17bdac03162a5124281bef26dbc79ead8d6797cb..71a2ae32c77d5dee596b9dd249702fd49420e60d 100644 (file)
@@ -29,6 +29,7 @@ class CRM_Upgrade_Incremental_php_FiveSixtySeven extends CRM_Upgrade_Incremental
    */
   public function upgrade_5_67_alpha1($rev): void {
     $this->addTask(ts('Upgrade DB to %1: SQL', [1 => $rev]), 'runSql', $rev);
+    $this->addTask('Make EntityFile.entity_table required', 'alterColumn', 'civicrm_entity_file', 'entity_table', "varchar(64) NOT NULL COMMENT 'physical tablename for entity being joined to file, e.g. civicrm_contact'");
     $this->addExtensionTask('Enable Authx extension', ['authx'], 1101);
     $this->addExtensionTask('Enable Afform extension', ['org.civicrm.afform'], 1102);
   }
index daf0e19721867dbf93eef784665534a3f3c97297..8780d46248be2e302701c8f40fcd8fb152b65394 100644 (file)
@@ -1 +1,4 @@
 {* file to handle db changes in 5.67.alpha1 during upgrade *}
+
+{* NULL values would be nonsensical and useless - no reason to keep them *}
+DELETE FROM civicrm_entity_file WHERE entity_table IS NULL;
index e030be409b49e969bb7f154d67e83994ceec8504..833204adbf58f0393213586c793faf63b5635213 100644 (file)
  */
 class CRM_Utils_File {
 
+  /**
+   * Used to remove md5 hash that was injected into uploaded file names.
+   */
+  const HASH_REMOVAL_PATTERN = '/_[a-f0-9]{32}\./';
+
   /**
    * Given a file name, determine if the file contents make it an ascii file
    *
@@ -405,7 +410,7 @@ class CRM_Utils_File {
    */
   public static function cleanFileName($name) {
     // replace the last 33 character before the '.' with null
-    $name = preg_replace('/(_[\w]{32})\./', '.', $name);
+    $name = preg_replace(self::HASH_REMOVAL_PATTERN, '.', $name);
     return $name;
   }
 
diff --git a/Civi/Api4/EntityFile.php b/Civi/Api4/EntityFile.php
new file mode 100644 (file)
index 0000000..bb05421
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | 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       |
+ +--------------------------------------------------------------------+
+ */
+namespace Civi\Api4;
+
+/**
+ * EntityFile Bridge.
+ *
+ * @searchable bridge
+ * @since 5.67
+ * @package Civi\Api4
+ */
+class EntityFile extends Generic\DAOEntity {
+  use Generic\Traits\EntityBridge;
+
+}
index 7d635e658846ded68af956cc57d84c146238f73c..43ee2b07fe5c764749162a58cdcb3f2615542914 100644 (file)
@@ -13,7 +13,7 @@ namespace Civi\Api4;
 /**
  * File entity.
  *
- * @searchable none
+ * @searchable secondary
  * @since 5.41
  * @package Civi\Api4
  */
index 15f7050cf3eabfacacee9f524f28264a0253ef6f..a40cfd4a67e790417bf76ca599c75b33f4812a9d 100644 (file)
@@ -147,6 +147,7 @@ abstract class Api4Query {
     }
     $this->apiFieldSpec[$path] = $field + [
       'name' => $path,
+      'path' => $path,
       'type' => 'Extra',
       'entity' => NULL,
       'implicit_join' => NULL,
index 95de27e36e24e5150867388eaed961bf0471ae81..80d3ad592005bb8e674a51e45e539a45fb6f41f4 100644 (file)
@@ -352,8 +352,9 @@ class Api4SelectQuery extends Api4Query {
    * @param bool $strict
    *   In strict mode, this will throw an exception if the field doesn't exist
    *
-   * @return array|null
+   * @return array|bool|null
    * @throws \CRM_Core_Exception
+   * @throws UnauthorizedException
    */
   public function getField($expr, $strict = FALSE) {
     // If the expression contains a pseudoconstant filter like activity_type_id:label,
@@ -380,6 +381,11 @@ class Api4SelectQuery extends Api4Query {
     return $field;
   }
 
+  public function getFieldSibling(array $field, string $siblingFieldName) {
+    $prefix = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . ($field['implicit_join'] ? $field['implicit_join'] . '.' : '');
+    return $this->getField($prefix . $siblingFieldName);
+  }
+
   /**
    * Check the "gatekeeper" permissions for performing "get" on a given entity.
    *
diff --git a/Civi/Api4/Service/Spec/Provider/FileGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/FileGetSpecProvider.php
new file mode 100644 (file)
index 0000000..517eb61
--- /dev/null
@@ -0,0 +1,139 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | 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       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+/**
+ * @service
+ * @internal
+ */
+class FileGetSpecProvider extends \Civi\Core\Service\AutoService implements Generic\SpecProviderInterface {
+
+  public function modifySpec(RequestSpec $spec): void {
+    $field = new FieldSpec('file_name', $spec->getEntity(), 'String');
+    $field->setLabel(ts('Filename'))
+      ->setTitle(ts('Filename'))
+      ->setColumnName('uri')
+      ->setDescription(ts('Name of uploaded file'))
+      ->setType('Extra')
+      ->addOutputFormatter([__CLASS__, 'formatFileName']);
+    // Uncomment this line and delete the function `formatFileName` when bumping min mysql version
+    // ->setSqlRenderer([__CLASS__, 'renderFileName']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('url', $spec->getEntity(), 'String');
+    $field->setLabel(ts('Download Url'))
+      ->setTitle(ts('File Url'))
+      ->setColumnName('id')
+      ->setDescription(ts('Url at which this file can be downloaded'))
+      ->setType('Extra')
+      ->setSqlRenderer([__CLASS__, 'renderFileUrl'])
+      ->addOutputFormatter([__CLASS__, 'formatFileUrl']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('icon', $spec->getEntity(), 'String');
+    $field->setLabel(ts('Icon'))
+      ->setTitle(ts('Filetype Icon'))
+      ->setColumnName('mime_type')
+      ->setDescription(ts('Icon associated with this filetype'))
+      ->setType('Extra')
+      ->addOutputFormatter([__CLASS__, 'formatFileIcon']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('is_image', $spec->getEntity(), 'Boolean');
+    $field->setLabel(ts('Is Image'))
+      ->setTitle(ts('File is Image'))
+      ->setColumnName('mime_type')
+      ->setDescription(ts('Is this a recognized image type file'))
+      ->setType('Extra')
+      ->setSqlRenderer([__CLASS__, 'renderFileIsImage']);
+    $spec->addFieldSpec($field);
+  }
+
+  public static function formatFileName(&$uri) {
+    if (version_compare(\CRM_Upgrade_Incremental_General::MIN_INSTALL_MYSQL_VER, 8, '>=')) {
+      // Warning to make the unit test fail after we bump min sql version to one that supports REGEX_REPLACE
+      \CRM_Core_Error::deprecatedWarning('Update FileGetSpecProvider to use renderFileName instead of formatFileName.');
+    }
+    if ($uri && is_string($uri)) {
+      $uri = \CRM_Utils_File::cleanFileName($uri);
+    }
+  }
+
+  /**
+   * Unused until we bump min sql version to 8
+   * @see formatFileName
+   */
+  public static function renderFileName(array $field): string {
+    // MySql doesn't use preg delimiters
+    $pattern = str_replace('/', "'", \CRM_Utils_File::HASH_REMOVAL_PATTERN);
+    return "REGEX_REPLACE({$field['sql_name']}, $pattern, '.')";
+  }
+
+  public static function renderFileUrl(array $idField, Api4SelectQuery $query): string {
+    // Getting a link to the file requires the `entity_id` from the `civicrm_entity_file` table
+    // If the file was implicitly joined, the joined-from-entity has the id we want
+    if ($idField['implicit_join']) {
+      $joinField = $query->getField($idField['implicit_join']);
+      $entityIdField = $query->getFieldSibling($joinField, 'id');
+    }
+    // If it's explicitly joined FROM another entity, get the id of the parent
+    elseif ($idField['explicit_join']) {
+      $parent = $query->getJoinParent($idField['explicit_join']);
+      $joinPrefix = $parent ? "$parent." : '';
+      $entityIdField = $query->getField($joinPrefix . 'id');
+    }
+    // If it's explicitly joined TO another entity, use the id of the other
+    if (!isset($entityIdField)) {
+      foreach ($query->getExplicitJoins() as $join) {
+        if ($join['bridge'] === 'EntityFile') {
+          $entityIdField = $query->getField($join['alias'] . '.id');
+        }
+      }
+    }
+    if (isset($entityIdField)) {
+      return "CONCAT('civicrm/file?reset=1&id=', $idField[sql_name], '&eid=', $entityIdField[sql_name])";
+    }
+    // Guess we couldn't find an `entity_id` in the query. This function could probably be improved.
+    return "NULL";
+  }
+
+  public static function renderFileIsImage(array $mimeTypeField, Api4SelectQuery $query): string {
+    $uriField = $query->getFieldSibling($mimeTypeField, 'uri');
+    return "IF(($mimeTypeField[sql_name] LIKE 'image/%') AND ($uriField[sql_name] NOT LIKE '%.unknown'), 1, 0)";
+  }
+
+  public static function formatFileUrl(&$value) {
+    $args = [];
+    // renderFileUrl() will have formatted the output in-sql to `civicrm/file?reset=1&id=id&eid=entity_id`
+    if (is_string($value) && str_contains($value, '?')) {
+      parse_str(explode('?', $value)[1], $args);
+      $value .= '&fcs=' . \CRM_Core_BAO_File::generateFileHash($args['eid'], $args['id']);
+      $value = (string) \Civi::url('frontend://' . $value, 'a');
+    }
+  }
+
+  public static function formatFileIcon(&$value) {
+    if (is_string($value)) {
+      $value = \CRM_Utils_File::getIconFromMimeType($value);
+    }
+  }
+
+  public function applies($entity, $action): bool {
+    return $entity === 'File' && $action === 'get';
+  }
+
+}
index aa408d1bf5d58b745d90953dbaed066b8ab646e1..5e16486707cbe7640bd24f3f48c0737f0c04380e 100644 (file)
@@ -205,6 +205,8 @@ function _civicrm_api3_attachment_delete_spec(&$spec) {
   unset($spec['id']['api.required']);
   $entityFileFields = CRM_Core_DAO_EntityFile::fields();
   $spec['entity_table'] = $entityFileFields['entity_table'];
+  // Historically this field had no pseudoconstant and APIv3 can't handle it
+  $spec['entity_table']['pseudoconstant'] = NULL;
   $spec['entity_table']['title'] = CRM_Utils_Array::value('title', $spec['entity_table'], 'Entity Table') . ' (write-once)';
   $spec['entity_id'] = $entityFileFields['entity_id'];
   $spec['entity_id']['title'] = CRM_Utils_Array::value('title', $spec['entity_id'], 'Entity ID') . ' (write-once)';
@@ -464,6 +466,8 @@ function _civicrm_api3_attachment_getfields() {
   $spec['description'] = $fileFields['description'];
   $spec['upload_date'] = $fileFields['upload_date'];
   $spec['entity_table'] = $entityFileFields['entity_table'];
+  // Historically this field had no pseudoconstant and APIv3 can't handle it
+  $spec['entity_table']['pseudoconstant'] = NULL;
   // Would be hard to securely handle changes.
   $spec['entity_table']['title'] = CRM_Utils_Array::value('title', $spec['entity_table'], 'Entity Table') . ' (write-once)';
   $spec['entity_id'] = $entityFileFields['entity_id'];
index e2dc7bbd6b5c31072a2730b41a5d47a2d76148db..20d75001d15e52a66282bb58e6fff67f0e7126d7 100644 (file)
     <name>entity_table</name>
     <title>Entity Table</title>
     <type>varchar</type>
+    <required>true</required>
     <length>64</length>
     <comment>physical tablename for entity being joined to file, e.g. civicrm_contact</comment>
+    <pseudoconstant>
+      <callback>CRM_Core_BAO_File::getEntityTables</callback>
+    </pseudoconstant>
     <add>1.5</add>
   </field>
   <field>