diff --git a/changelogs/DP-45698.yml b/changelogs/DP-45698.yml new file mode 100644 index 0000000000..fb53dc71cb --- /dev/null +++ b/changelogs/DP-45698.yml @@ -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 diff --git a/docroot/modules/custom/mass_media/src/Controller/MassMediaDownloadController.php b/docroot/modules/custom/mass_media/src/Controller/MassMediaDownloadController.php index 083cf313ae..2d98ef22cf 100644 --- a/docroot/modules/custom/mass_media/src/Controller/MassMediaDownloadController.php +++ b/docroot/modules/custom/mass_media/src/Controller/MassMediaDownloadController.php @@ -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; /** @@ -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; } /** @@ -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') ); } @@ -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 @@ -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)) { @@ -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; } diff --git a/docroot/modules/custom/mass_media/tests/src/ExistingSite/MediaDownloadTest.php b/docroot/modules/custom/mass_media/tests/src/ExistingSite/MediaDownloadTest.php index f558dffd03..cd5f10f8b6 100644 --- a/docroot/modules/custom/mass_media/tests/src/ExistingSite/MediaDownloadTest.php +++ b/docroot/modules/custom/mass_media/tests/src/ExistingSite/MediaDownloadTest.php @@ -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. @@ -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()); } } diff --git a/docroot/robots.txt b/docroot/robots.txt index 77a55b3567..80851a6099 100644 --- a/docroot/robots.txt +++ b/docroot/robots.txt @@ -56,6 +56,9 @@ Disallow: /media/* Disallow: /taxonomy/term/* Disallow: /node/* Disallow: /doc/courts-dwnld-* +# Legacy/raw document file URLs. +Disallow: /files/documents/ +Disallow: /sites/default/files/documents/ # Paths (no clean URLs) Disallow: /index.php/admin/ Disallow: /index.php/comment/reply/