image_to_media_swapper-2.x-dev/src/SwapperService.php

src/SwapperService.php
<?php

declare(strict_types=1);

namespace Drupal\image_to_media_swapper;

use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Site\Settings;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\media\Entity\Media;
use Drupal\media\MediaInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\Mime\MimeTypeGuesserInterface;

/**
 * Tools to help with swapping images with media.
 */
class SwapperService {

  use StringTranslationTrait;

  /**
   * The logger channel service.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected LoggerChannelInterface $logger;

  /**
   * Constructs an SwapperService object.
   */
  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly ClientInterface $guzzleClient,
    private readonly FileSystemInterface $fileSystem,
    LoggerChannelFactoryInterface $loggerFactory,
    private readonly SecurityValidationService $securityValidationService,
    private readonly ConfigFactoryInterface $configFactory,
    private readonly EntityTypeBundleInfoInterface $bundleInfo,
    private readonly EntityFieldManagerInterface $entityFieldManager,
    private readonly ModuleHandlerInterface $moduleHandler,
    private readonly MimeTypeGuesserInterface $mimeTypeGuesser,
    private readonly ContentVerificationService $contentVerificationService,
  ) {
    $this->logger = $loggerFactory->get('image_to_media_swapper');
  }

  /**
   * Converts a web path to a public:// URI with enhanced security.
   *
   * @param string $filePath
   *   The web path (e.g., "/sites/default/files/subdirectory/file.jpg")
   *
   * @return string|null
   *   The public URI ("public://subdirectory/file.jpg") or null if invalid.
   */
  public function convertWebPathToPublicUri(string $filePath): ?string {
    // If empty, return early.
    if (empty($filePath)) {
      $this->logger->warning('Empty file path provided to convertWebPathToPublicUri');
      return NULL;
    }

    // Handle URL case.
    $isUrl = filter_var($filePath, FILTER_VALIDATE_URL);
    if ($isUrl) {
      $filePath = parse_url($filePath, PHP_URL_PATH);
      if ($filePath === FALSE || $filePath === NULL) {
        $this->logger->warning('Invalid URL provided: @url', ['@url' => $filePath]);
        return NULL;
      }
    }

    // URL decode the path.
    $filePath = urldecode($filePath);

    // Remove any null bytes (possible null byte injection attack).
    $filePath = str_replace("\0", '', $filePath);

    // Get the public files directory path.
    $publicPath = '/' . Settings::get('file_public_path', 'sites/default/files');

    // Normalize the path (resolve '../' and './' segments)
    $normalizedPath = $this->normalizePath($filePath);

    // If normalization failed (returned NULL), it was an invalid or
    // malicious path.
    if ($normalizedPath === NULL) {
      $this->logger->warning('Path normalization failed for: @path', ['@path' => $filePath]);
      return NULL;
    }

    // Validate that normalized path doesn't try to escape public:// directory.
    if ($this->isPathTraversalAttempt($normalizedPath, $publicPath)) {
      $this->logger->warning('Path traversal attempt detected: @path', ['@path' => $filePath]);
      return NULL;
    }

    // Handle the case where the path starts with the public path.
    if (str_starts_with($normalizedPath, $publicPath)) {
      // Remove the public path prefix.
      $relativePath = substr($normalizedPath, strlen($publicPath));
      $relativePath = ltrim($relativePath, '/');
      return 'public://' . $relativePath;
    }

    // Special case: extract from '/sites/default/files/' if in the path.
    if (str_contains($normalizedPath, '/sites/default/files/')) {
      $pos = strpos($normalizedPath, '/sites/default/files/');
      $relativePath = substr($normalizedPath, $pos + strlen('/sites/default/files/'));
      return 'public://' . $relativePath;
    }

    // As a last resort, just use the filename itself
    // but ensure it's just a filename with no directory components.
    $fileName = basename($normalizedPath);
    // Additional security check - make sure the filename doesn't contain
    // path separators.
    if ($fileName !== $normalizedPath && str_contains($fileName, '/')) {
      $this->logger->warning('Invalid filename contains path separators: @file', ['@file' => $fileName]);
      return NULL;
    }

    return 'public://' . $fileName;
  }

  /**
   * Normalizes a file path by resolving '../' and './' segments.
   *
   * @param string $path
   *   The file path to normalize.
   *
   * @return string|null
   *   The normalized path, or NULL if the path is invalid or attempts to
   *   traverse above the root.
   */
  private function normalizePath(string $path): ?string {
    // Split the path into segments.
    $segments = explode('/', $path);
    $result = [];

    foreach ($segments as $segment) {
      if ($segment === '.' || $segment === '') {
        // Skip '.' and empty segments.
        continue;
      }

      if ($segment === '..') {
        // Handle '../' by removing the last segment from the result.
        if (empty($result)) {
          // Attempting to go above the root is invalid or a traversal attempt.
          return NULL;
        }
        array_pop($result);
      }
      else {
        // Regular segment - add it to the result.
        $result[] = $segment;
      }
    }

    // Rebuild the path.
    $normalizedPath = '/' . implode('/', $result);

    // If the original path ended with a slash, preserve it.
    if ($path !== '' && str_ends_with($path, '/')) {
      $normalizedPath .= '/';
    }

    return $normalizedPath;
  }

  /**
   * Checks if a path is attempting to traverse outside the public:// directory.
   *
   * @param string $path
   *   The normalized path to check.
   * @param string $publicPath
   *   The public files directory path.
   *
   * @return bool
   *   TRUE if the path is a traversal attempt, FALSE otherwise.
   */
  private function isPathTraversalAttempt(string $path, string $publicPath): bool {
    // If the path doesn't start with the public path, and it's not just a
    // filename, check if it's a subpath of the public path after resolving.
    if (!str_starts_with($path, $publicPath) && str_contains($path, '/')) {
      // Check if the path is trying to escape the public:// directory.
      // Realpath can't be used on non-existent files, so we'll check for
      // relative path segments that might go above the public directory.
      // The path can only be considered safe if it:
      // 1. Is under the public files directory, or
      // 2. Is within known safe directories (we don't have any defined yet)
      // 3. Is just a filename with no directory components.
      // Check if the path is attempting to traverse to a parent directory.
      if (str_contains($path, '..')) {
        return TRUE;
      }

      // If we're dealing with an absolute path that's not within public path.
      if ($path[0] === '/' && !str_contains($path, $publicPath)) {
        return TRUE;
      }
    }

    return FALSE;
  }

  /**
   * Finds a file entity by its UUID.
   *
   * @param string $uuid
   *   The UUID of the file entity to find.
   *
   * @return \Drupal\file\FileInterface|null
   *   The file entity if found, or NULL if not found.
   */
  public function findFileFromUuid(string $uuid): ?FileInterface {
    try {
      $files = $this->entityTypeManager
        ->getStorage('file')
        ->loadByProperties(['uuid' => $uuid]);
      if (!empty($files)) {
        // Use existing file entity.
        return reset($files);
      }
    }
    catch (InvalidPluginDefinitionException $e) {
      $this->logger->error('Invalid plugin definition: @message', ['@message' => $e->getMessage()]);
    }
    catch (PluginNotFoundException $e) {
      $this->logger->error('Plugin not found: @message', ['@message' => $e->getMessage()]);
    }
    catch (\Exception $e) {
      $this->logger->error('An error occurred while loading file entities: @message', ['@message' => $e->getMessage()]);
    }
    return NULL;
  }

  /**
   * Finds or creates a file entity for the given public URL.
   *
   * @param string $publicUri
   *   The public URL of the file.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function findOrCreateFileEntityByUri(string $publicUri): ?FileInterface {
    try {
      $files = $this->entityTypeManager
        ->getStorage('file')
        ->loadByProperties(['uri' => $publicUri]);
      if (!empty($files)) {
        // Use existing file entity.
        return reset($files);
      }
    }
    catch (InvalidPluginDefinitionException $e) {
      $this->logger->error('Invalid plugin definition: @message', ['@message' => $e->getMessage()]);
    }
    catch (PluginNotFoundException $e) {
      $this->logger->error('Plugin not found: @message', ['@message' => $e->getMessage()]);
    }
    catch (\Exception $e) {
      $this->logger->error('An error occurred while loading file entities: @message', ['@message' => $e->getMessage()]);
    }
    if (empty($files)) {
      // Create a new file entity for the existing physical file.
      $fileName = basename($publicUri);
      $file = File::create([
        'uri' => $publicUri,
        'filename' => $fileName,
        'filemime' => $this->mimeTypeGuesser->guessMimeType($publicUri) ?: 'application/octet-stream',
        'status' => 1,
      ]);
      $file->save();
      return $file;
    }
    return NULL;
  }

  /**
   * Finds or creates a media entity from a file entity.
   *
   * @param \Drupal\file\FileInterface $file
   *   The file entity to associate with the media.
   * @param string|null $bundle
   *   The media bundle to use. If NULL, will determine based on file MIME type.
   * @param string|null $field
   *   The field name to use for the media entity. If NULL, will determine based
   *   on the bundle.
   * @param array $attributes
   *   Additional attributes to set on the media entity.
   * @param string $mediaName
   *   The name of the media entity. If empty, will use the file name.
   *
   * @return \Drupal\media\MediaInterface|null
   *   The media entity if created or found, or NULL on failure.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function findOrCreateMediaFromFileEntity(FileInterface $file, ?string $bundle = NULL, ?string $field = NULL, array $attributes = [], string $mediaName = ''): ?MediaInterface {
    $media = NULL;
    // Determine bundle and field based on file MIME type if not provided.
    if ($bundle === NULL || $field === NULL) {
      $mimeType = $file->getMimeType();
      $dynamicBundle = $this->getMediaBundleForMimeType($mimeType);
      $dynamicField = $this->getFieldNameForBundle($dynamicBundle);

      $bundle = $bundle ?? $dynamicBundle;
      $field = $field ?? $dynamicField;
    }
    // Find out if the file is already associated with a media entity.
    try {
      $query = $this->entityTypeManager
        ->getStorage('media')
        ->getQuery()
        ->accessCheck(FALSE)
        ->condition($field . '.target_id', $file->id());
      $media_ids = $query->execute();
      if (!empty($media_ids)) {
        // If media already exists, return it.
        $media = $this->entityTypeManager->getStorage('media')
          ->load(reset($media_ids));
      }
    }
    catch (InvalidPluginDefinitionException $e) {
      $this->logger->error('Invalid plugin definition: @message', ['@message' => $e->getMessage()]);
      return NULL;
    }
    catch (PluginNotFoundException $e) {
      $this->logger->error('Plugin not found: @message', ['@message' => $e->getMessage()]);
      return NULL;
    }
    catch (\Exception $e) {
      $this->logger->error('An error occurred while loading media entities: @message', ['@message' => $e->getMessage()]);
      return NULL;
    }

    if (empty($media)) {
      if (empty($attributes)) {
        // Get default attributes for this bundle.
        $defaultAttrs = $this->getDefaultAttributesForBundle($bundle);
        $attributes = ['target_id' => $file->id()];

        // Set default attribute values using filename.
        foreach ($defaultAttrs as $attr) {
          $attributes[$attr] = $file->getFilename();
        }
      }
      // Decode the media name if it was provided.
      if (empty($mediaName)) {
        $mediaName = urldecode($file->getFilename());
        // Swap _ from the name and make it title case .
        $mediaName = str_replace('_', ' ', $mediaName);
        $mediaName = ucwords($mediaName);
      }
      else {
        $mediaName = urldecode($mediaName);
      }
      // Create a media entity for the file.
      $media = Media::create([
        'bundle' => $bundle,
        'name' => $mediaName,
        $field => $attributes,
      ]);
      $media->save();
    }
    return $media;
  }

  /**
   * Downloads a remote file and saves it to the public file system.
   *
   * @param string $fileUrl
   *   The URL of the remote file to download.
   * @param string $publicUri
   *   The public URI where the file should be saved.
   *
   * @return string
   *   The public URI of the downloaded file or an error message.
   */
  public function downloadRemoteFile(string $fileUrl, string $publicUri): string {
    // Check if remote downloads are enabled.
    if (!$this->securityValidationService->isRemoteDownloadEnabled()) {
      return $this->t('Remote file downloads are disabled')->render();
    }

    // Validate the URL.
    $urlErrors = $this->securityValidationService->validateUrl($fileUrl);
    if (!empty($urlErrors)) {
      $this->logger->warning('URL validation failed for @url: @errors', [
        '@url' => $fileUrl,
        '@errors' => implode(', ', $urlErrors),
      ]);
      return $this->t('URL validation failed: @errors', [
        '@errors' => implode(', ', $urlErrors),
      ])->render();
    }

    $maxFileSize = $this->securityValidationService->getMaxFileSize();

    try {
      // HEAD request first to check file info.
      $guzzleOptions = $this->securityValidationService->getGuzzleOptions();
      $headResponse = $this->guzzleClient->head($fileUrl, $guzzleOptions);

      $contentLength = (int) ($headResponse->getHeader('Content-Length')[0] ?? 0);
      $contentType = $headResponse->getHeader('Content-Type')[0] ?? '';

      // Check file size.
      if ($contentLength > $maxFileSize) {
        $megabytes = $contentLength / 1024 / 1024;
        return $this->t('File too large (@size MB)', [
          '@size' => (string) round($megabytes, 1, PHP_ROUND_HALF_UP),
        ])->render();
      }

      // Validate file type.
      $filename = basename(parse_url($fileUrl, PHP_URL_PATH));
      $fileTypeErrors = $this->securityValidationService->validateFileType($contentType, $filename);
      if (!empty($fileTypeErrors)) {
        $this->logger->warning('File type validation failed for @url: @errors', [
          '@url' => $fileUrl,
          '@errors' => implode(', ', $fileTypeErrors),
        ]);
        return $this->t('File type validation failed: @errors', [
          '@errors' => implode(', ', $fileTypeErrors),
        ]
        )->render();
      }

      // Download with streaming and size limit.
      $guzzleOptions['stream'] = TRUE;
      $response = $this->guzzleClient->get($fileUrl, $guzzleOptions);

      if ($response->getStatusCode() === 200) {
        $stream = $response->getBody();

        // Ensure the directory for the public URI exists.
        // Remove the filename to get the directory.
        $directory = str_replace(basename($publicUri), '', $publicUri);
        $this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);

        // Get the real path only after ensuring the directory exists.
        $destination = $this->fileSystem->realpath($publicUri);

        if (!$destination) {
          // On failure, use the createFilename method to get a valid path.
          $directory = $this->fileSystem->realpath(dirname($publicUri));
          $basename = basename($publicUri);
          $destination = $directory . '/' . $basename;
        }

        $fileHandle = fopen($destination, 'w');
        $bytesWritten = 0;

        while (!$stream->eof()) {
          $chunk = $stream->read(8192);
          $bytesWritten += strlen($chunk);

          // Check size limit during download.
          if ($bytesWritten > $maxFileSize) {
            fclose($fileHandle);
            if (file_exists($destination)) {
              unlink($destination);
            }
            return $this->t('File size limit exceeded during download')
              ->render();
          }

          fwrite($fileHandle, $chunk);
        }
        fclose($fileHandle);

        // Verify downloaded file.
        if (!file_exists($destination)) {
          return $this->t('File not found after download')->render();
        }

        // Final file size check.
        $actualSize = filesize($destination);
        $sizeErrors = $this->securityValidationService->validateFileSize($actualSize);
        if (!empty($sizeErrors)) {
          if (file_exists($destination)) {
            unlink($destination);
          }
          return implode(', ', $sizeErrors);
        }

        // Final MIME type check on actual file.
        $actualMimeType = $this->mimeTypeGuesser->guessMimeType($destination);
        $finalTypeErrors = $this->securityValidationService->validateFileType($actualMimeType, $publicUri);
        if (!empty($finalTypeErrors)) {
          if (file_exists($destination)) {
            unlink($destination);
          }
          $this->logger->warning('Final file type validation failed for @file: @errors', [
            '@file' => $destination,
            '@errors' => implode(', ', $finalTypeErrors),
          ]);
          return $this->t('Final file type validation failed: @errors', ['@errors' => implode(', ', $finalTypeErrors)])
            ->render();
        }

        // Enhanced content verification for downloaded files.
        $verificationResult = $this->contentVerificationService->verifyFileContent($destination, $actualMimeType);
        if (!$verificationResult['verified']) {
          if (file_exists($destination)) {
            unlink($destination);
          }
          $this->logger->warning(
            'Content verification failed for downloaded file from @url: @errors',
            [
              '@url' => $fileUrl,
              '@errors' => implode(', ', $verificationResult['errors']),
            ]
          );
          return $this->t('Downloaded file failed content verification: @errors', [
            '@errors' => implode(', ', $verificationResult['errors']),
          ])->render();
        }

        // If the actual type differs significantly from what was declared in
        // the HTTP headers, log it.
        if (!empty($verificationResult['actual_type']) &&
          $verificationResult['actual_type'] !== $actualMimeType &&
          explode('/', $verificationResult['actual_type'])[0] !== explode('/', $actualMimeType)[0]) {
          $this->logger->notice(
            'File type mismatch for downloaded file from @url: declared as @declared, detected as @actual',
            [
              '@url' => $fileUrl,
              '@declared' => $actualMimeType,
              '@actual' => $verificationResult['actual_type'],
            ]
          );
        }

        return $publicUri;
      }
    }
    catch (RequestException $e) {
      $this->logger->error('HTTP request failed for @url: @message', [
        '@url' => $fileUrl,
        '@message' => $e->getMessage(),
      ]);
      return $this->t('Download failed: @error', [
        '@error' => $e->getMessage(),
      ])->render();
    }
    catch (GuzzleException $e) {
      $this->logger->error('Guzzle error for @url: @message', [
        '@url' => $fileUrl,
        '@message' => $e->getMessage(),
      ]);
      return $this->t('Download failed: @error', ['@error' => $e->getMessage()])
        ->render();
    }
    catch (\Exception $e) {
      $this->logger->error('Unexpected error downloading @url: @message', [
        '@url' => $fileUrl,
        '@message' => $e->getMessage(),
      ]);
      return $this->t('Download failed: @error', ['@error' => $e->getMessage()])
        ->render();
    }

    return $this->t('Download failed')->render();
  }

  /**
   * Validates a file path and creates or finds associated media entity.
   *
   * @param string $filePath
   *   The file path to validate and process.
   *
   * @return \Drupal\media\MediaInterface|null
   *   The media entity or null on failure.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function validateAndProcessFilePath(string $filePath): ?MediaInterface {
    // Convert web path to public:// URI.
    $publicUri = $this->convertWebPathToPublicUri($filePath);
    if (!$publicUri) {
      $this->logger->warning('Invalid file path: @path', ['@path' => $filePath]);
      return NULL;
    }

    // Check if the file actually exists in the filesystem.
    $realPath = $this->fileSystem->realpath($publicUri);
    if (!$realPath || !file_exists($realPath)) {
      $this->logger->warning('File does not exist: @path', ['@path' => $publicUri]);
      return NULL;
    }

    // Verify it's a supported file type.
    if (!$this->isSupportedFile($publicUri)) {
      $this->logger->warning('File is not supported: @path', ['@path' => $publicUri]);
      return NULL;
    }

    // Find or create file entity.
    $file = $this->findOrCreateFileEntityByUri($publicUri);
    if (!$file instanceof FileInterface) {
      $this->logger->warning('Could not create file entity: @path', ['@path' => $publicUri]);
      return NULL;
    }

    // Create media entity using dynamic bundle determination.
    return $this->findOrCreateMediaFromFileEntity($file);
  }

  /**
   * Validates a file UUID and creates or finds associated media entity.
   *
   * @param string $uuid
   *   The UUID of the file entity.
   *
   * @return \Drupal\media\MediaInterface|null
   *   The media entity or null on failure.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function validateAndProcessFileUuid(string $uuid): ?MediaInterface {
    // Find file by UUID.
    $file = $this->findFileFromUuid($uuid);
    if (!$file instanceof FileInterface) {
      $this->logger->warning('File not found for UUID: @uuid', ['@uuid' => $uuid]);
      return NULL;
    }

    // Create media entity using dynamic bundle determination.
    return $this->findOrCreateMediaFromFileEntity($file);
  }

  /**
   * Downloads and validates a remote file.
   *
   * @param string $remoteUrl
   *   The remote URL of the file.
   *
   * @return \Drupal\media\MediaInterface|string
   *   The media entity on success, or error message string on failure.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function validateAndProcessRemoteFile(string $remoteUrl): MediaInterface|string {
    // Additional file-specific validation.
    if (!$this->isSupportedFileUrl($remoteUrl)) {
      return 'URL does not appear to be a supported file type';
    }

    // Use existing secure download method.
    $fileName = $this->generateSafeFileName($remoteUrl);
    $publicUri = 'public://' . $fileName;

    $result = $this->downloadRemoteFile($remoteUrl, $publicUri);
    if ($result !== $publicUri) {
      // Return error message.
      return $result;
    }

    // Verify downloaded file is supported.
    $realPath = $this->fileSystem->realpath($publicUri);
    if (!$this->isSupportedFileContent($realPath)) {
      if (file_exists($realPath)) {
        unlink($realPath);
      }
      return $this->t('Downloaded file is not a supported file type')->render();
    }

    // Create file entity.
    $file = $this->findOrCreateFileEntityByUri($publicUri);
    if (!$file instanceof FileInterface) {
      return $this->t('Could not create file entity for downloaded file')
        ->render();
    }

    // Create media entity using dynamic bundle determination.
    $media = $this->findOrCreateMediaFromFileEntity($file);
    return $media instanceof MediaInterface ? $media : $this->t('Could not create media entity for file')
      ->render();
  }

  /**
   * Checks if a file URI is supported based on extension and MIME type.
   *
   * @param string $uri
   *   The file URI to check.
   *
   * @return bool
   *   TRUE if the file appears to be supported.
   */
  private function isSupportedFile(string $uri): bool {
    // Check if extension is in available extensions.
    $extension = strtolower(pathinfo($uri, PATHINFO_EXTENSION));
    $availableExtensions = $this->getAvailableExtensions();
    if (!in_array($extension, $availableExtensions, TRUE)) {
      return FALSE;
    }

    // Check MIME type if file exists.
    $realPath = $this->fileSystem->realpath($uri);
    if ($realPath && file_exists($realPath)) {
      $mimeType = $this->mimeTypeGuesser->guessMimeType($realPath);
      // Additional security validation for MIME type.
      $typeErrors = $this->securityValidationService->validateFileType($mimeType, $uri);
      return empty($typeErrors);
    }

    // If file doesn't exist yet, trust the extension.
    return TRUE;
  }

  /**
   * Checks if a URL appears to be a supported file based on URL patterns.
   *
   * @param string $url
   *   The URL to check.
   *
   * @return bool
   *   TRUE if the URL appears to be a supported file type.
   */
  private function isSupportedFileUrl(string $url): bool {
    $availableExtensions = $this->getAvailableExtensions();
    if (empty($availableExtensions)) {
      return FALSE;
    }

    // Check for supported file extensions in URL.
    $pattern = '/\.(' . implode('|', array_map('preg_quote', $availableExtensions)) . ')(\?|#|$)/i';
    if (preg_match($pattern, $url)) {
      return TRUE;
    }

    // Check for file-related query parameters.
    $fileIndicators = [];
    foreach ($availableExtensions as $ext) {
      $fileIndicators[] = "type=$ext";
      $fileIndicators[] = "format=$ext";
      $fileIndicators[] = "/$ext/";
    }

    $urlLower = strtolower($url);
    foreach ($fileIndicators as $indicator) {
      if (str_contains($urlLower, strtolower($indicator))) {
        return TRUE;
      }
    }

    return FALSE;
  }

  /**
   * Validates actual file content to ensure it's a supported file type.
   *
   * @param string $filePath
   *   The absolute file path to check.
   *
   * @return bool
   *   TRUE if the file content is a supported file type.
   */
  private function isSupportedFileContent(string $filePath): bool {
    if (!file_exists($filePath)) {
      return FALSE;
    }

    // Get the basic MIME type using Drupal's guesser.
    $mimeType = $this->mimeTypeGuesser->guessMimeType($filePath);

    // First do basic validation with security service.
    $typeErrors = $this->securityValidationService->validateFileType($mimeType, $filePath);
    if (!empty($typeErrors)) {
      $this->logger->warning('File type validation failed: @errors', ['@errors' => implode(', ', $typeErrors)]);
      return FALSE;
    }

    // Check file size.
    $fileSize = filesize($filePath);
    $sizeErrors = $this->securityValidationService->validateFileSize($fileSize);
    if (!empty($sizeErrors)) {
      $this->logger->warning('File size validation failed: @errors', ['@errors' => implode(', ', $sizeErrors)]);
      return FALSE;
    }

    // Now perform enhanced content verification.
    $verificationResult = $this->contentVerificationService->verifyFileContent($filePath, $mimeType);

    if (!$verificationResult['verified']) {
      $this->logger->warning(
        'Content verification failed for @file of type @type: @errors',
        [
          '@file' => $filePath,
          '@type' => $mimeType,
          '@errors' => implode(', ', $verificationResult['errors']),
        ]
      );
      return FALSE;
    }

    // If the actual type differs significantly from the declared type, log it.
    if (!empty($verificationResult['actual_type']) &&
      $verificationResult['actual_type'] !== $mimeType &&
      explode('/', $verificationResult['actual_type'])[0] !== explode('/', $mimeType)[0]) {
      $this->logger->notice(
        'File type mismatch for @file: declared as @declared, detected as @actual',
        [
          '@file' => $filePath,
          '@declared' => $mimeType,
          '@actual' => $verificationResult['actual_type'],
        ]
      );
    }

    return TRUE;
  }

  /**
   * Generates a safe filename from a remote URL.
   *
   * @param string $url
   *   The remote URL.
   *
   * @return string
   *   A safe filename with appropriate extension.
   */
  public function generateSafeFileName(string $url): string {
    $filename = basename(parse_url($url, PHP_URL_PATH));
    $filename = urldecode($filename);
    $availableExtensions = $this->getAvailableExtensions();

    // Check if filename already has a supported extension.
    $hasValidExtension = FALSE;
    foreach ($availableExtensions as $ext) {
      if (preg_match('/\.' . preg_quote($ext, '/') . '$/i', $filename)) {
        $hasValidExtension = TRUE;
        break;
      }
    }

    // If no valid extension, try to determine from URL or add a default.
    if (!$hasValidExtension) {
      // Try to extract extension from URL path.
      $urlExtension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
      if ($urlExtension && in_array(strtolower($urlExtension), $availableExtensions, TRUE)) {
        $filename .= '.' . strtolower($urlExtension);
      }
      else {
        // Default to first available extension if none found.
        $filename .= '.' . ($availableExtensions[0] ?? 'bin');
      }
    }

    // Sanitize filename.
    $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
    $filename = preg_replace('/_+/', '_', $filename);
    $filename = trim($filename, '_');
    $filename = strtolower($filename);

    // Ensure it's not empty and not too long.
    $extension = '.' . pathinfo($filename, PATHINFO_EXTENSION);
    if (empty($filename) || $filename === $extension) {
      $filename = 'remote_file_' . time() . $extension;
    }

    if (strlen($filename) > 100) {
      $extension = '.' . pathinfo($filename, PATHINFO_EXTENSION);
      $filename = substr($filename, 0, 100 - strlen($extension)) . $extension;
    }

    return $filename;
  }

  /**
   * Determines the appropriate media bundle for a given MIME type.
   *
   * @param string $mimeType
   *   The MIME type of the file.
   *
   * @return string
   *   The media bundle name to use.
   */
  private function getMediaBundleForMimeType(string $mimeType): string {
    // Extract the main type from MIME type (e.g., 'application' from
    // 'application/pdf').
    $type = explode('/', $mimeType)[0] ?? '';

    // Map MIME types to file extensions for matching.
    $mimeToExtensionMap = [
      'application/pdf' => 'pdf',
      'image/jpeg' => 'jpg',
      'image/jpg' => 'jpg',
      'image/png' => 'png',
      'image/gif' => 'gif',
      'image/webp' => 'webp',
      'text/plain' => 'txt',
      'application/msword' => 'doc',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
    ];

    $extension = $mimeToExtensionMap[$mimeType] ?? explode('/', $mimeType)[1] ?? '';
    $config = $this->configFactory->get('image_to_media_swapper.security_settings');
    $allowedExtensions = $config->get('allowed_extensions');
    $mappings = [];

    // Only process if allowed_extensions is not null/empty.
    if (!empty($allowedExtensions)) {
      // Handle both string and array formats.
      $extensionList = is_array($allowedExtensions) ? $allowedExtensions : explode(' ', $allowedExtensions);
      // Convert to associative array if needed--this expects key-value pairs.
      foreach ($extensionList as $item) {
        if (str_contains($item, '=')) {
          [$ext, $bundle] = explode('=', $item, 2);
          $mappings[trim($ext)] = trim($bundle);
        }
      }
    }

    // Check for exact match first.
    if (isset($mappings[$extension])) {
      return $mappings[$extension];
    }

    // Check for wildcard matches.
    foreach ($mappings as $pattern => $bundle) {
      if (str_contains($pattern, '*')) {
        $regex = str_replace('*', '.*', preg_quote($pattern, '/'));
        if (preg_match('/^' . $regex . '$/', $mimeType)) {
          return $bundle;
        }
      }
    }

    // Fallback to auto-discovered bundles based on actual field extensions.
    try {
      $bundleFields = $this->getMediaBundlesWithFileFields(TRUE);
      if (!empty($bundleFields)) {
        // First, try to match by actual file extensions supported by fields.
        foreach ($bundleFields as $bundle => $fields) {
          foreach ($fields as $fieldInfo) {
            if (!empty($fieldInfo['extensions'])) {
              $supportedExtensions = explode(' ', $fieldInfo['extensions']);
              if (in_array($extension, $supportedExtensions, TRUE)) {
                return $bundle;
              }
            }
          }
        }

        // If no extension match, fall back to smart match by MIME type group.
        $availableBundles = array_keys($bundleFields);
        if ($type === 'image') {
          foreach ($availableBundles as $bundle) {
            if (str_contains($bundle, 'image')) {
              return $bundle;
            }
          }
        }
        elseif ($type === 'video') {
          foreach ($availableBundles as $bundle) {
            if (str_contains($bundle, 'video')) {
              return $bundle;
            }
          }
        }
        elseif (str_starts_with($mimeType, 'audio/')) {
          foreach ($availableBundles as $bundle) {
            if (str_contains($bundle, 'audio')) {
              return $bundle;
            }
          }
        }
        elseif (str_starts_with($mimeType, 'application/pdf')) {
          foreach ($availableBundles as $bundle) {
            if (str_contains($bundle, 'document') || str_contains($bundle, 'pdf')) {
              return $bundle;
            }
          }
        }

        // If no smart match found, use the first available bundle.
        return $availableBundles[0];
      }
    }
    catch (\Exception $e) {
      $this->logger->warning('Failed to get auto-discovered bundles: @message', ['@message' => $e->getMessage()]);
    }

    // Final fallback based on MIME type category.
    if (str_starts_with($mimeType, 'image/')) {
      return 'image';
    }
    elseif (str_starts_with($mimeType, 'video/')) {
      return 'video';
    }
    elseif (str_starts_with($mimeType, 'audio/')) {
      return 'audio';
    }
    else {
      return 'file';
    }
  }

  /**
   * Gets the field name for a given media bundle.
   *
   * @param string $bundle
   *   The media bundle name.
   *
   * @return string
   *   The field name to use for this bundle.
   */
  private function getFieldNameForBundle(string $bundle): string {
    $config = $this->configFactory->get('image_to_media_swapper.security_settings');
    $mappings = $config->get('field_mappings') ?? [];

    if (isset($mappings[$bundle])) {
      return $mappings[$bundle];
    }

    // Fallback to auto-discovered fields for this bundle.
    try {
      $bundleFields = $this->getMediaBundlesWithFileFields(TRUE);
      if (!empty($bundleFields[$bundle])) {
        // Use the first available file field for this bundle.
        return array_keys($bundleFields[$bundle])[0];
      }
    }
    catch (\Exception $e) {
      $this->logger->warning('Failed to get auto-discovered field for bundle @bundle: @message', [
        '@bundle' => $bundle,
        '@message' => $e->getMessage(),
      ]);
    }

    return '';
  }

  /**
   * Gets the default attributes for a given media bundle.
   *
   * @param string $bundle
   *   The media bundle name.
   *
   * @return array
   *   Array of attribute names to set by default.
   */
  private function getDefaultAttributesForBundle(string $bundle): array {
    $config = $this->configFactory->get('image_to_media_swapper.security_settings');
    $mappings = $config->get('default_attributes') ?? [];

    if (isset($mappings[$bundle])) {
      return array_map('trim', explode(',', $mappings[$bundle]));
    }

    // Fallback to auto-discovered smart defaults based on bundle name.
    try {
      $bundleFields = $this->getMediaBundlesWithFileFields(TRUE);
      if (isset($bundleFields[$bundle])) {
        // Set smart defaults based on bundle name patterns.
        if (str_contains($bundle, 'image')) {
          return ['alt', 'title'];
        }
        elseif (str_contains($bundle, 'video')) {
          return ['title'];
        }
        elseif (str_contains($bundle, 'audio')) {
          return ['title'];
        }
        else {
          // Default for document/file bundles.
          return ['title'];
        }
      }
    }
    catch (\Exception $e) {
      $this->logger->warning('Failed to get auto-discovered attributes for bundle @bundle: @message', [
        '@bundle' => $bundle,
        '@message' => $e->getMessage(),
      ]);
    }

    // No default attributes - if there are no fields, nothing can work.
    return [];
  }

  /**
   * Gets all media bundles and their fields that reference files.
   *
   * @param bool $getAll
   *   An override to allow evaluating files in all cases.
   *
   * @return array
   *   An associative array keyed by bundle name, with each value an array of
   *   field names.
   */
  public function getMediaBundlesWithFileFields(bool $getAll = FALSE): array {
    $results = [];
    $file_types = ['image'];
    // If linkit is enabled, add 'file' to the list of file types.
    if ($getAll || $this->moduleHandler->moduleExists('linkit')) {
      $file_types[] = 'file';
    }

    // Get all media bundles.
    $bundles = $this->bundleInfo->getBundleInfo('media');

    foreach ($bundles as $bundle => $bundle_label) {
      $fields = $this->entityFieldManager->getFieldDefinitions('media', $bundle);

      foreach ($fields as $field_name => $field_definition) {
        $type = $field_definition->getType();
        if (str_starts_with($field_name, 'field_') && in_array($type, $file_types)) {
          $results[$bundle][$field_name] = [
            'label' => $field_definition->getLabel(),
            'extensions' => $field_definition->getSetting('file_extensions'),
            'type' => $type,
          ];
        }
      }
    }

    return $results;
  }

  /**
   * Gets all available file extensions from media bundles.
   *
   * @return array
   *   An array of unique file extensions available in media bundles.
   */
  public function getAvailableExtensions(bool $force = FALSE): array {
    $allowedExtensions = $this->configFactory->get('image_to_media_swapper.security_settings')
      ->get('allowed_extensions');
    if (!$force && !empty($allowedExtensions) && !is_array($allowedExtensions)) {
      return explode(' ', $allowedExtensions);
    }
    $fields = $this->getMediaBundlesWithFileFields(TRUE);
    $extensions = [];
    foreach ($fields as $bundleFields) {
      foreach ($bundleFields as $bundleField) {
        if (!empty($bundleField['extensions'])) {
          $bundle_extensions = explode(' ', $bundleField['extensions']);
          foreach ($bundle_extensions as $extension) {
            $extension = trim($extension);
            if (!in_array($extension, $extensions)) {
              $extensions[] = $extension;
            }
          }
        }
      }
    }
    return $extensions;
  }

}

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

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