Chris@18: \*?)=\"(?.+)\"@'; Chris@18: Chris@18: /** Chris@18: * The amount of bytes to read in each iteration when streaming file data. Chris@18: * Chris@18: * @var int Chris@18: */ Chris@18: const BYTES_TO_READ = 8192; Chris@18: Chris@18: /** Chris@18: * A logger instance. Chris@18: * Chris@18: * @var \Psr\Log\LoggerInterface Chris@18: */ Chris@18: protected $logger; Chris@18: Chris@18: /** Chris@18: * The file system service. Chris@18: * Chris@18: * @var \Drupal\Core\File\FileSystemInterface Chris@18: */ Chris@18: protected $fileSystem; Chris@18: Chris@18: /** Chris@18: * The MIME type guesser. Chris@18: * Chris@18: * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface Chris@18: */ Chris@18: protected $mimeTypeGuesser; Chris@18: Chris@18: /** Chris@18: * The token replacement instance. Chris@18: * Chris@18: * @var \Drupal\Core\Utility\Token Chris@18: */ Chris@18: protected $token; Chris@18: Chris@18: /** Chris@18: * The lock service. Chris@18: * Chris@18: * @var \Drupal\Core\Lock\LockBackendInterface Chris@18: */ Chris@18: protected $lock; Chris@18: Chris@18: /** Chris@18: * System file configuration. Chris@18: * Chris@18: * @var \Drupal\Core\Config\ImmutableConfig Chris@18: */ Chris@18: protected $systemFileConfig; Chris@18: Chris@18: /** Chris@18: * Constructs a FileUploadResource instance. Chris@18: * Chris@18: * @param \Psr\Log\LoggerInterface $logger Chris@18: * A logger instance. Chris@18: * @param \Drupal\Core\File\FileSystemInterface $file_system Chris@18: * The file system service. Chris@18: * @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser Chris@18: * The MIME type guesser. Chris@18: * @param \Drupal\Core\Utility\Token $token Chris@18: * The token replacement instance. Chris@18: * @param \Drupal\Core\Lock\LockBackendInterface $lock Chris@18: * The lock service. Chris@18: * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory Chris@18: * The config factory. Chris@18: */ Chris@18: public function __construct(LoggerInterface $logger, FileSystemInterface $file_system, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, ConfigFactoryInterface $config_factory) { Chris@18: $this->logger = $logger; Chris@18: $this->fileSystem = $file_system; Chris@18: $this->mimeTypeGuesser = $mime_type_guesser; Chris@18: $this->token = $token; Chris@18: $this->lock = $lock; Chris@18: $this->systemFileConfig = $config_factory->get('system.file'); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Creates and validates a file entity for a file field from a file stream. Chris@18: * Chris@18: * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition Chris@18: * The field definition of the field for which the file is to be uploaded. Chris@18: * @param string $filename Chris@18: * The name of the file. Chris@18: * @param \Drupal\Core\Session\AccountInterface $owner Chris@18: * The owner of the file. Note, it is the responsibility of the caller to Chris@18: * enforce access. Chris@18: * Chris@18: * @return \Drupal\file\FileInterface|\Drupal\Core\Entity\EntityConstraintViolationListInterface Chris@18: * The newly uploaded file entity, or a list of validation constraint Chris@18: * violations Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\HttpException Chris@18: * Thrown when temporary files cannot be written, a lock cannot be acquired, Chris@18: * or when temporary files cannot be moved to their new location. Chris@18: */ Chris@18: public function handleFileUploadForField(FieldDefinitionInterface $field_definition, $filename, AccountInterface $owner) { Chris@18: assert(is_a($field_definition->getClass(), FileFieldItemList::class, TRUE)); Chris@18: $destination = $this->getUploadLocation($field_definition->getSettings()); Chris@18: Chris@18: // Check the destination file path is writable. Chris@18: if (!$this->fileSystem->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY)) { Chris@18: throw new HttpException(500, 'Destination file path is not writable'); Chris@18: } Chris@18: Chris@18: $validators = $this->getUploadValidators($field_definition); Chris@18: Chris@18: $prepared_filename = $this->prepareFilename($filename, $validators); Chris@18: Chris@18: // Create the file. Chris@18: $file_uri = "{$destination}/{$prepared_filename}"; Chris@18: Chris@18: $temp_file_path = $this->streamUploadData(); Chris@18: Chris@18: $file_uri = $this->fileSystem->getDestinationFilename($file_uri, FileSystemInterface::EXISTS_RENAME); Chris@18: Chris@18: // Lock based on the prepared file URI. Chris@18: $lock_id = $this->generateLockIdFromFileUri($file_uri); Chris@18: Chris@18: if (!$this->lock->acquire($lock_id)) { Chris@18: throw new HttpException(503, sprintf('File "%s" is already locked for writing.'), NULL, ['Retry-After' => 1]); Chris@18: } Chris@18: Chris@18: // Begin building file entity. Chris@18: $file = File::create([]); Chris@18: $file->setOwnerId($owner->id()); Chris@18: $file->setFilename($prepared_filename); Chris@18: $file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename)); Chris@18: $file->setFileUri($file_uri); Chris@18: // Set the size. This is done in File::preSave() but we validate the file Chris@18: // before it is saved. Chris@18: $file->setSize(@filesize($temp_file_path)); Chris@18: Chris@18: // Validate the file entity against entity-level validation and field-level Chris@18: // validators. Chris@18: $violations = $this->validate($file, $validators); Chris@18: if ($violations->count() > 0) { Chris@18: return $violations; Chris@18: } Chris@18: Chris@18: // Move the file to the correct location after validation. Use Chris@18: // FILE_EXISTS_ERROR as the file location has already been determined above Chris@18: // in FileSystem::getDestinationFilename(). Chris@18: try { Chris@18: $this->fileSystem->move($temp_file_path, $file_uri, FileSystemInterface::EXISTS_ERROR); Chris@18: } Chris@18: catch (FileException $e) { Chris@18: throw new HttpException(500, 'Temporary file could not be moved to file location'); Chris@18: } Chris@18: Chris@18: $file->save(); Chris@18: Chris@18: $this->lock->release($lock_id); Chris@18: Chris@18: return $file; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Validates and extracts the filename from the Content-Disposition header. Chris@18: * Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return string Chris@18: * The filename extracted from the header. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException Chris@18: * Thrown when the 'Content-Disposition' request header is invalid. Chris@18: */ Chris@18: public function validateAndParseContentDispositionHeader(Request $request) { Chris@18: // First, check the header exists. Chris@18: if (!$request->headers->has('content-disposition')) { Chris@18: throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.'); Chris@18: } Chris@18: Chris@18: $content_disposition = $request->headers->get('content-disposition'); Chris@18: Chris@18: // Parse the header value. This regex does not allow an empty filename. Chris@18: // i.e. 'filename=""'. This also matches on a word boundary so other keys Chris@18: // like 'not_a_filename' don't work. Chris@18: if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) { Chris@18: throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.'); Chris@18: } Chris@18: Chris@18: // Check for the "filename*" format. This is currently unsupported. Chris@18: if (!empty($matches['star'])) { Chris@18: throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header.'); Chris@18: } Chris@18: Chris@18: // Don't validate the actual filename here, that will be done by the upload Chris@18: // validators in validate(). Chris@18: // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate() Chris@18: $filename = $matches['filename']; Chris@18: Chris@18: // Make sure only the filename component is returned. Path information is Chris@18: // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3. Chris@18: return $this->fileSystem->basename($filename); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Checks if the current user has access to upload the file. Chris@18: * Chris@18: * @param \Drupal\Core\Session\AccountInterface $account Chris@18: * The account for which file upload access should be checked. Chris@18: * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition Chris@18: * The field definition for which to get validators. Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * (optional) The entity to which the file is to be uploaded, if it exists. Chris@18: * If the entity does not exist and it is not given, create access to the Chris@18: * file will be checked. Chris@18: * Chris@18: * @return \Drupal\Core\Access\AccessResultInterface Chris@18: * The file upload access result. Chris@18: */ Chris@18: public static function checkFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, EntityInterface $entity = NULL) { Chris@18: assert(is_null($entity) || $field_definition->getTargetEntityTypeId() === $entity->getEntityTypeId() && $field_definition->getTargetBundle() === $entity->bundle()); Chris@18: $entity_type_manager = \Drupal::entityTypeManager(); Chris@18: $entity_access_control_handler = $entity_type_manager->getAccessControlHandler($field_definition->getTargetEntityTypeId()); Chris@18: $bundle = $entity_type_manager->getDefinition($field_definition->getTargetEntityTypeId())->hasKey('bundle') ? $field_definition->getTargetBundle() : NULL; Chris@18: $entity_access_result = $entity Chris@18: ? $entity_access_control_handler->access($entity, 'update', $account, TRUE) Chris@18: : $entity_access_control_handler->createAccess($bundle, $account, [], TRUE); Chris@18: $field_access_result = $entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE); Chris@18: return $entity_access_result->andIf($field_access_result); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Streams file upload data to temporary file and moves to file destination. Chris@18: * Chris@18: * @return string Chris@18: * The temp file path. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\HttpException Chris@18: * Thrown when input data cannot be read, the temporary file cannot be Chris@18: * opened, or the temporary file cannot be written. Chris@18: */ Chris@18: protected function streamUploadData() { Chris@18: // 'rb' is needed so reading works correctly on Windows environments too. Chris@18: $file_data = fopen('php://input', 'rb'); Chris@18: Chris@18: $temp_file_path = $this->fileSystem->tempnam('temporary://', 'file'); Chris@18: $temp_file = fopen($temp_file_path, 'wb'); Chris@18: Chris@18: if ($temp_file) { Chris@18: while (!feof($file_data)) { Chris@18: $read = fread($file_data, static::BYTES_TO_READ); Chris@18: Chris@18: if ($read === FALSE) { Chris@18: // Close the file streams. Chris@18: fclose($temp_file); Chris@18: fclose($file_data); Chris@18: $this->logger->error('Input data could not be read'); Chris@18: throw new HttpException(500, 'Input file data could not be read.'); Chris@18: } Chris@18: Chris@18: if (fwrite($temp_file, $read) === FALSE) { Chris@18: // Close the file streams. Chris@18: fclose($temp_file); Chris@18: fclose($file_data); Chris@18: $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]); Chris@18: throw new HttpException(500, 'Temporary file data could not be written.'); Chris@18: } Chris@18: } Chris@18: Chris@18: // Close the temp file stream. Chris@18: fclose($temp_file); Chris@18: } Chris@18: else { Chris@18: // Close the input file stream since we can't proceed with the upload. Chris@18: // Don't try to close $temp_file since it's FALSE at this point. Chris@18: fclose($file_data); Chris@18: $this->logger->error('Temporary file "%path" could not be opened for file upload.', ['%path' => $temp_file_path]); Chris@18: throw new HttpException(500, 'Temporary file could not be opened'); Chris@18: } Chris@18: Chris@18: // Close the input stream. Chris@18: fclose($file_data); Chris@18: Chris@18: return $temp_file_path; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Validates the file. Chris@18: * Chris@18: * @param \Drupal\file\FileInterface $file Chris@18: * The file entity to validate. Chris@18: * @param array $validators Chris@18: * An array of upload validators to pass to file_validate(). Chris@18: * Chris@18: * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface Chris@18: * The list of constraint violations, if any. Chris@18: */ Chris@18: protected function validate(FileInterface $file, array $validators) { Chris@18: $violations = $file->validate(); Chris@18: Chris@18: // Remove violations of inaccessible fields as they cannot stem from our Chris@18: // changes. Chris@18: $violations->filterByFieldAccess(); Chris@18: Chris@18: // Validate the file based on the field definition configuration. Chris@18: $errors = file_validate($file, $validators); Chris@18: if (!empty($errors)) { Chris@18: $translator = new DrupalTranslator(); Chris@18: foreach ($errors as $error) { Chris@18: $violation = new ConstraintViolation($translator->trans($error), Chris@18: $error, Chris@18: [], Chris@18: EntityAdapter::createFromEntity($file), Chris@18: '', Chris@18: NULL Chris@18: ); Chris@18: $violations->add($violation); Chris@18: } Chris@18: } Chris@18: Chris@18: return $violations; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Prepares the filename to strip out any malicious extensions. Chris@18: * Chris@18: * @param string $filename Chris@18: * The file name. Chris@18: * @param array $validators Chris@18: * The array of upload validators. Chris@18: * Chris@18: * @return string Chris@18: * The prepared/munged filename. Chris@18: */ Chris@18: protected function prepareFilename($filename, array &$validators) { Chris@18: if (!empty($validators['file_validate_extensions'][0])) { Chris@18: // If there is a file_validate_extensions validator and a list of Chris@18: // valid extensions, munge the filename to protect against possible Chris@18: // malicious extension hiding within an unknown file type. For example, Chris@18: // "filename.html.foo". Chris@18: $filename = file_munge_filename($filename, $validators['file_validate_extensions'][0]); Chris@18: } Chris@18: Chris@18: // Rename potentially executable files, to help prevent exploits (i.e. will Chris@18: // rename filename.php.foo and filename.php to filename.php.foo.txt and Chris@18: // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads' Chris@18: // evaluates to TRUE. Chris@18: if (!$this->systemFileConfig->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename) && (substr($filename, -4) != '.txt')) { Chris@18: // The destination filename will also later be used to create the URI. Chris@18: $filename .= '.txt'; Chris@18: Chris@18: // The .txt extension may not be in the allowed list of extensions. We Chris@18: // have to add it here or else the file upload will fail. Chris@18: if (!empty($validators['file_validate_extensions'][0])) { Chris@18: $validators['file_validate_extensions'][0] .= ' txt'; Chris@18: } Chris@18: } Chris@18: Chris@18: return $filename; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Determines the URI for a file field. Chris@18: * Chris@18: * @param array $settings Chris@18: * The array of field settings. Chris@18: * Chris@18: * @return string Chris@18: * An un-sanitized file directory URI with tokens replaced. The result of Chris@18: * the token replacement is then converted to plain text and returned. Chris@18: */ Chris@18: protected function getUploadLocation(array $settings) { Chris@18: $destination = trim($settings['file_directory'], '/'); Chris@18: Chris@18: // Replace tokens. As the tokens might contain HTML we convert it to plain Chris@18: // text. Chris@18: $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [], [], new BubbleableMetadata())); Chris@18: return $settings['uri_scheme'] . '://' . $destination; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Retrieves the upload validators for a field definition. Chris@18: * Chris@18: * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there Chris@18: * is no entity instance available here that that a FileItem would exist for. Chris@18: * Chris@18: * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition Chris@18: * The field definition for which to get validators. Chris@18: * Chris@18: * @return array Chris@18: * An array suitable for passing to file_save_upload() or the file field Chris@18: * element's '#upload_validators' property. Chris@18: */ Chris@18: protected function getUploadValidators(FieldDefinitionInterface $field_definition) { Chris@18: $validators = [ Chris@18: // Add in our check of the file name length. Chris@18: 'file_validate_name_length' => [], Chris@18: ]; Chris@18: $settings = $field_definition->getSettings(); Chris@18: Chris@18: // Cap the upload size according to the PHP limit. Chris@18: $max_filesize = Bytes::toInt(Environment::getUploadMaxSize()); Chris@18: if (!empty($settings['max_filesize'])) { Chris@18: $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize'])); Chris@18: } Chris@18: Chris@18: // There is always a file size limit due to the PHP server limit. Chris@18: $validators['file_validate_size'] = [$max_filesize]; Chris@18: Chris@18: // Add the extension check if necessary. Chris@18: if (!empty($settings['file_extensions'])) { Chris@18: $validators['file_validate_extensions'] = [$settings['file_extensions']]; Chris@18: } Chris@18: Chris@18: return $validators; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Generates a lock ID based on the file URI. Chris@18: * Chris@18: * @param string $file_uri Chris@18: * The file URI. Chris@18: * Chris@18: * @return string Chris@18: * The generated lock ID. Chris@18: */ Chris@18: protected static function generateLockIdFromFileUri($file_uri) { Chris@18: return 'file:jsonapi:' . Crypt::hashBase64($file_uri); Chris@18: } Chris@18: Chris@18: }