cms_content_sync-3.0.x-dev/src/Plugin/cms_content_sync/entity_handler/DefaultFileHandler.php
src/Plugin/cms_content_sync/entity_handler/DefaultFileHandler.php
<?php
namespace Drupal\cms_content_sync\Plugin\cms_content_sync\entity_handler;
use Drupal\cms_content_sync\Entity\Flow;
use Drupal\cms_content_sync\Exception\SyncException;
use Drupal\cms_content_sync\Plugin\EntityHandlerBase;
use Drupal\cms_content_sync\PullIntent;
use Drupal\cms_content_sync\PushIntent;
use Drupal\cms_content_sync\SyncIntent;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\TranslatableInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StreamWrapper\PublicStream;
use Drupal\crop\Entity\Crop;
use Drupal\file\Entity\File;
use EdgeBox\SyncCore\V2\Raw\Model\RemoteEntityTypePropertyFormat;
use GuzzleHttp\Client;
use EdgeBox\SyncCore\Interfaces\Configuration\IDefineEntityType;
use EdgeBox\SyncCore\V2\Configuration\DefineProperty;
/**
* Class DefaultFileHandler, providing proper file handling capabilities.
*
* @EntityHandler(
* id = "cms_content_sync_default_file_handler",
* label = @Translation("Default File"),
* weight = 90
* )
*/
class DefaultFileHandler extends EntityHandlerBase {
public const USER_PROPERTY = 'uid';
/**
* {@inheritdoc}
*/
public static function supports($entity_type, $bundle) {
return 'file' == $entity_type;
}
/**
* {@inheritdoc}
*/
public function getAllowedPushOptions() {
return [
PushIntent::PUSH_DISABLED,
PushIntent::PUSH_AUTOMATICALLY,
PushIntent::PUSH_AS_DEPENDENCY,
PushIntent::PUSH_MANUALLY,
];
}
/**
* {@inheritdoc}
*/
public function getAllowedPreviewOptions() {
return [
'table' => 'Table',
'preview_mode' => 'Preview mode',
];
}
/**
* {@inheritdoc}
*/
public function getHandlerSettings($current_values, $type = 'both') {
$moduleHandler = \Drupal::service('module_handler');
if ($moduleHandler->moduleExists('crop')) {
$crop_types = \Drupal::entityTypeManager()->getStorage('crop_type')->loadMultiple();
if (!empty($crop_types) && 'pull' !== $type) {
return [
'export_crop' => [
'#type' => 'checkbox',
'#title' => 'Push cropping',
'#default_value' => isset($current_values['export_crop']) && 0 === $current_values['export_crop'] ? 0 : 1,
],
];
}
}
return [];
}
/**
* {@inheritdoc}
*/
public function validateHandlerSettings(array &$form, FormStateInterface $form_state, string $entity_type_name, string $bundle_name, $current_values) {
// Ensure that at least one crop bundle is enabled if export_crop is set.
$moduleHandler = \Drupal::service('module_handler');
if ($moduleHandler->moduleExists('crop')) {
if (isset($this->settings['handler_settings']['export_crop']) && $this->settings['handler_settings']['export_crop']) {
$crop_types = \Drupal::entityTypeManager()->getStorage('crop_type')->loadMultiple();
foreach ($crop_types as $crop_type_id => $crop_type) {
if (isset($current_values['per_bundle_settings']['crop'][$crop_type_id])) {
if (Flow::HANDLER_IGNORE == $current_values['per_bundle_settings']['crop'][$crop_type_id]['settings']['handler']) {
continue;
}
return;
}
}
$form_state->setError(
$form[$this->entityTypeName][$this->bundleName],
t(
'You have configured file entities to push crop entities but did not configure any crop entity type bundle to be exported.',
)
);
}
}
}
/**
* @param \EdgeBox\SyncCore\Interfaces\Configuration\IDefineEntityType|EdgeBox\SyncCore\V2\Configuration\DefineProperty $definition
*/
public function updateEntityTypeDefinition(IDefineEntityType|DefineProperty &$definition) {
parent::updateEntityTypeDefinition($definition);
$definition->isFile(TRUE);
$definition
->addObjectProperty('uri', 'URI', TRUE, TRUE, 'uri')
->addStringProperty('value', 'Value', FALSE, TRUE, 'string')
->setFormat(RemoteEntityTypePropertyFormat::URI);
// Add optional "parent_langcodes" property that's consistent with Drupal's current language implementation.
// As files are not translated today (always created in the default language instad of the parent's language),
// we try to guess this during the export. This makes Drupal more compatible with other systems that allow assets
// to be translatable.
// If e.g. a media item uses this file, we assign the media's langcode. If entities using this file have
// ambiguous langcodes, all of them are added as values.
$definition
->addObjectProperty('parent_langcodes', 'Languages', TRUE, FALSE, 'language')
->setShared(TRUE)
->addStringProperty('value', 'Language code', FALSE, TRUE, 'string')
->setMaxLength(12);
}
/**
* {@inheritdoc}
*/
public function getForbiddenFields() {
return array_merge(
parent::getForbiddenFields(),
[
'uri',
'filemime',
'filesize',
]
);
}
/**
*
*/
protected function deletePreviousFile(PullIntent $intent, ?string $previous_uri) {
if (!$previous_uri) {
return;
}
if ('http://' == mb_substr($previous_uri, 0, 7) || 'https://' == mb_substr($previous_uri, 0, 8)) {
return;
}
$intent->startTimer('delete-previous-file');
\Drupal::service('file_system')->unlink($previous_uri);
$intent->stopTimer('delete-previous-file');
}
/**
* {@inheritdoc}
*/
protected function doPull(PullIntent $intent) {
/**
* @var \Drupal\file\FileInterface $entity
*/
$entity = $intent->getEntity();
$action = $intent->getAction();
if (SyncIntent::ACTION_DELETE == $action) {
if ($entity) {
return $this->deleteEntity($entity);
}
return FALSE;
}
$previous_uri = $entity ? $entity->getFileUri() : NULL;
$uri = $intent->getProperty('uri');
if (empty($uri)) {
throw new SyncException(SyncException::CODE_INVALID_PULL_REQUEST, NULL, 'Missing file URI.');
}
if (!empty($uri[0]['value'])) {
$uri = $uri[0]['value'];
}
$entity_type = \Drupal::entityTypeManager()->getDefinition($intent->getEntityType());
$langcode_key = $entity_type->getKey('langcode');
if ('http://' == mb_substr($uri, 0, 7) || 'https://' == mb_substr($uri, 0, 8)) {
if (!$entity) {
$base_data = [];
if ($this->hasLabelProperty()) {
$base_data[$entity_type->getKey('label')] = $intent->getOperation()->getName();
}
$base_data[$entity_type->getKey('uuid')] = $intent->getUuid();
if ($langcode_key) {
$base_data[$langcode_key] = $intent->getProperty($langcode_key);
}
$base_data['uri'] = $uri;
$storage = \Drupal::entityTypeManager()->getStorage($intent->getEntityType());
$entity = $storage->create($base_data);
}
$entity->set('filename', $intent->getOperation()->getName());
$entity->set('uri', $uri);
$entity->save();
if ($previous_uri && $previous_uri !== $uri) {
$this->deletePreviousFile($intent, $previous_uri);
}
$intent->setEntity($entity);
return TRUE;
}
$remote_file = $intent
->getOperation()
->loadFile();
if (!$remote_file) {
throw new SyncException(SyncException::CODE_INVALID_PULL_REQUEST, NULL, 'Missing file.');
}
$previous_size = $intent->getStatusData(['file', 'size']);
$previous_hash = $intent->getStatusData(['file', 'hash']);
// We have already pulled the file.
if (NULL !== $previous_size && NULL !== $previous_hash) {
// Check whether the file is the same by using the file hash.
// To make sure it even works when the file is replaced outside
// of Content Sync, we need to also compare the file size as a safe-
// guard. There's a slim chance that the size won't change either
// but that combined with the file being replaced outside of
// Content Sync is unlikely enough.
if ($previous_size === $remote_file->getFileSize() && $previous_hash === $remote_file->getHash() && (!$entity || ($previous_uri === $uri && $entity->get('filename')->value === $intent->getOperation()->getName()))) {
return TRUE;
}
}
$intent->startTimer('download-file-content');
$content = $remote_file->download();
$intent->stopTimer('download-file-content');
if (NULL === $content) {
throw new SyncException(SyncException::CODE_INVALID_PULL_REQUEST, NULL, 'Missing file contents.');
}
if ($entity) {
$intent->startTimer('save-file-content');
// Drupal will re-use the existing file entity and keep it's ID, but
// *change the UUID* of the file entity to a new random value
// So we have to tell Drupal we actually want to keep it so references
// to it keep working for us. That's why we can't use file_save_data- it doesn't do what it promises (keeping the
// file entity and just replacing the file content).
if (\Drupal::service('file_system')->saveData($content, $uri, FileSystemInterface::EXISTS_REPLACE)) {
$entity->set('filename', $intent->getOperation()->getName());
$entity->set('uri', $uri);
if ($langcode_key) {
$entity->set($langcode_key, $intent->getProperty($langcode_key));
}
$entity->save();
$intent->setStatusData(['file', 'size'], $remote_file->getFileSize());
$intent->setStatusData(['file', 'hash'], $remote_file->getHash());
$intent->stopTimer('save-file-content');
if ($previous_uri && $previous_uri !== $uri) {
$this->deletePreviousFile($intent, $previous_uri);
}
return TRUE;
}
$intent->stopTimer('save-file-content');
throw new SyncException(SyncException::CODE_ENTITY_API_FAILURE);
}
else {
$intent->startTimer('prepare-directory');
$directory = \Drupal::service('file_system')->dirname($uri);
$was_prepared = \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
$intent->stopTimer('prepare-directory');
if ($was_prepared) {
/** @var \Drupal\file\Entity\FileInterface[] $existing_files */
$existing_files = \Drupal::entityTypeManager()
->getStorage('file')
->loadByProperties(['uri' => $uri]);
$intent->startTimer('save-file-content');
$entity = \Drupal::service('file.repository')->writeData($content, $uri, FileSystemInterface::EXISTS_REPLACE);
$intent->stopTimer('save-file-content');
// Drupal has a pending issue: https://www.drupal.org/node/2241865
// so it creates a new file entity even when overwriting an existing entity. This will throw an exception if we
// now try to save the new file with the same UUID as the old file.
// So if we're updating an existing file, we don't create a new file entity so just skipping the file safe.
if (count($existing_files)) {
$existing = reset($existing_files);
// Yes, file exists and UUID matches. So no need to create a new file entity.
if ($existing->uuid() === $intent->getUuid()) {
$use_entity = $entity;
// Delete duplicated file until Drupal resolves the issue above.
if ($entity->uuid() !== $intent->getUuid()) {
$entity->delete();
$use_entity = $existing;
}
// But name / URI may have changed.
if ($use_entity->get('filename')->value !== $intent->getOperation()->getName() || $use_entity->get('uri')->value !== $uri) {
$use_entity->set('filename', $intent->getOperation()->getName());
$use_entity->set('uri', $uri);
if ($langcode_key) {
$use_entity->set($langcode_key, $intent->getProperty($langcode_key));
}
$use_entity->save();
}
$intent->setStatusData(['file', 'size'], $remote_file->getFileSize());
$intent->setStatusData(['file', 'hash'], $remote_file->getHash());
$intent->setEntity($use_entity);
return TRUE;
}
}
$entity->setPermanent();
$entity->set('uuid', $intent->getUuid());
// Some modules allow replacing existing files.
$entity->set('filename', $intent->getOperation()->getName());
// And because the filename can change, the URI can change as well.
$entity->set('uri', $uri);
// Drupal doesn't provide a way to set the langcode during the upload
// so it will use the langcode from the URL which means the language
// depends on the base URL used to register the site or the site's
// default lang code for the files we create through REST.
if ($langcode_key) {
$entity->set($langcode_key, $intent->getProperty($langcode_key));
}
$entity->save();
$intent->setStatusData(['file', 'size'], $remote_file->getFileSize());
$intent->setStatusData(['file', 'hash'], $remote_file->getHash());
$intent->setEntity($entity);
return TRUE;
}
throw new SyncException(SyncException::CODE_ENTITY_API_FAILURE);
}
}
/**
* {@inheritdoc}
*/
public function push(PushIntent $intent, ?EntityInterface $entity = NULL) {
/**
* @var \Drupal\file\FileInterface $entity
*/
if (!$entity) {
$entity = $intent->getEntity();
}
if (!parent::push($intent)) {
return FALSE;
}
// Base Info.
$uri = $entity->getFileUri();
// Handle stage file proxy files.
// If the file does not exist locally, we request it once so that
// Stage File Proxy is able to download it to the local file system.
// Stage File Proxies option for "Hotlink" is not supported.
$moduleHandler = \Drupal::service('module_handler');
$content = @file_get_contents($uri);
if (FALSE === $content && $moduleHandler->moduleExists('stage_file_proxy')) {
$client = new Client([
'timeout' => 60,
]);
$response = $client->request('GET', $entity->createFileUrl(FALSE));
$content = $response->getBody()->getContents();
}
// File was removed from the file system. Trying to import it at another site will throw an error there and as the
// source of the error is here, we throw an Error here.
if (FALSE === $content) {
$this->logger->error(
'Can\'t push file: File @uri doesn\'t exist in the file system or the file permissions forbid access.<br>Flow: @flow_id | Pool: @pool_id',
[
'@uri' => $uri,
'@flow_id' => $intent->getFlow()->id(),
'@pool_id' => implode(',', $intent->getPoolIds()),
]
);
throw new \Exception("Can't push file: File " . $uri . " doesn't exist in the file system or the file permissions forbid access.");
}
$intent->getOperation()->uploadFile($content, $entity->getFilename(), $entity->getMimeType());
$intent->setProperty('uri', [['value' => $uri]]);
$intent->getOperation()->setName($entity->getFilename(), $intent->getActiveLanguage());
// Preview.
$view_mode = $this->flow->getController()->getPreviewType($entity->getEntityTypeId(), $entity->bundle());
if (Flow::PREVIEW_DISABLED != $view_mode) {
$this->setPreviewHtml('<img style="max-height: 200px" src="' . \Drupal::service('file_url_generator')->generateAbsoluteString($uri) . '"/>', $intent);
}
$intent->getOperation()->setSourceDeepLink($this->getViewUrl($entity), $intent->getActiveLanguage());
// Handle focal point crop entities.
$moduleHandler = \Drupal::service('module_handler');
$crop_types = $intent->getFlow()->getController()->getEntityTypeConfig('crop', NULL, TRUE);
if ($moduleHandler->moduleExists('crop') && !empty($crop_types)) {
if ($this->settings['handler_settings']['export_crop']) {
foreach ($crop_types as $bundle_name => $crop_type) {
if (Crop::cropExists($uri, $bundle_name)) {
$crop = Crop::findCrop($uri, $bundle_name);
if ($crop) {
$intent->addDependency($crop);
$intent->setStatusData('crop', $crop->position());
}
}
}
}
}
// Try to guess the language based on what languages the entities using this
// file have assigned.
$parent_langcodes = $this->getParentEntityLanguages($entity);
if ($parent_langcodes) {
$value = [];
foreach ($parent_langcodes as $langcode) {
$value[] = ['value' => $langcode];
}
$intent->setProperty('parent_langcodes', $value);
}
return TRUE;
}
/**
* Determine the langcode(s) based on the parent entities using the given file.
* This is important as file entities in Drupal aren't directly translatable,
* but we may need to know the language(s) the file is actually in based on
* the parent entity or entities using it, e.g. the node or media entity
* that the file is attached to.
*
* @param \Drupal\file\Entity\File $entity
*
* @return string[]|null Null if the lang code could not be determined based on parents.
*/
public function getParentEntityLanguages(File $entity) {
// Result language codes.
$languages = [];
/**
* @var \Drupal\file\FileUsage\FileUsageInterface $file_usage_service
*/
$file_usage_service = \Drupal::service('file.usage');
$per_module_usage = $file_usage_service->listUsage($entity);
$target_file_url = \Drupal::service('file_url_generator')->generate($entity->getFileUri())->toString();
// Iterate through the usages to find out which entities and translations
// reference our file entity.
foreach ($per_module_usage as $module => $per_type_usage) {
foreach ($per_type_usage as $type_name => $per_entity_id_usage) {
foreach ($per_entity_id_usage as $entity_id => $count) {
$other = \Drupal::entityTypeManager()->getStorage($type_name)->load($entity_id);
// As Drupal doesn't store which translation references the file, we need to iterate through all
// translation to find the reference field(s) using the file.
if ($other && $other instanceof TranslatableInterface && $other instanceof FieldableEntityInterface) {
$other_default_langcode = $other->getUntranslated()->language()->getId();
/**
* @var \Drupal\Core\Field\FieldDefinitionInterface[] $fields
*/
$fields = $other->getFieldDefinitions();
// File reference fields.
$file_fields = [];
// Rich text fields may also contain references in the text body.
$text_fields = [];
// First, check which file fields exist for this type.
foreach ($fields as $key => $field) {
if (in_array($field->getType(), ['image', 'file_uri', 'file', 'svg_image_field'])) {
$file_fields[$key] = $field;
}
elseif (in_array($field->getType(), ['text_with_summary', 'text_long'])) {
$text_fields[$key] = $field;
}
}
$translation_languages = $other->getTranslationLanguages();
// Then iterate through all translations (including the default translation)
// to check which of them reference this file.
foreach ($translation_languages as $langcode => $language) {
$translation = $other->getTranslation($langcode);
if (in_array($langcode, $translation_languages)) {
continue;
}
// Check file fields first.
foreach ($file_fields as $key => $field) {
if (!$translation->isDefaultTranslation() && !$field->isTranslatable()) {
continue;
}
$field_data = $translation->get($key)->getValue();
foreach ($field_data as $field_value) {
// Check if the file field references our file.
if ($field_value['target_id'] == $entity->id()) {
$languages[] = $langcode;
// Break the outer loop as we need to collect each langcode
// only once.
break 2;
}
}
}
// Maybe we've found it and can skip the more expensive text check.
if (in_array($langcode, $translation_languages)) {
continue;
}
// Then check text fields.
foreach ($text_fields as $key => $field) {
if (!$translation->isDefaultTranslation() && !$field->isTranslatable()) {
continue;
}
$field_values = $translation->get($key)->getValue();
foreach ($field_values as $field_value) {
if (empty($field_value['value'])) {
continue;
}
// Check if the text field uses our file based on the file URL.
if (str_contains($field_value['value'], $target_file_url)) {
$languages[] = $langcode;
// Break the outer loop as we need to collect each langcode
// only once.
break 2;
}
}
}
}
}
}
}
}
return count($languages) ? $languages : NULL;
}
/**
*
*/
public function getViewUrl(EntityInterface $entity) {
$uri = $entity->getFileUri();
return \Drupal::service('file_url_generator')->generateAbsoluteString($uri);
}
/**
*
*/
protected function getEntityName(EntityInterface $file, PushIntent $intent) {
/**
* @var \Drupal\file\Entity\File $file
*/
return $file->getFilename();
}
}
