Merge pull request #17398 from eileenmcnaughton/mem_recur
[civicrm-core.git] / api / v3 / Attachment.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * "Attachment" is a pseudo-entity which represents a record in civicrm_file
14 * combined with a record in civicrm_entity_file as well as the underlying
15 * file content.
16 * For core fields use "entity_table", for custom fields use "field_name"
17 *
18 * ```
19 * // Create an attachment for a core field
20 * $result = civicrm_api3('Attachment', 'create', array(
21 * 'entity_table' => 'civicrm_activity',
22 * 'entity_id' => 123,
23 * 'name' => 'README.txt',
24 * 'mime_type' => 'text/plain',
25 * 'content' => 'Please to read the README',
26 * ));
27 * $attachment = $result['values'][$result['id']];
28 * echo sprintf("<a href='%s'>View %s</a>", $attachment['url'], $attachment['name']);
29 * ```
30 *
31 * ```
32 * // Create an attachment for a custom file field
33 * $result = civicrm_api3('Attachment', 'create', array(
34 * 'field_name' => 'custom_6',
35 * 'entity_id' => 123,
36 * 'name' => 'README.txt',
37 * 'mime_type' => 'text/plain',
38 * 'content' => 'Please to read the README',
39 * ));
40 * $attachment = $result['values'][$result['id']];
41 * echo sprintf("<a href='%s'>View %s</a>", $attachment['url'], $attachment['name']);
42 * ```
43 *
44 * ```
45 * // Move an existing file and save as an attachment
46 * $result = civicrm_api3('Attachment', 'create', array(
47 * 'entity_table' => 'civicrm_activity',
48 * 'entity_id' => 123,
49 * 'name' => 'README.txt',
50 * 'mime_type' => 'text/plain',
51 * 'options' => array(
52 * 'move-file' => '/tmp/upload1a2b3c4d',
53 * ),
54 * ));
55 * $attachment = $result['values'][$result['id']];
56 * echo sprintf("<a href='%s'>View %s</a>", $attachment['url'], $attachment['name']);
57 * ```
58 *
59 * Notes:
60 * - File content is not returned by default. One must specify 'return => content'.
61 * - Features which deal with local file system (e.g. passing "options.move-file"
62 * or returning a "path") are only valid when executed as a local API (ie
63 * "check_permissions"==false)
64 *
65 * @package CiviCRM_APIv3
66 */
67
68 /**
69 * Adjust metadata for "create" action.
70 *
71 * @param array $spec
72 * List of fields.
73 */
74 function _civicrm_api3_attachment_create_spec(&$spec) {
75 $spec = array_merge($spec, _civicrm_api3_attachment_getfields());
76 $spec['name']['api.required'] = 1;
77 $spec['mime_type']['api.required'] = 1;
78 $spec['entity_id']['api.required'] = 1;
79 $spec['upload_date']['api.default'] = 'now';
80 }
81
82 /**
83 * Create an Attachment.
84 *
85 * @param array $params
86 *
87 * @return array
88 * @throws API_Exception validation errors
89 * @see Civi\API\Subscriber\DynamicFKAuthorization
90 */
91 function civicrm_api3_attachment_create($params) {
92 if (empty($params['id'])) {
93 // When creating we need either entity_table or field_name.
94 civicrm_api3_verify_one_mandatory($params, NULL, ['entity_table', 'field_name']);
95 }
96
97 $config = CRM_Core_Config::singleton();
98 list($id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent) = _civicrm_api3_attachment_parse_params($params);
99
100 $fileDao = new CRM_Core_BAO_File();
101 $entityFileDao = new CRM_Core_DAO_EntityFile();
102
103 if ($id) {
104 $file['id'] = $fileDao->id = $id;
105
106 if (!$fileDao->find(TRUE)) {
107 throw new API_Exception("Invalid ID");
108 }
109
110 $entityFileDao->file_id = $id;
111 if (!$entityFileDao->find(TRUE)) {
112 throw new API_Exception("Cannot modify orphaned file");
113 }
114 }
115
116 if (!$id && !is_string($content) && !is_string($moveFile)) {
117 throw new API_Exception("Mandatory key(s) missing from params array: 'id' or 'content' or 'options.move-file'");
118 }
119 if (!$isTrusted && $moveFile) {
120 throw new API_Exception("options.move-file is only supported on secure calls");
121 }
122 if (is_string($content) && is_string($moveFile)) {
123 throw new API_Exception("'content' and 'options.move-file' are mutually exclusive");
124 }
125 if ($id && !$isTrusted && isset($file['upload_date']) && $file['upload_date'] != CRM_Utils_Date::isoToMysql($fileDao->upload_date)) {
126 throw new API_Exception("Cannot modify upload_date" . var_export([$file['upload_date'], $fileDao->upload_date, CRM_Utils_Date::isoToMysql($fileDao->upload_date)], TRUE));
127 }
128 if ($id && $name && $name != CRM_Utils_File::cleanFileName($fileDao->uri)) {
129 throw new API_Exception("Cannot modify name");
130 }
131
132 if (!$id) {
133 $file['uri'] = CRM_Utils_File::makeFileName($name);
134 }
135 $fileDao = CRM_Core_BAO_File::create($file);
136 $fileDao->find(TRUE);
137
138 $entityFileDao->copyValues($entityFile);
139 $entityFileDao->file_id = $fileDao->id;
140 $entityFileDao->save();
141
142 $path = $config->customFileUploadDir . DIRECTORY_SEPARATOR . $fileDao->uri;
143 if (is_string($content)) {
144 file_put_contents($path, $content);
145 }
146 elseif (is_string($moveFile)) {
147 // CRM-17432 Do not use rename() since it will break file permissions.
148 // Also avoid move_uploaded_file() because the API can use options.move-file.
149 if (!copy($moveFile, $path)) {
150 throw new API_Exception("Cannot copy uploaded file $moveFile to $path");
151 }
152 unlink($moveFile);
153 }
154
155 // Save custom field to entity
156 if (!$id && empty($params['entity_table']) && isset($params['field_name'])) {
157 civicrm_api3('custom_value', 'create', [
158 'entity_id' => $params['entity_id'],
159 $params['field_name'] => $fileDao->id,
160 ]);
161 }
162
163 $result = [
164 $fileDao->id => _civicrm_api3_attachment_format_result($fileDao, $entityFileDao, $returnContent, $isTrusted),
165 ];
166 return civicrm_api3_create_success($result, $params, 'Attachment', 'create');
167 }
168
169 /**
170 * Adjust metadata for get action.
171 *
172 * @param array $spec
173 * List of fields.
174 */
175 function _civicrm_api3_attachment_get_spec(&$spec) {
176 $spec = array_merge($spec, _civicrm_api3_attachment_getfields());
177 }
178
179 /**
180 * Get Attachment.
181 *
182 * @param array $params
183 *
184 * @return array
185 * per APIv3
186 * @throws API_Exception validation errors
187 */
188 function civicrm_api3_attachment_get($params) {
189 list($id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent) = _civicrm_api3_attachment_parse_params($params);
190
191 $dao = __civicrm_api3_attachment_find($params, $id, $file, $entityFile, $isTrusted);
192 $result = [];
193 while ($dao->fetch()) {
194 $result[$dao->id] = _civicrm_api3_attachment_format_result($dao, $dao, $returnContent, $isTrusted);
195 }
196 return civicrm_api3_create_success($result, $params, 'Attachment', 'create');
197 }
198
199 /**
200 * Adjust metadata for Attachment delete action.
201 *
202 * @param $spec
203 */
204 function _civicrm_api3_attachment_delete_spec(&$spec) {
205 unset($spec['id']['api.required']);
206 $entityFileFields = CRM_Core_DAO_EntityFile::fields();
207 $spec['entity_table'] = $entityFileFields['entity_table'];
208 $spec['entity_table']['title'] = CRM_Utils_Array::value('title', $spec['entity_table'], 'Entity Table') . ' (write-once)';
209 $spec['entity_id'] = $entityFileFields['entity_id'];
210 $spec['entity_id']['title'] = CRM_Utils_Array::value('title', $spec['entity_id'], 'Entity ID') . ' (write-once)';
211 }
212
213 /**
214 * Delete Attachment.
215 *
216 * @param array $params
217 *
218 * @return array
219 * @throws API_Exception
220 */
221 function civicrm_api3_attachment_delete($params) {
222 if (!empty($params['id'])) {
223 // ok
224 }
225 elseif (!empty($params['entity_table']) && !empty($params['entity_id'])) {
226 // ok
227 }
228 else {
229 throw new API_Exception("Mandatory key(s) missing from params array: id or entity_table+entity_table");
230 }
231
232 $config = CRM_Core_Config::singleton();
233 list($id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent) = _civicrm_api3_attachment_parse_params($params);
234 $dao = __civicrm_api3_attachment_find($params, $id, $file, $entityFile, $isTrusted);
235
236 $filePaths = [];
237 $fileIds = [];
238 while ($dao->fetch()) {
239 $filePaths[] = $config->customFileUploadDir . DIRECTORY_SEPARATOR . $dao->uri;
240 $fileIds[] = $dao->id;
241 }
242
243 if (!empty($fileIds)) {
244 $idString = implode(',', array_filter($fileIds, 'is_numeric'));
245 CRM_Core_DAO::executeQuery("DELETE FROM civicrm_entity_file WHERE file_id in ($idString)");
246 CRM_Core_DAO::executeQuery("DELETE FROM civicrm_file WHERE id in ($idString)");
247 }
248
249 // unlink is non-transactional, so we do this as the last step -- just in case the other steps produce errors
250 if (!empty($filePaths)) {
251 foreach ($filePaths as $filePath) {
252 unlink($filePath);
253 }
254 }
255
256 $result = [];
257 return civicrm_api3_create_success($result, $params, 'Attachment', 'create');
258 }
259
260 /**
261 * Attachment find helper.
262 *
263 * @param array $params
264 * @param int|null $id the user-supplied ID of the attachment record
265 * @param array $file
266 * The user-supplied vales for the file (mime_type, description, upload_date).
267 * @param array $entityFile
268 * The user-supplied values of the entity-file (entity_table, entity_id).
269 * @param bool $isTrusted
270 *
271 * @return CRM_Core_DAO
272 * @throws API_Exception
273 */
274 function __civicrm_api3_attachment_find($params, $id, $file, $entityFile, $isTrusted) {
275 foreach (['name', 'content', 'path', 'url'] as $unsupportedFilter) {
276 if (!empty($params[$unsupportedFilter])) {
277 throw new API_Exception("Get by $unsupportedFilter is not currently supported");
278 }
279 }
280
281 $select = CRM_Utils_SQL_Select::from('civicrm_file cf')
282 ->join('cef', 'INNER JOIN civicrm_entity_file cef ON cf.id = cef.file_id')
283 ->select([
284 'cf.id',
285 'cf.uri',
286 'cf.mime_type',
287 'cf.description',
288 'cf.upload_date',
289 'cf.created_id',
290 'cef.entity_table',
291 'cef.entity_id',
292 ]);
293
294 if ($id) {
295 $select->where('cf.id = #id', ['#id' => $id]);
296 }
297 // Recall: $file is filtered by parse_params.
298 foreach ($file as $key => $value) {
299 $select->where('cf.!field = @value', [
300 '!field' => $key,
301 '@value' => $value,
302 ]);
303 }
304 // Recall: $entityFile is filtered by parse_params.
305 foreach ($entityFile as $key => $value) {
306 $select->where('cef.!field = @value', [
307 '!field' => $key,
308 '@value' => $value,
309 ]);
310 }
311 if (!$isTrusted) {
312 // FIXME ACLs: Add any JOIN or WHERE clauses needed to enforce access-controls for the target entity.
313 //
314 // The target entity is identified by "cef.entity_table" (aka $entityFile['entity_table']) and "cef.entity_id".
315 //
316 // As a simplification, we *require* the "get" actions to filter on a single "entity_table" which should
317 // avoid the complexity of matching ACL's against multiple entity types.
318 }
319
320 $dao = CRM_Core_DAO::executeQuery($select->toSQL());
321 return $dao;
322 }
323
324 /**
325 * Attachment parsing helper.
326 *
327 * @param array $params
328 *
329 * @return array
330 * (0 => int $id, 1 => array $file, 2 => array $entityFile, 3 => string $name, 4 => string $content,
331 * 5 => string $moveFile, 6 => $isTrusted, 7 => bool $returnContent)
332 * - array $file: whitelisted fields that can pass through directly to civicrm_file
333 * - array $entityFile: whitelisted fields that can pass through directly to civicrm_entity_file
334 * - string $name: the printable name
335 * - string $moveFile: the full path to a local file whose content should be loaded
336 * - bool $isTrusted: whether we trust the requester to do sketchy things (like moving files or reassigning entities)
337 * - bool $returnContent: whether we are expected to return the full content of the file
338 * @throws API_Exception validation errors
339 */
340 function _civicrm_api3_attachment_parse_params($params) {
341 $id = $params['id'] ?? NULL;
342 if ($id && !is_numeric($id)) {
343 throw new API_Exception("Malformed id");
344 }
345
346 $file = [];
347 foreach (['mime_type', 'description', 'upload_date'] as $field) {
348 if (array_key_exists($field, $params)) {
349 $file[$field] = $params[$field];
350 }
351 }
352
353 $entityFile = [];
354 foreach (['entity_table', 'entity_id'] as $field) {
355 if (array_key_exists($field, $params)) {
356 $entityFile[$field] = $params[$field];
357 }
358 }
359
360 if (empty($params['entity_table']) && isset($params['field_name'])) {
361 $tableInfo = CRM_Core_BAO_CustomField::getTableColumnGroup(intval(str_replace('custom_', '', $params['field_name'])));
362 $entityFile['entity_table'] = $tableInfo[0];
363 }
364
365 $name = NULL;
366 if (array_key_exists('name', $params)) {
367 if ($params['name'] != basename($params['name']) || preg_match(':[/\\\\]:', $params['name'])) {
368 throw new API_Exception('Malformed name');
369 }
370 $name = $params['name'];
371 }
372
373 $content = NULL;
374 if (isset($params['content'])) {
375 $content = $params['content'];
376 }
377
378 $moveFile = NULL;
379 if (isset($params['options']['move-file'])) {
380 $moveFile = $params['options']['move-file'];
381 }
382 elseif (isset($params['options.move-file'])) {
383 $moveFile = $params['options.move-file'];
384 }
385
386 $isTrusted = empty($params['check_permissions']);
387
388 $returns = $params['return'] ?? [];
389 $returns = is_array($returns) ? $returns : [$returns];
390 $returnContent = in_array('content', $returns);
391
392 return [$id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent];
393 }
394
395 /**
396 * Attachment result formatting helper.
397 *
398 * @param CRM_Core_DAO_File $fileDao
399 * Maybe "File" or "File JOIN EntityFile".
400 * @param CRM_Core_DAO_EntityFile $entityFileDao
401 * Maybe "EntityFile" or "File JOIN EntityFile".
402 * @param bool $returnContent
403 * Whether to return the full content of the file.
404 * @param bool $isTrusted
405 * Whether the current request is trusted to perform file-specific operations.
406 *
407 * @return array
408 */
409 function _civicrm_api3_attachment_format_result($fileDao, $entityFileDao, $returnContent, $isTrusted) {
410 $config = CRM_Core_Config::singleton();
411 $path = $config->customFileUploadDir . DIRECTORY_SEPARATOR . $fileDao->uri;
412
413 $result = [
414 'id' => $fileDao->id,
415 'name' => CRM_Utils_File::cleanFileName($fileDao->uri),
416 'mime_type' => $fileDao->mime_type,
417 'description' => $fileDao->description,
418 'upload_date' => is_numeric($fileDao->upload_date) ? CRM_Utils_Date::mysqlToIso($fileDao->upload_date) : $fileDao->upload_date,
419 'entity_table' => $entityFileDao->entity_table,
420 'entity_id' => $entityFileDao->entity_id,
421 'icon' => CRM_Utils_File::getIconFromMimeType($fileDao->mime_type),
422 'created_id' => $fileDao->created_id,
423 ];
424 $fileHash = CRM_Core_BAO_File::generateFileHash($result['entity_id'], $result['id']);
425 $result['url'] = CRM_Utils_System::url(
426 'civicrm/file', 'reset=1&id=' . $result['id'] . '&eid=' . $result['entity_id'] . '&fcs=' . $fileHash,
427 TRUE,
428 NULL,
429 FALSE,
430 TRUE
431 );
432 if ($isTrusted) {
433 $result['path'] = $path;
434 }
435 if ($returnContent) {
436 $result['content'] = file_get_contents($path);
437 }
438 return $result;
439 }
440
441 /**
442 * Attachment getfields helper.
443 *
444 * @return array
445 * list of fields (indexed by name)
446 */
447 function _civicrm_api3_attachment_getfields() {
448 $fileFields = CRM_Core_DAO_File::fields();
449 $entityFileFields = CRM_Core_DAO_EntityFile::fields();
450
451 $spec = [];
452 $spec['id'] = $fileFields['id'];
453 $spec['name'] = [
454 'title' => 'Name (write-once)',
455 'description' => 'The logical file name (not searchable)',
456 'type' => CRM_Utils_Type::T_STRING,
457 ];
458 $spec['field_name'] = [
459 'title' => 'Field Name (write-once)',
460 'description' => 'Alternative to "entity_table" param - sets custom field value.',
461 'type' => CRM_Utils_Type::T_STRING,
462 ];
463 $spec['mime_type'] = $fileFields['mime_type'];
464 $spec['description'] = $fileFields['description'];
465 $spec['upload_date'] = $fileFields['upload_date'];
466 $spec['entity_table'] = $entityFileFields['entity_table'];
467 // Would be hard to securely handle changes.
468 $spec['entity_table']['title'] = CRM_Utils_Array::value('title', $spec['entity_table'], 'Entity Table') . ' (write-once)';
469 $spec['entity_id'] = $entityFileFields['entity_id'];
470 // would be hard to securely handle changes
471 $spec['entity_id']['title'] = CRM_Utils_Array::value('title', $spec['entity_id'], 'Entity ID') . ' (write-once)';
472 $spec['url'] = [
473 'title' => 'URL (read-only)',
474 'description' => 'URL for downloading the file (not searchable, expire-able)',
475 'type' => CRM_Utils_Type::T_STRING,
476 ];
477 $spec['path'] = [
478 'title' => 'Path (read-only)',
479 'description' => 'Local file path (not searchable, local-only)',
480 'type' => CRM_Utils_Type::T_STRING,
481 ];
482 $spec['content'] = [
483 'title' => 'Content',
484 'description' => 'File content (not searchable, not returned by default)',
485 'type' => CRM_Utils_Type::T_STRING,
486 ];
487 $spec['created_id'] = [
488 'title' => 'Created By Contact ID',
489 'type' => CRM_Utils_Type::T_INT,
490 'description' => 'FK to civicrm_contact, who uploaded this file',
491 ];
492
493 return $spec;
494 }