Mercurial > hg > cmmr2012-drupal-site
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 } |