Pull merge.
[yaffs-website] / web / core / modules / file / src / Plugin / rest / resource / FileUploadResource.php
1 <?php
2
3 namespace Drupal\file\Plugin\rest\resource;
4
5 use Drupal\Component\Utility\Bytes;
6 use Drupal\Component\Utility\Crypt;
7 use Drupal\Core\Config\Config;
8 use Drupal\Core\Entity\EntityTypeManagerInterface;
9 use Drupal\Core\Field\FieldDefinitionInterface;
10 use Drupal\Core\File\FileSystemInterface;
11 use Drupal\Core\Lock\LockBackendInterface;
12 use Drupal\Core\Session\AccountInterface;
13 use Drupal\Core\Utility\Token;
14 use Drupal\file\FileInterface;
15 use Drupal\rest\ModifiedResourceResponse;
16 use Drupal\rest\Plugin\ResourceBase;
17 use Drupal\Component\Render\PlainTextOutput;
18 use Drupal\Core\Entity\EntityFieldManagerInterface;
19 use Drupal\file\Entity\File;
20 use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait;
21 use Drupal\rest\RequestHandler;
22 use Psr\Log\LoggerInterface;
23 use Symfony\Component\DependencyInjection\ContainerInterface;
24 use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
25 use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
26 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
27 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
28 use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
29 use Symfony\Component\Routing\Route;
30 use Symfony\Component\HttpFoundation\Request;
31 use Symfony\Component\HttpKernel\Exception\HttpException;
32
33 /**
34  * File upload resource.
35  *
36  * This is implemented as a field-level resource for the following reasons:
37  *   - Validation for uploaded files is tied to fields (allowed extensions, max
38  *     size, etc..).
39  *   - The actual files do not need to be stored in another temporary location,
40  *     to be later moved when they are referenced from a file field.
41  *   - Permission to upload a file can be determined by a users field level
42  *     create access to the file field.
43  *
44  * @RestResource(
45  *   id = "file:upload",
46  *   label = @Translation("File Upload"),
47  *   serialization_class = "Drupal\file\Entity\File",
48  *   uri_paths = {
49  *     "https://www.drupal.org/link-relations/create" = "/file/upload/{entity_type_id}/{bundle}/{field_name}"
50  *   }
51  * )
52  */
53 class FileUploadResource extends ResourceBase {
54
55   use EntityResourceValidationTrait {
56     validate as resourceValidate;
57   }
58
59   /**
60    * The regex used to extract the filename from the content disposition header.
61    *
62    * @var string
63    */
64   const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
65
66   /**
67    * The amount of bytes to read in each iteration when streaming file data.
68    *
69    * @var int
70    */
71   const BYTES_TO_READ = 8192;
72
73   /**
74    * The file system service.
75    *
76    * @var \Drupal\Core\File\FileSystemInterface
77    */
78   protected $fileSystem;
79
80   /**
81    * The entity type manager.
82    *
83    * @var \Drupal\Core\Entity\EntityTypeManagerInterface
84    */
85   protected $entityTypeManager;
86
87   /**
88    * The entity field manager.
89    *
90    * @var \Drupal\Core\Entity\EntityFieldManagerInterface
91    */
92   protected $entityFieldManager;
93
94   /**
95    * The currently authenticated user.
96    *
97    * @var \Drupal\Core\Session\AccountInterface
98    */
99   protected $currentUser;
100
101   /**
102    * The MIME type guesser.
103    *
104    * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
105    */
106   protected $mimeTypeGuesser;
107
108   /**
109    * The token replacement instance.
110    *
111    * @var \Drupal\Core\Utility\Token
112    */
113   protected $token;
114
115   /**
116    * The lock service.
117    *
118    * @var \Drupal\Core\Lock\LockBackendInterface
119    */
120   protected $lock;
121
122   /**
123    * @var \Drupal\Core\Config\ImmutableConfig
124    */
125   protected $systemFileConfig;
126
127   /**
128    * Constructs a FileUploadResource instance.
129    *
130    * @param array $configuration
131    *   A configuration array containing information about the plugin instance.
132    * @param string $plugin_id
133    *   The plugin_id for the plugin instance.
134    * @param mixed $plugin_definition
135    *   The plugin implementation definition.
136    * @param array $serializer_formats
137    *   The available serialization formats.
138    * @param \Psr\Log\LoggerInterface $logger
139    *   A logger instance.
140    * @param \Drupal\Core\File\FileSystemInterface $file_system
141    *   The file system service.
142    * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
143    *   The entity type manager.
144    * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
145    *   The entity field manager.
146    * @param \Drupal\Core\Session\AccountInterface $current_user
147    *   The currently authenticated user.
148    * @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser
149    *   The MIME type guesser.
150    * @param \Drupal\Core\Utility\Token $token
151    *   The token replacement instance.
152    * @param \Drupal\Core\Lock\LockBackendInterface $lock
153    *   The lock service.
154    * @param \Drupal\Core\Config\Config $system_file_config
155    *   The system file configuration.
156    */
157   public function __construct(array $configuration, $plugin_id, $plugin_definition, $serializer_formats, LoggerInterface $logger, FileSystemInterface $file_system, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, AccountInterface $current_user, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, Config $system_file_config) {
158     parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
159     $this->fileSystem = $file_system;
160     $this->entityTypeManager = $entity_type_manager;
161     $this->entityFieldManager = $entity_field_manager;
162     $this->currentUser = $current_user;
163     $this->mimeTypeGuesser = $mime_type_guesser;
164     $this->token = $token;
165     $this->lock = $lock;
166     $this->systemFileConfig = $system_file_config;
167   }
168
169   /**
170    * {@inheritdoc}
171    */
172   public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
173     return new static(
174       $configuration,
175       $plugin_id,
176       $plugin_definition,
177       $container->getParameter('serializer.formats'),
178       $container->get('logger.factory')->get('rest'),
179       $container->get('file_system'),
180       $container->get('entity_type.manager'),
181       $container->get('entity_field.manager'),
182       $container->get('current_user'),
183       $container->get('file.mime_type.guesser'),
184       $container->get('token'),
185       $container->get('lock'),
186       $container->get('config.factory')->get('system.file')
187     );
188   }
189
190   /**
191    * {@inheritdoc}
192    */
193   public function permissions() {
194     // Access to this resource depends on field-level access so no explicit
195     // permissions are required.
196     // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validateAndLoadFieldDefinition()
197     // @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
198     return [];
199   }
200
201   /**
202    * Creates a file from an endpoint.
203    *
204    * @param \Symfony\Component\HttpFoundation\Request $request
205    *   The current request.
206    * @param string $entity_type_id
207    *   The entity type ID.
208    * @param string $bundle
209    *   The entity bundle. This will be the same as $entity_type_id for entity
210    *   types that don't support bundles.
211    * @param string $field_name
212    *   The field name.
213    *
214    * @return \Drupal\rest\ModifiedResourceResponse
215    *   A 201 response, on success.
216    *
217    * @throws \Symfony\Component\HttpKernel\Exception\HttpException
218    *   Thrown when temporary files cannot be written, a lock cannot be acquired,
219    *   or when temporary files cannot be moved to their new location.
220    */
221   public function post(Request $request, $entity_type_id, $bundle, $field_name) {
222     $filename = $this->validateAndParseContentDispositionHeader($request);
223
224     $field_definition = $this->validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name);
225
226     $destination = $this->getUploadLocation($field_definition->getSettings());
227
228     // Check the destination file path is writable.
229     if (!file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
230       throw new HttpException(500, 'Destination file path is not writable');
231     }
232
233     $validators = $this->getUploadValidators($field_definition);
234
235     $prepared_filename = $this->prepareFilename($filename, $validators);
236
237     // Create the file.
238     $file_uri = "{$destination}/{$prepared_filename}";
239
240     $temp_file_path = $this->streamUploadData();
241
242     // This will take care of altering $file_uri if a file already exists.
243     file_unmanaged_prepare($temp_file_path, $file_uri);
244
245     // Lock based on the prepared file URI.
246     $lock_id = $this->generateLockIdFromFileUri($file_uri);
247
248     if (!$this->lock->acquire($lock_id)) {
249       throw new HttpException(503, sprintf('File "%s" is already locked for writing'), NULL, ['Retry-After' => 1]);
250     }
251
252     // Begin building file entity.
253     $file = File::create([]);
254     $file->setOwnerId($this->currentUser->id());
255     $file->setFilename($prepared_filename);
256     $file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename));
257     $file->setFileUri($file_uri);
258     // Set the size. This is done in File::preSave() but we validate the file
259     // before it is saved.
260     $file->setSize(@filesize($temp_file_path));
261
262     // Validate the file entity against entity-level validation and field-level
263     // validators.
264     $this->validate($file, $validators);
265
266     // Move the file to the correct location after validation. Use
267     // FILE_EXISTS_ERROR as the file location has already been determined above
268     // in file_unmanaged_prepare().
269     if (!file_unmanaged_move($temp_file_path, $file_uri, FILE_EXISTS_ERROR)) {
270       throw new HttpException(500, 'Temporary file could not be moved to file location');
271     }
272
273     $file->save();
274
275     $this->lock->release($lock_id);
276
277     // 201 Created responses return the newly created entity in the response
278     // body. These responses are not cacheable, so we add no cacheability
279     // metadata here.
280     return new ModifiedResourceResponse($file, 201);
281   }
282
283   /**
284    * Streams file upload data to temporary file and moves to file destination.
285    *
286    * @return string
287    *   The temp file path.
288    *
289    * @throws \Symfony\Component\HttpKernel\Exception\HttpException
290    *   Thrown when input data cannot be read, the temporary file cannot be
291    *   opened, or the temporary file cannot be written.
292    */
293   protected function streamUploadData() {
294     // 'rb' is needed so reading works correctly on Windows environments too.
295     $file_data = fopen('php://input', 'rb');
296
297     $temp_file_path = $this->fileSystem->tempnam('temporary://', 'file');
298     $temp_file = fopen($temp_file_path, 'wb');
299
300     if ($temp_file) {
301       while (!feof($file_data)) {
302         $read = fread($file_data, static::BYTES_TO_READ);
303
304         if ($read === FALSE) {
305           // Close the file streams.
306           fclose($temp_file);
307           fclose($file_data);
308           $this->logger->error('Input data could not be read');
309           throw new HttpException(500, 'Input file data could not be read');
310         }
311
312         if (fwrite($temp_file, $read) === FALSE) {
313           // Close the file streams.
314           fclose($temp_file);
315           fclose($file_data);
316           $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]);
317           throw new HttpException(500, 'Temporary file data could not be written');
318         }
319       }
320
321       // Close the temp file stream.
322       fclose($temp_file);
323     }
324     else {
325       // Close the file streams.
326       fclose($temp_file);
327       fclose($file_data);
328       $this->logger->error('Temporary file "%path" could not be opened for file upload', ['%path' => $temp_file_path]);
329       throw new HttpException(500, 'Temporary file could not be opened');
330     }
331
332     // Close the input stream.
333     fclose($file_data);
334
335     return $temp_file_path;
336   }
337
338   /**
339    * Validates and extracts the filename from the Content-Disposition header.
340    *
341    * @param \Symfony\Component\HttpFoundation\Request $request
342    *   The request object.
343    *
344    * @return string
345    *   The filename extracted from the header.
346    *
347    * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
348    *   Thrown when the 'Content-Disposition' request header is invalid.
349    */
350   protected function validateAndParseContentDispositionHeader(Request $request) {
351     // Firstly, check the header exists.
352     if (!$request->headers->has('content-disposition')) {
353       throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided');
354     }
355
356     $content_disposition = $request->headers->get('content-disposition');
357
358     // Parse the header value. This regex does not allow an empty filename.
359     // i.e. 'filename=""'. This also matches on a word boundary so other keys
360     // like 'not_a_filename' don't work.
361     if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
362       throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided');
363     }
364
365     // Check for the "filename*" format. This is currently unsupported.
366     if (!empty($matches['star'])) {
367       throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header');
368     }
369
370     // Don't validate the actual filename here, that will be done by the upload
371     // validators in validate().
372     // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
373     $filename = $matches['filename'];
374
375     // Make sure only the filename component is returned. Path information is
376     // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
377     return basename($filename);
378   }
379
380   /**
381    * Validates and loads a field definition instance.
382    *
383    * @param string $entity_type_id
384    *   The entity type ID the field is attached to.
385    * @param string $bundle
386    *   The bundle the field is attached to.
387    * @param string $field_name
388    *   The field name.
389    *
390    * @return \Drupal\Core\Field\FieldDefinitionInterface
391    *   The field definition.
392    *
393    * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
394    *   Thrown when the field does not exist.
395    * @throws \Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException
396    *   Thrown when the target type of the field is not a file, or the current
397    *   user does not have 'edit' access for the field.
398    */
399   protected function validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name) {
400     $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle);
401     if (!isset($field_definitions[$field_name])) {
402       throw new NotFoundHttpException(sprintf('Field "%s" does not exist', $field_name));
403     }
404
405     /** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
406     $field_definition = $field_definitions[$field_name];
407     if ($field_definition->getSetting('target_type') !== 'file') {
408       throw new AccessDeniedHttpException(sprintf('"%s" is not a file field', $field_name));
409     }
410
411     $entity_access_control_handler = $this->entityTypeManager->getAccessControlHandler($entity_type_id);
412     $bundle = $this->entityTypeManager->getDefinition($entity_type_id)->hasKey('bundle') ? $bundle : NULL;
413     $access_result = $entity_access_control_handler->createAccess($bundle, NULL, [], TRUE)
414       ->andIf($entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE));
415     if (!$access_result->isAllowed()) {
416       throw new AccessDeniedHttpException($access_result->getReason());
417     }
418
419     return $field_definition;
420   }
421
422   /**
423    * Validates the file.
424    *
425    * @param \Drupal\file\FileInterface $file
426    *   The file entity to validate.
427    * @param array $validators
428    *   An array of upload validators to pass to file_validate().
429    *
430    * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
431    *   Thrown when there are file validation errors.
432    */
433   protected function validate(FileInterface $file, array $validators) {
434     $this->resourceValidate($file);
435
436     // Validate the file based on the field definition configuration.
437     $errors = file_validate($file, $validators);
438
439     if (!empty($errors)) {
440       $message = "Unprocessable Entity: file validation failed.\n";
441       $message .= implode("\n", array_map(function ($error) {
442         return PlainTextOutput::renderFromHtml($error);
443       }, $errors));
444
445       throw new UnprocessableEntityHttpException($message);
446     }
447   }
448
449   /**
450    * Prepares the filename to strip out any malicious extensions.
451    *
452    * @param string $filename
453    *   The file name.
454    * @param array $validators
455    *   The array of upload validators.
456    *
457    * @return string
458    *   The prepared/munged filename.
459    */
460   protected function prepareFilename($filename, array &$validators) {
461     if (!empty($validators['file_validate_extensions'][0])) {
462       // If there is a file_validate_extensions validator and a list of
463       // valid extensions, munge the filename to protect against possible
464       // malicious extension hiding within an unknown file type. For example,
465       // "filename.html.foo".
466       $filename = file_munge_filename($filename, $validators['file_validate_extensions'][0]);
467     }
468
469     // Rename potentially executable files, to help prevent exploits (i.e. will
470     // rename filename.php.foo and filename.php to filename.php.foo.txt and
471     // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads'
472     // evaluates to TRUE.
473     if (!$this->systemFileConfig->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename) && (substr($filename, -4) != '.txt')) {
474       // The destination filename will also later be used to create the URI.
475       $filename .= '.txt';
476
477       // The .txt extension may not be in the allowed list of extensions. We
478       // have to add it here or else the file upload will fail.
479       if (!empty($validators['file_validate_extensions'][0])) {
480         $validators['file_validate_extensions'][0] .= ' txt';
481       }
482     }
483
484     return $filename;
485   }
486
487   /**
488    * Determines the URI for a file field.
489    *
490    * @param array $settings
491    *   The array of field settings.
492    *
493    * @return string
494    *   An un-sanitized file directory URI with tokens replaced. The result of
495    *   the token replacement is then converted to plain text and returned.
496    */
497   protected function getUploadLocation(array $settings) {
498     $destination = trim($settings['file_directory'], '/');
499
500     // Replace tokens. As the tokens might contain HTML we convert it to plain
501     // text.
502     $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, []));
503     return $settings['uri_scheme'] . '://' . $destination;
504   }
505
506   /**
507    * Retrieves the upload validators for a field definition.
508    *
509    * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there
510    * is no entity instance available here that that a FileItem would exist for.
511    *
512    * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
513    *   The field definition for which to get validators.
514    *
515    * @return array
516    *   An array suitable for passing to file_save_upload() or the file field
517    *   element's '#upload_validators' property.
518    */
519   protected function getUploadValidators(FieldDefinitionInterface $field_definition) {
520     $validators = [
521       // Add in our check of the file name length.
522       'file_validate_name_length' => [],
523     ];
524     $settings = $field_definition->getSettings();
525
526     // Cap the upload size according to the PHP limit.
527     $max_filesize = Bytes::toInt(file_upload_max_size());
528     if (!empty($settings['max_filesize'])) {
529       $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
530     }
531
532     // There is always a file size limit due to the PHP server limit.
533     $validators['file_validate_size'] = [$max_filesize];
534
535     // Add the extension check if necessary.
536     if (!empty($settings['file_extensions'])) {
537       $validators['file_validate_extensions'] = [$settings['file_extensions']];
538     }
539
540     return $validators;
541   }
542
543   /**
544    * {@inheritdoc}
545    */
546   protected function getBaseRoute($canonical_path, $method) {
547     return new Route($canonical_path, [
548       '_controller' => RequestHandler::class . '::handleRaw',
549     ],
550       $this->getBaseRouteRequirements($method),
551       [],
552       '',
553       [],
554       // The HTTP method is a requirement for this route.
555       [$method]
556     );
557   }
558
559   /**
560    * {@inheritdoc}
561    */
562   protected function getBaseRouteRequirements($method) {
563     $requirements = parent::getBaseRouteRequirements($method);
564
565     // Add the content type format access check. This will enforce that all
566     // incoming requests can only use the 'application/octet-stream'
567     // Content-Type header.
568     $requirements['_content_type_format'] = 'bin';
569
570     return $requirements;
571   }
572
573   /**
574    * Generates a lock ID based on the file URI.
575    *
576    * @param $file_uri
577    *   The file URI.
578    *
579    * @return string
580    *   The generated lock ID.
581    */
582   protected static function generateLockIdFromFileUri($file_uri) {
583     return 'file:rest:' . Crypt::hashBase64($file_uri);
584   }
585
586 }