comparison core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php @ 5:12f9dff5fda9 tip

Update to Drupal core 8.7.1
author Chris Cannam
date Thu, 09 May 2019 15:34:47 +0100
parents
children
comparison
equal deleted inserted replaced
4:a9cd425dd02b 5:12f9dff5fda9
1 <?php
2
3 namespace Drupal\jsonapi\Controller;
4
5 use Drupal\Component\Utility\Bytes;
6 use Drupal\Component\Utility\Crypt;
7 use Drupal\Component\Utility\Environment;
8 use Drupal\Core\Entity\EntityInterface;
9 use Drupal\Core\Entity\Plugin\DataType\EntityAdapter;
10 use Drupal\Core\Field\FieldDefinitionInterface;
11 use Drupal\Core\File\Exception\FileException;
12 use Drupal\Core\Validation\DrupalTranslator;
13 use Drupal\file\FileInterface;
14 use Drupal\Core\File\FileSystemInterface;
15 use Drupal\Core\Config\ConfigFactoryInterface;
16 use Drupal\Core\Lock\LockBackendInterface;
17 use Drupal\Core\Render\BubbleableMetadata;
18 use Drupal\Core\Session\AccountInterface;
19 use Drupal\Core\Utility\Token;
20 use Drupal\Component\Render\PlainTextOutput;
21 use Drupal\file\Entity\File;
22 use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
23 use Psr\Log\LoggerInterface;
24 use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
25 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
26 use Symfony\Component\HttpFoundation\Request;
27 use Symfony\Component\HttpKernel\Exception\HttpException;
28 use Symfony\Component\Validator\ConstraintViolation;
29
30 /**
31 * Reads data from an upload stream and creates a corresponding file entity.
32 *
33 * This is implemented at the field level for the following reasons:
34 * - Validation for uploaded files is tied to fields (allowed extensions, max
35 * size, etc..).
36 * - The actual files do not need to be stored in another temporary location,
37 * to be later moved when they are referenced from a file field.
38 * - Permission to upload a file can be determined by a user's field- and
39 * entity-level access.
40 *
41 * @internal This will be removed once https://www.drupal.org/project/drupal/issues/2940383 lands.
42 */
43 class TemporaryJsonapiFileFieldUploader {
44
45 /**
46 * The regex used to extract the filename from the content disposition header.
47 *
48 * @var string
49 */
50 const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
51
52 /**
53 * The amount of bytes to read in each iteration when streaming file data.
54 *
55 * @var int
56 */
57 const BYTES_TO_READ = 8192;
58
59 /**
60 * A logger instance.
61 *
62 * @var \Psr\Log\LoggerInterface
63 */
64 protected $logger;
65
66 /**
67 * The file system service.
68 *
69 * @var \Drupal\Core\File\FileSystemInterface
70 */
71 protected $fileSystem;
72
73 /**
74 * The MIME type guesser.
75 *
76 * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
77 */
78 protected $mimeTypeGuesser;
79
80 /**
81 * The token replacement instance.
82 *
83 * @var \Drupal\Core\Utility\Token
84 */
85 protected $token;
86
87 /**
88 * The lock service.
89 *
90 * @var \Drupal\Core\Lock\LockBackendInterface
91 */
92 protected $lock;
93
94 /**
95 * System file configuration.
96 *
97 * @var \Drupal\Core\Config\ImmutableConfig
98 */
99 protected $systemFileConfig;
100
101 /**
102 * Constructs a FileUploadResource instance.
103 *
104 * @param \Psr\Log\LoggerInterface $logger
105 * A logger instance.
106 * @param \Drupal\Core\File\FileSystemInterface $file_system
107 * The file system service.
108 * @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser
109 * The MIME type guesser.
110 * @param \Drupal\Core\Utility\Token $token
111 * The token replacement instance.
112 * @param \Drupal\Core\Lock\LockBackendInterface $lock
113 * The lock service.
114 * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
115 * The config factory.
116 */
117 public function __construct(LoggerInterface $logger, FileSystemInterface $file_system, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, ConfigFactoryInterface $config_factory) {
118 $this->logger = $logger;
119 $this->fileSystem = $file_system;
120 $this->mimeTypeGuesser = $mime_type_guesser;
121 $this->token = $token;
122 $this->lock = $lock;
123 $this->systemFileConfig = $config_factory->get('system.file');
124 }
125
126 /**
127 * Creates and validates a file entity for a file field from a file stream.
128 *
129 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
130 * The field definition of the field for which the file is to be uploaded.
131 * @param string $filename
132 * The name of the file.
133 * @param \Drupal\Core\Session\AccountInterface $owner
134 * The owner of the file. Note, it is the responsibility of the caller to
135 * enforce access.
136 *
137 * @return \Drupal\file\FileInterface|\Drupal\Core\Entity\EntityConstraintViolationListInterface
138 * The newly uploaded file entity, or a list of validation constraint
139 * violations
140 *
141 * @throws \Symfony\Component\HttpKernel\Exception\HttpException
142 * Thrown when temporary files cannot be written, a lock cannot be acquired,
143 * or when temporary files cannot be moved to their new location.
144 */
145 public function handleFileUploadForField(FieldDefinitionInterface $field_definition, $filename, AccountInterface $owner) {
146 assert(is_a($field_definition->getClass(), FileFieldItemList::class, TRUE));
147 $destination = $this->getUploadLocation($field_definition->getSettings());
148
149 // Check the destination file path is writable.
150 if (!$this->fileSystem->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY)) {
151 throw new HttpException(500, 'Destination file path is not writable');
152 }
153
154 $validators = $this->getUploadValidators($field_definition);
155
156 $prepared_filename = $this->prepareFilename($filename, $validators);
157
158 // Create the file.
159 $file_uri = "{$destination}/{$prepared_filename}";
160
161 $temp_file_path = $this->streamUploadData();
162
163 $file_uri = $this->fileSystem->getDestinationFilename($file_uri, FileSystemInterface::EXISTS_RENAME);
164
165 // Lock based on the prepared file URI.
166 $lock_id = $this->generateLockIdFromFileUri($file_uri);
167
168 if (!$this->lock->acquire($lock_id)) {
169 throw new HttpException(503, sprintf('File "%s" is already locked for writing.'), NULL, ['Retry-After' => 1]);
170 }
171
172 // Begin building file entity.
173 $file = File::create([]);
174 $file->setOwnerId($owner->id());
175 $file->setFilename($prepared_filename);
176 $file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename));
177 $file->setFileUri($file_uri);
178 // Set the size. This is done in File::preSave() but we validate the file
179 // before it is saved.
180 $file->setSize(@filesize($temp_file_path));
181
182 // Validate the file entity against entity-level validation and field-level
183 // validators.
184 $violations = $this->validate($file, $validators);
185 if ($violations->count() > 0) {
186 return $violations;
187 }
188
189 // Move the file to the correct location after validation. Use
190 // FILE_EXISTS_ERROR as the file location has already been determined above
191 // in FileSystem::getDestinationFilename().
192 try {
193 $this->fileSystem->move($temp_file_path, $file_uri, FileSystemInterface::EXISTS_ERROR);
194 }
195 catch (FileException $e) {
196 throw new HttpException(500, 'Temporary file could not be moved to file location');
197 }
198
199 $file->save();
200
201 $this->lock->release($lock_id);
202
203 return $file;
204 }
205
206 /**
207 * Validates and extracts the filename from the Content-Disposition header.
208 *
209 * @param \Symfony\Component\HttpFoundation\Request $request
210 * The request object.
211 *
212 * @return string
213 * The filename extracted from the header.
214 *
215 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
216 * Thrown when the 'Content-Disposition' request header is invalid.
217 */
218 public function validateAndParseContentDispositionHeader(Request $request) {
219 // First, check the header exists.
220 if (!$request->headers->has('content-disposition')) {
221 throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.');
222 }
223
224 $content_disposition = $request->headers->get('content-disposition');
225
226 // Parse the header value. This regex does not allow an empty filename.
227 // i.e. 'filename=""'. This also matches on a word boundary so other keys
228 // like 'not_a_filename' don't work.
229 if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
230 throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.');
231 }
232
233 // Check for the "filename*" format. This is currently unsupported.
234 if (!empty($matches['star'])) {
235 throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header.');
236 }
237
238 // Don't validate the actual filename here, that will be done by the upload
239 // validators in validate().
240 // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
241 $filename = $matches['filename'];
242
243 // Make sure only the filename component is returned. Path information is
244 // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
245 return $this->fileSystem->basename($filename);
246 }
247
248 /**
249 * Checks if the current user has access to upload the file.
250 *
251 * @param \Drupal\Core\Session\AccountInterface $account
252 * The account for which file upload access should be checked.
253 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
254 * The field definition for which to get validators.
255 * @param \Drupal\Core\Entity\EntityInterface $entity
256 * (optional) The entity to which the file is to be uploaded, if it exists.
257 * If the entity does not exist and it is not given, create access to the
258 * file will be checked.
259 *
260 * @return \Drupal\Core\Access\AccessResultInterface
261 * The file upload access result.
262 */
263 public static function checkFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, EntityInterface $entity = NULL) {
264 assert(is_null($entity) || $field_definition->getTargetEntityTypeId() === $entity->getEntityTypeId() && $field_definition->getTargetBundle() === $entity->bundle());
265 $entity_type_manager = \Drupal::entityTypeManager();
266 $entity_access_control_handler = $entity_type_manager->getAccessControlHandler($field_definition->getTargetEntityTypeId());
267 $bundle = $entity_type_manager->getDefinition($field_definition->getTargetEntityTypeId())->hasKey('bundle') ? $field_definition->getTargetBundle() : NULL;
268 $entity_access_result = $entity
269 ? $entity_access_control_handler->access($entity, 'update', $account, TRUE)
270 : $entity_access_control_handler->createAccess($bundle, $account, [], TRUE);
271 $field_access_result = $entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE);
272 return $entity_access_result->andIf($field_access_result);
273 }
274
275 /**
276 * Streams file upload data to temporary file and moves to file destination.
277 *
278 * @return string
279 * The temp file path.
280 *
281 * @throws \Symfony\Component\HttpKernel\Exception\HttpException
282 * Thrown when input data cannot be read, the temporary file cannot be
283 * opened, or the temporary file cannot be written.
284 */
285 protected function streamUploadData() {
286 // 'rb' is needed so reading works correctly on Windows environments too.
287 $file_data = fopen('php://input', 'rb');
288
289 $temp_file_path = $this->fileSystem->tempnam('temporary://', 'file');
290 $temp_file = fopen($temp_file_path, 'wb');
291
292 if ($temp_file) {
293 while (!feof($file_data)) {
294 $read = fread($file_data, static::BYTES_TO_READ);
295
296 if ($read === FALSE) {
297 // Close the file streams.
298 fclose($temp_file);
299 fclose($file_data);
300 $this->logger->error('Input data could not be read');
301 throw new HttpException(500, 'Input file data could not be read.');
302 }
303
304 if (fwrite($temp_file, $read) === FALSE) {
305 // Close the file streams.
306 fclose($temp_file);
307 fclose($file_data);
308 $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]);
309 throw new HttpException(500, 'Temporary file data could not be written.');
310 }
311 }
312
313 // Close the temp file stream.
314 fclose($temp_file);
315 }
316 else {
317 // Close the input file stream since we can't proceed with the upload.
318 // Don't try to close $temp_file since it's FALSE at this point.
319 fclose($file_data);
320 $this->logger->error('Temporary file "%path" could not be opened for file upload.', ['%path' => $temp_file_path]);
321 throw new HttpException(500, 'Temporary file could not be opened');
322 }
323
324 // Close the input stream.
325 fclose($file_data);
326
327 return $temp_file_path;
328 }
329
330 /**
331 * Validates the file.
332 *
333 * @param \Drupal\file\FileInterface $file
334 * The file entity to validate.
335 * @param array $validators
336 * An array of upload validators to pass to file_validate().
337 *
338 * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
339 * The list of constraint violations, if any.
340 */
341 protected function validate(FileInterface $file, array $validators) {
342 $violations = $file->validate();
343
344 // Remove violations of inaccessible fields as they cannot stem from our
345 // changes.
346 $violations->filterByFieldAccess();
347
348 // Validate the file based on the field definition configuration.
349 $errors = file_validate($file, $validators);
350 if (!empty($errors)) {
351 $translator = new DrupalTranslator();
352 foreach ($errors as $error) {
353 $violation = new ConstraintViolation($translator->trans($error),
354 $error,
355 [],
356 EntityAdapter::createFromEntity($file),
357 '',
358 NULL
359 );
360 $violations->add($violation);
361 }
362 }
363
364 return $violations;
365 }
366
367 /**
368 * Prepares the filename to strip out any malicious extensions.
369 *
370 * @param string $filename
371 * The file name.
372 * @param array $validators
373 * The array of upload validators.
374 *
375 * @return string
376 * The prepared/munged filename.
377 */
378 protected function prepareFilename($filename, array &$validators) {
379 if (!empty($validators['file_validate_extensions'][0])) {
380 // If there is a file_validate_extensions validator and a list of
381 // valid extensions, munge the filename to protect against possible
382 // malicious extension hiding within an unknown file type. For example,
383 // "filename.html.foo".
384 $filename = file_munge_filename($filename, $validators['file_validate_extensions'][0]);
385 }
386
387 // Rename potentially executable files, to help prevent exploits (i.e. will
388 // rename filename.php.foo and filename.php to filename.php.foo.txt and
389 // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads'
390 // evaluates to TRUE.
391 if (!$this->systemFileConfig->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename) && (substr($filename, -4) != '.txt')) {
392 // The destination filename will also later be used to create the URI.
393 $filename .= '.txt';
394
395 // The .txt extension may not be in the allowed list of extensions. We
396 // have to add it here or else the file upload will fail.
397 if (!empty($validators['file_validate_extensions'][0])) {
398 $validators['file_validate_extensions'][0] .= ' txt';
399 }
400 }
401
402 return $filename;
403 }
404
405 /**
406 * Determines the URI for a file field.
407 *
408 * @param array $settings
409 * The array of field settings.
410 *
411 * @return string
412 * An un-sanitized file directory URI with tokens replaced. The result of
413 * the token replacement is then converted to plain text and returned.
414 */
415 protected function getUploadLocation(array $settings) {
416 $destination = trim($settings['file_directory'], '/');
417
418 // Replace tokens. As the tokens might contain HTML we convert it to plain
419 // text.
420 $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [], [], new BubbleableMetadata()));
421 return $settings['uri_scheme'] . '://' . $destination;
422 }
423
424 /**
425 * Retrieves the upload validators for a field definition.
426 *
427 * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there
428 * is no entity instance available here that that a FileItem would exist for.
429 *
430 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
431 * The field definition for which to get validators.
432 *
433 * @return array
434 * An array suitable for passing to file_save_upload() or the file field
435 * element's '#upload_validators' property.
436 */
437 protected function getUploadValidators(FieldDefinitionInterface $field_definition) {
438 $validators = [
439 // Add in our check of the file name length.
440 'file_validate_name_length' => [],
441 ];
442 $settings = $field_definition->getSettings();
443
444 // Cap the upload size according to the PHP limit.
445 $max_filesize = Bytes::toInt(Environment::getUploadMaxSize());
446 if (!empty($settings['max_filesize'])) {
447 $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
448 }
449
450 // There is always a file size limit due to the PHP server limit.
451 $validators['file_validate_size'] = [$max_filesize];
452
453 // Add the extension check if necessary.
454 if (!empty($settings['file_extensions'])) {
455 $validators['file_validate_extensions'] = [$settings['file_extensions']];
456 }
457
458 return $validators;
459 }
460
461 /**
462 * Generates a lock ID based on the file URI.
463 *
464 * @param string $file_uri
465 * The file URI.
466 *
467 * @return string
468 * The generated lock ID.
469 */
470 protected static function generateLockIdFromFileUri($file_uri) {
471 return 'file:jsonapi:' . Crypt::hashBase64($file_uri);
472 }
473
474 }