Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions changelogs/DP-45698.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#
# Write your changelog entry here. Every pull request must have a changelog yml file.
#
# Change types:
# #############################################################################
# You can use one of the following types:
# - Added: For new features.
# - Changed: For changes to existing functionality.
# - Deprecated: For soon-to-be removed features.
# - Removed: For removed features.
# - Fixed: For any bug fixes.
# - Security: In case of vulnerabilities.
#
# Format
# #############################################################################
# The format is crucial. Please follow the examples below. For reference, the requirements are:
# - All 3 parts are required and you must include "Type", "description" and "issue".
# - "Type" must be left aligned and followed by a colon.
# - "description" must be indented with 2 spaces followed by a colon
# - "issue" must be indented with 4 spaces followed by a colon.
# - "issue" is for the Jira ticket number only e.g. DP-1234
# - No extra spaces, indents, or blank lines are allowed.
#
# Example:
# #############################################################################
# Fixed:
# - description: Fixes scrolling on edit pages in Safari.
# issue: DP-13314
#
# You may add more than 1 description & issue for each type using the following format:
# Changed:
# - description: Automating the release branch.
# issue: DP-10166
# - description: Second change item that needs a description.
# issue: DP-19875
# - description: Third change item that needs a description along with an issue.
# issue: DP-19843
#
Changed:
- description: Serve media download links as binary instead of redirect.
issue: DP-45698
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
namespace Drupal\mass_media\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\Renderer;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\media\MediaInterface;
use Drupal\stage_file_proxy\DownloadManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
Expand All @@ -17,25 +22,48 @@
class MassMediaDownloadController extends ControllerBase {

/**
* Renderer service object.
* Request stack.
*
* @var \Drupal\Core\Render\Renderer
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
private $renderer;
private $requestStack;

/**
* Request stack.
* The stream wrapper manager.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
* @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
*/
private $requestStack;
private StreamWrapperManagerInterface $streamWrapperManager;

/**
* Drupal filesystem service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
private FileSystemInterface $fileSystem;

/**
* Stage File Proxy download manager.
*
* @var \Drupal\stage_file_proxy\DownloadManagerInterface
*/
private DownloadManagerInterface $stageFileProxyDownloadManager;

/**
* {@inheritdoc}
*/
public function __construct(RequestStack $request_stack, Renderer $renderer) {
public function __construct(
RequestStack $request_stack,
StreamWrapperManagerInterface $stream_wrapper_manager,
FileSystemInterface $file_system,
DownloadManagerInterface $stage_file_proxy_download_manager,
ConfigFactoryInterface $config_factory,
) {
$this->requestStack = $request_stack;
$this->renderer = $renderer;
$this->streamWrapperManager = $stream_wrapper_manager;
$this->fileSystem = $file_system;
$this->stageFileProxyDownloadManager = $stage_file_proxy_download_manager;
$this->configFactory = $config_factory;
}

/**
Expand All @@ -44,7 +72,10 @@ public function __construct(RequestStack $request_stack, Renderer $renderer) {
public static function create(ContainerInterface $container) {
return new static(
$container->get('request_stack'),
$container->get('renderer')
$container->get('stream_wrapper_manager'),
$container->get('file_system'),
$container->get('stage_file_proxy.download_manager'),
$container->get('config.factory')
);
}

Expand All @@ -54,8 +85,8 @@ public static function create(ContainerInterface $container) {
* @param \Drupal\media\MediaInterface $media
* A valid media object.
*
* @return \Drupal\Core\Routing\TrustedRedirectResponse
* TrustedRedirectResponse object.
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse
* File response that serves the media bytes directly.
*
* @throws \Exception
* @throws NotFoundHttpException
Expand All @@ -71,8 +102,10 @@ public function download(MediaInterface $media) {
throw new \Exception("No source field configured for the {$bundle} media type.");
}

$request_query = $this->requestStack->getCurrentRequest()->query;

// If a delta was provided, use that.
$delta = $this->requestStack->getCurrentRequest()->query->get('delta');
$delta = $request_query->get('delta');

// Get the ID of the requested file by its field delta.
if (is_numeric($delta)) {
Expand Down Expand Up @@ -102,23 +135,68 @@ public function download(MediaInterface $media) {
}

$uri = $file->getFileUri();
$scheme = $this->streamWrapperManager->getScheme($uri);

// If the file doesn't exist locally on non-production environments,
// try fetching it from the configured Stage File Proxy origin.
if (!$this->streamWrapperManager->isValidScheme($scheme)) {
throw new NotFoundHttpException("The file {$uri} does not exist.");
}

if (!file_exists($uri)) {
// Stage File Proxy is intended for public assets; private files should
// remain non-public and must not be fetched from origin.
if ($scheme === 'public') {
$stageConfig = $this->configFactory->get('stage_file_proxy.settings');
$origin = (string) $stageConfig->get('origin');
$originHost = (string) parse_url($origin, PHP_URL_HOST);
$requestHost = $this->requestStack->getCurrentRequest()->getHost();

// Only fetch when we are not on mass.gov production host.
if (!empty($originHost) && strcasecmp($requestHost, $originHost) !== 0) {
$originDir = trim((string) ($stageConfig->get('origin_dir') ?? 'files'));
$relativePath = str_replace('public://', '', $uri);
$options = ['verify' => (bool) $stageConfig->get('verify')];

$this->stageFileProxyDownloadManager->fetch(
$origin,
$originDir,
$relativePath,
$options
);
}
}
}

// Still missing on disk: return 404.
if (!file_exists($uri)) {
throw new NotFoundHttpException("The file {$uri} does not exist.");
}

// Let other modules provide headers and controls access to the file.
$headers = $this->moduleHandler()->invokeAll('file_download', [$uri]);
foreach ($headers as $result) {
if ($result == -1) {
throw new AccessDeniedHttpException();
}
}

$response = new BinaryFileResponse($uri, Response::HTTP_OK, $headers, $scheme !== 'private');

if (empty($headers['Content-Disposition'])) {
if ($request_query->has(ResponseHeaderBag::DISPOSITION_INLINE)) {
$disposition = ResponseHeaderBag::DISPOSITION_INLINE;
}
else {
$disposition = ResponseHeaderBag::DISPOSITION_ATTACHMENT;
}
$response->setContentDisposition($disposition, $file->getFilename());
}

// Catches stray metadata not handled properly by file_create_url().
// @see https://www.drupal.org/project/drupal/issues/2867355
$context = new RenderContext();
$uri = $this->renderer->executeInRenderContext($context, function () use ($uri) {
return \Drupal::service('file_url_generator')->generateAbsoluteString($uri);
});

// Returns a 301 Moved Permanently redirect response.
$response = new TrustedRedirectResponse($uri, 301);
// Adds cache metadata.
$response->getCacheableMetadata()->addCacheContexts(['url.site']);
$response->addCacheableDependency($media);
$response->addCacheableDependency($file);
if (!$context->isEmpty()) {
$response->addCacheableDependency($context->pop());
if (!$response->headers->has('Content-Type')) {
$response->headers->set('Content-Type', $file->getMimeType() ?: 'application/octet-stream');
}

return $response;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class MediaDownloadTest extends MassExistingSiteBase {
use MediaCreationTrait;

/**
* Ensure that a request to media/$ID/download redirects to the file.
* Ensure that a request to media/$ID/download serves the file.
*/
public function testMediaDownload() {
// Create a file to upload.
Expand Down Expand Up @@ -45,8 +45,145 @@ public function testMediaDownload() {
]);

$this->visit($media->toUrl()->toString() . '/download');
$this->assertEquals(\Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri()), $this->getSession()->getCurrentUrl());
$this->assertEquals('text/plain', $this->getSession()->getResponseHeader('Content-Type'), 'url.site cache context is added to the response.');
$expected_path = $media->toUrl()->toString() . '/download';
$this->assertStringContainsString($expected_path, $this->getSession()->getCurrentUrl());

$content_type = $this->getSession()->getResponseHeader('Content-Type');
$this->assertNotEmpty($content_type);
$this->assertStringContainsString('text/plain', $content_type);
}

/**
* Test file replacement.
*
* If the underlying media file is replaced, /download should serve
* the new bytes (not a stale cached response).
*/
public function testMediaDownloadServesUpdatedFileAfterReplacement() {
// v1 file.
$destination1 = 'public://llama-download-v1.txt';
file_put_contents($destination1, 'Version 1');
$file1 = File::create([
'uri' => $destination1,
]);
$file1->setPermanent();
$file1->save();

// v2 file.
$destination2 = 'public://llama-download-v2.txt';
file_put_contents($destination2, 'Version 2');
$file2 = File::create([
'uri' => $destination2,
]);
$file2->setPermanent();
$file2->save();

// Create a published document media entity pointing to v1.
$media = $this->createMedia([
'title' => 'Llama Download Cache',
'bundle' => 'document',
'field_upload_file' => [
'target_id' => $file1->id(),
],
'status' => 1,
'moderation_state' => MassModeration::PUBLISHED,
]);

$download_path = ltrim($media->toUrl()->toString() . '/download', '/');

// First request should return v1 bytes.
$content_v1 = $this->drupalGet($download_path);
$this->assertStringContainsString('Version 1', $content_v1);

// Replace the file reference and create a new revision while staying
// published. The controller should serve the new file bytes and Drupal
// cache should not keep serving the old response body.
$media->set('field_upload_file', [
'target_id' => $file2->id(),
]);
$media->setNewRevision();
$media->set('moderation_state', MassModeration::PUBLISHED);
$media->save();

$content_v2 = $this->drupalGet($download_path);
$this->assertStringContainsString('Version 2', $content_v2);
$this->assertStringNotContainsString('Version 1', $content_v2);
}

/**
* Unpublished documents move their file to private storage.
*
* Anonymous users must not be able to download those bytes.
*/
public function testMediaDownloadPrivateFileDeniedForUnpublishedDocument(): void {
$destination = 'public://llama-download-private-unpublished.txt';
file_put_contents($destination, 'UNPUBLISHED PRIVATE BYTES');
$file = File::create([
'uri' => $destination,
]);
$file->setPermanent();
$file->save();

// Create an unpublished document; mass_media_presave should move the
// uploaded file to private://.
$media = $this->createMedia([
'title' => 'Unpublished document download',
'bundle' => 'document',
'field_upload_file' => [
'target_id' => $file->id(),
],
'status' => 0,
'moderation_state' => 'unpublished',
]);

$unpublished_file = File::load($media->field_upload_file->target_id);
$this->assertNotNull($unpublished_file);
$this->assertEquals('private', \Drupal\Core\StreamWrapper\StreamWrapperManager::getScheme($unpublished_file->getFileUri()));

$this->visit($media->toUrl()->toString() . '/download');

$this->assertNotEquals(200, $this->getSession()->getStatusCode());
$this->assertStringNotContainsString('UNPUBLISHED PRIVATE BYTES', $this->getSession()->getPage()->getContent());
}

/**
* Restricted documents should be viewable only by their owner.
*
* Anonymous users must not be able to download private files for those
* documents.
*/
public function testMediaDownloadPrivateFileDeniedForRestrictedDocument(): void {
// Login as author so we can create a restricted media owned by them.
$admin = $this->createUser();
$admin->addRole('administrator');
$admin->activate();
$admin->save();
$this->drupalLogin($admin);

$destination = 'private://llama-download-private-restricted.txt';
file_put_contents($destination, 'RESTRICTED PRIVATE BYTES');
$file = File::create([
'uri' => $destination,
]);
$file->setPermanent();
$file->save();

$media = $this->createMedia([
'title' => 'Restricted document download',
'bundle' => 'document',
'field_upload_file' => [
'target_id' => $file->id(),
],
'status' => 1,
'moderation_state' => 'restricted',
]);

$this->drupalLogout();

$this->visit($media->toUrl()->toString() . '/download');

$this->assertNotEquals(200, $this->getSession()->getStatusCode());
$this->assertStringNotContainsString('RESTRICTED PRIVATE BYTES', $this->getSession()->getPage()->getContent());
}

}
Loading