image_to_media_swapper-2.x-dev/src/Controller/SwapperController.php
src/Controller/SwapperController.php
<?php
declare(strict_types=1);
namespace Drupal\image_to_media_swapper\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Drupal\Core\Access\CsrfTokenGenerator;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\file\Entity\File;
use Drupal\image_to_media_swapper\Entity\MediaSwapRecordInterface;
use Drupal\image_to_media_swapper\MediaSwapFormService;
use Drupal\image_to_media_swapper\SwapperService;
use Drupal\media\MediaInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Returns responses for Media Swapper routes.
*/
final class SwapperController extends ControllerBase {
public function __construct(
protected SwapperService $swapperService,
protected SerializerInterface $serializer,
protected CsrfTokenGenerator $csrfTokenGenerator,
protected AccountProxyInterface $account,
protected CacheBackendInterface $cache,
protected MediaSwapFormService $mediaSwapFormService,
) {}
/**
* Factory method to create the controller.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* The service container.
*
* @return \Drupal\image_to_media_swapper\Controller\SwapperController
* The controller instance.
*/
public static function create(ContainerInterface $container): SwapperController {
/** @var \Drupal\image_to_media_swapper\SwapperService $swapperService */
$swapperService = $container->get('image_to_media_swapper.service');
/** @var \Symfony\Component\Serializer\SerializerInterface $serializer */
$serializer = $container->get('serializer');
/** @var \Drupal\Core\Access\CsrfTokenGenerator $csrfTokenGenerator */
$csrfTokenGenerator = $container->get('csrf_token');
/** @var \Drupal\Core\Session\AccountProxyInterface $account */
$account = $container->get('current_user');
/** @var \Drupal\Core\Cache\CacheBackendInterface $cache */
$cache = $container->get('cache.image_to_media_swapper');
/** @var \Drupal\image_to_media_swapper\MediaSwapFormService $mediaSwapFormService */
$mediaSwapFormService = $container->get('image_to_media_swapper.form_service');
return new SwapperController(
$swapperService,
$serializer,
$csrfTokenGenerator,
$account,
$cache,
$mediaSwapFormService,
);
}
/**
* Builds the response.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function swapFileEntityWithMediaFromUuid(Request $request): JsonResponse {
// Security validation first.
$securityCheck = $this->validateSecurityRequirements($request);
if ($securityCheck) {
return $securityCheck;
}
// Check media configuration.
$configCheck = $this->checkMediaConfiguration();
if ($configCheck) {
return $configCheck;
}
$data = json_decode($request->getContent(), TRUE);
$uuid = $data['uuid'] ?? NULL;
if (!$uuid) {
return new JsonResponse(['error' => 'Missing UUID'], 400);
}
$file = $this->swapperService->findFileFromUuid($uuid);
if (!$file instanceof File) {
return new JsonResponse(['error' => 'File not found'], 404);
}
// Use the SwapperService's dynamic bundle/field determination.
// instead of hardcoded mappings.
$media = $this->swapperService->findOrCreateMediaFromFileEntity($file);
if (!$media instanceof MediaInterface) {
return new JsonResponse(['error' => 'Media entity could not be created'], 500);
}
$context = ['groups' => ['default']];
$normalized = $this->serializer->normalize($media, 'json', $context);
return new JsonResponse($normalized);
}
/**
* Swaps an image file based on the filename.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function swapFilePathToMedia(Request $request): JsonResponse {
// Security validation first.
$securityCheck = $this->validateSecurityRequirements($request);
if ($securityCheck) {
return $securityCheck;
}
// Check media configuration.
$configCheck = $this->checkMediaConfiguration();
if ($configCheck) {
return $configCheck;
}
$data = json_decode($request->getContent(), TRUE);
$filePath = $data['filepath'] ?? NULL;
if (!$filePath) {
return new JsonResponse(['error' => 'Missing filename'], 400);
}
// Convert web path to public:// URI.
$publicUri = $this->swapperService->convertWebPathToPublicUri($filePath);
if (!$publicUri) {
return new JsonResponse(['error' => 'Invalid file path'], 400);
}
$file = $this->swapperService->findOrCreateFileEntityByUri($publicUri);
if ($file instanceof File) {
$media = $this->swapperService->findOrCreateMediaFromFileEntity($file);
// Find out if the file is already associated with a media entity.
$context = ['groups' => ['default']];
$normalized = $this->serializer->normalize($media, 'json', $context);
return new JsonResponse($normalized);
}
return new JsonResponse(['error' => 'File not found'], 404);
}
/**
* Swaps a remote file URL to a media entity.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object containing the remote file URL.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON response containing the media entity or an error message.
*
* This method downloads a file from a remote URL, saves it to the public
* file system, and converts it to a media entity.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function swapRemoteFilePathToMedia(Request $request): JsonResponse {
// Security validation first.
$securityCheck = $this->validateSecurityRequirements($request);
if ($securityCheck) {
return $securityCheck;
}
// Check media configuration.
$configCheck = $this->checkMediaConfiguration();
if ($configCheck) {
return $configCheck;
}
$data = json_decode($request->getContent(), TRUE);
$fileUrl = $data['remote_file'] ?? NULL;
if (!$fileUrl) {
return new JsonResponse(['error' => 'Missing filename'], 400);
}
// Download the file to the public file system with security validation.
$fileName = urldecode(basename(parse_url($fileUrl, PHP_URL_PATH)));
// Clean up the filename to prevent directory traversal attacks.
$fileName = $this->swapperService->generateSafeFileName($fileName);
$publicUri = 'public://' . $fileName;
$result = $this->swapperService->downloadRemoteFile($fileUrl, $publicUri);
if ($result !== $publicUri) {
return new JsonResponse(['error' => $result], 400);
}
$file = $this->swapperService->findOrCreateFileEntityByUri($publicUri);
if (!$file instanceof File) {
return new JsonResponse(['error' => 'Local file not found'], 404);
}
// Check if the file already exists in the system.
$media = $this->swapperService->findOrCreateMediaFromFileEntity($file);
if (!$media instanceof MediaInterface) {
return new JsonResponse(['error' => 'Media entity could not be created'], 500);
}
// Return the serialized media entity.
$context = ['groups' => ['default']];
$normalized = $this->serializer->normalize($media, 'json', $context);
return new JsonResponse($normalized);
}
/**
* Rechecks a single media swap record.
*
* @param \Drupal\image_to_media_swapper\Entity\MediaSwapRecordInterface $media_swap_record
* The MediaSwapRecord entity or its ID.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirect to the referring page or the admin content page.
*/
public function recheckRecord(MediaSwapRecordInterface $media_swap_record): RedirectResponse {
$result = $this->mediaSwapFormService->recheckSingleRecord($media_swap_record);
if ($result) {
$this->messenger()->addStatus($this->t('The record has been rechecked.'));
}
else {
$this->messenger()->addError($this->t('Unable to recheck the record.'));
}
// Return to the manual swap queue page.
return $this->redirect('image_to_media_swapper.manual_swap_queue');
}
/**
* Checks if media type configuration is set up and returns error if not.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|null
* A JSON error response if configuration is missing, null if configured.
*/
private function checkMediaConfiguration(): ?JsonResponse {
// With auto-discovery, we're more lenient on configuration checks.
// The SwapperService will handle fallbacks automatically.
// Only show configuration warning if there are absolutely no media bundles
// available.
try {
$bundleFields = $this->swapperService->getMediaBundlesWithFileFields();
// If no media bundles with file fields exist, that's a serious problem.
if (empty($bundleFields)) {
$settingsUrl = Url::fromRoute('image_to_media_swapper.security_settings', [], [
'absolute' => TRUE,
])->toString();
return new JsonResponse([
'error' => 'No media bundles with file fields found. Please create media types or check your <a href="' . $settingsUrl . '" target="_blank">module settings</a>.',
], 400);
}
}
catch (\Exception $e) {
// If we can't check bundles, let the conversion proceed - SwapperService
// will handle errors.
return NULL;
}
return NULL;
}
/**
* Provides security tokens for the CKEditor plugin.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON response containing security tokens and user context.
*/
public function getSecurityTokens(Request $request): JsonResponse {
// Basic origin check for token endpoint.
$referer = $request->headers->get('Referer');
$serverHost = $request->getSchemeAndHttpHost();
if (!$referer || !str_starts_with($referer, $serverHost)) {
return new JsonResponse(['error' => 'Invalid request origin'], 403);
}
// Load the user entity to get the UUID.
$user = $this->entityTypeManager()->getStorage('user')->load($this->account->id());
return new JsonResponse([
'csrf_token' => $this->csrfTokenGenerator->get('image_to_media_swapper_api'),
'user_uuid' => $user ? $user->uuid() : '',
'user_id' => $this->account->id(),
'timestamp' => time(),
]);
}
/**
* Validates comprehensive security requirements for API endpoints.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|null
* A JSON error response if validation fails, null if passes.
*/
private function validateSecurityRequirements(Request $request): ?JsonResponse {
$data = json_decode($request->getContent(), TRUE);
// 1. CSRF Token Validation.
$token = $data['csrf_token'] ?? $request->headers->get('X-CSRF-Token');
if (!$token) {
return new JsonResponse(['error' => 'Missing CSRF token'], 403);
}
// Validate CSRF token.
$expectedToken = $this->csrfTokenGenerator->get('image_to_media_swapper_api');
if (!hash_equals($expectedToken, $token)) {
return new JsonResponse(['error' => 'Invalid CSRF token'], 403);
}
// 2. User Context Validation.
$userUuid = $data['user_uuid'] ?? NULL;
if (!$userUuid) {
return new JsonResponse(['error' => 'Missing user context'], 403);
}
// Load the user entity to get the UUID for comparison.
$user = $this->entityTypeManager()->getStorage('user')->load($this->account->id());
$currentUserUuid = $user ? $user->uuid() : '';
if ($userUuid !== $currentUserUuid) {
return new JsonResponse(['error' => 'Invalid user context'], 403);
}
// 3. Host Origin Verification.
$origin = $request->headers->get('Origin');
$referer = $request->headers->get('Referer');
$serverHost = $request->getSchemeAndHttpHost();
$validOrigin = FALSE;
if ($origin && str_starts_with($origin, $serverHost)) {
$validOrigin = TRUE;
}
elseif ($referer && str_starts_with($referer, $serverHost)) {
$validOrigin = TRUE;
}
if (!$validOrigin) {
return new JsonResponse(['error' => 'Invalid request origin'], 403);
}
// 4. Rate Limiting (basic implementation).
$userIp = $request->getClientIp();
$rateLimitKey = 'image_to_media_swapper_rate_limit:' . $userIp . ':' . $this->account->id();
// Get current timestamp in minutes for rate limiting window.
$currentMinute = floor(time() / 60);
$cacheKey = $rateLimitKey . ':' . $currentMinute;
$cached = $this->cache->get($cacheKey);
$requestCount = $cached ? $cached->data : 0;
// Allow maximum 30 requests per minute per user/IP combo.
if ($requestCount >= 30) {
return new JsonResponse(['error' => 'Rate limit exceeded. Try again later.'], 429);
}
// Increment counter.
$this->cache->set($cacheKey, $requestCount + 1, $currentMinute * 60 + 60);
// 5. Content-Type Validation.
$contentType = $request->headers->get('Content-Type');
if (!str_contains($contentType, 'application/json')) {
return new JsonResponse(['error' => 'Invalid content type'], 400);
}
return NULL;
}
}
