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();
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc