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