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