diff --git a/core/modules/file/src/FileServiceProvider.php b/core/modules/file/src/FileServiceProvider.php new file mode 100644 index 0000000000..35dd4a5df8 --- /dev/null +++ b/core/modules/file/src/FileServiceProvider.php @@ -0,0 +1,24 @@ +has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation')->getClass(), NegotiationMiddleware::class, TRUE)) { + $container->getDefinition('http_middleware.negotiation')->addMethodCall('registerFormat', ['bin', ['application/octet-stream']]); + } + } + +} diff --git a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php new file mode 100644 index 0000000000..bf4e056ad5 --- /dev/null +++ b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php @@ -0,0 +1,526 @@ +\*?)=\"(?.+)\"@'; + + /** + * @var \Drupal\Core\File\FileSystem + */ + protected $fileSystem; + + /** + * @var \Symfony\Component\Serializer\SerializerInterface|SerializerInterface + */ + protected $serializer; + + /** + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected $entityFieldManager; + + /** + * @var \Drupal\Core\Session\AccountInterface + */ + protected $currentUser; + + /** + * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface + */ + protected $mimeTypeGuesser; + + /** + * @var \Drupal\Core\Utility\Token + */ + protected $token; + + /** + * @var \Drupal\Core\Lock\LockBackendInterface + */ + protected $lock; + + /** + * Constructs a FileUploadResource instance. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param array $serializer_formats + * The available serialization formats. + * @param \Psr\Log\LoggerInterface $logger + * A logger instance. + * @param \Drupal\Core\File\FileSystem $file_system + * The file system service. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager + * The entity field manager. + * @param \Drupal\Core\Session\AccountInterface $current_user + * The currently authenticated user. + * @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser + * The MIME type guesser. + * @param \Drupal\Core\Utility\Token $token + * The token replacement instance. + * + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, $serializer_formats, LoggerInterface $logger, FileSystem $file_system, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, AccountInterface $current_user, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger); + $this->fileSystem = $file_system; + $this->entityTypeManager = $entity_type_manager; + $this->entityFieldManager = $entity_field_manager; + $this->currentUser = $current_user; + $this->mimeTypeGuesser = $mime_type_guesser; + $this->token = $token; + $this->lock = $lock; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.factory')->get('rest'), + $container->get('file_system'), + $container->get('entity_type.manager'), + $container->get('entity_field.manager'), + $container->get('current_user'), + $container->get('file.mime_type.guesser'), + $container->get('token'), + $container->get('lock') + ); + } + + /** + * Creates a file from endpoint. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * @param string $entity_type_id + * The entity type ID. + * @param string $bundle + * The bundle. + * @param string $field_name + * The field name. + * + * @return \Drupal\rest\ModifiedResourceResponse + * A 201 response, on success. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function post(Request $request, $entity_type_id, $bundle, $field_name) { + $filename = $this->validateAndParseContentDispositionHeader($request); + + $field_definition = $this->validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name); + + $destination = $this->getUploadLocation($field_definition->getSettings()); + + // Check the destination file path is writable. + if (!file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) { + throw new HttpException(500, 'Destination file path is not writable'); + } + + $validators = $this->getUploadValidators($field_definition); + + $prepared_filename = $this->prepareFilename($filename, $validators); + + // Create the file. + $file_uri = "{$destination}/{$prepared_filename}"; + + $temp_file_path = $this->streamUploadData(); + + // This will take care of altering $file_uri if a file already exists. + file_unmanaged_prepare($temp_file_path, $file_uri); + + // Lock based on the prepared file URI. + $lock_id = $this->generateLockIdFromFileUri($file_uri); + + if (!$this->lock->acquire($lock_id)) { + throw new HttpException(503, sprintf('File "%s" is already locked for writing'), NULL, ['Retry-After' => 1]); + } + + // Begin building file entity. + $values = [ + 'uid' => $this->currentUser->id(), + 'filename' => $prepared_filename, + 'filemime' => $this->mimeTypeGuesser->guess($prepared_filename), + 'uri' => $file_uri, + // Set the size. This is done in File::preSave() but we validate the file + // before it is saved. + 'filesize' => @filesize($temp_file_path), + ]; + $file = File::create($values); + + // Validate the file entity against entity level validation and field level + // validators. + $this->validate($file, $field_definition, $validators); + + // Move the file to the correct location after validation. Use + // FILE_EXISTS_ERROR as the file location has already been determined above + // in file_unmanaged_prepare(). + if (!file_unmanaged_move($temp_file_path, $file_uri, FILE_EXISTS_ERROR)) { + throw new HttpException(500, 'Temporary file could not be moved to file location'); + } + + $file->save(); + + $this->lock->release($lock_id); + + // 201 Created responses return the newly created entity in the response + // body. These responses are not cacheable, so we add no cacheability + // metadata here. + $headers = [ + // @todo Do we want the File URI (for the actual file) like this? + 'Location' => $file->url(), + ]; + + return new ModifiedResourceResponse($file, 201, $headers); + } + + /** + * Streams file upload data to temporary file and moves to file destination. + * + * @return string + * The temp file path. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + protected function streamUploadData() { + // 'rb' is needed so reading works correctly on windows environments too. + $file_data = fopen('php://input','rb'); + + $temp_file_path = $this->fileSystem->tempnam('temporary://', 'file'); + $temp_file = fopen($temp_file_path, 'wb'); + + if ($temp_file) { + while (!feof($file_data)) { + $read = fread($file_data, 8192); + + if ($read === FALSE) { + // Close the temp file stream. + fclose($temp_file); + $this->logger->error('Input data could not be read'); + throw new HttpException(500, 'Input file data could not be read'); + } + + if (fwrite($temp_file, $read) === FALSE) { + // Close the temp file stream. + fclose($temp_file); + $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]); + throw new HttpException(500, 'Temporary file data could not be written'); + } + } + + // Close the temp file stream. + fclose($temp_file); + } + else { + $this->logger->error('Temporary file "%path" could not be opened for file upload', ['%path' => $temp_file_path]); + throw new HttpException(500, 'Temporary file could not be opened'); + } + + // Close the input stream. + fclose($file_data); + + return $temp_file_path; + } + + /** + * Validates and extracts the filename from the Content-Disposition header. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return string + * The filename extracted from the header. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + */ + 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::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 file_validate() + $filename = $matches['filename']; + + // Make sure only the trailing filename is returned. + return basename($filename); + } + + /** + * Validates and loads a field definition instance. + * + * @param string $entity_type_id + * The entity type ID the field is attached to. + * @param string $bundle + * The bundle the field is attached to. + * @param string $field_name + * The field name. + * + * @return \Drupal\Core\Field\FieldDefinitionInterface + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @throws \Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException + */ + protected function validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name) { + $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle); + + if (!isset($field_definitions[$field_name])) { + throw new BadRequestHttpException(sprintf('Field "%s" does not exist', $field_name)); + } + + // @todo check the definition is a file field. + $field_definition = $field_definitions[$field_name]; + + if (!$this->entityTypeManager->getAccessControlHandler($entity_type_id)->fieldAccess('create', $field_definition)) { + throw new AccessDeniedHttpException(sprintf('Access denied for field "%s"', $field_name)); + } + + return $field_definition; + } + + /** + * Validates the file. + * + * @param \Drupal\file\FileInterface $file + * The file entity to validate. + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition to validate against. + * @param array $validators + * AN array of upload validators to pass to file_validate(). + * + * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException + */ + protected function validate(FileInterface $file, FieldDefinitionInterface $field_definition, array $validators) { + $this->resourceValidate($file); + + // Validate the file based on the field definition configuration. + $errors = file_validate($file, $validators); + + if (!empty($errors)) { + $message = "Unprocessable Entity: file validation failed.\n"; + $message .= implode("\n", $errors); + + throw new UnprocessableEntityHttpException($message); + } + } + + /** + * Prepares the filename to strip out any malicious extensions. + * + * @param string $filename + * The file name. + * @param array $validators + * The array of upload validators. + * + * @return string + * The prepared/munged filename. + */ + protected function prepareFilename($filename, array &$validators) { + // Build the list of non-munged extensions if the caller provided them. + $extensions = $validators['file_validate_extensions'][0]; + + + if (!empty($extensions)) { + // Munge the filename to protect against possible malicious extension + // hiding within an unknown file type (ie: filename.html.foo). + $filename = file_munge_filename($filename, $extensions); + } + + // Rename potentially executable files, to help prevent exploits (i.e. will + // rename filename.php.foo and filename.php to filename.php.foo.txt and + // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads' + // evaluates to TRUE. + if (!\Drupal::config('system.file')->get('allow_insecure_uploads') && preg_match('/\.(php|pl|py|cgi|asp|js)(\.|$)/i', $filename) && (substr($filename, -4) != '.txt')) { + // The destination filename will also later be used to create the URI. + $filename .= '.txt'; + + // The .txt extension may not be in the allowed list of extensions. We have + // to add it here or else the file upload will fail. + if (!empty($extensions)) { + $validators['file_validate_extensions'][0] .= ' txt'; + } + } + + return $filename; + } + + /** + * Determines the URI for a file field. + * + * @param array $settings + * The array of field settings. + * + * @return string + * An un-sanitized file directory URI with tokens replaced. The result of + * the token replacement is then converted to plain text and returned. + */ + protected function getUploadLocation(array $settings) { + $destination = trim($settings['file_directory'], '/'); + + // Replace tokens. As the tokens might contain HTML we convert it to plain + // text. + $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [])); + return $settings['uri_scheme'] . '://' . $destination; + } + + /** + * Retrieves the upload validators for a field definition. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * + * @return array + * An array suitable for passing to file_save_upload() or the file field + * element's '#upload_validators' property. + * + * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there + * is no entity instance available here that that a FileItem would exist for. + */ + protected function getUploadValidators($field_definition) { + $validators = [ + // Add in our check of the file name length. + 'file_validate_name_length' => [], + ]; + $settings = $field_definition->getSettings(); + + // Cap the upload size according to the PHP limit. + $max_filesize = Bytes::toInt(file_upload_max_size()); + if (!empty($settings['max_filesize'])) { + $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize'])); + } + + // There is always a file size limit due to the PHP server limit. + $validators['file_validate_size'] = [$max_filesize]; + + // Add the extension check if necessary. + if (!empty($settings['file_extensions'])) { + $validators['file_validate_extensions'] = [$settings['file_extensions']]; + } + + return $validators; + } + + /** + * {@inheritdoc} + */ + protected function getBaseRoute($canonical_path, $method) { + return new Route($canonical_path, [ + '_controller' => 'Drupal\rest\RequestHandler::handleRaw', + ], + $this->getBaseRouteRequirements($method), + [], + '', + [], + // The HTTP method is a requirement for this route. + [$method] + ); + } + + /** + * {@inheritdoc} + */ + protected function getBaseRouteRequirements($method) { + $requirements = parent::getBaseRouteRequirements($method); + + // Add the content type format access check. This will enforce that all + // incoming requests can only use the 'application/octet-stream' + // Content-Type header. + $requirements['_content_type_format'] = 'bin'; + // Allow all serializer formats to be returned as a response format. + $requirements['_format'] = implode('|', $this->serializerFormats); + + return $requirements; + } + + /** + * Generates a lock ID based on the file URI. + * + * @param $file_uri + * THe file URI. + * + * @return string + * The generated lock ID. + */ + protected function generateLockIdFromFileUri($file_uri) { + return 'file:rest:' . Crypt::hashBase64($file_uri); + } + +} diff --git a/core/modules/file/tests/src/Functional/FileUploadJsonBasicAuthTest.php b/core/modules/file/tests/src/Functional/FileUploadJsonBasicAuthTest.php new file mode 100644 index 0000000000..cef59f83cf --- /dev/null +++ b/core/modules/file/tests/src/Functional/FileUploadJsonBasicAuthTest.php @@ -0,0 +1,35 @@ +httpClient = $http_client; } /** @@ -55,16 +44,4 @@ public function normalize($entity, $format = NULL, array $context = []) { return $data; } - /** - * {@inheritdoc} - */ - public function denormalize($data, $class, $format = NULL, array $context = []) { - $file_data = (string) $this->httpClient->get($data['uri'][0]['value'])->getBody(); - - $path = 'temporary://' . drupal_basename($data['uri'][0]['value']); - $data['uri'] = file_unmanaged_save_data($file_data, $path); - - return $this->entityManager->getStorage('file')->create($data); - } - } diff --git a/core/modules/hal/tests/src/Functional/EntityResource/File/FileUploadHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/File/FileUploadHalJsonBasicAuthTest.php new file mode 100644 index 0000000000..9d2348bb88 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/File/FileUploadHalJsonBasicAuthTest.php @@ -0,0 +1,24 @@ + [ + 'self' => [ + 'href' => $normalization['uri'][0]['value'], + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/file/file', + ], + $this->baseUrl . '/rest/relation/file/file/uid' => [ + ['href' => $this->baseUrl . '/user/' . $this->account->id() . '?_format=hal_json'] + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/file/file/uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/' . $this->account->id() . '?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + [ + 'value' => $this->account->uuid(), + ], + ], + ], + ], + ], + ]; + } + + +} diff --git a/core/modules/hal/tests/src/Functional/FileDenormalizeTest.php b/core/modules/hal/tests/src/Functional/FileDenormalizeTest.php deleted file mode 100644 index 05ee234893..0000000000 --- a/core/modules/hal/tests/src/Functional/FileDenormalizeTest.php +++ /dev/null @@ -1,75 +0,0 @@ - 'test_1.txt', - 'uri' => 'public://test_1.txt', - 'filemime' => 'text/plain', - 'status' => FILE_STATUS_PERMANENT, - ]; - // Create a new file entity. - $file = File::create($file_params); - file_put_contents($file->getFileUri(), 'hello world'); - $file->save(); - - $serializer = \Drupal::service('serializer'); - $normalized_data = $serializer->normalize($file, 'hal_json'); - $denormalized = $serializer->denormalize($normalized_data, 'Drupal\file\Entity\File', 'hal_json'); - - $this->assertTrue($denormalized instanceof File, 'A File instance was created.'); - - $this->assertIdentical('temporary://' . $file->getFilename(), $denormalized->getFileUri(), 'The expected file URI was found.'); - $this->assertTrue(file_exists($denormalized->getFileUri()), 'The temporary file was found.'); - - $this->assertIdentical($file->uuid(), $denormalized->uuid(), 'The expected UUID was found'); - $this->assertIdentical($file->getMimeType(), $denormalized->getMimeType(), 'The expected MIME type was found.'); - $this->assertIdentical($file->getFilename(), $denormalized->getFilename(), 'The expected filename was found.'); - $this->assertTrue($denormalized->isPermanent(), 'The file has a permanent status.'); - - // Try to denormalize with the file uri only. - $file_name = 'test_2.txt'; - $file_path = 'public://' . $file_name; - - file_put_contents($file_path, 'hello world'); - $file_uri = file_create_url($file_path); - - $data = [ - 'uri' => [ - ['value' => $file_uri], - ], - ]; - - $denormalized = $serializer->denormalize($data, 'Drupal\file\Entity\File', 'hal_json'); - - $this->assertIdentical('temporary://' . $file_name, $denormalized->getFileUri(), 'The expected file URI was found.'); - $this->assertTrue(file_exists($denormalized->getFileUri()), 'The temporary file was found.'); - - $this->assertIdentical('text/plain', $denormalized->getMimeType(), 'The expected MIME type was found.'); - $this->assertIdentical($file_name, $denormalized->getFilename(), 'The expected filename was found.'); - $this->assertFalse($denormalized->isPermanent(), 'The file has a permanent status.'); - } - -} diff --git a/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php b/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php index 75e0d06c16..46aabe27c9 100644 --- a/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php +++ b/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php @@ -1,6 +1,7 @@ getRouteObject(); - $acceptable_response_formats = $route->hasRequirement('_format') ? explode('|', $route->getRequirement('_format')) : []; - $acceptable_request_formats = $route->hasRequirement('_content_type_format') ? explode('|', $route->getRequirement('_content_type_format')) : []; - $acceptable_formats = $request->isMethodCacheable() ? $acceptable_response_formats : $acceptable_request_formats; + $acceptable_request_formats = $route->hasRequirement('_format') ? explode('|', $route->getRequirement('_format')) : []; + $acceptable_content_type_formats = $route->hasRequirement('_content_type_format') ? explode('|', $route->getRequirement('_content_type_format')) : []; + $acceptable_formats = $request->isMethodCacheable() ? $acceptable_request_formats : $acceptable_content_type_formats; $requested_format = $request->getRequestFormat(); $content_type_format = $request->getContentType(); - // If an acceptable response format is requested, then use that. Otherwise, - // including and particularly when the client forgot to specify a response - // format, then use heuristics to select the format that is most likely - // expected. - if (in_array($requested_format, $acceptable_response_formats, TRUE)) { + // If an acceptable format is requested, then use that. Otherwise, including + // and particularly when the client forgot to specify a format, then use + // heuristics to select the format that is most likely expected. + if (in_array($requested_format, $acceptable_request_formats)) { return $requested_format; } - // If a request body is present, then use the format corresponding to the // request body's Content-Type for the response, if it's an acceptable // format for the request. - if (!empty($request->getContent()) && in_array($content_type_format, $acceptable_request_formats, TRUE)) { + elseif (!empty($request->getContent()) && in_array($content_type_format, $acceptable_content_type_formats)) { return $content_type_format; } - // Otherwise, use the first acceptable format. - if (!empty($acceptable_formats)) { + elseif (!empty($acceptable_formats)) { return $acceptable_formats[0]; } - // Sometimes, there are no acceptable formats, e.g. DELETE routes. - return NULL; + else { + return NULL; + } } /** diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 4b7020c128..2ad22c528c 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\rest\Plugin\ResourceInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerAwareTrait; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -62,6 +63,77 @@ public static function create(ContainerInterface $container) { * The response object. */ public function handle(RouteMatchInterface $route_match, Request $request) { + $method = $this->getRequestMethod($route_match); + + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + + // Deserialize incoming data if available. + $unserialized = $this->denormalizeRequestData($request, $resource, $method); + + // Determine the request parameters that should be passed to the resource + // plugin. + $argument_resolver = $this->createArgumentResolver($route_match, $unserialized, $request); + + try { + $arguments = $argument_resolver->getArguments([$resource, $method]); + } + catch (\RuntimeException $exception) { + @trigger_error('Passing in arguments the legacy way is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Provide the right parameter names in the method, similar to controllers. See https://www.drupal.org/node/2894819', E_USER_DEPRECATED); + $arguments = $this->getLegacyParameters($route_match, $unserialized, $request); + } + + // Invoke the operation on the resource plugin. + $response = call_user_func_array([$resource, $method], $arguments); + + return $this->prepareResponse($response, $resource_config); + } + + /** + * Handles a web API request without deserializing the request content. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match. + * @param \Symfony\Component\HttpFoundation\Request $request + * The HTTP request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response object. + */ + public function handleRaw(RouteMatchInterface $route_match, Request $request) { + $method = $this->getRequestMethod($route_match); + + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + + // Determine the request parameters that should be passed to the resource + // plugin. + $argument_resolver = $this->createArgumentResolver($route_match, NULL, $request); + + try { + $arguments = $argument_resolver->getArguments([$resource, $method]); + } + catch (\RuntimeException $exception) { + @trigger_error('Passing in arguments the legacy way is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Provide the right parameter names in the method, similar to controllers. See https://www.drupal.org/node/2894819', E_USER_DEPRECATED); + $arguments = $this->getLegacyParameters($route_match, NULL, $request); + } + + // Invoke the operation on the resource plugin. + $response = call_user_func_array([$resource, $method], $arguments); + + return $this->prepareResponse($response, $resource_config); + } + + /** + * Gets the request method from the route match. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match interface. + * + * @return string + * The request method. + */ + protected function getRequestMethod(RouteMatchInterface $route_match) { // Symfony is built to transparently map HEAD requests to a GET request. In // the case of the REST module's RequestHandler though, we essentially have // our own light-weight routing system on top of the Drupal/symfony routing @@ -76,18 +148,47 @@ public function handle(RouteMatchInterface $route_match, Request $request) { $method = strtolower($route_match->getRouteObject()->getMethods()[0]); assert(count($route_match->getRouteObject()->getMethods()) === 1); + return $method; + } + /** + * Loads a REST resource plugin from the route match object. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match instance. + * + * @return \Drupal\rest\RestResourceConfigInterface + * The loaded REST config. + */ + protected function loadRestResourceConfigFromRouteMatch(RouteMatchInterface $route_match) { $resource_config_id = $route_match->getRouteObject()->getDefault('_rest_resource_config'); - /** @var \Drupal\rest\RestResourceConfigInterface $resource_config */ - $resource_config = $this->resourceStorage->load($resource_config_id); - $resource = $resource_config->getResourcePlugin(); + return $this->resourceStorage->load($resource_config_id); + } - // Deserialize incoming data if available. - /** @var \Symfony\Component\Serializer\SerializerInterface $serializer */ - $serializer = $this->container->get('serializer'); + /** + * Deserializes and denormalizes request content. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * @param \Drupal\rest\Plugin\ResourceInterface $resource + * The REST resource plugin. + * @param string $method + * The request method. + * + * @return mixed|NULL + * The denormalized \Drupal\Core\Entity\EntityInterface object if there is a + * serialization class, an array of decoded data, or NULL if there is no + * request content. + * + */ + protected function denormalizeRequestData(Request $request, ResourceInterface $resource, $method) { $received = $request->getContent(); $unserialized = NULL; + if (!empty($received)) { + /** @var \Symfony\Component\Serializer\SerializerInterface $serializer */ + $serializer = $this->container->get('serializer'); + $format = $request->getContentType(); $definition = $resource->getPluginDefinition(); @@ -119,20 +220,20 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } } - // Determine the request parameters that should be passed to the resource - // plugin. - $argument_resolver = $this->createArgumentResolver($route_match, $unserialized, $request); - try { - $arguments = $argument_resolver->getArguments([$resource, $method]); - } - catch (\RuntimeException $exception) { - @trigger_error('Passing in arguments the legacy way is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Provide the right parameter names in the method, similar to controllers. See https://www.drupal.org/node/2894819', E_USER_DEPRECATED); - $arguments = $this->getLegacyParameters($route_match, $unserialized, $request); - } - - // Invoke the operation on the resource plugin. - $response = call_user_func_array([$resource, $method], $arguments); + return $unserialized; + } + /** + * Prepares the REST response. + * + * @param \Drupal\rest\ResourceResponseInterface $response + * The REST resource response. + * @param \Drupal\rest\RestResourceConfigInterface $resource_config + * The REST resource config entity. + * + * @return $response + */ + protected function prepareResponse($response, RestResourceConfigInterface $resource_config) { if ($response instanceof CacheableResponseInterface) { // Add rest config's cache tags. $response->addCacheableDependency($resource_config); diff --git a/core/modules/rest/src/Routing/ResourceRoutes.php b/core/modules/rest/src/Routing/ResourceRoutes.php index 5ba4c5da62..c5e1fa006f 100644 --- a/core/modules/rest/src/Routing/ResourceRoutes.php +++ b/core/modules/rest/src/Routing/ResourceRoutes.php @@ -108,20 +108,12 @@ protected function getRoutesForResourceConfig(RestResourceConfigInterface $rest_ continue; } - // If the route has a format requirement, then verify that the - // resource has it. - $format_requirement = $route->getRequirement('_format'); - if ($format_requirement && !in_array($format_requirement, $rest_resource_config->getFormats($method))) { - continue; - } - // The configuration has been validated, so we update the route to: // - set the allowed request body content types/formats for methods that // allow request bodies to be sent // - set the allowed authentication providers - if (in_array($method, ['POST', 'PATCH', 'PUT'], TRUE)) { - // Restrict the incoming HTTP Content-type header to the allowed - // formats. + // @todo clean this up further in https://www.drupal.org/node/2858482 + if (in_array($method, ['POST', 'PATCH', 'PUT'], TRUE) && !$route->hasRequirement('_content_type_format')) { $route->addRequirements(['_content_type_format' => implode('|', $rest_resource_config->getFormats($method))]); } $route->setOption('_auth', $rest_resource_config->getAuthenticationProviders($method)); diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index fe4ebb8310..82007b5005 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -664,27 +664,6 @@ protected static function castToString(array $normalization) { return $normalization; } - /** - * Recursively sorts an array by key. - * - * @param array $array - * An array to sort. - * - * @return array - * The sorted array. - */ - protected static function recursiveKSort(array &$array) { - // First, sort the main array. - ksort($array); - - // Then check for child arrays. - foreach ($array as $key => &$value) { - if (is_array($value)) { - static::recursiveKSort($value); - } - } - } - /** * Tests a POST request for an entity, plus edge cases to ensure good DX. */ diff --git a/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php b/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php new file mode 100644 index 0000000000..e026e98191 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php @@ -0,0 +1,584 @@ +fieldStorage = FieldStorageConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_rest_file_test', + 'type' => 'file', + 'settings' => [ + 'uri_scheme' => 'public', + ], + ]) + ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + $this->fieldStorage->save(); + + $this->field = FieldConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_rest_file_test', + 'bundle' => 'entity_test', + 'settings' => [ + 'file_directory' => 'foobar', + 'file_extensions' => 'txt', + 'max_filesize' => '', + ], + ]) + ->setLabel('Test file field') + ->setTranslatable(FALSE); + $this->field->save(); + + // Create an entity that a file can be attached to. + $this->entity = EntityTest::create([ + 'name' => 'Llama', + 'type' => 'entity_test', + ]); + $this->entity->setOwnerId(isset($this->account) ? $this->account->id() : 0); + $this->entity->save(); + + $this->refreshTestStateAfterRestConfigChange(); + } + + /** + * Tests using the file upload POST route. + */ + public function testPostFileUpload() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // This request will have the default 'application/octet-stream' content + // type header. + $response = $this->fileRequest($uri, $this->testFileData); + + $this->assertSame(201, $response->getStatusCode()); + + $expected = $this->getExpectedNormalizedEntity(); + + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt')); + + // Test the file again but using 'filename' in the Content-Disposition + // header with no 'file' prefix. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']); + + $this->assertSame(201, $response->getStatusCode()); + + $expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt'); + + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt')); + } + + /** + * Tests using the file upload POST route with invalid headers. + */ + public function testPostFileUploadInvalidHeaders() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // The wrong content type header should return a 415 code. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => static::$mimeType]); + $this->assertResourceErrorResponse(415, sprintf('No route found that matches "Content-Type: %s"', static::$mimeType), $response); + + // An empty Content-Disposition header should return a 400. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => '']); + $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); + + // 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); + + // 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); + + // 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); + } + + /** + * Tests using the file upload POST route with a duplicate file name. + * + * A new file should be created with a suffixed name. + */ + public function testPostFileUploadDuplicateFile() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // This request will have the default 'application/octet-stream' content + // type header. + $response = $this->fileRequest($uri, $this->testFileData); + + $this->assertSame(201, $response->getStatusCode()); + + // Make the same request again. The file should be saved as a new file + // entity that has the same file name but a suffixed file URI. + $response = $this->fileRequest($uri, $this->testFileData); + + $this->assertSame(201, $response->getStatusCode()); + + // Loading expected normalized data for file 2, the duplicate file. + $expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt'); + + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt')); + } + + /** + * Tests using the file upload route with any path prefixes being stripped. + */ + public function testFileUploadStrippedFilePath() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="directory/example.txt"']); + + $this->assertSame(201, $response->getStatusCode()); + + $expected = $this->getExpectedNormalizedEntity(); + $this->assertResponseData($expected, $response); + + // Check the actual file data. It should have been written to the configured + // directory, not /foobar/directory/example.txt. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt')); + } + + /** + * Tests using the file upload route with a unicode file name. + */ + public function testFileUploadUnicodeFilename() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="example-✓.txt"']); + + $this->assertSame(201, $response->getStatusCode()); + + $expected = $this->getExpectedNormalizedEntity(1, 'example-✓.txt', TRUE); + $this->assertResponseData($expected, $response); + + // Check the actual file data. It should have been written to the configured + // directory, not /foobar/directory/example.txt. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example-✓.txt')); + } + + /** + * Tests using the file upload route with a zero byte file. + */ + public function testFileUploadZeroByteFile() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // Test with a zero byte file. + $response = $this->fileRequest($uri, NULL); + + $this->assertSame(201, $response->getStatusCode()); + + $expected = $this->getExpectedNormalizedEntity(); + // Modify the default expected data to account for the 0 byte file. + $expected['filesize'][0]['value'] = 0; + + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame('', file_get_contents('public://foobar/example.txt')); + } + + /** + * Tests using the file upload route with an invalid file type. + */ + public function testFileUploadInvalidFileType() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // Test with a JSON file. + $response = $this->fileRequest($uri, '{"test":123}', ['Content-Disposition' => 'filename="example.json"']); + + $this->assertResourceErrorResponse(422, "Unprocessable Entity: file validation failed.\nOnly files with the following extensions are allowed: txt.", $response); + + // Make sure that no file was saved. + $this->assertEmpty(File::load(1)); + $this->assertFalse(file_exists('public://foobar/example.txt')); + } + + /** + * Tests using the file upload route with a file size larger than allowed. + */ + public function testFileUploadLargerFileSize() { + // Set a limit of 50 bytes. + $this->field->setSetting('max_filesize', 50) + ->save(); + $this->refreshTestStateAfterRestConfigChange(); + + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // Generate a string larger than the 50 byte limit set. + $response = $this->fileRequest($uri, $this->randomString(100)); + + $this->assertResourceErrorResponse(422, "Unprocessable Entity: file validation failed.\nThe file is 100 bytes exceeding the maximum file size of 50 bytes.", $response); + + // Make sure that no file was saved. + $this->assertEmpty(File::load(1)); + $this->assertFalse(file_exists('public://foobar/example.txt')); + } + + /** + * Tests using the file upload POST route with malicious extensions. + */ + public function testFileUploadMaliciousExtension() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + $php_string = ''; + + // Test using a masked exploit file. + $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php.txt"']); + + $expected = $this->getExpectedNormalizedEntity(1, 'example.php_.txt', TRUE); + // Override the expected filesize. + $expected['filesize'][0]['value'] = strlen($php_string); + + $this->assertResponseData($expected, $response); + + $this->assertTrue(file_exists('public://foobar/example.php_.txt')); + + // Add php as an allowed format. Allow insecure uploads still being FALSE + // should still not allow this. So it should still have a .txt extension + // appended. + $this->field->setSetting('file_extensions', 'txt php') + ->save(); + $this->refreshTestStateAfterRestConfigChange(); + + $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']); + + $expected = $this->getExpectedNormalizedEntity(2, 'example.php.txt', TRUE); + // Override the expected filesize. + $expected['filesize'][0]['value'] = strlen($php_string); + + $this->assertResponseData($expected, $response); + + // Now allow insecure uploads. + \Drupal::configFactory() + ->getEditable('system.file') + ->set('allow_insecure_uploads', TRUE) + ->save(); + + $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']); + + $expected = $this->getExpectedNormalizedEntity(3, 'example.php', TRUE); + // Override the expected filesize. + $expected['filesize'][0]['value'] = strlen($php_string); + // The file mime should also now be PHP. + $expected['filemime'][0]['value'] = 'application/x-httpd-php'; + + $this->assertResponseData($expected, $response); + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + // TODO: Implement assertNormalizationEdgeCases() method. + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessMessage($method) { + // TODO: Implement getExpectedUnauthorizedAccessMessage() method. + } + + /** + * Gets the expected file entity. + * + * @param int $fid + * The file ID to load and create normalized data for. + * @param string $expected_filename + * The expected filename for the stored file. + * @param bool $expected_as_filename + * Whether the expected filename should be the filename property too. + * + * @return array + * The expected normalized data array. + */ + protected function getExpectedNormalizedEntity($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE) { + $author = User::load(static::$auth ? $this->account->id() : 0); + $file = File::load($fid); + + $expected_normalization = [ + 'fid' => [ + [ + 'value' => (int) $file->id(), + ], + ], + 'uuid' => [ + [ + 'value' => $file->uuid(), + ], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'uid' => [ + [ + 'target_id' => (int) $author->id(), + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/' . $author->id(), + ], + ], + 'filename' => [ + [ + 'value' => $expected_as_filename ? $expected_filename : 'example.txt', + ], + ], + 'uri' => [ + [ + 'value' => 'public://foobar/' . $expected_filename, + ], + ], + 'filemime' => [ + [ + 'value' => 'text/plain', + ], + ], + 'filesize' => [ + [ + 'value' => strlen($this->testFileData), + ], + ], + 'status' => [ + [ + 'value' => FALSE, + ], + ], + 'created' => [ + $this->formatExpectedTimestampItemValues($file->getCreatedTime()), + ], + 'changed' => [ + $this->formatExpectedTimestampItemValues($file->getChangedTime()), + ], + ]; + + return $expected_normalization; + } + + /** + * Performs a file upload request. Wraps the Guzzle HTTP client. + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param \Drupal\Core\Url $url + * URL to request. + * @param string $file_contents + * The file contents to send as the request body. + * @param array $headers + * Additional headers to send with the request. Defaults will be added for + * Content-Type and Content-Disposition. + * + * @return \Psr\Http\Message\ResponseInterface + */ + protected function fileRequest(Url $url, $file_contents, array $headers = []) { + // Set the format for the response. + $url->setOption('query', ['_format' => static::$format]); + + $request_options = []; + $request_options[RequestOptions::HTTP_ERRORS] = FALSE; + + $request_options['headers'] = $headers + [ + // Set the required (and only accepted) content type for the request. + 'Content-Type' => 'application/octet-stream', + // Set the required Content-Disposition header for the file name. + 'Content-Disposition' => 'file; filename="example.txt"', + ]; + + $request_options['body'] = $file_contents; + + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST')); + + $session = $this->getSession(); + $this->prepareRequest(); + + $client = $session->getDriver()->getClient()->getClient(); + return $client->request('POST', $url->setAbsolute(TRUE)->toString(), $request_options); + } + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['view test entity']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['create entity_test entity_test entities', 'restful post file:upload']); + break; + } + } + + /** + * Asserts expected normalized data matches response data. + * + * @param array $expected + * The expected data. + * @param \Psr\Http\Message\ResponseInterface $response + * The file upload response. + */ + protected function assertResponseData(array $expected, ResponseInterface $response) { + static::recursiveKSort($expected); + $actual = $this->serializer->decode((string) $response->getBody(), static::$format); + static::recursiveKSort($actual); + + $this->assertSame($expected, $actual); + } + +} diff --git a/core/modules/rest/tests/src/Functional/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php index 0a0e34c35d..be328a17ec 100644 --- a/core/modules/rest/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -144,12 +144,12 @@ public function setUp() { * @param string[] $authentication * The allowed authentication providers for this resource. */ - protected function provisionResource($formats = [], $authentication = []) { + protected function provisionResource($formats = [], $authentication = [], array $methods = ['GET', 'POST', 'PATCH', 'DELETE']) { $this->resourceConfigStorage->create([ 'id' => static::$resourceConfigId, 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, 'configuration' => [ - 'methods' => ['GET', 'POST', 'PATCH', 'DELETE'], + 'methods' => $methods, 'formats' => $formats, 'authentication' => $authentication, ], @@ -398,4 +398,25 @@ protected function decorateWithXdebugCookie(array $request_options) { return $request_options; } + /** + * Recursively sorts an array by key. + * + * @param array $array + * An array to sort. + * + * @return array + * The sorted array. + */ + protected static function recursiveKSort(array &$array) { + // First, sort the main array. + ksort($array); + + // Then check for child arrays. + foreach ($array as $key => &$value) { + if (is_array($value)) { + static::recursiveKSort($value); + } + } + } + }