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

}

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

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