From 56b614e7e62c368672daa21daca792039462cfbc Mon Sep 17 00:00:00 2001
From: Lee Rowlands <lee.rowlands@previousnext.com.au>
Date: Mon, 13 Nov 2023 12:52:19 +1000
Subject: [PATCH] Issue #3380379 by kim.pepper, smustgrave, quietone: Create
 content disposition filename extractor and deprecate duplicate code in REST
 and JSON API

---
 .../rest/resource/FileUploadResource.php      | 45 ++++-------
 .../ContentDispositionFilenameParser.php      | 67 ++++++++++++++++
 .../ContentDispositionFilenameParserTest.php  | 80 +++++++++++++++++++
 .../jsonapi/src/Controller/FileUpload.php     |  5 +-
 .../TemporaryJsonapiFileFieldUploader.php     | 43 ++++------
 .../Functional/FileUploadResourceTestBase.php | 10 +--
 6 files changed, 186 insertions(+), 64 deletions(-)
 create mode 100644 core/modules/file/src/Upload/ContentDispositionFilenameParser.php
 create mode 100644 core/modules/file/tests/src/Unit/Upload/ContentDispositionFilenameParserTest.php

diff --git a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php
index 84d346fcf053..8abc3cc29f13 100644
--- a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php
+++ b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php
@@ -17,6 +17,7 @@
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Utility\Token;
 use Drupal\file\Entity\File;
+use Drupal\file\Upload\ContentDispositionFilenameParser;
 use Drupal\file\Upload\InputStreamFileWriterInterface;
 use Drupal\file\Validation\FileValidatorInterface;
 use Drupal\rest\ModifiedResourceResponse;
@@ -30,7 +31,6 @@
 use Symfony\Component\HttpFoundation\File\Exception\UploadException;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
-use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -67,6 +67,12 @@ class FileUploadResource extends ResourceBase {
    * The regex used to extract the filename from the content disposition header.
    *
    * @var string
+   *
+   * @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
+   *   \Drupal\file\Upload\ContentDispositionFilenameParser::REQUEST_HEADER_FILENAME_REGEX
+   *   instead.
+   *
+   * @see https://www.drupal.org/node/3380380
    */
   const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
 
@@ -265,7 +271,7 @@ public function permissions() {
    *   or when temporary files cannot be moved to their new location.
    */
   public function post(Request $request, $entity_type_id, $bundle, $field_name) {
-    $filename = $this->validateAndParseContentDispositionHeader($request);
+    $filename = ContentDispositionFilenameParser::parseFilename($request);
 
     $field_definition = $this->validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name);
 
@@ -389,35 +395,16 @@ protected function streamUploadData(): string {
    *
    * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
    *   Thrown when the 'Content-Disposition' request header is invalid.
+   *
+   * @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
+   *   \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename()
+   *   instead.
+   *
+   * @see https://www.drupal.org/node/3380380
    */
   protected function validateAndParseContentDispositionHeader(Request $request) {
-    // Firstly, check the header exists.
-    if (!$request->headers->has('content-disposition')) {
-      throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided');
-    }
-
-    $content_disposition = $request->headers->get('content-disposition');
-
-    // Parse the header value. This regex does not allow an empty filename.
-    // i.e. 'filename=""'. This also matches on a word boundary so other keys
-    // like 'not_a_filename' don't work.
-    if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
-      throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided');
-    }
-
-    // Check for the "filename*" format. This is currently unsupported.
-    if (!empty($matches['star'])) {
-      throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header');
-    }
-
-    // Don't validate the actual filename here, that will be done by the upload
-    // validators in validate().
-    // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
-    $filename = $matches['filename'];
-
-    // Make sure only the filename component is returned. Path information is
-    // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
-    return $this->fileSystem->basename($filename);
+    @trigger_error('Calling ' . __METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename() instead. See https://www.drupal.org/node/3380380', E_USER_DEPRECATED);
+    return ContentDispositionFilenameParser::parseFilename($request);
   }
 
   /**
diff --git a/core/modules/file/src/Upload/ContentDispositionFilenameParser.php b/core/modules/file/src/Upload/ContentDispositionFilenameParser.php
new file mode 100644
index 000000000000..c49038308ddc
--- /dev/null
+++ b/core/modules/file/src/Upload/ContentDispositionFilenameParser.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\file\Upload;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Parses the content-disposition header to extract the client filename.
+ */
+final class ContentDispositionFilenameParser {
+
+  /**
+   * The regex used to extract the filename from the content disposition header.
+   */
+  const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
+
+  /**
+   * Private constructor to prevent instantiation.
+   */
+  private function __construct() {}
+
+  /**
+   * Parse the content disposition header and return the filename.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request.
+   *
+   * @return string
+   *   The filename.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown when the 'Content-Disposition' request header is invalid.
+   */
+  public static function parseFilename(Request $request): string {
+    // Firstly, check the header exists.
+    if (!$request->headers->has('content-disposition')) {
+      throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.');
+    }
+
+    $content_disposition = $request->headers->get('content-disposition');
+
+    // Parse the header value. This regex does not allow an empty filename.
+    // i.e. 'filename=""'. This also matches on a word boundary so other keys
+    // like 'not_a_filename' don't work.
+    if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
+      throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.');
+    }
+
+    // Check for the "filename*" format. This is currently unsupported.
+    if (!empty($matches['star'])) {
+      throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header.');
+    }
+
+    // Don't validate the actual filename here, that will be done by the upload
+    // validators in validate().
+    // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
+    $filename = $matches['filename'];
+
+    // Make sure only the filename component is returned. Path information is
+    // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
+    // We do not need to use Drupal's FileSystem service here as we are not
+    // dealing with StreamWrappers.
+    return \basename($filename);
+  }
+
+}
diff --git a/core/modules/file/tests/src/Unit/Upload/ContentDispositionFilenameParserTest.php b/core/modules/file/tests/src/Unit/Upload/ContentDispositionFilenameParserTest.php
new file mode 100644
index 000000000000..04c6f9d1f614
--- /dev/null
+++ b/core/modules/file/tests/src/Unit/Upload/ContentDispositionFilenameParserTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Drupal\Tests\file\Unit\Upload;
+
+use Drupal\file\Upload\ContentDispositionFilenameParser;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Tests the ContentDispositionFilenameParser class.
+ *
+ * @group file
+ * @coversDefaultClass \Drupal\file\Upload\ContentDispositionFilenameParser
+ */
+class ContentDispositionFilenameParserTest extends UnitTestCase {
+
+  /**
+   * Tests the parseFilename() method.
+   *
+   * @covers ::parseFilename
+   */
+  public function testParseFilenameSuccess(): void {
+    $request = $this->createRequest('filename="test.txt"');
+    $filename = ContentDispositionFilenameParser::parseFilename($request);
+    $this->assertEquals('test.txt', $filename);
+  }
+
+  /**
+   * @covers ::parseFilename
+   * @dataProvider invalidHeaderProvider
+   */
+  public function testParseFilenameInvalid(string | bool $contentDisposition): void {
+    $this->expectException(BadRequestHttpException::class);
+    $this->expectExceptionMessage('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.');
+    $request = $this->createRequest($contentDisposition);
+    ContentDispositionFilenameParser::parseFilename($request);
+  }
+
+  /**
+   * @covers ::parseFilename
+   */
+  public function testParseFilenameMissing(): void {
+    $this->expectException(BadRequestHttpException::class);
+    $this->expectExceptionMessage('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.');
+    $request = new Request();
+    ContentDispositionFilenameParser::parseFilename($request);
+  }
+
+  /**
+   * @covers ::parseFilename
+   */
+  public function testParseFilenameExtended(): void {
+    $this->expectException(BadRequestHttpException::class);
+    $this->expectExceptionMessage('The extended "filename*" format is currently not supported in the "Content-Disposition" header.');
+    $request = $this->createRequest('filename*="UTF-8 \' \' example.txt"');
+    $filename = ContentDispositionFilenameParser::parseFilename($request);
+  }
+
+  /**
+   * A data provider for invalid headers.
+   */
+  public function invalidHeaderProvider(): array {
+    return [
+      'multiple' => ['file; filename=""'],
+      'empty' => ['filename=""'],
+      'bad key' => ['not_a_filename="example.txt"'],
+    ];
+  }
+
+  /**
+   * Creates a request with the given content-disposition header.
+   */
+  protected function createRequest(string $contentDisposition): Request {
+    $request = new Request();
+    $request->headers->set('Content-Disposition', $contentDisposition);
+    return $request;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/FileUpload.php b/core/modules/jsonapi/src/Controller/FileUpload.php
index 4c85a3aabaad..38dacc64ddab 100644
--- a/core/modules/jsonapi/src/Controller/FileUpload.php
+++ b/core/modules/jsonapi/src/Controller/FileUpload.php
@@ -10,6 +10,7 @@
 use Drupal\Core\Field\FieldDefinitionInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Url;
+use Drupal\file\Upload\ContentDispositionFilenameParser;
 use Drupal\jsonapi\Entity\EntityValidationTrait;
 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
 use Drupal\jsonapi\JsonApiResource\Link;
@@ -117,7 +118,7 @@ public function handleFileUploadForExistingResource(Request $request, ResourceTy
 
     static::ensureFileUploadAccess($this->currentUser, $field_definition, $entity);
 
-    $filename = $this->fileUploader->validateAndParseContentDispositionHeader($request);
+    $filename = ContentDispositionFilenameParser::parseFilename($request);
     $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $this->currentUser);
 
     if ($file instanceof ConstraintViolationListInterface) {
@@ -167,7 +168,7 @@ public function handleFileUploadForNewResource(Request $request, ResourceType $r
 
     static::ensureFileUploadAccess($this->currentUser, $field_definition);
 
-    $filename = $this->fileUploader->validateAndParseContentDispositionHeader($request);
+    $filename = ContentDispositionFilenameParser::parseFilename($request);
     $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $this->currentUser);
 
     if ($file instanceof ConstraintViolationListInterface) {
diff --git a/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php b/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php
index 3b4934f78101..b897ed5dfb2b 100644
--- a/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php
+++ b/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php
@@ -19,6 +19,7 @@
 use Drupal\file\Entity\File;
 use Drupal\file\FileInterface;
 use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
+use Drupal\file\Upload\ContentDispositionFilenameParser;
 use Drupal\file\Upload\InputStreamFileWriterInterface;
 use Drupal\file\Validation\FileValidatorInterface;
 use Psr\Log\LoggerInterface;
@@ -26,7 +27,6 @@
 use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
 use Symfony\Component\HttpFoundation\File\Exception\UploadException;
 use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 
@@ -49,6 +49,12 @@ class TemporaryJsonapiFileFieldUploader {
    * The regex used to extract the filename from the content disposition header.
    *
    * @var string
+   *
+   * @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
+   *   \Drupal\file\Upload\ContentDispositionFilenameParser::REQUEST_HEADER_FILENAME_REGEX
+   *   instead.
+   *
+   * @see https://www.drupal.org/node/3380380
    */
   const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
 
@@ -276,35 +282,16 @@ public function handleFileUploadForField(FieldDefinitionInterface $field_definit
    *
    * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
    *   Thrown when the 'Content-Disposition' request header is invalid.
+   *
+   * @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
+   *   \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename()
+   *   instead.
+   *
+   * @see https://www.drupal.org/node/3380380
    */
   public function validateAndParseContentDispositionHeader(Request $request) {
-    // First, check the header exists.
-    if (!$request->headers->has('content-disposition')) {
-      throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.');
-    }
-
-    $content_disposition = $request->headers->get('content-disposition');
-
-    // Parse the header value. This regex does not allow an empty filename.
-    // i.e. 'filename=""'. This also matches on a word boundary so other keys
-    // like 'not_a_filename' don't work.
-    if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
-      throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.');
-    }
-
-    // Check for the "filename*" format. This is currently unsupported.
-    if (!empty($matches['star'])) {
-      throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header.');
-    }
-
-    // Don't validate the actual filename here, that will be done by the upload
-    // validators in validate().
-    // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
-    $filename = $matches['filename'];
-
-    // Make sure only the filename component is returned. Path information is
-    // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
-    return $this->fileSystem->basename($filename);
+    @trigger_error('Calling ' . __METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename() instead. See https://www.drupal.org/node/3380380', E_USER_DEPRECATED);
+    return ContentDispositionFilenameParser::parseFilename($request);
   }
 
   /**
diff --git a/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php b/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php
index 5e1afccfcdd3..cc47f39fb765 100644
--- a/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php
@@ -267,26 +267,26 @@ public function testPostFileUploadInvalidHeaders() {
 
     // An empty Content-Disposition header should return a 400.
     $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => FALSE]);
-    $this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided', $response);
+    $this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.', $response);
 
     // An empty filename with a context in the Content-Disposition header should
     // return a 400.
     $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename=""']);
-    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response);
+    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
 
     // An empty filename without a context in the Content-Disposition header
     // should return a 400.
     $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename=""']);
-    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response);
+    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
 
     // An invalid key-value pair in the Content-Disposition header should return
     // a 400.
     $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'not_a_filename="example.txt"']);
-    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response);
+    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
 
     // Using filename* extended format is not currently supported.
     $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename*="UTF-8 \' \' example.txt"']);
-    $this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header', $response);
+    $this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header.', $response);
   }
 
   /**
-- 
GitLab