inline_image_saver-1.0.x-dev/src/InlineImageSaver.php

src/InlineImageSaver.php
<?php

declare(strict_types=1);

namespace Drupal\inline_image_saver;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\DiffArray;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\Core\Entity\SynchronizableInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Routing\RequestContext;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\TypedData\TypedDataInterface;
use Drupal\file\FileInterface;
use Drupal\inline_image_saver\Form\InlineImageSaverSettingsForm;
use Drupal\inline_image_saver\Struct\InlineImageValidation as Validation;
use Drupal\inline_image_saver\Struct\InlineImageData;
use Drupal\inline_image_saver\Struct\InlineImageError as Errors;
use Drupal\text\Plugin\Field\FieldType\TextItemBase;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mime\MimeTypes;

/**
 * Provides a inline image saver.
 */
class InlineImageSaver implements InlineImageSaverInterface {

  /**
   * The default upload directory cache.
   */
  protected string $defaultUploadDirectory;

  /**
   * The upload directories cache keyed by the format ID.
   */
  protected array $uploadDirectories = [];

  /**
   * The module config cache.
   */
  protected ?ImmutableConfig $config;

  public function __construct(
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly FileSystemInterface $fileSystem,
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected readonly ClientInterface $httpClient,
    protected readonly RequestContext $requestContext,
    protected readonly AccountInterface $currentUser,
    protected readonly TimeInterface $time,
    protected readonly EventDispatcherInterface $eventDispatcher,
    protected readonly InlineImageMimeInterface $inlineImageMime,
    protected readonly InlineImageFinderInterface $inlineImageFinder,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function isFieldTypeProcessable(FieldDefinitionInterface|string $field_definition_or_class): bool {
    if ($field_definition_or_class instanceof FieldDefinitionInterface) {
      $field_definition_or_class = $field_definition_or_class->getItemDefinition()->getClass();
    }
    return is_subclass_of($field_definition_or_class, TextItemBase::class);
  }

  /**
   * {@inheritdoc}
   */
  public function isEntityProcessable(FieldableEntityInterface $entity): bool {
    $settings = $this->getSettings();
    if (!$settings['enable_download'] && !$settings['enable_replace']) {
      return FALSE;
    }
    if ($settings['skip_on_sync'] && $entity instanceof SynchronizableInterface && $entity->isSyncing()) {
      return FALSE;
    }
    return TRUE;
  }

  /**
   * {@inheritdoc}
   */
  public function processEntity(FieldableEntityInterface $entity): bool {
    $changed = FALSE;
    foreach ($entity->getFieldDefinitions() as $field_name => $field_definition) {
      if (!$this->isFieldTypeProcessable($field_definition)) {
        continue;
      }
      /** @var \Drupal\text\Plugin\Field\FieldType\TextItemBase $item */
      foreach ($entity->get($field_name) as $item) {
        if ($this->isTextItemProcessable($item) && $this->processTextItem($item)) {
          $changed = TRUE;
        }
      }
    }
    if ($changed && $entity instanceof RevisionableInterface) {
      $settings = $this->getSettings();
      if ($settings['create_revision']) {
        if ($revision_created_now = !$entity->isNewRevision()) {
          $entity->setNewRevision();
        }
        if ($entity instanceof RevisionLogInterface) {
          if ($revision_created_now) {
            $entity
              ->setRevisionUserId($this->currentUser->id())
              ->setRevisionCreationTime($this->time->getRequestTime());
          }
          if (!$entity->getRevisionLogMessage()) {
            $entity->setRevisionLogMessage($settings['revision_log']);
          }
        }
      }
    }
    return $changed;
  }

  /**
   * {@inheritdoc}
   */
  public function isTextItemProcessable(TextItemBase $item): bool {
    if (!$format_id = $this->getTextItemFormatId($item)) {
      return FALSE;
    }
    $processable_format_ids = $this->getSetting('processable_formats');
    return !$processable_format_ids || in_array($format_id, $processable_format_ids);
  }

  /**
   * {@inheritdoc}
   */
  public function processTextItem(TextItemBase $item): bool {
    if (!$dom = $this->parseTextItemDom($item)) {
      return FALSE;
    }
    $changed = FALSE;
    [
      'enable_download' => $enable_download,
      'enable_replace' => $enable_replace,
      'fallback_markup' => $fallback_markup,
    ] = $this->getSettings();
    $process_placeholders = $this->containsPlaceholders($fallback_markup);
    /** @var \DOMElement $img */
    foreach ($dom->getElementsByTagName('img') as $img) {
      if (!$this->validateImage($img)->error) {
        continue;
      }
      // Replace the image with the downloaded image if it's downloaded.
      if ($enable_download && $data = $this->downloadImage($img)) {
        $file = $this->saveImage($data, $this->getTextItemUploadDirectory($item));
        $img->setAttribute('data-entity-type', $file->getEntityTypeId());
        $img->setAttribute('data-entity-uuid', $file->uuid());
        $img->setAttribute('src', $file->createFileUrl());
        $changed = TRUE;
      }
      // Remove the image if it's broken.
      elseif ($enable_replace) {
        if ($process_placeholders && $placeholders = $this->placeholdersFromElement($img)) {
          $text = strtr($fallback_markup, $placeholders);
        }
        else {
          $text = $fallback_markup;
        }
        $this->replaceNodeWithMarkup($img, (string) check_markup($text, $this->getTextItemFormatId($item)));
        $changed = TRUE;
      }
    }
    if ($changed) {
      $this->serializeTextItemDom($item, $dom);
    }
    return $changed;
  }

  /**
   * {@inheritdoc}
   */
  public function parseTextItemDom(TextItemBase $item): ?\DOMDocument {
    if (!$value = trim($this->getTextItemMainProperty($item)->getString())) {
      return NULL;
    }
    if (stripos($value, '<img') === FALSE) {
      return NULL;
    }
    return Html::load($value);
  }

  /**
   * {@inheritdoc}
   */
  public function serializeTextItemDom(TextItemBase $item, \DOMDocument $value): void {
    $this->getTextItemMainProperty($item)->setValue(Html::serialize($value));
  }

  /**
   * {@inheritdoc}
   */
  public function validateImage(\DOMElement $img): Validation {
    $this->ensureImage($img);
    $src = $img->getAttribute('src');
    $settings = $this->getSetting('validation_settings');
    if ($this->isDataUriImage($src)) {
      if ($settings['allow_data_uri']) {
        return Validation::ok(img: $img, is_data_uri: TRUE);
      }
      if (!$data = $this->extractDataUriImage($src)) {
        return Validation::error(
          img: $img,
          error: Errors::InvalidDataUriValue,
          is_data_uri: TRUE,
        );
      }
      if ($this->inlineImageMime->isSupported() && !$this->inlineImageMime->resolveByData($data, $mime_type)) {
        return Validation::error(
          img: $img,
          error: Errors::UnsupportedDataUriMimeType,
          is_data_uri: TRUE,
          context: ['mime_type' => $mime_type],
        );
      }
      return Validation::error(
        img: $img,
        error: Errors::DataUriNotAllowed,
        is_data_uri: TRUE,
      );
    }
    if (!$actual_entity_type_id = $img->getAttribute('data-entity-type')) {
      return Validation::error(img: $img, error: Errors::EmptyEntityTypeAttribute);
    }
    $expected_entity_type_id = 'file';
    if ($actual_entity_type_id !== $expected_entity_type_id) {
      return Validation::error(
        img: $img,
        error: Errors::UnsupportedEntityTypeAttribute,
        context: [
          'actual_entity_type_id' => $actual_entity_type_id,
          'expected_entity_type_id' => $expected_entity_type_id,
        ]);
    }
    if (!$uuid = $img->getAttribute('data-entity-uuid')) {
      return Validation::error(img: $img, error: Errors::EmptyEntityUuid);
    }
    $file_storage = $this->entityTypeManager->getStorage($expected_entity_type_id);
    $uuid_key = $file_storage->getEntityType()->getKey('uuid');
    $ids = $file_storage->getQuery()
      ->accessCheck(FALSE)
      ->condition($uuid_key, $uuid)
      ->range(0, 1)
      ->execute();
    if (!$ids) {
      return Validation::error(
        img: $img,
        error: Errors::EntityNotFound,
        context: [
          'entity_type_id' => $actual_entity_type_id,
          'uuid' => $uuid,
        ]);
    }
    if (!$settings['check_file_exists'] && !$settings['validate_url'] && !$settings['check_file_mime']) {
      return Validation::ok(img: $img);
    }
    /** @var \Drupal\file\FileInterface $file */
    $file = $file_storage->load(reset($ids));
    if ($settings['check_file_exists'] && !file_exists($file->getFileUri())) {
      return Validation::error(
        img: $img,
        error: Errors::FileNotFound,
        file: $file,
      );
    }
    if ($settings['check_file_mime'] && $this->inlineImageMime->isSupported() && !$this->inlineImageMime->resolveByUri($file->getFileUri(), $mime_type)) {
      return Validation::error(
        img: $img,
        error: Errors::UnsupportedFileMime,
        file: $file,
        context: ['mime_type' => $mime_type],
      );
    }
    if ($settings['validate_url']) {
      $actual_url = parse_url($src) ?: [];
      $expected_url = parse_url($file->createFileUrl(FALSE));
      if ($actual_host = $actual_url['host'] ?? NULL) {
        $expected_host = $expected_url['host'] ?? NULL;
        if ($actual_host !== $expected_host) {
          return Validation::error(
            img: $img,
            error: Errors::UrlHostMismatch,
            file: $file,
            context: [
              'actual_host' => $actual_host,
              'expected_host' => $expected_host,
            ]);
        }
      }
      $actual_path = $actual_url['path'] ?? NULL;
      $expected_path = $expected_url['path'] ?? NULL;
      if ($actual_path !== $expected_path) {
        return Validation::error(
          img: $img,
          error: Errors::UrlPathMismatch,
          file: $file,
          context: [
            'actual_path' => $actual_path,
            'expected_path' => $expected_path,
          ]);
      }
      if ($settings['validate_url_query']) {
        parse_str($actual_query = $actual_url['query'] ?? '', $parsed_actual_query);
        parse_str($expected_query = $expected_url['query'] ?? '', $parsed_expected_query);
        ksort($parsed_actual_query);
        ksort($parsed_expected_query);
        if ($query_diff = DiffArray::diffAssocRecursive($parsed_actual_query, $parsed_expected_query)) {
          return Validation::error(
            img: $img,
            error: Errors::UrlQueryMismatch,
            file: $file,
            context: [
              'actual_query' => $actual_query,
              'expected_query' => $expected_query,
              'query_diff' => $query_diff,
              'parsed_actual_query' => $parsed_actual_query,
              'parsed_expected_query' => $parsed_expected_query,
            ]);
        }
      }
    }
    return Validation::ok(img: $img, file: $file);
  }

  /**
   * {@inheritdoc}
   */
  public function downloadImage(\DOMElement $img): ?InlineImageData {
    // Skip if the image has no src attribute.
    $this->ensureImage($img);
    if (!$this->inlineImageMime->isSupported() || !$src = $img->getAttribute('src')) {
      return NULL;
    }
    // Try to download the image.
    if ($this->isDataUriImage($src)) {
      if (!$data = $this->extractDataUriImage($src)) {
        return NULL;
      }
      $src = NULL;
    }
    else {
      if (!UrlHelper::isExternal($src)) {
        $src = ltrim($src, '/');
        $src = "{$this->requestContext->getScheme()}://{$this->requestContext->getHost()}/$src";
      }
      try {
        $data = $this->httpClient
          ->request(Request::METHOD_GET, $src)
          ->getBody()
          ->getContents();
      }
      catch (GuzzleException) {
        return NULL;
      }
    }
    if (!$this->inlineImageMime->resolveByData($data, $mime_type)) {
      return NULL;
    }
    return new InlineImageData(
      img: $img,
      data: $data,
      mime_type: $mime_type,
      src: $src,
    );
  }

  /**
   * {@inheritdoc}
   */
  public function saveImage(InlineImageData $data, string $directory): FileInterface {
    if ($this->getSetting('prefer_reuse_files') && $file = $this->inlineImageFinder->findByHash($data) ?? $this->inlineImageFinder->findBySrc($data)) {
      return $file;
    }
    /** @var \Drupal\file\FileInterface $file */
    $file = $this->entityTypeManager->getStorage('file')->create([
      'uri' => $this->fileSystem->saveData($data->data, "$directory/{$this->generateImageFilename($data)}"),
      'filemime' => $data->mime_type,
    ]);
    $file->setOwnerId($this->currentUser->id());
    $file->setTemporary();
    $file->save();
    return $file;
  }

  /**
   * {@inheritdoc}
   */
  public function generateImageFilename(InlineImageData $data): string {
    if ($data->src) {
      $pathinfo = pathinfo(parse_url($data->src, PHP_URL_PATH));
      $filename = $pathinfo['filename'] ?? '';
      $extension = $pathinfo['extension'] ?? '';
    }
    else {
      $filename = '';
      $extension = '';
    }
    if (!$filename && !$filename = $data->img->getAttribute('alt') ?? $data->img->getAttribute('title')) {
      $hash = hash(static::HASH_ALGO, $data->data);
      $filename = "inline-image-$hash";
    }
    // Ensure the file extension matches the mime type.
    if ($mime_extensions = MimeTypes::getDefault()->getExtensions($data->mime_type)) {
      $is_allowed_extension = FALSE;
      foreach ($mime_extensions as $mime_extension) {
        if ($mime_extension === $extension) {
          $is_allowed_extension = TRUE;
          break;
        }
      }
      if (!$is_allowed_extension) {
        $extension = reset($mime_extensions);
      }
    }
    if ($extension) {
      $filename .= ".$extension";
    }
    $event = new FileUploadSanitizeNameEvent($filename, $extension);
    $this->eventDispatcher->dispatch($event);
    return $event->getFilename();
  }

  /**
   * {@inheritdoc}
   */
  public function getTextItemUploadDirectory(TextItemBase $item): string {
    $format = $this->getTextItemFormatId($item);
    if (!isset($this->uploadDirectories[$format])) {
      if ($editor = $this->entityTypeManager->getStorage('editor')->load($format)) {
        /** @var \Drupal\editor\EditorInterface $editor */
        $upload_settings = $editor->getImageUploadSettings();
        if (!empty($upload_settings['scheme']) && !empty($upload_settings['directory'])) {
          $upload_directory = "{$upload_settings['scheme']}://{$upload_settings['directory']}";
          $this->fileSystem->prepareDirectory($upload_directory, FileSystemInterface::CREATE_DIRECTORY);
        }
      }
      $this->uploadDirectories[$format] = $upload_directory ?? $this->getDefaultUploadDirectory();
    }
    return $this->uploadDirectories[$format];
  }

  /**
   * {@inheritdoc}
   */
  public function getDefaultUploadDirectory(): string {
    if (!isset($this->defaultUploadDirectory)) {
      $default_scheme = $this->configFactory->get('system.file')->get('default_scheme');
      $this->defaultUploadDirectory = "$default_scheme://" . static::DEFAULT_UPLOAD_DIRECTORY_NAME;
      $this->fileSystem->prepareDirectory($this->defaultUploadDirectory, FileSystemInterface::CREATE_DIRECTORY);
    }
    return $this->defaultUploadDirectory;
  }

  /**
   * {@inheritdoc}
   */
  public function isDataUriImage(string $src): bool {
    return str_starts_with($src, 'data:');
  }

  /**
   * {@inheritdoc}
   */
  public function extractDataUriImage(string $src): ?string {
    $parts = explode(',', $src, 2);
    if (count($parts) !== 2) {
      return NULL;
    }
    [$meta, $data] = $parts;
    if (str_contains($meta, ';base64')) {
      return base64_decode($data) ?: NULL;
    }
    return rawurldecode($data);
  }

  /**
   * {@inheritdoc}
   */
  public function containsPlaceholders(string $text): bool {
    return str_contains($text, static::PLACEHOLDER_SYMBOL);
  }

  /**
   * {@inheritdoc}
   */
  public function placeholdersFromElement(\DOMElement $element): array {
    $placeholders = [];
    if ($element->hasAttributes()) {
      /** @var \DOMNode $attribute */
      foreach ($element->attributes as $attribute) {
        $placeholders[static::PLACEHOLDER_SYMBOL . $attribute->nodeName] = Html::escape($attribute->nodeValue);
      }
    }
    return $placeholders;
  }

  /**
   * {@inheritdoc}
   */
  public function processPlaceholders(string $markup, array $placeholders): string {
    return strtr($markup, $placeholders);
  }

  /**
   * {@inheritdoc}
   */
  public function replaceNodeWithMarkup(\DOMNode $node, string $markup): ?\DOMDocumentFragment {
    $dom = $node->ownerDocument;
    $fragment = $dom->createDocumentFragment();
    if (!$body = Html::load($markup)->getElementsByTagName('body')->item(0)) {
      return NULL;
    }
    foreach ($body->childNodes as $child) {
      $fragment->appendChild($dom->importNode($child, TRUE));
    }
    $node->parentNode->replaceChild($fragment, $node);
    return $fragment;
  }

  /**
   * {@inheritdoc}
   */
  public function getSettings(): array {
    return $this->getConfig()->get();
  }

  /**
   * {@inheritdoc}
   */
  public function getSetting(?string $name = NULL): bool|string|array|null {
    return $this->getConfig()->get($name);
  }

  /**
   * {@inheritdoc}
   */
  public function clearCachedSettings(): void {
    $this->config = NULL;
  }

  /**
   * Returns the module config.
   *
   * @return \Drupal\Core\Config\ImmutableConfig
   *   The module config.
   */
  protected function getConfig(): ImmutableConfig {
    if (!isset($this->config) || $this->config->isNew()) {
      $this->config = $this->configFactory->get(InlineImageSaverSettingsForm::CONFIG_NAME);
    }
    return $this->config;
  }

  /**
   * Returns the format ID of the text item.
   *
   * @return string
   *   The format ID.
   */
  protected function getTextItemFormatId(TextItemBase $item): string {
    return $item->get('format')->getString();
  }

  /**
   * Returns the main property instance of the text item.
   *
   * @return \Drupal\Core\TypedData\TypedDataInterface
   *   The main property instance.
   */
  protected function getTextItemMainProperty(TextItemBase $item): TypedDataInterface {
    return $item->get($item->getFieldDefinition()->getFieldStorageDefinition()->getMainPropertyName());
  }

  /**
   * Ensures the given DOM element is an <img> tag.
   *
   * @param \DOMElement $element
   *   The DOM element to validate.
   *
   * @throws \InvalidArgumentException
   *   If the element is not an <img> tag.
   */
  protected function ensureImage(\DOMElement $element): void {
    if ($element->nodeName !== 'img') {
      throw new \InvalidArgumentException("Expected an <img> element, got <$element->nodeName> instead.");
    }
  }

  /**
   * {@inheritdoc}
   */
  public function prepareUploadDirectory(TextItemBase $item): string {
    // @phpcs:ignore Drupal.Semantics.FunctionTriggerError.TriggerErrorTextLayoutRelaxed
    @trigger_error(__METHOD__ . 'is deprecated in inline_image_saver:2.1.0 and is removed from drupal:3.0.0. Use ::getTextItemUploadDirectory() instead.', E_USER_DEPRECATED);
    return $this->getTextItemUploadDirectory($item);
  }

}

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

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