Commit c379fdb1 authored by codebymikey's avatar codebymikey Committed by Stephen Mustgrave
Browse files

Issue #3295294: Provide a way to specify an attachment disposition

parent 627885e6
Loading
Loading
Loading
Loading
+6 −3
Original line number Diff line number Diff line
@@ -23,7 +23,9 @@ REQUIREMENTS

This module requires:

* PHP > 7.2CONTENTS OF THIS FILE
* PHP > 7.4

CONTENTS OF THIS FILE
---------------------

* Introduction
@@ -48,7 +50,7 @@ REQUIREMENTS

This module requires:

* PHP > 7.2
* PHP > 7.4
* Drupal Core Media


@@ -69,7 +71,8 @@ TRICKS
------

* If you're viewing a file with an alias and need help finding the media object just append
  ?edit-media.  This will redirect you straight to the media edit page.
  `?edit-media` to the URL, this will redirect you straight to the media edit page.
* If you want the media file to be downloaded, you may append `?download` or `?dl` to the URL.

CONFIGURATION
-------------
+0 −1
Original line number Diff line number Diff line
@@ -7,4 +7,3 @@ package: Media
configure: media_alias_display.settings_form
dependencies:
  - drupal:media
  - drupal:path_alias
+90 −89
Original line number Diff line number Diff line
@@ -2,25 +2,26 @@

namespace Drupal\media_alias_display\Controller;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Entity\Controller\EntityViewController;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\file\Entity\File;
use Drupal\path_alias\AliasManagerInterface;
use Drupal\file\FileInterface;
use Drupal\media\MediaInterface;
use Drupal\media\Plugin\media\Source\File as FileMediaSource;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Drupal\Core\Url;
use Drupal\media\Entity\Media;

/**
 * Defines a controller to render a file with Media Alias being used.
@@ -35,35 +36,21 @@ class DisplayController extends EntityViewController {
  protected AccountInterface $currentUser;

  /**
   * The logger factory.
   * The media alias display logger.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected LoggerChannelFactoryInterface $loggerFactory;
  protected LoggerChannelInterface $logger;

  /**
   * The request stack.
   * The current request.
   *
   * @var \Symfony\Component\HttpFoundation\Request
   */
  protected Request $request;

  /**
   * The current path.
   *
   * @var \Drupal\Core\Path\CurrentPathStack
   */
  protected CurrentPathStack $currentPath;

  /**
   * The path alias manager.
   *
   * @var \Drupal\path_alias\AliasManagerInterface
   */
  protected AliasManagerInterface $aliasManager;

  /**
   * Drupal\Core\Config\ConfigManagerInterface definition.
   * The config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
@@ -76,6 +63,13 @@ class DisplayController extends EntityViewController {
   */
  protected StreamWrapperManagerInterface $streamWrapperManager;

  /**
   * The module handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected ModuleHandlerInterface $moduleHandler;

  /**
   * The controller constructor.
   *
@@ -85,37 +79,33 @@ class DisplayController extends EntityViewController {
   *   The renderer service.
   * @param \Drupal\Core\Session\AccountInterface $current_user
   *   Current user.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $loggerFactory
   *   The logger factory.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
   *   The media alias display logger.
   * @param \Symfony\Component\HttpFoundation\Request $request_stack
   *   Request stack.
   * @param \Drupal\Core\Path\CurrentPathStack $current_path
   *   The current path.
   * @param \Drupal\path_alias\AliasManagerInterface $alias_manager
   *   The path alias manager.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config
   *   Configuration Interface.
   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
   *   The stream wrapper manager.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager,
                              RendererInterface $renderer,
                              AccountInterface $current_user,
                              LoggerChannelFactoryInterface $loggerFactory,
                              LoggerChannelInterface $logger,
                              Request $request_stack,
                              CurrentPathStack $current_path,
                              AliasManagerInterface $alias_manager,
                              ConfigFactoryInterface $config,
                              StreamWrapperManagerInterface $stream_wrapper_manager
                              StreamWrapperManagerInterface $stream_wrapper_manager,
                              ModuleHandlerInterface $module_handler
  ) {
    parent::__construct($entity_type_manager, $renderer);
    $this->currentUser = $current_user;
    $this->loggerFactory = $loggerFactory;
    $this->logger = $logger;
    $this->request = $request_stack;
    $this->currentPath = $current_path;
    $this->aliasManager = $alias_manager;
    $this->configManager = $config;
    $this->streamWrapperManager = $stream_wrapper_manager;
    $this->moduleHandler = $module_handler;
  }

  /**
@@ -126,12 +116,11 @@ class DisplayController extends EntityViewController {
      $container->get('entity_type.manager'),
      $container->get('renderer'),
      $container->get('current_user'),
      $container->get('logger.factory'),
      $container->get('logger.factory')->get('media_alias_display'),
      $container->get('request_stack')->getCurrentRequest(),
      $container->get('path.current'),
      $container->get('path_alias.manager'),
      $container->get('config.factory'),
      $container->get('stream_wrapper_manager'),
      $container->get('module_handler')
    );
  }

@@ -139,96 +128,108 @@ class DisplayController extends EntityViewController {
   * {@inheritdoc}
   */
  public function view(EntityInterface $media, $view_mode = 'full', $langcode = NULL) {
    assert($media instanceof MediaInterface);
    $config = $this->configManager->get('media_alias_display.settings');

    if (!empty($config->get('kill_switch')) && $config->get('kill_switch')) {
      return parent::view($media, $view_mode);
      return $this->updateRenderCache(parent::view($media, $view_mode), $config);
    }

    $media_bundle = $media->bundle();
    $allowed_bundles = $config->get('media_bundles');
    if (!empty($config->get('media_bundles'))) {
      $allowAllBundles = TRUE;
      foreach ($config->get('media_bundles') as $bundle) {
        if ($bundle !== 0) {
          $allowAllBundles = FALSE;
      $allow_all_bundles = TRUE;
      foreach ($config->get('media_bundles') as $allowed_bundle) {
        if ($allowed_bundle !== 0) {
          $allow_all_bundles = FALSE;
          break;
        }
      }

      if (!$allowAllBundles && isset($allowed_bundles[$media->bundle()]) && $allowed_bundles[$media->bundle()] === 0) {
        return parent::view($media, $view_mode);
      if (!$allow_all_bundles && isset($allowed_bundles[$media_bundle]) && $allowed_bundles[$media_bundle] === 0) {
        return $this->updateRenderCache(parent::view($media, $view_mode), $config);
      }
    }

    $current_path = $this->currentPath->getPath();

    $alias = $this->aliasManager->getPathByAlias($current_path);
    $params = Url::fromUri('internal:' . $alias)->getRouteParameters();
    $entity_type = key($params);
    $mid = $params[$entity_type];
    $media = Media::load($mid);

    $bundle = $media->bundle();
    $edit_own = 'edit own ' . $bundle . ' media';
    $edit_any = 'edit any ' . $bundle . ' media';
    $edit_own = 'edit own ' . $media_bundle . ' media';
    $edit_any = 'edit any ' . $media_bundle . ' media';

    // Skip redirect and go straight to media object.
    if ($this->request->query->has('edit-media') &&
      (($this->currentUser->hasPermission($edit_own) || $this->currentUser->hasPermission($edit_any)) || $this->currentUser->hasPermission('administer media'))) {
      return new RedirectResponse('/media/' . $mid . '/edit');
      return new RedirectResponse($media->toUrl('edit-form')->toString());
    }

    if (\Drupal::moduleHandler()->moduleExists('media_alias_display_field_override')) {
    if (
      $this->moduleHandler->moduleExists('media_alias_display_field_override') &&
      $media->hasField('field_override_mad_module')
    ) {
      $override_module = $media->get('field_override_mad_module')->value;
      if (isset($override_module) && $override_module) {
        return parent::view($media, $view_mode);
        return $this->updateRenderCache(parent::view($media, $view_mode), $config);
      }
    }

    $source = $media->getSource();
    $config = $source->getConfiguration();
    $field = $config['source_field'];
    $fid = $media->{$field}->target_id;

    // If media has no file item.
    if (!$fid) {
      $this->loggerFactory->get('media_alias_display')
        ->notice('The media item requested has no file referenced/uploaded for @path', [
          '@path' => $current_path,
    if (!($source instanceof FileMediaSource)) {
      // The module only supports file media sources at the moment. Could
      // potentially add support for redirect to oEmbed sources.
      $this->logger
        ->notice('Media item "@media_entity_id" does not have a file media source', [
          '@media_entity_id' => $media->id(),
        ]);
      return parent::view($media, $view_mode);
      return $this->updateRenderCache(parent::view($media, $view_mode), $config);
    }

    $file = File::load($fid);
    $file = $media->get($source->getConfiguration()['source_field'])->entity;

    // Or file entity could not be loaded. Very unlikely to happen.
    // If media has no file item.
    if (!$file) {
      $this->loggerFactory->get('media_alias_display')
        ->notice('File id could not be loaded for ' . $current_path);
      return parent::view($media, $view_mode);
      $this->logger
        ->notice('Media item "@media_entity_id" does not have a file entity attached', [
          '@media_entity_id' => $media->id(),
        ]);
      return $this->updateRenderCache(parent::view($media, $view_mode), $config);
    }

    assert($file instanceof FileInterface);
    $uri = $file->getFileUri();
    $scheme = $this->streamWrapperManager::getScheme($uri);

    // Or item does not exist on disk.
    if (!$this->streamWrapperManager->isValidScheme($scheme) || !file_exists($uri)) {
      $this->loggerFactory->get('media_alias_display')
        ->notice('File does not exist for @path', [
          '@path' => $current_path,
    if (!$this->streamWrapperManager->isValidScheme($scheme) || !is_file($uri)) {
      $this->logger
        ->notice('File attached to Media item "@media_entity_id" does not exist on disk', [
          '@media_entity_id' => $media->id(),
        ]);
      return parent::view($media, $view_mode);
      return $this->updateRenderCache(parent::view($media, $view_mode), $config);
    }

    $filename = $file->getFilename();
    $response = new BinaryFileResponse($uri, Response::HTTP_OK, [], $scheme !== 'private');
    // Force a direct download if a "dl" or "download" query string is present.
    if ($this->request->query->has('dl') || $this->request->query->has('download')) {
      $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file->getFilename());
    }

    $response = new BinaryFileResponse($uri);
    $response->setContentDisposition(
      ResponseHeaderBag::DISPOSITION_INLINE,
      $filename
    );
    return $response;
  }

    return new BinaryFileResponse($uri, Response::HTTP_OK, [], $scheme !== 'private');
  /**
   * Add appropriate cache tags to the render array.
   */
  protected function updateRenderCache($response, ImmutableConfig $config) {
    if (!is_array($response)) {
      return $response;
    }
    CacheableMetadata::createFromRenderArray($response)
      ->addCacheableDependency($config)
      ->addCacheContexts([
        'url.query_args:dl',
        'url.query_args:download',
      ])
      ->applyTo($response);

    return $response;
  }

}
+92 −22
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@

namespace Drupal\Tests\media_alias_display\Functional;

use Drupal\Core\File\FileSystemInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
@@ -11,6 +12,7 @@ use Drupal\Tests\media\Functional\MediaFunctionalTestBase;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\Entity\Role;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;

/**
 * Simple test to ensure that main page loads with module enabled.
@@ -28,7 +30,7 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
   * @var array
   */
  protected static $modules = [
    'media_alias_display',
    'media_alias_display', 'path',
  ];

  /**
@@ -41,7 +43,7 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
   *
   * @var \Drupal\file\FileInterface
   */
  protected FileInterface $file;
  protected ?FileInterface $file;

  /**
   * Store Media Type.
@@ -86,7 +88,7 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  protected function createMedia($include_file = TRUE) {
  protected function createMedia($include_file = TRUE, $path_alias = NULL, $index = 0, $private_file = FALSE) {
    $media_type_id = $this->mediaType->id();
    /** @var \Drupal\field\FieldConfigInterface $field */
    $field = FieldConfig::load("media.$media_type_id.field_media_image");
@@ -100,11 +102,26 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
      'name' => 'Custom name',
      'bundle' => $media_type_id,
      'status' => TRUE,
      'path' => $path_alias,
    ]);

    $this->file = NULL;

    if ($include_file) {
      /** @var \Drupal\Core\File\FileSystemInterface $file_system */
      $file_system = \Drupal::service('file_system');
      $original_uri = $this->getTestFiles('image')[$index]->uri;
      // Use a separate media_alias_display directory and file for this.
      // This should allow us to manipulate the files without any side effects.
      $destination_dir = ($private_file ? 'private://' : 'public://') . 'media_alias_display/';
      $file_system->prepareDirectory($destination_dir, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
      $destination_uri = $destination_dir . '/' . pathinfo($original_uri, PATHINFO_BASENAME);
      $uri = $file_system->copy($original_uri, $destination_uri, FileSystemInterface::EXISTS_REPLACE);
      $file = File::create([
        'uri' => $this->getTestFiles('image')[0]->uri,
        // Add a custom file name that might be different to the real internal
        // file.
        'filename' => 'custom-file-name-' . $file_system->basename($uri),
        'uri' => $uri,
      ]);
      $file->save();
      $this->file = $file;
@@ -126,16 +143,24 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   * @throws \Behat\Mink\Exception\ExpectationException
   *
   * @dataProvider providerDisplayController
   */
  public function testDisplayController() {
  public function testDisplayController($current_alias, bool $private_file) {
    $assert_session = $this->assertSession();

    $media = $this->createMedia();

    $media = $this->createMedia(TRUE, $current_alias, 0, $private_file);
    $media_file = $this->file;
    if ($current_alias) {
      $path_alias = ltrim($current_alias, '/');
    }
    else {
      $path_alias = 'media/' . $media->id();
    }
    // Verifies kill switch isn't enabled + verifies it's not an allowed bundle.
    $this->drupalGet('media/' . $media->id());
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Type', $this->file->getMimeType());
    $assert_session->responseHeaderContains('Content-Type', $media_file->getMimeType());

    // Test when the kill switch is enabled.
    \Drupal::configFactory()
@@ -153,11 +178,13 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
      ->getEditable('media_alias_display.settings')
      ->set('kill_switch', FALSE)
      ->save(TRUE);
    $this->resetAll();
    // There should be no need to explicitly flush the Drupal cache assuming
    // we pass in all the relevant cache tags to the original responses.
    /* $this->resetAll(); */

    $this->drupalGet('media/' . $media->id());
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Type', $this->file->getMimeType());
    $assert_session->responseHeaderContains('Content-Type', $media_file->getMimeType());

    // Test when a single bundle is selected.
    \Drupal::configFactory()
@@ -168,15 +195,15 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {

    $this->drupalGet('media/' . $media->id());
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Type', $this->file->getMimeType());
    $assert_session->responseHeaderContains('Content-Type', $media_file->getMimeType());

    // Test when edit-media is placed in the URL.
    // Should redirect to media edit page. User should have permission.
    $this->drupalGet('media/' . $media->id(), [
      'query' => ['edit-media' => 1],
      'query' => ['edit-media' => ''],
      'absolute' => TRUE,
    ]);
    $this->assertSession()->addressEquals('media/' . $media->id() . '/edit');
    $assert_session->addressEquals('media/' . $media->id() . '/edit');

    // Test when user doesn't have permission.
    $current_roles = $this->loggedInUser->getRoles(TRUE);
@@ -188,7 +215,7 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
      'absolute' => TRUE,
    ]);
    // User won't be redirected because they don't have permission.
    $this->assertSession()->addressEquals('media/' . $media->id());
    $assert_session->addressEquals($path_alias);

    // Grant "edit any bundle media" permission that should allow a user to
    // access the edit page.
@@ -197,7 +224,7 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
      'query' => ['edit-media' => 1],
      'absolute' => TRUE,
    ]);
    $this->assertSession()->addressEquals('media/' . $media->id() . '/edit');
    $assert_session->addressEquals('media/' . $media->id() . '/edit');

    user_role_revoke_permissions($role->id(), ['edit any ' . $this->mediaType->id() . ' media']);
    // Grant "edit own bundle media" permission that should allow a user to
@@ -207,24 +234,67 @@ class MediaAliasDisplayControllerTest extends MediaFunctionalTestBase {
      'query' => ['edit-media' => 1],
      'absolute' => TRUE,
    ]);
    $this->assertSession()->addressEquals('media/' . $media->id() . '/edit');
    $assert_session->addressEquals('media/' . $media->id() . '/edit');

    // Test content dispositions.
    $this->drupalGet('media/' . $media->id());
    // The content disposition header should not exist by default.
    $assert_session->responseHeaderDoesNotExist('Content-Disposition');

    $this->drupalGet('media/' . $media->id(), ['query' => ['download' => '']]);
    $assert_session->responseHeaderContains('Content-Disposition', ResponseHeaderBag::DISPOSITION_ATTACHMENT);
    $assert_session->responseHeaderContains('Content-Disposition', $media_file->getFilename());

    $this->drupalGet('media/' . $media->id(), ['query' => ['dl' => '1']]);
    $assert_session->responseHeaderContains('Content-Disposition', ResponseHeaderBag::DISPOSITION_ATTACHMENT);
    $assert_session->responseHeaderContains('Content-Disposition', $media_file->getFilename());

    // Test when there is no file attached to an allowed bundle.
    $media_no_file = $this->createMedia(FALSE);
    $media_no_file_alias = $current_alias ? "$current_alias-no-file" : NULL;
    $media_no_file = $this->createMedia(FALSE, $media_no_file_alias, 1, $private_file);
    $this->drupalGet('media/' . $media_no_file->id());
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Type', 'text/html');
    // Test disposition behaviour without files.
    $this->drupalGet('media/' . $media_no_file->id(), ['query' => ['download' => '']]);
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Type', 'text/html');
    $assert_session->responseHeaderDoesNotExist('Content-Disposition');

    // Test when file doesn't exist on server by deleting it.
    // Create a new media object.
    $media = $this->createMedia();
    $this->assertFileExists($this->file->getFileUri());
    $file_uri = $this->file->getFileUri();
    $media_with_deleted_file_alias = $current_alias ? "$current_alias-deleted-file" : NULL;
    $media_with_deleted_file = $this->createMedia(TRUE, $media_with_deleted_file_alias, 2, $private_file);
    $media_with_deleted_file_file = $this->file;
    $this->assertFileExists($media_with_deleted_file_file->getFileUri());
    // Test disposition behaviour before files were deleted.
    $this->drupalGet('media/' . $media_with_deleted_file->id(), ['query' => ['download' => '']]);
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Disposition', ResponseHeaderBag::DISPOSITION_ATTACHMENT);

    $file_uri = $media_with_deleted_file_file->getFileUri();
    unlink($file_uri);
    $this->assertFileDoesNotExist($this->file->getFileUri());
    $this->drupalGet('media/' . $media->id());
    self::assertFileDoesNotExist($media_with_deleted_file_file->getFileUri());
    $this->drupalGet('media/' . $media_with_deleted_file->id());
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Type', 'text/html');
    // Test disposition behaviour after file was deleted.
    $this->drupalGet('media/' . $media_with_deleted_file->id(), ['query' => ['download' => '']]);
    $assert_session->statusCodeEquals(200);
    $assert_session->responseHeaderContains('Content-Type', 'text/html');
    $assert_session->responseHeaderDoesNotExist('Content-Disposition');
  }

  /**
   * Data provider for testDisplayController().
   */
  public function providerDisplayController() {
    return [
      'media with custom alias' => ['/custom-media-alias', FALSE],
      'media without alias' => [NULL, FALSE],
      'private media with custom alias' => ['/custom-media-alias', TRUE],
      'private media without alias' => [NULL, TRUE],
    ];
  }

}