first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
.field-icon-file_upload {
background-image: url("data:image/svg+xml,%3csvg height='36' viewBox='0 0 36 36' width='36' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='m3.87 1.163c-.361.17-.581.394-.745.757-.122.269-.122.303-.123 16.08l-.001 15.81.141.303c.166.355.54.699.87.797.173.052 3.612.07 13.532.07 14.778 0 13.513.037 13.978-.408.128-.122.282-.33.344-.462.107-.232.111-.689.113-12.93l.001-12.69-3.735-3.735-3.735-3.735-10.17.001h-10.17zm19.11 5.857v3h6v21.96h-22.98v-27.96h16.98zm-9.215 11.981-3.703 3.949 1.959.016 1.959.016v5.998h7.02v-5.998l1.969-.016 1.968-.016-3.684-3.93c-2.027-2.162-3.707-3.938-3.734-3.949-.028-.01-1.717 1.759-3.754 3.93' fill='%2355565b'/%3e%3c/svg%3e");
}

View File

@@ -0,0 +1,3 @@
.field-icon-file_upload {
background-image: url(../../../misc/icons/55565b/file_upload.svg);
}

136
core/modules/file/file.api.php Executable file
View File

@@ -0,0 +1,136 @@
<?php
/**
* @file
* Hooks for file module.
*/
/**
* @defgroup file File interface
* @{
* Common file handling functions.
*
* @section file_security Uploading files and security considerations
*
* Using \Drupal\file\Element\ManagedFile field with a defined list of allowed
* extensions is best way to provide a file upload field. It will ensure that:
* - File names are sanitized by the FileUploadSanitizeNameEvent event.
* - Files are validated by \Drupal\file\Validation\FileValidatorInterface().
* - Files with insecure extensions will be blocked by default even if they are
* listed. If .txt is an allowed extension such files will be renamed.
*
* The \Drupal\Core\Render\Element\File field requires the developer to ensure
* security concerns are taken care of. To do this, a developer should:
* - Add the #upload_validators property to the form element. For example,
* @code
* $form['file_upload'] = [
* '#type' => 'file',
* '#title' => $this->t('Upload file'),
* '#upload_validators' => [
* 'FileExtension' => [
* 'extensions' => 'png gif jpg',
* ],
* ],
* ],
* ];
* @endcode
* - Use file_save_upload() to trigger the FileUploadSanitizeNameEvent event and
* \Drupal\file\Validation\FileValidatorInterface::validate().
*
* Important considerations, regardless of the form element used:
* - Always use and validate against a list of allowed extensions.
* - If the configuration system.file:allow_insecure_uploads is set to TRUE
* then potentially insecure files will not be renamed. This setting is not
* recommended.
*
* @see https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
* @see \Drupal\file\Validation\FileValidatorInterface
* @see file_save_upload()
* @see \Drupal\Core\File\Event\FileUploadSanitizeNameEvent
* @see \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber
* @see \Drupal\file\Element\ManagedFile
* @see \Drupal\Core\Render\Element\File
*
* @}
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Check that files meet a given criteria.
*
* This hook lets modules perform additional validation on files. They're able
* to report a failure by returning one or more error messages.
*
* @param \Drupal\file\FileInterface $file
* The file entity being validated.
*
* @return array
* An array of error messages. If there are no problems with the file return
* an empty array.
*
* @deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use the
* 'file.validator' service instead.
*
* @see https://www.drupal.org/node/3363700
* @see \Drupal\file\Validation\FileValidatorInterface
*/
function hook_file_validate(\Drupal\file\FileInterface $file) {
$errors = [];
if (!$file->getFilename()) {
$errors[] = t("The file's name is empty. Give a name to the file.");
}
if (strlen($file->getFilename()) > 255) {
$errors[] = t("The file's name exceeds the 255 characters limit. Rename the file and try again.");
}
return $errors;
}
/**
* Respond to a file that has been copied.
*
* @param \Drupal\file\FileInterface $file
* The newly copied file entity.
* @param \Drupal\file\FileInterface $source
* The original file before the copy.
*
* @see \Drupal\file\FileRepositoryInterface::copy()
*/
function hook_file_copy(\Drupal\file\FileInterface $file, \Drupal\file\FileInterface $source) {
// Make sure that the file name starts with the owner's user name.
if (!str_starts_with($file->getFilename(), $file->getOwner()->name)) {
$file->setFilename($file->getOwner()->name . '_' . $file->getFilename());
$file->save();
\Drupal::logger('file')->notice('Copied file %source has been renamed to %destination', ['%source' => $source->filename, '%destination' => $file->getFilename()]);
}
}
/**
* Respond to a file that has been moved.
*
* @param \Drupal\file\FileInterface $file
* The updated file entity after the move.
* @param \Drupal\file\FileInterface $source
* The original file entity before the move.
*
* @see \Drupal\file\FileRepositoryInterface::move()
*/
function hook_file_move(\Drupal\file\FileInterface $file, \Drupal\file\FileInterface $source) {
// Make sure that the file name starts with the owner's user name.
if (!str_starts_with($file->getFilename(), $file->getOwner()->name)) {
$file->setFilename($file->getOwner()->name . '_' . $file->getFilename());
$file->save();
\Drupal::logger('file')->notice('Moved file %source has been renamed to %destination', ['%source' => $source->filename, '%destination' => $file->getFilename()]);
}
}
/**
* @} End of "addtogroup hooks".
*/

View File

@@ -0,0 +1,8 @@
<?php
/**
* @file
* Field module functionality for the File module.
*/
@trigger_error(__FILE__ . ' is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Functions have been moved to file.module. See https://www.drupal.org/node/3369330', E_USER_DEPRECATED);

View File

@@ -0,0 +1,6 @@
file_upload:
label: 'File upload'
description: 'Field to upload any type of files.'
weight: -15
libraries:
- file/drupal.file-icon

12
core/modules/file/file.info.yml Executable file
View File

@@ -0,0 +1,12 @@
name: File
type: module
description: 'Provides a field type for files and defines a "managed_file" Form API element.'
package: Field types
# version: VERSION
dependencies:
- drupal:field
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

113
core/modules/file/file.install Executable file
View File

@@ -0,0 +1,113 @@
<?php
/**
* @file
* Install, update and uninstall functions for File module.
*/
/**
* Implements hook_schema().
*/
function file_schema() {
$schema['file_usage'] = [
'description' => 'Track where a file is used.',
'fields' => [
'fid' => [
'description' => 'File ID.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
],
'module' => [
'description' => 'The name of the module that is using the file.',
'type' => 'varchar_ascii',
'length' => DRUPAL_EXTENSION_NAME_MAX_LENGTH,
'not null' => TRUE,
'default' => '',
],
'type' => [
'description' => 'The name of the object type in which the file is used.',
'type' => 'varchar_ascii',
'length' => 64,
'not null' => TRUE,
'default' => '',
],
'id' => [
'description' => 'The primary key of the object using the file.',
'type' => 'varchar_ascii',
'length' => 64,
'not null' => TRUE,
'default' => 0,
],
'count' => [
'description' => 'The number of times this file is used by this object.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
],
'primary key' => ['fid', 'type', 'id', 'module'],
'indexes' => [
'type_id' => ['type', 'id'],
'fid_count' => ['fid', 'count'],
'fid_module' => ['fid', 'module'],
],
];
return $schema;
}
/**
* Implements hook_requirements().
*
* Display information about getting upload progress bars working.
*/
function file_requirements($phase) {
$requirements = [];
if ($phase != 'runtime') {
return $requirements;
}
$server_software = \Drupal::request()->server->get('SERVER_SOFTWARE', '');
// Get the web server identity.
$is_nginx = preg_match("/Nginx/i", $server_software);
$is_apache = preg_match("/Apache/i", $server_software);
$fastcgi = $is_apache && ((str_contains($server_software, 'mod_fastcgi') || str_contains($server_software, 'mod_fcgi')));
// Check the uploadprogress extension is loaded.
if (extension_loaded('uploadprogress')) {
$value = t('Enabled (<a href="http://pecl.php.net/package/uploadprogress">PECL uploadprogress</a>)');
$description = NULL;
}
else {
$value = t('Not enabled');
$description = t('Your server is capable of displaying file upload progress, but does not have the required libraries. It is recommended to install the <a href="http://pecl.php.net/package/uploadprogress">PECL uploadprogress library</a>.');
}
// Adjust the requirement depending on what the server supports.
if (!$is_apache && !$is_nginx) {
$value = t('Not enabled');
$description = t('Your server is not capable of displaying file upload progress. File upload progress requires an Apache server running PHP with mod_php or Nginx with PHP-FPM.');
}
elseif ($fastcgi) {
$value = t('Not enabled');
$description = t('Your server is not capable of displaying file upload progress. File upload progress requires PHP be run with mod_php or PHP-FPM and not as FastCGI.');
}
$requirements['file_progress'] = [
'title' => t('Upload progress'),
'value' => $value,
'description' => $description,
];
return $requirements;
}
/**
* Implements hook_update_last_removed().
*/
function file_update_last_removed() {
return 8700;
}

299
core/modules/file/file.js Executable file
View File

@@ -0,0 +1,299 @@
/**
* @file
* Provides JavaScript additions to the managed file field type.
*
* This file provides progress bar support (if available), popup windows for
* file previews, and disabling of other file fields during Ajax uploads (which
* prevents separate file fields from accidentally uploading files).
*/
(function ($, Drupal) {
/**
* Attach behaviors to the file fields passed in the settings.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches validation for file extensions.
* @prop {Drupal~behaviorDetach} detach
* Detaches validation for file extensions.
*/
Drupal.behaviors.fileValidateAutoAttach = {
attach(context, settings) {
const $context = $(context);
let elements;
function initFileValidation(selector) {
$(once('fileValidate', $context.find(selector))).on(
'change.fileValidate',
{ extensions: elements[selector] },
Drupal.file.validateExtension,
);
}
if (settings.file && settings.file.elements) {
elements = settings.file.elements;
Object.keys(elements).forEach(initFileValidation);
}
},
detach(context, settings, trigger) {
const $context = $(context);
let elements;
function removeFileValidation(selector) {
$(once.remove('fileValidate', $context.find(selector))).off(
'change.fileValidate',
Drupal.file.validateExtension,
);
}
if (trigger === 'unload' && settings.file && settings.file.elements) {
elements = settings.file.elements;
Object.keys(elements).forEach(removeFileValidation);
}
},
};
/**
* Attach behaviors to file element auto upload.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches triggers for the upload button.
* @prop {Drupal~behaviorDetach} detach
* Detaches auto file upload trigger.
*/
Drupal.behaviors.fileAutoUpload = {
attach(context) {
$(once('auto-file-upload', 'input[type="file"]', context)).on(
'change.autoFileUpload',
Drupal.file.triggerUploadButton,
);
},
detach(context, settings, trigger) {
if (trigger === 'unload') {
$(once.remove('auto-file-upload', 'input[type="file"]', context)).off(
'.autoFileUpload',
);
}
},
};
/**
* Attach behaviors to the file upload and remove buttons.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches form submit events.
* @prop {Drupal~behaviorDetach} detach
* Detaches form submit events.
*/
Drupal.behaviors.fileButtons = {
attach(context) {
const $context = $(context);
$context
.find('.js-form-submit')
.on('mousedown', Drupal.file.disableFields);
$context
.find('.js-form-managed-file .js-form-submit')
.on('mousedown', Drupal.file.progressBar);
},
detach(context, settings, trigger) {
if (trigger === 'unload') {
const $context = $(context);
$context
.find('.js-form-submit')
.off('mousedown', Drupal.file.disableFields);
$context
.find('.js-form-managed-file .js-form-submit')
.off('mousedown', Drupal.file.progressBar);
}
},
};
/**
* Attach behaviors to links within managed file elements for preview windows.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches triggers.
* @prop {Drupal~behaviorDetach} detach
* Detaches triggers.
*/
Drupal.behaviors.filePreviewLinks = {
attach(context) {
$(context)
.find('div.js-form-managed-file .file a')
.on('click', Drupal.file.openInNewWindow);
},
detach(context) {
$(context)
.find('div.js-form-managed-file .file a')
.off('click', Drupal.file.openInNewWindow);
},
};
/**
* File upload utility functions.
*
* @namespace
*/
Drupal.file = Drupal.file || {
/**
* Client-side file input validation of file extensions.
*
* @name Drupal.file.validateExtension
*
* @param {jQuery.Event} event
* The event triggered. For example `change.fileValidate`.
*/
validateExtension(event) {
event.preventDefault();
// Remove any previous errors.
$('.file-upload-js-error').remove();
// Add client side validation for the input[type=file].
const extensionPattern = event.data.extensions.replace(/,\s*/g, '|');
if (extensionPattern.length > 1 && this.value.length > 0) {
const acceptableMatch = new RegExp(`\\.(${extensionPattern})$`, 'gi');
if (!acceptableMatch.test(this.value)) {
const error = Drupal.t(
'The selected file %filename cannot be uploaded. Only files with the following extensions are allowed: %extensions.',
{
// According to the specifications of HTML5, a file upload control
// should not reveal the real local path to the file that a user
// has selected. Some web browsers implement this restriction by
// replacing the local path with "C:\fakepath\", which can cause
// confusion by leaving the user thinking perhaps Drupal could not
// find the file because it messed up the file path. To avoid this
// confusion, therefore, we strip out the bogus fakepath string.
'%filename': this.value.replace('C:\\fakepath\\', ''),
'%extensions': extensionPattern.replace(/\|/g, ', '),
},
);
$(this)
.closest('div.js-form-managed-file')
.prepend(
`<div class="messages messages--error file-upload-js-error" aria-live="polite">${error}</div>`,
);
this.value = '';
// Cancel all other change event handlers.
event.stopImmediatePropagation();
}
}
},
/**
* Trigger the upload_button mouse event to auto-upload as a managed file.
*
* @name Drupal.file.triggerUploadButton
*
* @param {jQuery.Event} event
* The event triggered. For example `change.autoFileUpload`.
*/
triggerUploadButton(event) {
$(event.target)
.closest('.js-form-managed-file')
.find('.js-form-submit[data-drupal-selector$="upload-button"]')
.trigger('mousedown');
},
/**
* Prevent file uploads when using buttons not intended to upload.
*
* @name Drupal.file.disableFields
*
* @param {jQuery.Event} event
* The event triggered, most likely a `mousedown` event.
*/
disableFields(event) {
const $clickedButton = $(this);
$clickedButton.trigger('formUpdated');
// Check if we're working with an "Upload" button.
let $enabledFields = [];
if ($clickedButton.closest('div.js-form-managed-file').length > 0) {
$enabledFields = $clickedButton
.closest('div.js-form-managed-file')
.find('input.js-form-file');
}
// Temporarily disable upload fields other than the one we're currently
// working with. Filter out fields that are already disabled so that they
// do not get enabled when we re-enable these fields at the end of
// behavior processing. Re-enable in a setTimeout set to a relatively
// short amount of time (1 second). All the other mousedown handlers
// (like Drupal's Ajax behaviors) are executed before any timeout
// functions are called, so we don't have to worry about the fields being
// re-enabled too soon. @todo If the previous sentence is true, why not
// set the timeout to 0?
const $fieldsToTemporarilyDisable = $(
'div.js-form-managed-file input.js-form-file',
)
.not($enabledFields)
.not(':disabled');
$fieldsToTemporarilyDisable.prop('disabled', true);
setTimeout(() => {
$fieldsToTemporarilyDisable.prop('disabled', false);
}, 1000);
},
/**
* Add progress bar support if possible.
*
* @name Drupal.file.progressBar
*
* @param {jQuery.Event} event
* The event triggered, most likely a `mousedown` event.
*/
progressBar(event) {
const $clickedButton = $(this);
const $progressId = $clickedButton
.closest('div.js-form-managed-file')
.find('input.file-progress');
if ($progressId.length) {
const originalName = $progressId.attr('name');
// Replace the name with the required identifier.
$progressId.attr(
'name',
originalName.match(/APC_UPLOAD_PROGRESS|UPLOAD_IDENTIFIER/)[0],
);
// Restore the original name after the upload begins.
setTimeout(() => {
$progressId.attr('name', originalName);
}, 1000);
}
// Show the progress bar if the upload takes longer than half a second.
setTimeout(() => {
$clickedButton
.closest('div.js-form-managed-file')
.find('div.ajax-progress-bar')
.slideDown();
}, 500);
$clickedButton.trigger('fileUpload');
},
/**
* Open links to files within forms in a new window.
*
* @name Drupal.file.openInNewWindow
*
* @param {jQuery.Event} event
* The event triggered, most likely a `click` event.
*/
openInNewWindow(event) {
event.preventDefault();
$(this).attr('target', '_blank');
window.open(
this.href,
'filePreview',
'toolbar=0,scrollbars=1,location=1,statusbar=1,menubar=0,resizable=1,width=500,height=550',
);
},
};
})(jQuery, Drupal);

View File

@@ -0,0 +1,17 @@
drupal.file:
version: VERSION
js:
file.js: {}
dependencies:
- core/jquery
- core/once
- core/drupal
- core/drupalSettings
drupal.file-icon:
version: VERSION
css:
theme:
css/file.icon.theme.css: {}
dependencies:
- field_ui/drupal.field_ui.manage_fields

1514
core/modules/file/file.module Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
access files overview:
title: 'Access the Files overview page'
delete own files:
title: 'Delete own files'
delete any file:
title: 'Delete any file'
restrict access: true

View File

@@ -0,0 +1,45 @@
<?php
/**
* @file
* Post update functions for File.
*/
use Drupal\Core\Config\Entity\ConfigEntityUpdater;
use Drupal\user\RoleInterface;
/**
* Implements hook_removed_post_updates().
*/
function file_removed_post_updates() {
return [
'file_post_update_add_txt_if_allows_insecure_extensions' => '10.0.0',
];
}
/**
* Grant all non-anonymous roles the 'delete own files' permission.
*/
function file_post_update_add_permissions_to_roles(?array &$sandbox = NULL): void {
\Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'user_role', function (RoleInterface $role): bool {
if ($role->id() === RoleInterface::ANONYMOUS_ID || $role->isAdmin()) {
return FALSE;
}
$role->grantPermission('delete own files');
return TRUE;
});
}
/**
* Add default filename sanitization configuration.
*/
function file_post_update_add_default_filename_sanitization_configuration() {
$config = \Drupal::configFactory()->getEditable('file.settings');
$config->set('filename_sanitization.transliterate', FALSE);
$config->set('filename_sanitization.replace_whitespace', FALSE);
$config->set('filename_sanitization.replace_non_alphanumeric', FALSE);
$config->set('filename_sanitization.deduplicate_separators', FALSE);
$config->set('filename_sanitization.lowercase', FALSE);
$config->set('filename_sanitization.replacement_character', '-');
$config->save();
}

View File

@@ -0,0 +1,6 @@
file.ajax_progress:
path: '/file/progress/{key}'
defaults:
_controller: '\Drupal\file\Controller\FileWidgetAjaxController::progress'
requirements:
_permission: 'access content'

View File

@@ -0,0 +1,34 @@
services:
_defaults:
autoconfigure: true
file.event.subscriber:
class: Drupal\file\EventSubscriber\FileEventSubscriber
arguments: ['@config.factory', '@transliteration', '@language_manager']
file.usage:
class: Drupal\file\FileUsage\DatabaseFileUsageBackend
arguments: ['@config.factory', '@database', 'file_usage']
tags:
- { name: backend_overridable }
file.upload_handler:
class: Drupal\file\Upload\FileUploadHandler
arguments: ['@file_system', '@entity_type.manager', '@stream_wrapper_manager', '@event_dispatcher', '@file.mime_type.guesser', '@current_user', '@request_stack', '@file.repository', '@file.validator', '@lock', '@validation.basic_recursive_validator_factory']
Drupal\file\Upload\FileUploadHandler: '@file.upload_handler'
file.repository:
class: Drupal\file\FileRepository
arguments: [ '@file_system', '@stream_wrapper_manager', '@entity_type.manager', '@module_handler', '@file.usage', '@current_user' ]
Drupal\file\FileRepositoryInterface: '@file.repository'
file.recursive_validator_factory:
class: Drupal\file\Validation\RecursiveValidatorFactory
arguments: ['@class_resolver', '@typed_data_manager']
Drupal\file\Validation\RecursiveValidatorFactory: '@file.recursive_validator_factory'
file.recursive_validator:
class: Symfony\Component\Validator\Validator\ValidatorInterface
factory: ['@file.recursive_validator_factory', 'createValidator']
file.validator:
class: Drupal\file\Validation\FileValidator
arguments: ['@file.recursive_validator', '@validation.constraint', '@event_dispatcher', '@module_handler']
Drupal\file\Validation\FileValidatorInterface: '@file.validator'
file.input_stream_file_writer:
class: Drupal\file\Upload\InputStreamFileWriter
arguments: ['@file_system']
Drupal\file\Upload\InputStreamFileWriterInterface: '@file.input_stream_file_writer'

View File

@@ -0,0 +1,70 @@
<?php
/**
* @file
* Provide views data for file.module.
*/
use Drupal\field\FieldStorageConfigInterface;
/**
* Implements hook_field_views_data().
*
* Views integration for file fields. Adds a file relationship to the default
* field data.
*
* @see views_field_default_views_data()
*/
function file_field_views_data(FieldStorageConfigInterface $field_storage) {
$data = views_field_default_views_data($field_storage);
foreach ($data as $table_name => $table_data) {
// Add the relationship only on the fid field.
$data[$table_name][$field_storage->getName() . '_target_id']['relationship'] = [
'id' => 'standard',
'base' => 'file_managed',
'entity type' => 'file',
'base field' => 'fid',
'label' => t('file from @field_name', ['@field_name' => $field_storage->getName()]),
];
}
return $data;
}
/**
* Implements hook_field_views_data_views_data_alter().
*
* Views integration to provide reverse relationships on file fields.
*/
function file_field_views_data_views_data_alter(array &$data, FieldStorageConfigInterface $field_storage) {
$entity_type_id = $field_storage->getTargetEntityTypeId();
$entity_type_manager = \Drupal::entityTypeManager();
$entity_type = $entity_type_manager->getDefinition($entity_type_id);
$field_name = $field_storage->getName();
$pseudo_field_name = 'reverse_' . $field_name . '_' . $entity_type_id;
/** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
$table_mapping = $entity_type_manager->getStorage($entity_type_id)->getTableMapping();
[$label] = views_entity_field_label($entity_type_id, $field_name);
$data['file_managed'][$pseudo_field_name]['relationship'] = [
'title' => t('@entity using @field', ['@entity' => $entity_type->getLabel(), '@field' => $label]),
'label' => t('@field_name', ['@field_name' => $field_name]),
'group' => $entity_type->getLabel(),
'help' => t('Relate each @entity with a @field set to the file.', ['@entity' => $entity_type->getLabel(), '@field' => $label]),
'id' => 'entity_reverse',
'base' => $entity_type->getDataTable() ?: $entity_type->getBaseTable(),
'entity_type' => $entity_type_id,
'base field' => $entity_type->getKey('id'),
'field_name' => $field_name,
'field table' => $table_mapping->getDedicatedDataTableName($field_storage),
'field field' => $field_name . '_target_id',
'join_extra' => [
0 => [
'field' => 'deleted',
'value' => 0,
'numeric' => TRUE,
],
],
];
}

View File

@@ -0,0 +1,72 @@
# Every migration that references a file by Drupal 6 fid should specify this
# migration as an optional dependency.
id: d6_file
label: Public files
audit: true
migration_tags:
- Drupal 6
- Content
source:
plugin: d6_file
constants:
# The tool configuring this migration must set source_base_path. It
# represents the fully qualified path relative to which URIs in the files
# table are specified. This can be a local file directory containing the
# source site, e.g. /var/www/docroot, or the site address,
# e.g. https://example.com. This value will be concatenated with the file
# path (typically sites/default/files) and used as the source location for
# the files.
#
# Suppose that the source files have been moved by other means to a location
# on the destination site.
# Source site:
# Location of files: /var/www/html/legacy/sites/default/files
# Public scheme: sites/default/files
# In this example, source_base_path should be '/var/www/html/legacy'.
#
# Suppose that the source site is a multisite installation at
# https://example.com, and you plan to copy the files from there.
# Source site:
# Location of files: https://example.com/sites/example.com/files
# Public scheme: sites/example.com/files
# In this example, source_base_path should be 'https://example.com'.
#
# See the configuration for the source_full_path property in the process
# section below.
source_base_path: ''
process:
# If you are using both this migration and d6_user_picture_file in a custom
# migration and executing migrations incrementally, it is strongly
# recommended that you remove the fid mapping to avoid potential ID conflicts.
# For that reason, this mapping is commented out by default.
# fid: fid
filename: filename
source_full_path:
-
plugin: concat
delimiter: /
source:
- constants/source_base_path
- filepath
-
plugin: urlencode
destination_full_path:
plugin: file_uri
source:
- filepath
- file_directory_path
- temp_directory_path
- is_public
uri:
plugin: file_copy
source:
- '@source_full_path'
- '@destination_full_path'
filemime: filemime
# No need to migrate filesize, it is computed when file entities are saved.
# filesize: filesize
status: status
changed: timestamp
uid: uid
destination:
plugin: entity:file

View File

@@ -0,0 +1,32 @@
id: d6_upload
label: File uploads
migration_tags:
- Drupal 6
- Content
source:
plugin: d6_upload
process:
nid: nid
vid: vid
langcode:
plugin: user_langcode
source: language
fallback_to_site_default: true
type: type
upload:
plugin: sub_process
source: upload
process:
target_id:
plugin: migration_lookup
migration: d6_file
source: fid
display: list
description: description
destination:
plugin: entity:node
migration_dependencies:
required:
- d6_file
- d6_node
- d6_upload_field_instance

View File

@@ -0,0 +1,35 @@
id: d6_upload_entity_display
label: Upload display configuration
migration_tags:
- Drupal 6
- Configuration
source:
plugin: d6_upload_instance
constants:
entity_type: node
view_mode: default
name: upload
type: file_default
options:
label: hidden
settings: {}
process:
entity_type: 'constants/entity_type'
bundle:
-
plugin: migration_lookup
migration: d6_node_type
source: node_type
-
plugin: skip_on_empty
method: row
view_mode: 'constants/view_mode'
field_name: 'constants/name'
type: 'constants/type'
options: 'constants/options'
'options/type': '@type'
destination:
plugin: component_entity_display
migration_dependencies:
required:
- d6_upload_field_instance

View File

@@ -0,0 +1,36 @@
id: d6_upload_entity_form_display
label: Upload form display configuration
migration_tags:
- Drupal 6
- Configuration
source:
plugin: d6_upload_instance
constants:
empty: {}
entity_type: node
form_mode: default
name: upload
type: file_generic
options:
settings:
progress_indicator: throbber
process:
entity_type: 'constants/entity_type'
bundle:
-
plugin: migration_lookup
migration: d6_node_type
source: node_type
-
plugin: skip_on_empty
method: row
field_name: 'constants/name'
form_mode: 'constants/form_mode'
type: 'constants/type'
options: 'constants/options'
'options/type': '@type'
destination:
plugin: component_entity_form_display
migration_dependencies:
required:
- d6_upload_field_instance

View File

@@ -0,0 +1,27 @@
id: d6_upload_field
label: Upload field configuration
migration_tags:
- Drupal 6
- Configuration
source:
# We do an empty source and a proper destination to have an ID map for
# migration_dependencies.
plugin: md_empty
source_module: upload
constants:
entity_type: node
type: file
name: upload
cardinality: -1
display_field: true
process:
entity_type: 'constants/entity_type'
field_name: 'constants/name'
type: 'constants/type'
cardinality: 'constants/cardinality'
'settings/display_field': 'constants/display_field'
destination:
plugin: entity:field_storage_config
dependencies:
module:
- file

View File

@@ -0,0 +1,32 @@
id: d6_upload_field_instance
label: Upload field instance configuration
migration_tags:
- Drupal 6
- Configuration
source:
plugin: d6_upload_instance
constants:
entity_type: node
name: upload
settings:
description_field: 1
process:
entity_type: 'constants/entity_type'
bundle:
-
plugin: migration_lookup
migration: d6_node_type
source: node_type
-
plugin: skip_on_empty
method: row
field_name: 'constants/name'
settings: 'constants/settings'
'settings/file_extensions': file_extensions
'settings/max_filesize': max_filesize
destination:
plugin: entity:field_config
migration_dependencies:
required:
- d6_upload_field
- d6_node_type

View File

@@ -0,0 +1,67 @@
# Every migration that references a file by Drupal 7 fid should specify this
# migration as an optional dependency.
id: d7_file
label: Public files
audit: true
migration_tags:
- Drupal 7
- Content
source:
plugin: d7_file
scheme: public
constants:
# The tool configuring this migration must set source_base_path. It
# represents the fully qualified path relative to which URIs in the files
# table are specified. This can be a local file directory containing the
# source site, e.g. /var/www/docroot, or the site address,
# e.g. https://example.com. This value will be concatenated with the file
# path (typically sites/default/files) and used as the source location for
# the files.
#
# Suppose that the source files have been moved by other means to a location
# on the destination site.
# Source site:
# Location of files: /var/www/html/legacy/sites/default/files
# Public scheme: sites/default/files
# In this example, source_base_path should be '/var/www/html/legacy'.
#
# Suppose that the source site is a multisite installation at
# https://example.com, and you plan to copy the files from there.
# Source site:
# Location of files: https://example.com/sites/example.com/files
# Public scheme: sites/example.com/files
# In this example, source_base_path should be 'https://example.com'.
#
# See the configuration for the source_full_path property in the process
# section below.
source_base_path: ''
process:
# If you are using this file to build a custom migration consider removing
# the fid field to allow incremental migrations.
fid: fid
filename: filename
source_full_path:
-
plugin: concat
delimiter: /
source:
- constants/source_base_path
- filepath
-
plugin: urlencode
uri:
plugin: file_copy
source:
- '@source_full_path'
- uri
filemime: filemime
# No need to migrate filesize, it is computed when file entities are saved.
# filesize: filesize
status: status
# Drupal 7 didn't keep track of the file's creation or update time -- all it
# had was the vague "timestamp" column. So we'll use it for both.
created: timestamp
changed: timestamp
uid: uid
destination:
plugin: entity:file

View File

@@ -0,0 +1,41 @@
id: d7_file_private
label: Private files
audit: true
migration_tags:
- Drupal 7
- Content
source:
plugin: d7_file
scheme: private
constants:
# source_base_path must be set by the tool configuring this migration.
# It represents the fully qualified path relative to which uris in the files
# table are specified, and must end with a /. See source_full_path
# configuration in this migration's process pipeline as an example.
source_base_path: ''
process:
# If you are using this file to build a custom migration consider removing
# the fid field to allow incremental migrations.
fid: fid
filename: filename
source_full_path:
-
plugin: concat
delimiter: /
source:
- constants/source_base_path
- filepath
uri:
plugin: file_copy
source:
- '@source_full_path'
- uri
filemime: filemime
status: status
# Drupal 7 didn't keep track of the file's creation or update time -- all it
# had was the vague "timestamp" column. So we'll use it for both.
created: timestamp
changed: timestamp
uid: uid
destination:
plugin: entity:file

View File

@@ -0,0 +1,20 @@
id: file_settings
label: File configuration
migration_tags:
- Drupal 6
- Drupal 7
- Configuration
source:
plugin: variable
variables:
- file_description_type
- file_description_length
- file_icon_directory
source_module: system
process:
'description/type': file_description_type
'description/length': file_description_length
'icon/directory': file_icon_directory
destination:
plugin: config
config_name: file.settings

View File

@@ -0,0 +1,9 @@
# cspell:ignore filefield
finished:
6:
filefield: file
system: file
upload: file
7:
file: file
system: file

View File

@@ -0,0 +1,49 @@
<?php
namespace Drupal\file;
use Drupal\Core\TypedData\TypedData;
/**
* Computed file URL property class.
*/
class ComputedFileUrl extends TypedData {
/**
* Computed root-relative file URL.
*
* @var string
*/
protected $url = NULL;
/**
* {@inheritdoc}
*/
public function getValue() {
if ($this->url !== NULL) {
return $this->url;
}
assert($this->getParent()->getEntity() instanceof FileInterface);
$uri = $this->getParent()->getEntity()->getFileUri();
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
$this->url = $file_url_generator->generateString($uri);
return $this->url;
}
/**
* {@inheritdoc}
*/
public function setValue($value, $notify = TRUE) {
$this->url = $value;
// Notify the parent of any changes.
if ($notify && isset($this->parent)) {
$this->parent->onChange($this->name);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Drupal\file\Controller;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Defines a controller to respond to file widget AJAX requests.
*/
class FileWidgetAjaxController {
use StringTranslationTrait;
/**
* Returns the progress status for a file upload process.
*
* @param string $key
* The unique key for this upload process.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JsonResponse object.
*/
public function progress($key) {
$progress = [
'message' => $this->t('Starting upload...'),
'percentage' => -1,
];
if (extension_loaded('uploadprogress')) {
$status = uploadprogress_get_info($key);
if (isset($status['bytes_uploaded']) && !empty($status['bytes_total'])) {
$progress['message'] = t('Uploading... (@current of @total)', [
'@current' => ByteSizeMarkup::create($status['bytes_uploaded']),
'@total' => ByteSizeMarkup::create($status['bytes_total']),
]);
$progress['percentage'] = round(100 * $status['bytes_uploaded'] / $status['bytes_total']);
}
}
return new JsonResponse($progress);
}
}

View File

@@ -0,0 +1,475 @@
<?php
namespace Drupal\file\Element;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Attribute\FormElement;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\Element\FormElementBase;
use Drupal\Core\Site\Settings;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Symfony\Component\HttpFoundation\Request;
// cspell:ignore filefield
/**
* Provides an AJAX/progress aware widget for uploading and saving a file.
*/
#[FormElement('managed_file')]
class ManagedFile extends FormElementBase {
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = static::class;
return [
'#input' => TRUE,
'#process' => [
[$class, 'processManagedFile'],
],
'#element_validate' => [
[$class, 'validateManagedFile'],
],
'#pre_render' => [
[$class, 'preRenderManagedFile'],
],
'#theme' => 'file_managed_file',
'#theme_wrappers' => ['form_element'],
'#progress_indicator' => 'throbber',
'#progress_message' => NULL,
'#upload_validators' => [],
'#upload_location' => NULL,
'#size' => 22,
'#multiple' => FALSE,
'#extended' => FALSE,
'#attached' => [
'library' => ['file/drupal.file'],
],
'#accept' => NULL,
];
}
/**
* {@inheritdoc}
*/
public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
// Find the current value of this field.
$fids = !empty($input['fids']) ? explode(' ', $input['fids']) : [];
foreach ($fids as $key => $fid) {
$fids[$key] = (int) $fid;
}
$force_default = FALSE;
// Process any input and save new uploads.
if ($input !== FALSE) {
$input['fids'] = $fids;
$return = $input;
// Uploads take priority over all other values.
if ($files = file_managed_file_save_upload($element, $form_state)) {
if ($element['#multiple']) {
$fids = array_merge($fids, array_keys($files));
}
else {
$fids = array_keys($files);
}
}
else {
// Check for #filefield_value_callback values.
// Because FAPI does not allow multiple #value_callback values like it
// does for #element_validate and #process, this fills the missing
// functionality to allow File fields to be extended through FAPI.
if (isset($element['#file_value_callbacks'])) {
foreach ($element['#file_value_callbacks'] as $callback) {
$callback($element, $input, $form_state);
}
}
// Load files if the FIDs have changed to confirm they exist.
if (!empty($input['fids'])) {
$fids = [];
foreach ($input['fids'] as $fid) {
if ($file = File::load($fid)) {
$fids[] = $file->id();
if (!$file->access('download')) {
$force_default = TRUE;
break;
}
// Temporary files that belong to other users should never be
// allowed.
if ($file->isTemporary()) {
if ($file->getOwnerId() != \Drupal::currentUser()->id()) {
$force_default = TRUE;
break;
}
// Since file ownership can't be determined for anonymous users,
// they are not allowed to reuse temporary files at all. But
// they do need to be able to reuse their own files from earlier
// submissions of the same form, so to allow that, check for the
// token added by $this->processManagedFile().
elseif (\Drupal::currentUser()->isAnonymous()) {
$token = NestedArray::getValue($form_state->getUserInput(), array_merge($element['#parents'], ['file_' . $file->id(), 'fid_token']));
$file_hmac = Crypt::hmacBase64('file-' . $file->id(), \Drupal::service('private_key')->get() . Settings::getHashSalt());
if ($token === NULL || !hash_equals($file_hmac, $token)) {
$force_default = TRUE;
break;
}
}
}
}
}
if ($force_default) {
$fids = [];
}
}
}
}
// If there is no input or if the default value was requested above, use the
// default value.
if ($input === FALSE || $force_default) {
if ($element['#extended']) {
$default_fids = $element['#default_value']['fids'] ?? [];
$return = $element['#default_value'] ?? ['fids' => []];
}
else {
$default_fids = $element['#default_value'] ?? [];
$return = ['fids' => []];
}
// Confirm that the file exists when used as a default value.
if (!empty($default_fids)) {
$fids = [];
foreach ($default_fids as $fid) {
if ($file = File::load($fid)) {
$fids[] = $file->id();
}
}
}
}
$return['fids'] = $fids;
return $return;
}
/**
* #ajax callback for managed_file upload forms.
*
* This ajax callback takes care of the following things:
* - Ensures that broken requests due to too big files are caught.
* - Adds a class to the response to be able to highlight in the UI, that a
* new file got uploaded.
*
* @param array $form
* The build form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The ajax response of the ajax upload.
*/
public static function uploadAjaxCallback(&$form, FormStateInterface &$form_state, Request $request) {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$form_parents = explode('/', $request->query->get('element_parents'));
// Sanitize form parents before using them.
$form_parents = array_filter($form_parents, [Element::class, 'child']);
// Retrieve the element to be rendered.
$form = NestedArray::getValue($form, $form_parents);
// Add the special AJAX class if a new file was added.
$current_file_count = $form_state->get('file_upload_delta_initial');
if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
$form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
}
$status_messages = ['#type' => 'status_messages'];
$form['#prefix'] .= $renderer->renderRoot($status_messages);
$output = $renderer->renderRoot($form);
$response = new AjaxResponse();
$response->setAttachments($form['#attached']);
return $response->addCommand(new ReplaceCommand(NULL, $output));
}
/**
* Render API callback: Expands the managed_file element type.
*
* Expands the file type to include Upload and Remove buttons, as well as
* support for a default value.
*/
public static function processManagedFile(&$element, FormStateInterface $form_state, &$complete_form) {
// This is used sometimes so let's implode it just once.
$parents_prefix = implode('_', $element['#parents']);
$fids = $element['#value']['fids'] ?? [];
// Set some default element properties.
$element['#progress_indicator'] = empty($element['#progress_indicator']) ? 'none' : $element['#progress_indicator'];
$element['#files'] = !empty($fids) ? File::loadMultiple($fids) : [];
$element['#tree'] = TRUE;
// Generate a unique wrapper HTML ID.
$ajax_wrapper_id = Html::getUniqueId('ajax-wrapper');
$ajax_settings = [
'callback' => [static::class, 'uploadAjaxCallback'],
'options' => [
'query' => [
'element_parents' => implode('/', $element['#array_parents']),
],
],
'wrapper' => $ajax_wrapper_id,
'effect' => 'fade',
'progress' => [
'type' => $element['#progress_indicator'],
'message' => $element['#progress_message'],
],
];
// Set up the buttons first since we need to check if they were clicked.
$element['upload_button'] = [
'#name' => $parents_prefix . '_upload_button',
'#type' => 'submit',
'#value' => t('Upload'),
'#attributes' => ['class' => ['js-hide']],
'#validate' => [],
'#submit' => ['file_managed_file_submit'],
'#limit_validation_errors' => [$element['#parents']],
'#ajax' => $ajax_settings,
'#weight' => -5,
];
// Force the progress indicator for the remove button to be either 'none' or
// 'throbber', even if the upload button is using something else.
$ajax_settings['progress']['type'] = ($element['#progress_indicator'] == 'none') ? 'none' : 'throbber';
$ajax_settings['progress']['message'] = NULL;
$ajax_settings['effect'] = 'none';
$element['remove_button'] = [
'#name' => $parents_prefix . '_remove_button',
'#type' => 'submit',
'#value' => $element['#multiple'] ? t('Remove selected') : t('Remove'),
'#validate' => [],
'#submit' => ['file_managed_file_submit'],
'#limit_validation_errors' => [$element['#parents']],
'#ajax' => $ajax_settings,
'#weight' => 1,
];
$element['fids'] = [
'#type' => 'hidden',
'#value' => $fids,
];
// Add progress bar support to the upload if possible.
if ($element['#progress_indicator'] == 'bar' && extension_loaded('uploadprogress')) {
$upload_progress_key = mt_rand();
$element['UPLOAD_IDENTIFIER'] = [
'#type' => 'hidden',
'#value' => $upload_progress_key,
'#attributes' => ['class' => ['file-progress']],
// Uploadprogress extension requires this field to be at the top of
// the form.
'#weight' => -20,
];
// Add the upload progress callback.
$element['upload_button']['#ajax']['progress']['url'] = Url::fromRoute('file.ajax_progress', ['key' => $upload_progress_key]);
// Set a custom submit event so we can modify the upload progress
// identifier element before the form gets submitted.
$element['upload_button']['#ajax']['event'] = 'fileUpload';
}
// Use a manually generated ID for the file upload field so the desired
// field label can be associated with it below. Use the same method for
// setting the ID that the form API autogenerator does.
// @see \Drupal\Core\Form\FormBuilder::doBuildForm()
$id = Html::getUniqueId('edit-' . implode('-', array_merge($element['#parents'], ['upload'])));
// The file upload field itself.
$element['upload'] = [
'#name' => 'files[' . $parents_prefix . ']',
'#type' => 'file',
// This #title will not actually be used as the upload field's HTML label,
// since the theme function for upload fields never passes the element
// through theme('form_element'). Instead the parent element's #title is
// used as the label (see below). That is usually a more meaningful label
// anyway.
'#title' => t('Choose a file'),
'#title_display' => 'invisible',
'#id' => $id,
'#size' => $element['#size'],
'#multiple' => $element['#multiple'],
'#theme_wrappers' => [],
'#weight' => -10,
'#error_no_message' => TRUE,
];
if (!empty($element['#accept'])) {
$element['upload']['#attributes'] = ['accept' => $element['#accept']];
}
// Indicate that $element['#title'] should be used as the HTML label for the
// file upload field.
$element['#label_for'] = $element['upload']['#id'];
if (!empty($fids) && $element['#files']) {
foreach ($element['#files'] as $delta => $file) {
$file_link = [
'#theme' => 'file_link',
'#file' => $file,
];
if ($element['#multiple']) {
$element['file_' . $delta]['selected'] = [
'#type' => 'checkbox',
'#title' => \Drupal::service('renderer')->renderInIsolation($file_link),
];
}
else {
$element['file_' . $delta]['filename'] = $file_link + ['#weight' => -10];
}
// Anonymous users who have uploaded a temporary file need a
// non-session-based token added so $this->valueCallback() can check
// that they have permission to use this file on subsequent submissions
// of the same form (for example, after an Ajax upload or form
// validation error).
if ($file->isTemporary() && \Drupal::currentUser()->isAnonymous()) {
$element['file_' . $delta]['fid_token'] = [
'#type' => 'hidden',
'#value' => Crypt::hmacBase64('file-' . $delta, \Drupal::service('private_key')->get() . Settings::getHashSalt()),
];
}
}
}
// Add the extension list to the page as JavaScript settings.
if (isset($element['#upload_validators']['file_validate_extensions'][0]) || isset($element['#upload_validators']['FileExtension']['extensions'])) {
if (isset($element['#upload_validators']['file_validate_extensions'][0])) {
@trigger_error('\'file_validate_extensions\' is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use the \'FileExtension\' constraint instead. See https://www.drupal.org/node/3363700', E_USER_DEPRECATED);
$allowed_extensions = $element['#upload_validators']['file_validate_extensions'][0];
}
else {
$allowed_extensions = $element['#upload_validators']['FileExtension']['extensions'];
}
$extension_list = implode(',', array_filter(explode(' ', $allowed_extensions)));
$element['upload']['#attached']['drupalSettings']['file']['elements']['#' . $id] = $extension_list;
}
// Prefix and suffix used for Ajax replacement.
$element['#prefix'] = '<div id="' . $ajax_wrapper_id . '">';
$element['#suffix'] = '</div>';
return $element;
}
/**
* Render API callback: Hides display of the upload or remove controls.
*
* Upload controls are hidden when a file is already uploaded. Remove controls
* are hidden when there is no file attached. Controls are hidden here instead
* of in \Drupal\file\Element\ManagedFile::processManagedFile(), because
* #access for these buttons depends on the managed_file element's #value. See
* the documentation of \Drupal\Core\Form\FormBuilderInterface::doBuildForm()
* for more detailed information about the relationship between #process,
* #value, and #access.
*
* Because #access is set here, it affects display only and does not prevent
* JavaScript or other untrusted code from submitting the form as though
* access were enabled. The form processing functions for these elements
* should not assume that the buttons can't be "clicked" just because they are
* not displayed.
*
* @see \Drupal\file\Element\ManagedFile::processManagedFile()
* @see \Drupal\Core\Form\FormBuilderInterface::doBuildForm()
*/
public static function preRenderManagedFile($element) {
// If we already have a file, we don't want to show the upload controls.
if (!empty($element['#value']['fids'])) {
if (!$element['#multiple']) {
$element['upload']['#access'] = FALSE;
$element['upload_button']['#access'] = FALSE;
}
}
// If we don't already have a file, there is nothing to remove.
else {
$element['remove_button']['#access'] = FALSE;
}
return $element;
}
/**
* Render API callback: Validates the managed_file element.
*/
public static function validateManagedFile(&$element, FormStateInterface $form_state, &$complete_form) {
$triggering_element = $form_state->getTriggeringElement();
$clicked_button = isset($triggering_element['#parents']) ? end($triggering_element['#parents']) : '';
if ($clicked_button != 'remove_button' && !empty($element['fids']['#value'])) {
$fids = $element['fids']['#value'];
foreach ($fids as $fid) {
if ($file = File::load($fid)) {
// If referencing an existing file, only allow if there are existing
// references. This prevents unmanaged files from being deleted if
// this item were to be deleted. When files that are no longer in use
// are automatically marked as temporary (now disabled by default),
// it is not safe to reference a permanent file without usage. Adding
// a usage and then later on removing it again would delete the file,
// but it is unknown if and where it is currently referenced. However,
// when files are not marked temporary (and then removed)
// automatically, it is safe to add and remove usages, as it would
// simply return to the current state.
// @see https://www.drupal.org/node/2891902
if ($file->isPermanent() && \Drupal::config('file.settings')->get('make_unused_managed_files_temporary')) {
$references = static::fileUsage()->listUsage($file);
if (empty($references)) {
// We expect the field name placeholder value to be wrapped in t()
// here, so it won't be escaped again as it's already marked safe.
$form_state->setError($element, t('The file used in the @name field may not be referenced.', ['@name' => $element['#title']]));
}
}
}
else {
// We expect the field name placeholder value to be wrapped in t()
// here, so it won't be escaped again as it's already marked safe.
$form_state->setError($element, t('The file referenced by the @name field does not exist.', ['@name' => $element['#title']]));
}
}
}
// Check required property based on the FID.
if ($element['#required'] && empty($element['fids']['#value']) && !in_array($clicked_button, ['upload_button', 'remove_button'])) {
// We expect the field name placeholder value to be wrapped in t()
// here, so it won't be escaped again as it's already marked safe.
$form_state->setError($element, t('@name field is required.', ['@name' => $element['#title']]));
}
// Consolidate the array value of this field to array of FIDs.
if (!$element['#extended']) {
$form_state->setValueForElement($element, $element['fids']['#value']);
}
}
/**
* Wraps the file usage service.
*
* @return \Drupal\file\FileUsage\FileUsageInterface
*/
protected static function fileUsage() {
return \Drupal::service('file.usage');
}
}

View File

@@ -0,0 +1,300 @@
<?php
namespace Drupal\file\Entity;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\File\Exception\FileException;
use Drupal\file\FileInterface;
use Drupal\user\EntityOwnerTrait;
/**
* Defines the file entity class.
*
* @ingroup file
*
* @ContentEntityType(
* id = "file",
* label = @Translation("File"),
* label_collection = @Translation("Files"),
* label_singular = @Translation("file"),
* label_plural = @Translation("files"),
* label_count = @PluralTranslation(
* singular = "@count file",
* plural = "@count files",
* ),
* handlers = {
* "storage" = "Drupal\file\FileStorage",
* "storage_schema" = "Drupal\file\FileStorageSchema",
* "access" = "Drupal\file\FileAccessControlHandler",
* "views_data" = "Drupal\file\FileViewsData",
* "list_builder" = "Drupal\Core\Entity\EntityListBuilder",
* "form" = {
* "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
* },
* "route_provider" = {
* "html" = "Drupal\file\Entity\FileRouteProvider",
* },
* },
* base_table = "file_managed",
* entity_keys = {
* "id" = "fid",
* "label" = "filename",
* "langcode" = "langcode",
* "uuid" = "uuid",
* "owner" = "uid",
* },
* links = {
* "delete-form" = "/file/{file}/delete",
* }
* )
*/
class File extends ContentEntityBase implements FileInterface {
use EntityChangedTrait;
use EntityOwnerTrait;
/**
* {@inheritdoc}
*/
public function getFilename() {
return $this->get('filename')->value;
}
/**
* {@inheritdoc}
*/
public function setFilename($filename) {
$this->get('filename')->value = $filename;
}
/**
* {@inheritdoc}
*/
public function getFileUri() {
return $this->get('uri')->value;
}
/**
* {@inheritdoc}
*/
public function setFileUri($uri) {
$this->get('uri')->value = $uri;
}
/**
* {@inheritdoc}
*/
public function createFileUrl($relative = TRUE) {
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
return $relative ? $file_url_generator->generateString($this->getFileUri()) : $file_url_generator->generateAbsoluteString($this->getFileUri());
}
/**
* {@inheritdoc}
*/
public function getMimeType() {
return $this->get('filemime')->value;
}
/**
* {@inheritdoc}
*/
public function setMimeType($mime) {
$this->get('filemime')->value = $mime;
}
/**
* {@inheritdoc}
*/
public function getSize() {
$filesize = $this->get('filesize')->value;
return isset($filesize) ? (int) $filesize : NULL;
}
/**
* {@inheritdoc}
*/
public function setSize($size) {
$this->get('filesize')->value = $size;
}
/**
* {@inheritdoc}
*/
public function getCreatedTime() {
$created = $this->get('created')->value;
return isset($created) ? (int) $created : NULL;
}
/**
* {@inheritdoc}
*/
public function isPermanent() {
return $this->get('status')->value == static::STATUS_PERMANENT;
}
/**
* {@inheritdoc}
*/
public function isTemporary() {
return $this->get('status')->value == 0;
}
/**
* {@inheritdoc}
*/
public function setPermanent() {
$this->get('status')->value = static::STATUS_PERMANENT;
}
/**
* {@inheritdoc}
*/
public function setTemporary() {
$this->get('status')->value = 0;
}
/**
* {@inheritdoc}
*/
public static function preCreate(EntityStorageInterface $storage, array &$values) {
// Automatically detect filename if not set.
if (!isset($values['filename']) && isset($values['uri'])) {
$values['filename'] = \Drupal::service('file_system')->basename($values['uri']);
}
// Automatically detect filemime if not set.
if (!isset($values['filemime']) && isset($values['uri'])) {
$values['filemime'] = \Drupal::service('file.mime_type.guesser')->guessMimeType($values['uri']);
}
}
/**
* {@inheritdoc}
*/
public function preSave(EntityStorageInterface $storage) {
parent::preSave($storage);
// The file itself might not exist or be available right now.
$uri = $this->getFileUri();
$size = @filesize($uri);
// Set size unless there was an error.
if ($size !== FALSE) {
$this->setSize($size);
}
}
/**
* {@inheritdoc}
*/
public static function preDelete(EntityStorageInterface $storage, array $entities) {
parent::preDelete($storage, $entities);
foreach ($entities as $entity) {
// Delete all remaining references to this file.
$file_usage = \Drupal::service('file.usage')->listUsage($entity);
if (!empty($file_usage)) {
foreach ($file_usage as $module => $usage) {
\Drupal::service('file.usage')->delete($entity, $module);
}
}
// Delete the actual file. Failures due to invalid files and files that
// were already deleted are logged to watchdog but ignored, the
// corresponding file entity will be deleted.
try {
\Drupal::service('file_system')->delete($entity->getFileUri());
}
catch (FileException $e) {
// Ignore and continue.
}
}
}
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
/** @var \Drupal\Core\Field\BaseFieldDefinition[] $fields */
$fields = parent::baseFieldDefinitions($entity_type);
$fields += static::ownerBaseFieldDefinitions($entity_type);
$fields['fid']->setLabel(t('File ID'))
->setDescription(t('The file ID.'));
$fields['uuid']->setDescription(t('The file UUID.'));
$fields['langcode']->setLabel(t('Language code'))
->setDescription(t('The file language code.'));
$fields['uid']
->setDescription(t('The user ID of the file.'));
$fields['filename'] = BaseFieldDefinition::create('string')
->setLabel(t('Filename'))
->setDescription(t('Name of the file with no path components.'));
$fields['uri'] = BaseFieldDefinition::create('file_uri')
->setLabel(t('URI'))
->setDescription(t('The URI to access the file (either local or remote).'))
->setSetting('max_length', 255)
->setSetting('case_sensitive', TRUE)
->addConstraint('FileUriUnique');
$fields['filemime'] = BaseFieldDefinition::create('string')
->setLabel(t('File MIME type'))
->setSetting('is_ascii', TRUE)
->setDescription(t("The file's MIME type."));
$fields['filesize'] = BaseFieldDefinition::create('integer')
->setLabel(t('File size'))
->setDescription(t('The size of the file in bytes.'))
->setSetting('unsigned', TRUE)
->setSetting('size', 'big');
$fields['status'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Status'))
->setDescription(t('The status of the file, temporary (FALSE) and permanent (TRUE).'))
->setDefaultValue(FALSE);
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(t('Created'))
->setDescription(t('The timestamp that the file was created.'));
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Changed'))
->setDescription(t('The timestamp that the file was last changed.'));
return $fields;
}
/**
* {@inheritdoc}
*/
public static function getDefaultEntityOwner() {
return NULL;
}
/**
* {@inheritdoc}
*/
protected function invalidateTagsOnSave($update) {
$tags = $this->getListCacheTagsToInvalidate();
// Always invalidate the 404 or 403 response cache because while files do
// not have a canonical URL as such, they may be served via routes such as
// private files.
// Creating or updating an entity may change a cached 403 or 404 response.
$tags = Cache::mergeTags($tags, ['4xx-response']);
if ($update) {
$tags = Cache::mergeTags($tags, $this->getCacheTagsToInvalidate());
}
Cache::invalidateTags($tags);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Drupal\file\Entity;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Provides routes for files.
*/
class FileRouteProvider implements EntityRouteProviderInterface {
/**
* {@inheritdoc}
*/
public function getRoutes(EntityTypeInterface $entity_type) {
$route_collection = new RouteCollection();
$route = (new Route('/file/{file}/delete'))
->addDefaults([
'_entity_form' => 'file.delete',
'_title' => 'Delete',
])
->setRequirement('file', '\d+')
->setRequirement('_entity_access', 'file.delete')
->setOption('_admin_route', TRUE);
$route_collection->add('entity.file.delete_form', $route);
return $route_collection;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Drupal\file\EventSubscriber;
use Drupal\Component\Transliteration\TransliterationInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Sanitizes uploaded filenames.
*
* @package Drupal\file\EventSubscriber
*/
class FileEventSubscriber implements EventSubscriberInterface {
/**
* Constructs a new file event listener.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
* @param \Drupal\Component\Transliteration\TransliterationInterface $transliteration
* The transliteration service.
* @param \Drupal\Core\Language\LanguageManagerInterface $languageManager
* The language manager.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected TransliterationInterface $transliteration,
protected LanguageManagerInterface $languageManager,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
FileUploadSanitizeNameEvent::class => 'sanitizeFilename',
];
}
/**
* Sanitizes the filename of a file being uploaded.
*
* @param \Drupal\Core\File\Event\FileUploadSanitizeNameEvent $event
* File upload sanitize name event.
*
* @see file_form_system_file_system_settings_alter()
*/
public function sanitizeFilename(FileUploadSanitizeNameEvent $event) {
$fileSettings = $this->configFactory->get('file.settings');
$transliterate = $fileSettings->get('filename_sanitization.transliterate');
$filename = $event->getFilename();
$extension = pathinfo($filename, PATHINFO_EXTENSION);
if ($extension !== '') {
$extension = '.' . $extension;
$filename = pathinfo($filename, PATHINFO_FILENAME);
}
// Sanitize the filename according to configuration.
$alphanumeric = $fileSettings->get('filename_sanitization.replace_non_alphanumeric');
$replacement = $fileSettings->get('filename_sanitization.replacement_character');
if ($transliterate) {
$transliterated_filename = $this->transliteration->transliterate(
$filename,
$this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(),
$replacement
);
if (mb_strlen($transliterated_filename) > 0) {
$filename = $transliterated_filename;
}
else {
// If transliteration has resulted in a zero length string enable the
// 'replace_non_alphanumeric' option and ignore the result of
// transliteration.
$alphanumeric = TRUE;
}
}
if ($fileSettings->get('filename_sanitization.replace_whitespace')) {
$filename = preg_replace('/\s/u', $replacement, trim($filename));
}
// Only honor replace_non_alphanumeric if transliterate is enabled.
if ($transliterate && $alphanumeric) {
$filename = preg_replace('/[^0-9A-Za-z_.-]/u', $replacement, $filename);
}
if ($fileSettings->get('filename_sanitization.deduplicate_separators')) {
$filename = preg_replace('/(_)_+|(\.)\.+|(-)-+/u', $replacement, $filename);
// Replace multiple separators with single one.
$filename = preg_replace('/(_|\.|\-)[(_|\.|\-)]+/u', $replacement, $filename);
$filename = preg_replace('/' . preg_quote($replacement) . '[' . preg_quote($replacement) . ']*/u', $replacement, $filename);
// Remove replacement character from the end of the filename.
$filename = rtrim($filename, $replacement);
// If there is an extension remove dots from the end of the filename to
// prevent duplicate dots.
if (!empty($extension)) {
$filename = rtrim($filename, '.');
}
}
if ($fileSettings->get('filename_sanitization.lowercase')) {
// Force lowercase to prevent issues on case-insensitive file systems.
$filename = mb_strtolower($filename);
}
$event->setFilename($filename . $extension);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Drupal\file;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Provides a File access control handler.
*/
class FileAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var \Drupal\file\FileInterface $entity */
if ($operation == 'download' || $operation == 'view') {
if (\Drupal::service('stream_wrapper_manager')->getScheme($entity->getFileUri()) === 'public') {
if ($operation === 'download') {
return AccessResult::allowed();
}
else {
return AccessResult::allowedIfHasPermission($account, 'access content');
}
}
elseif ($references = $this->getFileReferences($entity)) {
foreach ($references as $field_name => $entity_map) {
foreach ($entity_map as $referencing_entities) {
/** @var \Drupal\Core\Entity\EntityInterface $referencing_entity */
foreach ($referencing_entities as $referencing_entity) {
$entity_and_field_access = $referencing_entity->access('view', $account, TRUE)->andIf($referencing_entity->$field_name->access('view', $account, TRUE));
if ($entity_and_field_access->isAllowed()) {
return $entity_and_field_access;
}
}
}
}
}
elseif ($entity->getOwnerId() == $account->id()) {
// This case handles new nodes, or detached files. The user who uploaded
// the file can access it even if it's not yet used.
if ($account->isAnonymous()) {
// For anonymous users, only the browser session that uploaded the
// file is positively allowed access to it. See file_save_upload().
// @todo Implement \Drupal\Core\Entity\EntityHandlerInterface so that
// services can be more properly injected.
$allowed_fids = \Drupal::service('session')->get('anonymous_allowed_file_ids', []);
if (!empty($allowed_fids[$entity->id()])) {
return AccessResult::allowed()->addCacheContexts(['session', 'user']);
}
}
else {
return AccessResult::allowed()->addCacheContexts(['user']);
}
}
}
elseif ($operation == 'update') {
$account = $this->prepareUser($account);
$file_uid = $entity->get('uid')->getValue();
// Only the file owner can update the file entity.
if (isset($file_uid[0]['target_id']) && $account->id() == $file_uid[0]['target_id']) {
return AccessResult::allowed();
}
return AccessResult::forbidden('Only the file owner can update the file entity.');
}
elseif ($operation == 'delete') {
$access = AccessResult::allowedIfHasPermission($account, 'delete any file');
if (!$access->isAllowed() && $account->hasPermission('delete own files')) {
$access = $access->orIf(AccessResult::allowedIf($account->id() == $entity->getOwnerId()))->cachePerUser()->addCacheableDependency($entity);
}
return $access;
}
// No opinion.
return AccessResult::neutral();
}
/**
* Wrapper for file_get_file_references().
*
* @param \Drupal\file\FileInterface $file
* The file object for which to get references.
*
* @return array
* A multidimensional array. The keys are field_name, entity_type,
* entity_id and the value is an entity referencing this file.
*
* @see file_get_file_references()
*/
protected function getFileReferences(FileInterface $file) {
return file_get_file_references($file, NULL, EntityStorageInterface::FIELD_LOAD_REVISION, NULL);
}
/**
* {@inheritdoc}
*/
protected function checkFieldAccess($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
// Deny access to fields that should only be set on file creation, and
// "status" which should only be changed based on a file's usage.
$create_only_fields = [
'uri',
'filemime',
'filesize',
];
// The operation is 'edit' when the entity is being created or updated.
// Determine if the entity is being updated by checking if it is new.
$field_name = $field_definition->getName();
if ($operation === 'edit' && $items && ($entity = $items->getEntity()) && !$entity->isNew() && in_array($field_name, $create_only_fields, TRUE)) {
return AccessResult::forbidden();
}
// Regardless of whether the entity exists access should be denied to the
// status field as this is managed via other APIs, for example:
// - \Drupal\file\FileUsage\FileUsageBase::add()
// - \Drupal\file\Plugin\EntityReferenceSelection\FileSelection::createNewEntity()
if ($operation === 'edit' && $field_name === 'status') {
return AccessResult::forbidden();
}
return parent::checkFieldAccess($operation, $field_definition, $account, $items);
}
/**
* {@inheritdoc}
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
// The file entity has no "create" permission because by default Drupal core
// does not allow creating file entities independently. It allows you to
// create file entities that are referenced from another entity
// (e.g. an image for an article). A contributed module is free to alter
// this to allow file entities to be created directly.
return AccessResult::neutral();
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Drupal\file;
use Drupal\Core\Entity\EntityAccessControlHandlerInterface;
/**
* Defines an interface for file access handlers that need to run on file formatters.
*
* \Drupal\Core\Field\Plugin\Field\FieldFormatter\EntityReferenceFormatterBase,
* which file and image formatters extend, checks 'view' access on the
* referenced files before displaying them. That check would be useless and
* costly with Core's default access control implementation for files
* (\Drupal\file\FileAccessControlHandler grants access based on whether
* there are existing entities with granted access that reference the file). But
* it might be needed if a different access control handler with different logic
* is swapped in.
*
* \Drupal\file\Plugin\Field\FieldFormatter\FileFormatterBase thus adjusts that
* behavior, and only checks access if the access control handler in use for
* files opts in by implementing this interface.
*
* @see \Drupal\file\Plugin\Field\FieldFormatter\FileFormatterBase::needsAccessCheck()
*/
interface FileAccessFormatterControlHandlerInterface extends EntityAccessControlHandlerInterface {}

View File

@@ -0,0 +1,145 @@
<?php
namespace Drupal\file;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\user\EntityOwnerInterface;
use Drupal\Core\Entity\EntityChangedInterface;
/**
* Defines getter and setter methods for file entity base fields.
*
* @ingroup file
*/
interface FileInterface extends ContentEntityInterface, EntityChangedInterface, EntityOwnerInterface {
/**
* Indicates that the file is permanent and should not be deleted.
*
* Temporary files older than the system.file.temporary_maximum_age will be
* removed during cron runs if cleanup is not disabled. (Permanent files will
* not be removed during the file garbage collection process.)
*/
const STATUS_PERMANENT = 1;
/**
* Returns the name of the file.
*
* This may differ from the basename of the URI if the file is renamed to
* avoid overwriting an existing file.
*
* @return string|null
* Name of the file, or NULL if unknown.
*/
public function getFilename();
/**
* Sets the name of the file.
*
* @param string|null $filename
* The file name that corresponds to this file, or NULL if unknown. May
* differ from the basename of the URI and changing the filename does not
* change the URI.
*/
public function setFilename($filename);
/**
* Returns the URI of the file.
*
* @return string|null
* The URI of the file, e.g. public://directory/file.jpg, or NULL if it has
* not yet been set.
*/
public function getFileUri();
/**
* Sets the URI of the file.
*
* @param string $uri
* The URI of the file, e.g. public://directory/file.jpg. Does not change
* the location of the file.
*/
public function setFileUri($uri);
/**
* Creates a file URL for the URI of this file.
*
* @param bool $relative
* (optional) Whether the URL should be root-relative, defaults to TRUE.
*
* @return string
* A string containing a URL that may be used to access the file.
*
* @see \Drupal\Core\File\FileUrlGeneratorInterface
*/
public function createFileUrl($relative = TRUE);
/**
* Returns the MIME type of the file.
*
* @return string|null
* The MIME type of the file, e.g. image/jpeg or text/xml, or NULL if it
* could not be determined.
*/
public function getMimeType();
/**
* Sets the MIME type of the file.
*
* @param string|null $mime
* The MIME type of the file, e.g. image/jpeg or text/xml, or NULL if it
* could not be determined.
*/
public function setMimeType($mime);
/**
* Returns the size of the file.
*
* @return int|null
* The size of the file in bytes, or NULL if it could not be determined.
*/
public function getSize();
/**
* Sets the size of the file.
*
* @param int|null $size
* The size of the file in bytes, or NULL if it could not be determined.
*/
public function setSize($size);
/**
* Returns TRUE if the file is permanent.
*
* @return bool
* TRUE if the file status is permanent.
*/
public function isPermanent();
/**
* Returns TRUE if the file is temporary.
*
* @return bool
* TRUE if the file status is temporary.
*/
public function isTemporary();
/**
* Sets the file status to permanent.
*/
public function setPermanent();
/**
* Sets the file status to temporary.
*/
public function setTemporary();
/**
* Returns the file entity creation timestamp.
*
* @return int|null
* Creation timestamp of the file entity, or NULL if unknown.
*/
public function getCreatedTime();
}

View File

@@ -0,0 +1,236 @@
<?php
namespace Drupal\file;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\File\Exception\InvalidStreamWrapperException;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\file\Entity\File;
use Drupal\file\FileUsage\FileUsageInterface;
/**
* Provides a file entity repository.
*/
class FileRepository implements FileRepositoryInterface {
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* The stream wrapper manager.
*
* @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
*/
protected $streamWrapperManager;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The file usage service.
*
* @var \Drupal\file\FileUsage\FileUsageInterface
*/
protected $fileUsage;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* FileRepository constructor.
*
* @param \Drupal\Core\File\FileSystemInterface $fileSystem
* The file system.
* @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager
* The stream wrapper manager.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler.
* @param \Drupal\file\FileUsage\FileUsageInterface $fileUsage
* The file usage service.
* @param \Drupal\Core\Session\AccountInterface $currentUser
* The current user.
*/
public function __construct(FileSystemInterface $fileSystem, StreamWrapperManagerInterface $streamWrapperManager, EntityTypeManagerInterface $entityTypeManager, ModuleHandlerInterface $moduleHandler, FileUsageInterface $fileUsage, AccountInterface $currentUser) {
$this->fileSystem = $fileSystem;
$this->streamWrapperManager = $streamWrapperManager;
$this->entityTypeManager = $entityTypeManager;
$this->moduleHandler = $moduleHandler;
$this->fileUsage = $fileUsage;
$this->currentUser = $currentUser;
}
/**
* {@inheritdoc}
*/
public function writeData(string $data, string $destination, FileExists|int $fileExists = FileExists::Rename): FileInterface {
if (!$fileExists instanceof FileExists) {
// @phpstan-ignore-next-line
$fileExists = FileExists::fromLegacyInt($fileExists, __METHOD__);
}
if (!$this->streamWrapperManager->isValidUri($destination)) {
throw new InvalidStreamWrapperException("Invalid stream wrapper: {$destination}");
}
$uri = $this->fileSystem->saveData($data, $destination, $fileExists);
return $this->createOrUpdate($uri, $destination, $fileExists === FileExists::Rename);
}
/**
* Create a file entity or update if it exists.
*
* @param string $uri
* The file URI.
* @param string $destination
* The destination URI.
* @param bool $rename
* Whether to rename the file.
*
* @return \Drupal\file\Entity\File|\Drupal\file\FileInterface
* The file entity.
*
* @throws \Drupal\Core\Entity\EntityStorageException
* Thrown when there is an error saving the file.
*/
protected function createOrUpdate(string $uri, string $destination, bool $rename): FileInterface {
$file = $this->loadByUri($uri);
if ($file === NULL) {
$file = File::create(['uri' => $uri]);
$file->setOwnerId($this->currentUser->id());
}
if ($rename && is_file($destination)) {
$file->setFilename($this->fileSystem->basename($destination));
}
$file->setPermanent();
$file->save();
return $file;
}
/**
* {@inheritdoc}
*/
public function copy(FileInterface $source, string $destination, FileExists|int $fileExists = FileExists::Rename): FileInterface {
if (!$fileExists instanceof FileExists) {
// @phpstan-ignore-next-line
$fileExists = FileExists::fromLegacyInt($fileExists, __METHOD__);
}
if (!$this->streamWrapperManager->isValidUri($destination)) {
throw new InvalidStreamWrapperException("Invalid stream wrapper: {$destination}");
}
$uri = $this->fileSystem->copy($source->getFileUri(), $destination, $fileExists);
// If we are replacing an existing file, load it.
if ($fileExists === FileExists::Replace && $existing = $this->loadByUri($uri)) {
$file = $existing;
}
else {
$file = $source->createDuplicate();
$file->setFileUri($uri);
// If we are renaming around an existing file (rather than a directory),
// use its basename for the filename.
if ($fileExists === FileExists::Rename && is_file($destination)) {
$file->setFilename($this->fileSystem->basename($destination));
}
else {
$file->setFilename($this->fileSystem->basename($uri));
}
}
$file->save();
// Inform modules that the file has been copied.
$this->moduleHandler->invokeAll('file_copy', [$file, $source]);
return $file;
}
/**
* {@inheritdoc}
*/
public function move(FileInterface $source, string $destination, FileExists|int $fileExists = FileExists::Rename): FileInterface {
if (!$fileExists instanceof FileExists) {
// @phpstan-ignore-next-line
$fileExists = FileExists::fromLegacyInt($fileExists, __METHOD__);
}
if (!$this->streamWrapperManager->isValidUri($destination)) {
throw new InvalidStreamWrapperException("Invalid stream wrapper: {$destination}");
}
$uri = $this->fileSystem->move($source->getFileUri(), $destination, $fileExists);
$delete_source = FALSE;
$file = clone $source;
$file->setFileUri($uri);
// If we are replacing an existing file re-use its database record.
if ($fileExists === FileExists::Replace) {
if ($existing = $this->loadByUri($uri)) {
$delete_source = TRUE;
$file->fid = $existing->id();
$file->uuid = $existing->uuid();
}
}
// If we are renaming around an existing file (rather than a directory),
// use its basename for the filename.
elseif ($fileExists === FileExists::Rename && is_file($destination)) {
$file->setFilename($this->fileSystem->basename($destination));
}
$file->save();
// Inform modules that the file has been moved.
$this->moduleHandler->invokeAll('file_move', [$file, $source]);
// Delete the original if it's not in use elsewhere.
if ($delete_source && !$this->fileUsage->listUsage($source)) {
$source->delete();
}
return $file;
}
/**
* {@inheritdoc}
*/
public function loadByUri(string $uri): ?FileInterface {
$fileStorage = $this->entityTypeManager->getStorage('file');
/** @var \Drupal\file\FileInterface[] $files */
$files = $fileStorage->loadByProperties(['uri' => $uri]);
if (count($files)) {
foreach ($files as $item) {
// Since some database servers sometimes use a case-insensitive
// comparison by default, double check that the filename is an exact
// match.
if ($item->getFileUri() === $uri) {
return $item;
}
}
}
return NULL;
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Drupal\file;
use Drupal\Core\File\FileExists;
/**
* Performs file system operations and updates database records accordingly.
*/
interface FileRepositoryInterface {
/**
* Writes a file to the specified destination and creates a file entity.
*
* @param string $data
* A string containing the contents of the file.
* @param string $destination
* A string containing the destination URI. This must be a stream
* wrapper URI.
* @param \Drupal\Core\File\FileExists|int $fileExists
* (optional) The replace behavior when the destination file already exists.
*
* @return \Drupal\file\FileInterface
* The file entity.
*
* @throws \Drupal\Core\File\Exception\FileException
* Thrown when there is an error writing to the file system.
* @throws \Drupal\Core\File\Exception\FileExistsException
* Thrown when the destination exists and $replace is set to
* FileExists::Error.
* @throws \Drupal\Core\File\Exception\InvalidStreamWrapperException
* Thrown when the destination is an invalid stream wrapper.
* @throws \Drupal\Core\Entity\EntityStorageException
* Thrown when there is an error saving the file.
*
* @see \Drupal\Core\File\FileSystemInterface::saveData()
*/
public function writeData(string $data, string $destination, FileExists|int $fileExists = FileExists::Rename): FileInterface;
/**
* Copies a file to a new location and adds a file record to the database.
*
* This function should be used when manipulating files that have records
* stored in the database. This is a powerful function that in many ways
* performs like an advanced version of copy().
* - Checks if $source and $destination are valid and readable/writable.
* - If file already exists in $destination either the call will error out,
* replace the file or rename the file based on the $replace parameter.
* - If the $source and $destination are equal, the behavior depends on the
* $replace parameter. FileExists::Replace will error out.
* FileExists::Rename will rename the file until the
* $destination is unique.
* - Adds the new file to the files database. If the source file is a
* temporary file, the resulting file will also be a temporary file. See
* file_save_upload() for details on temporary files.
*
* @param \Drupal\file\FileInterface $source
* A file entity.
* @param string $destination
* A string containing the destination that $source should be
* copied to. This must be a stream wrapper URI.
* @param \Drupal\Core\File\FileExists|int $fileExists
* (optional) Replace behavior when the destination file already exists.
*
* @return \Drupal\file\FileInterface
* The file entity.
*
* @throws \Drupal\Core\File\Exception\FileException
* Thrown when there is an error writing to the file system.
* @throws \Drupal\Core\File\Exception\FileExistsException
* Thrown when the destination exists and $replace is set to
* FileExists::Error.
* @throws \Drupal\Core\File\Exception\InvalidStreamWrapperException
* Thrown when the destination is an invalid stream wrapper.
* @throws \Drupal\Core\Entity\EntityStorageException
* Thrown when there is an error saving the file.
*
* @see \Drupal\Core\File\FileSystemInterface::copy()
* @see hook_file_copy()
*/
public function copy(FileInterface $source, string $destination, FileExists|int $fileExists = FileExists::Rename): FileInterface;
/**
* Moves a file to a new location and update the file's database entry.
*
* - Checks if $source and $destination are valid and readable/writable.
* - Performs a file move if $source is not equal to $destination.
* - If file already exists in $destination either the call will error out,
* replace the file or rename the file based on the $replace parameter.
* - Adds the new file to the files database.
*
* @param \Drupal\file\FileInterface $source
* A file entity.
* @param string $destination
* A string containing the destination that $source should be moved
* to. This must be a stream wrapper URI.
* @param \Drupal\Core\File\FileExists|int $fileExists
* (optional) The replace behavior when the destination file already exists.
*
* @return \Drupal\file\FileInterface
* The file entity.
*
* @throws \Drupal\Core\File\Exception\FileException
* Thrown when there is an error writing to the file system.
* @throws \Drupal\Core\File\Exception\FileExistsException
* Thrown when the destination exists and $replace is set to
* FileExists::Error.
* @throws \Drupal\Core\File\Exception\InvalidStreamWrapperException
* Thrown when the destination is an invalid stream wrapper.
* @throws \Drupal\Core\Entity\EntityStorageException
* Thrown when there is an error saving the file.
*
* @see \Drupal\Core\File\FileSystemInterface::move()
* @see hook_file_move()
*/
public function move(FileInterface $source, string $destination, FileExists|int $fileExists = FileExists::Rename): FileInterface;
/**
* Loads the first File entity found with the specified URI.
*
* @param string $uri
* The file URI.
*
* @return \Drupal\file\FileInterface|null
* The first file with the matched URI if found, NULL otherwise.
*/
public function loadByUri(string $uri): ?FileInterface;
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\file;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceModifierInterface;
use Drupal\Core\StackMiddleware\NegotiationMiddleware;
/**
* Adds 'application/octet-stream' as a known (bin) format.
*/
class FileServiceProvider implements ServiceModifierInterface {
/**
* {@inheritdoc}
*/
public function alter(ContainerBuilder $container) {
if ($container->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']]);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Drupal\file;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
/**
* File storage for files.
*/
class FileStorage extends SqlContentEntityStorage implements FileStorageInterface {
/**
* {@inheritdoc}
*/
public function spaceUsed($uid = NULL, $status = FileInterface::STATUS_PERMANENT) {
$query = $this->database->select($this->entityType->getBaseTable(), 'f')
->condition('f.status', $status);
$query->addExpression('SUM([f].[filesize])', 'filesize');
if (isset($uid)) {
$query->condition('f.uid', $uid);
}
return $query->execute()->fetchField();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\file;
use Drupal\Core\Entity\ContentEntityStorageInterface;
/**
* Defines an interface for file entity storage classes.
*/
interface FileStorageInterface extends ContentEntityStorageInterface {
/**
* Determines total disk space used by a single user or the whole filesystem.
*
* @param int $uid
* Optional. A user id, specifying NULL returns the total space used by all
* non-temporary files.
* @param int $status
* (Optional) The file status to consider. The default is to only
* consider files in status FileInterface::STATUS_PERMANENT.
*
* @return int
* An integer containing the number of bytes used.
*/
public function spaceUsed($uid = NULL, $status = FileInterface::STATUS_PERMANENT);
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Drupal\file;
use Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
/**
* Defines the file schema handler.
*/
class FileStorageSchema extends SqlContentEntityStorageSchema {
/**
* {@inheritdoc}
*/
protected function getSharedTableFieldSchema(FieldStorageDefinitionInterface $storage_definition, $table_name, array $column_mapping) {
$schema = parent::getSharedTableFieldSchema($storage_definition, $table_name, $column_mapping);
$field_name = $storage_definition->getName();
if ($table_name == $this->storage->getBaseTable()) {
switch ($field_name) {
case 'status':
case 'changed':
case 'uri':
$this->addSharedTableFieldIndex($storage_definition, $schema, TRUE);
break;
}
}
// Entity keys automatically have not null assigned to TRUE, but for the
// file entity, NULL is a valid value for uid.
if ($field_name === 'uid') {
$schema['fields']['uid']['not null'] = FALSE;
}
return $schema;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Drupal\file\FileUsage;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\file\FileInterface;
/**
* Defines the database file usage backend. This is the default Drupal backend.
*/
class DatabaseFileUsageBackend extends FileUsageBase {
/**
* The database connection used to store file usage information.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* The name of the SQL table used to store file usage information.
*
* @var string
*/
protected $tableName;
/**
* Construct the DatabaseFileUsageBackend.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Database\Connection $connection
* The database connection which will be used to store the file usage
* information.
* @param string $table
* (optional) The table to store file usage info. Defaults to 'file_usage'.
*/
public function __construct(ConfigFactoryInterface $config_factory, Connection $connection, $table = 'file_usage') {
parent::__construct($config_factory);
$this->connection = $connection;
$this->tableName = $table;
}
/**
* {@inheritdoc}
*/
public function add(FileInterface $file, $module, $type, $id, $count = 1) {
$this->connection->merge($this->tableName)
->keys([
'fid' => $file->id(),
'module' => $module,
'type' => $type,
'id' => $id,
])
->fields(['count' => $count])
->expression('count', '[count] + :count', [':count' => $count])
->execute();
parent::add($file, $module, $type, $id, $count);
}
/**
* {@inheritdoc}
*/
public function delete(FileInterface $file, $module, $type = NULL, $id = NULL, $count = 1) {
// Delete rows that have an exact or less value to prevent empty rows.
$query = $this->connection->delete($this->tableName)
->condition('module', $module)
->condition('fid', $file->id());
if ($type && $id) {
$query
->condition('type', $type)
->condition('id', $id);
}
if ($count) {
$query->condition('count', $count, '<=');
}
$result = $query->execute();
// If the row has more than the specified count decrement it by that number.
if (!$result && $count > 0) {
$query = $this->connection->update($this->tableName)
->condition('module', $module)
->condition('fid', $file->id());
if ($type && $id) {
$query
->condition('type', $type)
->condition('id', $id);
}
$query->expression('count', '[count] - :count', [':count' => $count]);
$query->execute();
}
parent::delete($file, $module, $type, $id, $count);
}
/**
* {@inheritdoc}
*/
public function listUsage(FileInterface $file) {
$result = $this->connection->select($this->tableName, 'f')
->fields('f', ['module', 'type', 'id', 'count'])
->condition('fid', $file->id())
->condition('count', 0, '>')
->execute();
$references = [];
foreach ($result as $usage) {
$references[$usage->module][$usage->type][$usage->id] = $usage->count;
}
return $references;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Drupal\file\FileUsage;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\file\FileInterface;
/**
* Defines the base class for database file usage backend.
*/
abstract class FileUsageBase implements FileUsageInterface {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* Creates a FileUsageBase object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
*/
public function __construct(ConfigFactoryInterface $config_factory) {
$this->configFactory = $config_factory;
}
/**
* {@inheritdoc}
*/
public function add(FileInterface $file, $module, $type, $id, $count = 1) {
// Make sure that a used file is permanent.
if (!$file->isPermanent()) {
$file->setPermanent();
$file->save();
}
}
/**
* {@inheritdoc}
*/
public function delete(FileInterface $file, $module, $type = NULL, $id = NULL, $count = 1) {
// Do not actually mark files as temporary when the behavior is disabled.
if (!$this->configFactory->get('file.settings')->get('make_unused_managed_files_temporary')) {
return;
}
// If there are no more remaining usages of this file, mark it as temporary,
// which result in a delete through system_cron().
$usage = \Drupal::service('file.usage')->listUsage($file);
if (empty($usage)) {
$file->setTemporary();
$file->save();
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Drupal\file\FileUsage;
use Drupal\file\FileInterface;
/**
* File usage backend interface.
*/
interface FileUsageInterface {
/**
* Records that a module is using a file.
*
* Examples:
* - A module that associates files with nodes, so $type would be
* 'node' and $id would be the node's nid. Files for all revisions are
* stored within a single nid.
* - The User module associates an image with a user, so $type would be 'user'
* and the $id would be the user's uid.
*
* @param \Drupal\file\FileInterface $file
* A file entity.
* @param string $module
* The name of the module using the file.
* @param string $type
* The type of the object that contains the referenced file.
* @param string $id
* The unique ID of the object containing the referenced file.
* @param int $count
* (optional) The number of references to add to the object. Defaults to 1.
*/
public function add(FileInterface $file, $module, $type, $id, $count = 1);
/**
* Removes a record to indicate that a module is no longer using a file.
*
* @param \Drupal\file\FileInterface $file
* A file entity.
* @param string $module
* The name of the module using the file.
* @param string $type
* (optional) The type of the object that contains the referenced file. May
* be omitted if all module references to a file are being deleted. Defaults
* to NULL.
* @param string $id
* (optional) The unique ID of the object containing the referenced file.
* May be omitted if all module references to a file are being deleted.
* Defaults to NULL.
* @param int $count
* (optional) The number of references to delete from the object. Defaults
* to 1. Zero may be specified to delete all references to the file within a
* specific object.
*/
public function delete(FileInterface $file, $module, $type = NULL, $id = NULL, $count = 1);
/**
* Determines where a file is used.
*
* @param \Drupal\file\FileInterface $file
* A file entity.
*
* @return array
* A nested array with usage data. The first level is keyed by module name,
* the second by object type and the third by the object id. The value of
* the third level contains the usage count.
*/
public function listUsage(FileInterface $file);
}

View File

@@ -0,0 +1,350 @@
<?php
namespace Drupal\file;
use Drupal\views\EntityViewsData;
/**
* Provides views data for the file entity type.
*/
class FileViewsData extends EntityViewsData {
/**
* {@inheritdoc}
*/
public function getViewsData() {
$data = parent::getViewsData();
// @todo There is no corresponding information in entity metadata.
$data['file_managed']['table']['base']['help'] = $this->t('Files maintained by Drupal and various modules.');
$data['file_managed']['table']['base']['defaults']['field'] = 'filename';
$data['file_managed']['table']['wizard_id'] = 'file_managed';
$data['file_managed']['fid']['argument'] = [
'id' => 'file_fid',
// The field to display in the summary.
'name field' => 'filename',
'numeric' => TRUE,
];
$data['file_managed']['fid']['relationship'] = [
'title' => $this->t('File usage'),
'help' => $this->t('Relate file entities to their usage.'),
'id' => 'standard',
'base' => 'file_usage',
'base field' => 'fid',
'field' => 'fid',
'label' => $this->t('File usage'),
];
$data['file_managed']['uri']['field']['default_formatter'] = 'file_uri';
$data['file_managed']['filemime']['field']['default_formatter'] = 'file_filemime';
$data['file_managed']['extension'] = [
'title' => $this->t('Extension'),
'help' => $this->t('The extension of the file.'),
'real field' => 'filename',
'field' => [
'entity_type' => 'file',
'field_name' => 'filename',
'default_formatter' => 'file_extension',
'id' => 'field',
'click sortable' => FALSE,
],
];
$data['file_managed']['filesize']['field']['default_formatter'] = 'file_size';
$data['file_managed']['status']['field']['default_formatter_settings'] = [
'format' => 'custom',
'format_custom_false' => $this->t('Temporary'),
'format_custom_true' => $this->t('Permanent'),
];
$data['file_managed']['status']['filter']['id'] = 'file_status';
$data['file_managed']['uid']['relationship']['title'] = $this->t('User who uploaded');
$data['file_managed']['uid']['relationship']['label'] = $this->t('User who uploaded');
$data['file_usage']['table']['group'] = $this->t('File Usage');
// Provide field-type-things to several base tables; on the core files table
// ("file_managed") so that we can create relationships from files to
// entities, and then on each core entity type base table so that we can
// provide general relationships between entities and files.
$data['file_usage']['table']['join'] = [
'file_managed' => [
'field' => 'fid',
'left_field' => 'fid',
],
// Link ourselves to the {node_field_data} table
// so we can provide node->file relationships.
'node_field_data' => [
'join_id' => 'casted_int_field_join',
'cast' => 'right',
'field' => 'id',
'left_field' => 'nid',
'extra' => [['field' => 'type', 'value' => 'node']],
],
// Link ourselves to the {users_field_data} table
// so we can provide user->file relationships.
'users_field_data' => [
'join_id' => 'casted_int_field_join',
'cast' => 'right',
'field' => 'id',
'left_field' => 'uid',
'extra' => [['field' => 'type', 'value' => 'user']],
],
// Link ourselves to the {comment_field_data} table
// so we can provide comment->file relationships.
'comment' => [
'join_id' => 'casted_int_field_join',
'cast' => 'right',
'field' => 'id',
'left_field' => 'cid',
'extra' => [['field' => 'type', 'value' => 'comment']],
],
// Link ourselves to the {taxonomy_term_field_data} table
// so we can provide taxonomy_term->file relationships.
'taxonomy_term_data' => [
'join_id' => 'casted_int_field_join',
'cast' => 'right',
'field' => 'id',
'left_field' => 'tid',
'extra' => [['field' => 'type', 'value' => 'taxonomy_term']],
],
];
// Provide a relationship between the files table and each entity type,
// and between each entity type and the files table. Entity->file
// relationships are type-restricted in the joins declared above, and
// file->entity relationships are type-restricted in the relationship
// declarations below.
// Describes relationships between files and nodes.
$data['file_usage']['file_to_node'] = [
'title' => $this->t('Content'),
'help' => $this->t('Content that is associated with this file, usually because this file is in a field on the content.'),
// Only provide this field/relationship/etc.,
// when the 'file_managed' base table is present.
'skip base' => ['node_field_data', 'node_field_revision', 'users_field_data', 'comment_field_data', 'taxonomy_term_field_data'],
'real field' => 'id',
'relationship' => [
'id' => 'standard',
'join_id' => 'casted_int_field_join',
'cast' => 'left',
'title' => $this->t('Content'),
'label' => $this->t('Content'),
'base' => 'node_field_data',
'base field' => 'nid',
'relationship field' => 'id',
'extra' => [['table' => 'file_usage', 'field' => 'type', 'operator' => '=', 'value' => 'node']],
],
];
$data['file_usage']['node_to_file'] = [
'title' => $this->t('File'),
'help' => $this->t('A file that is associated with this node, usually because it is in a field on the node.'),
// Only provide this field/relationship/etc.,
// when the 'node' base table is present.
'skip base' => ['file_managed', 'users_field_data', 'comment_field_data', 'taxonomy_term_field_data'],
'real field' => 'fid',
'relationship' => [
'id' => 'standard',
'title' => $this->t('File'),
'label' => $this->t('File'),
'base' => 'file_managed',
'base field' => 'fid',
'relationship field' => 'fid',
],
];
// Describes relationships between files and users.
$data['file_usage']['file_to_user'] = [
'title' => $this->t('User'),
'help' => $this->t('A user that is associated with this file, usually because this file is in a field on the user.'),
// Only provide this field/relationship/etc.,
// when the 'file_managed' base table is present.
'skip base' => ['node_field_data', 'node_field_revision', 'users_field_data', 'comment_field_data', 'taxonomy_term_field_data'],
'real field' => 'id',
'relationship' => [
'id' => 'standard',
'join_id' => 'casted_int_field_join',
'cast' => 'left',
'title' => $this->t('User'),
'label' => $this->t('User'),
'base' => 'users',
'base field' => 'uid',
'relationship field' => 'id',
'extra' => [['table' => 'file_usage', 'field' => 'type', 'operator' => '=', 'value' => 'user']],
],
];
$data['file_usage']['user_to_file'] = [
'title' => $this->t('File'),
'help' => $this->t('A file that is associated with this user, usually because it is in a field on the user.'),
// Only provide this field/relationship/etc.,
// when the 'users' base table is present.
'skip base' => ['file_managed', 'node_field_data', 'node_field_revision', 'comment_field_data', 'taxonomy_term_field_data'],
'real field' => 'fid',
'relationship' => [
'id' => 'standard',
'join_id' => 'casted_int_field_join',
'cast' => 'left',
'title' => $this->t('File'),
'label' => $this->t('File'),
'base' => 'file_managed',
'base field' => 'fid',
'relationship field' => 'fid',
],
];
// Describes relationships between files and comments.
$data['file_usage']['file_to_comment'] = [
'title' => $this->t('Comment'),
'help' => $this->t('A comment that is associated with this file, usually because this file is in a field on the comment.'),
// Only provide this field/relationship/etc.,
// when the 'file_managed' base table is present.
'skip base' => ['node_field_data', 'node_field_revision', 'users_field_data', 'comment_field_data', 'taxonomy_term_field_data'],
'real field' => 'id',
'relationship' => [
'id' => 'standard',
'join_id' => 'casted_int_field_join',
'cast' => 'left',
'title' => $this->t('Comment'),
'label' => $this->t('Comment'),
'base' => 'comment_field_data',
'base field' => 'cid',
'relationship field' => 'id',
'extra' => [['table' => 'file_usage', 'field' => 'type', 'operator' => '=', 'value' => 'comment']],
],
];
$data['file_usage']['comment_to_file'] = [
'title' => $this->t('File'),
'help' => $this->t('A file that is associated with this comment, usually because it is in a field on the comment.'),
// Only provide this field/relationship/etc.,
// when the 'comment' base table is present.
'skip base' => ['file_managed', 'node_field_data', 'node_field_revision', 'users_field_data', 'taxonomy_term_field_data'],
'real field' => 'fid',
'relationship' => [
'id' => 'standard',
'title' => $this->t('File'),
'label' => $this->t('File'),
'base' => 'file_managed',
'base field' => 'fid',
'relationship field' => 'fid',
],
];
// Describes relationships between files and taxonomy_terms.
$data['file_usage']['file_to_taxonomy_term'] = [
'title' => $this->t('Taxonomy Term'),
'help' => $this->t('A taxonomy term that is associated with this file, usually because this file is in a field on the taxonomy term.'),
// Only provide this field/relationship/etc.,
// when the 'file_managed' base table is present.
'skip base' => ['node_field_data', 'node_field_revision', 'users_field_data', 'comment_field_data', 'taxonomy_term_field_data'],
'real field' => 'id',
'relationship' => [
'id' => 'standard',
'join_id' => 'casted_int_field_join',
'cast' => 'left',
'title' => $this->t('Taxonomy Term'),
'label' => $this->t('Taxonomy Term'),
'base' => 'taxonomy_term_data',
'base field' => 'tid',
'relationship field' => 'id',
'extra' => [['table' => 'file_usage', 'field' => 'type', 'operator' => '=', 'value' => 'taxonomy_term']],
],
];
$data['file_usage']['taxonomy_term_to_file'] = [
'title' => $this->t('File'),
'help' => $this->t('A file that is associated with this taxonomy term, usually because it is in a field on the taxonomy term.'),
// Only provide this field/relationship/etc.,
// when the 'taxonomy_term_data' base table is present.
'skip base' => ['file_managed', 'node_field_data', 'node_field_revision', 'users_field_data', 'comment_field_data'],
'real field' => 'fid',
'relationship' => [
'id' => 'standard',
'title' => $this->t('File'),
'label' => $this->t('File'),
'base' => 'file_managed',
'base field' => 'fid',
'relationship field' => 'fid',
],
];
// Provide basic fields from the {file_usage} table to all of the base tables
// we've declared joins to, because there is no 'skip base' property on these
// fields.
$data['file_usage']['module'] = [
'title' => $this->t('Module'),
'help' => $this->t('The module managing this file relationship.'),
'field' => [
'id' => 'standard',
],
'filter' => [
'id' => 'string',
],
'argument' => [
'id' => 'string',
],
'sort' => [
'id' => 'standard',
],
];
$data['file_usage']['type'] = [
'title' => $this->t('Entity type'),
'help' => $this->t('The type of entity that is related to the file.'),
'field' => [
'id' => 'standard',
],
'filter' => [
'id' => 'string',
],
'argument' => [
'id' => 'string',
],
'sort' => [
'id' => 'standard',
],
];
$data['file_usage']['id'] = [
'title' => $this->t('Entity ID'),
'help' => $this->t('The ID of the entity that is related to the file.'),
'field' => [
'id' => 'numeric',
],
'argument' => [
'id' => 'numeric',
],
'filter' => [
'id' => 'numeric',
],
'sort' => [
'id' => 'standard',
],
];
$data['file_usage']['count'] = [
'title' => $this->t('Use count'),
'help' => $this->t('The number of times the file is used by this entity.'),
'field' => [
'id' => 'numeric',
],
'filter' => [
'id' => 'numeric',
],
'sort' => [
'id' => 'standard',
],
];
$data['file_usage']['entity_label'] = [
'title' => $this->t('Entity label'),
'help' => $this->t('The label of the entity that is related to the file.'),
'real field' => 'id',
'field' => [
'id' => 'entity_label',
'entity type field' => 'type',
],
];
return $data;
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Drupal\file;
/**
* A utility class for working with MIME types.
*/
final class IconMimeTypes {
/**
* Gets a class for the icon for a MIME type.
*
* @param string $mimeType
* A MIME type.
*
* @return string
* A class associated with the file.
*/
public static function getIconClass(string $mimeType): string {
// Search for a group with the files MIME type.
$genericMime = (string) self::getGenericMimeType($mimeType);
if (!empty($genericMime)) {
return $genericMime;
}
// Use generic icons for each category that provides such icons.
foreach (['audio', 'image', 'text', 'video'] as $category) {
if (str_starts_with($mimeType, $category)) {
return $category;
}
}
// If there's no generic icon for the type the general class.
return 'general';
}
/**
* Determines the generic icon MIME package based on a file's MIME type.
*
* @param string $mimeType
* A MIME type.
*
* @return string|false
* The generic icon MIME package expected for this file.
*/
public static function getGenericMimeType(string $mimeType): string | false {
// cspell:disable
switch ($mimeType) {
// Word document types.
case 'application/msword':
case 'application/vnd.ms-word.document.macroEnabled.12':
case 'application/vnd.oasis.opendocument.text':
case 'application/vnd.oasis.opendocument.text-template':
case 'application/vnd.oasis.opendocument.text-master':
case 'application/vnd.oasis.opendocument.text-web':
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
case 'application/vnd.stardivision.writer':
case 'application/vnd.sun.xml.writer':
case 'application/vnd.sun.xml.writer.template':
case 'application/vnd.sun.xml.writer.global':
case 'application/vnd.wordperfect':
case 'application/x-abiword':
case 'application/x-applix-word':
case 'application/x-kword':
case 'application/x-kword-crypt':
return 'x-office-document';
// Spreadsheet document types.
case 'application/vnd.ms-excel':
case 'application/vnd.ms-excel.sheet.macroEnabled.12':
case 'application/vnd.oasis.opendocument.spreadsheet':
case 'application/vnd.oasis.opendocument.spreadsheet-template':
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
case 'application/vnd.stardivision.calc':
case 'application/vnd.sun.xml.calc':
case 'application/vnd.sun.xml.calc.template':
case 'application/vnd.lotus-1-2-3':
case 'application/x-applix-spreadsheet':
case 'application/x-gnumeric':
case 'application/x-kspread':
case 'application/x-kspread-crypt':
return 'x-office-spreadsheet';
// Presentation document types.
case 'application/vnd.ms-powerpoint':
case 'application/vnd.ms-powerpoint.presentation.macroEnabled.12':
case 'application/vnd.oasis.opendocument.presentation':
case 'application/vnd.oasis.opendocument.presentation-template':
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
case 'application/vnd.stardivision.impress':
case 'application/vnd.sun.xml.impress':
case 'application/vnd.sun.xml.impress.template':
case 'application/x-kpresenter':
return 'x-office-presentation';
// Compressed archive types.
case 'application/zip':
case 'application/x-zip':
case 'application/stuffit':
case 'application/x-stuffit':
case 'application/x-7z-compressed':
case 'application/x-ace':
case 'application/x-arj':
case 'application/x-bzip':
case 'application/x-bzip-compressed-tar':
case 'application/x-compress':
case 'application/x-compressed-tar':
case 'application/x-cpio-compressed':
case 'application/x-deb':
case 'application/x-gzip':
case 'application/x-java-archive':
case 'application/x-lha':
case 'application/x-lhz':
case 'application/x-lzop':
case 'application/x-rar':
case 'application/x-rpm':
case 'application/x-tzo':
case 'application/x-tar':
case 'application/x-tarz':
case 'application/x-tgz':
return 'package-x-generic';
// Script file types.
case 'application/ecmascript':
case 'application/javascript':
case 'application/mathematica':
case 'application/vnd.mozilla.xul+xml':
case 'application/x-asp':
case 'application/x-awk':
case 'application/x-cgi':
case 'application/x-csh':
case 'application/x-m4':
case 'application/x-perl':
case 'application/x-php':
case 'application/x-ruby':
case 'application/x-shellscript':
case 'text/javascript':
case 'text/vnd.wap.wmlscript':
case 'text/x-emacs-lisp':
case 'text/x-haskell':
case 'text/x-literate-haskell':
case 'text/x-lua':
case 'text/x-makefile':
case 'text/x-matlab':
case 'text/x-python':
case 'text/x-sql':
case 'text/x-tcl':
return 'text-x-script';
// HTML aliases.
case 'application/xhtml+xml':
return 'text-html';
// Executable types.
case 'application/x-macbinary':
case 'application/x-ms-dos-executable':
case 'application/x-pef-executable':
return 'application-x-executable';
// Acrobat types.
case 'application/pdf':
case 'application/x-pdf':
case 'applications/vnd.pdf':
case 'text/pdf':
case 'text/x-pdf':
return 'application-pdf';
default:
return FALSE;
}
// cspell:enable
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Drupal\file\Plugin\EntityReferenceSelection;
use Drupal\Core\Entity\Attribute\EntityReferenceSelection;
use Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\file\FileInterface;
/**
* Provides specific access control for the file entity type.
*/
#[EntityReferenceSelection(
id: "default:file",
label: new TranslatableMarkup("File selection"),
entity_types: ["file"],
group: "default",
weight: 1
)]
class FileSelection extends DefaultSelection {
/**
* {@inheritdoc}
*/
protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
$query = parent::buildEntityQuery($match, $match_operator);
// Allow referencing :
// - files with status "permanent"
// - or files uploaded by the current user (since newly uploaded files only
// become "permanent" after the containing entity gets validated and
// saved.)
$query->condition($query->orConditionGroup()
->condition('status', FileInterface::STATUS_PERMANENT)
->condition('uid', $this->currentUser->id()));
return $query;
}
/**
* {@inheritdoc}
*/
public function createNewEntity($entity_type_id, $bundle, $label, $uid) {
$file = parent::createNewEntity($entity_type_id, $bundle, $label, $uid);
// In order to create a referenceable file, it needs to have a "permanent"
// status.
/** @var \Drupal\file\FileInterface $file */
$file->setPermanent();
return $file;
}
/**
* {@inheritdoc}
*/
public function validateReferenceableNewEntities(array $entities) {
$entities = parent::validateReferenceableNewEntities($entities);
$entities = array_filter($entities, function ($file) {
/** @var \Drupal\file\FileInterface $file */
return $file->isPermanent() || $file->getOwnerId() === $this->currentUser->id();
});
return $entities;
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for file formatters, which allow to link to the file download URL.
*/
abstract class BaseFieldFileFormatterBase extends FormatterBase {
/**
* The file URL generator.
*
* @var \Drupal\Core\File\FileUrlGeneratorInterface
*/
protected $fileUrlGenerator;
/**
* Constructs a BaseFieldFileFormatterBase object.
*
* @param string $plugin_id
* The plugin_id for the formatter.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The definition of the field to which the formatter is associated.
* @param array $settings
* The formatter settings.
* @param string $label
* The formatter label display setting.
* @param string $view_mode
* The view mode.
* @param array $third_party_settings
* Any third party settings.
* @param \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator
* The file URL generator.
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, FileUrlGeneratorInterface $file_url_generator) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
$this->fileUrlGenerator = $file_url_generator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['label'],
$configuration['view_mode'],
$configuration['third_party_settings'],
$container->get('file_url_generator')
);
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
$settings['link_to_file'] = FALSE;
return $settings;
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$form = parent::settingsForm($form, $form_state);
$form['link_to_file'] = [
'#title' => $this->t('Link this field to the file download URL'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('link_to_file'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
$url = NULL;
// Add support to link to the entity itself.
if ($this->getSetting('link_to_file')) {
$url = $this->fileUrlGenerator->generate($items->getEntity()->getFileUri());
}
foreach ($items as $delta => $item) {
$view_value = $this->viewValue($item);
if ($url) {
$elements[$delta] = [
'#type' => 'link',
'#title' => $view_value,
'#url' => $url,
];
}
else {
$elements[$delta] = is_array($view_value) ? $view_value : ['#markup' => $view_value];
}
}
return $elements;
}
/**
* Generate the output appropriate for one field item.
*
* @param \Drupal\Core\Field\FieldItemInterface $item
* One field item.
*
* @return mixed
* The textual output generated.
*/
abstract protected function viewValue(FieldItemInterface $item);
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return $field_definition->getTargetEntityTypeId() === 'file';
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Formatter for a text field on a file entity that links the field to the file.
*/
#[FieldFormatter(
id: 'file_link',
label: new TranslatableMarkup('File link'),
field_types: [
'string',
],
)]
class DefaultFileFormatter extends BaseFieldFileFormatterBase {
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
$settings = parent::defaultSettings();
$settings['link_to_file'] = TRUE;
return $settings;
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
// We don't call the parent in order to bypass the link to file form.
return $form;
}
/**
* {@inheritdoc}
*/
protected function viewValue(FieldItemInterface $item) {
return $item->value;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Form\FormStateInterface;
/**
* Base class for file formatters that have to deal with file descriptions.
*/
abstract class DescriptionAwareFileFormatterBase extends FileFormatterBase {
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
$settings = parent::defaultSettings();
$settings['use_description_as_link_text'] = TRUE;
return $settings;
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$form = parent::settingsForm($form, $form_state);
$form['use_description_as_link_text'] = [
'#title' => $this->t('Use description as link text'),
'#description' => $this->t('Replace the file name by its description when available'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('use_description_as_link_text'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = parent::settingsSummary();
if ($this->getSetting('use_description_as_link_text')) {
$summary[] = $this->t('Use description as link text');
}
return $summary;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Plugin implementation of the 'file_audio' formatter.
*/
#[FieldFormatter(
id: 'file_audio',
label: new TranslatableMarkup('Audio'),
description: new TranslatableMarkup('Display the file using an HTML5 audio tag.'),
field_types: [
'file',
],
)]
class FileAudioFormatter extends FileMediaFormatterBase {
/**
* {@inheritdoc}
*/
public static function getMediaType() {
return 'audio';
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Formatter to render a filename as file extension.
*/
#[FieldFormatter(
id: 'file_extension',
label: new TranslatableMarkup('File extension'),
field_types: [
'string',
],
)]
class FileExtensionFormatter extends BaseFieldFileFormatterBase {
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
$settings = parent::defaultSettings();
$settings['extension_detect_tar'] = FALSE;
return $settings;
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$form = parent::settingsForm($form, $form_state);
$form['extension_detect_tar'] = [
'#type' => 'checkbox',
'#title' => $this->t('Include tar in extension'),
'#description' => $this->t("If the part of the filename just before the extension is '.tar', include this in the extension output."),
'#default_value' => $this->getSetting('extension_detect_tar'),
];
return $form;
}
/**
* {@inheritdoc}
*/
protected function viewValue(FieldItemInterface $item) {
$filename = $item->value;
if (!$this->getSetting('extension_detect_tar')) {
return pathinfo($filename, PATHINFO_EXTENSION);
}
else {
$file_parts = explode('.', basename($filename));
if (count($file_parts) > 1) {
$extension = array_pop($file_parts);
$last_part_in_name = array_pop($file_parts);
if ($last_part_in_name === 'tar') {
$extension = 'tar.' . $extension;
}
return $extension;
}
}
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
// Just show this file extension formatter on the filename field.
return parent::isApplicable($field_definition) && $field_definition->getName() === 'filename';
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\Plugin\Field\FieldFormatter\EntityReferenceFormatterBase;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
/**
* Base class for file formatters.
*/
abstract class FileFormatterBase extends EntityReferenceFormatterBase {
/**
* {@inheritdoc}
*/
protected function needsEntityLoad(EntityReferenceItem $item) {
return parent::needsEntityLoad($item) && $item->isDisplayed();
}
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity) {
// Only check access if the current file access control handler explicitly
// opts in by implementing FileAccessFormatterControlHandlerInterface.
$access_handler_class = $entity->getEntityType()->getHandlerClass('access');
if (is_subclass_of($access_handler_class, '\Drupal\file\FileAccessFormatterControlHandlerInterface')) {
return $entity->access('view', NULL, TRUE);
}
else {
return AccessResult::allowed();
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Template\Attribute;
/**
* Base class for media file formatter.
*/
abstract class FileMediaFormatterBase extends FileFormatterBase implements FileMediaFormatterInterface {
/**
* Gets the HTML tag for the formatter.
*
* @return string
* The HTML tag of this formatter.
*/
protected function getHtmlTag() {
return static::getMediaType();
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'controls' => TRUE,
'autoplay' => FALSE,
'loop' => FALSE,
'multiple_file_display_type' => 'tags',
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
return [
'controls' => [
'#title' => $this->t('Show playback controls'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('controls'),
],
'autoplay' => [
'#title' => $this->t('Autoplay'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('autoplay'),
],
'loop' => [
'#title' => $this->t('Loop'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('loop'),
],
'multiple_file_display_type' => [
'#title' => $this->t('Display of multiple files'),
'#type' => 'radios',
'#options' => [
'tags' => $this->t('Use multiple @tag tags, each with a single source.', ['@tag' => '<' . $this->getHtmlTag() . '>']),
'sources' => $this->t('Use multiple sources within a single @tag tag.', ['@tag' => '<' . $this->getHtmlTag() . '>']),
],
'#default_value' => $this->getSetting('multiple_file_display_type'),
],
];
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
if (!parent::isApplicable($field_definition)) {
return FALSE;
}
/** @var \Symfony\Component\Mime\MimeTypeGuesserInterface $extension_mime_type_guesser */
$extension_mime_type_guesser = \Drupal::service('file.mime_type.guesser.extension');
$extension_list = array_filter(preg_split('/\s+/', $field_definition->getSetting('file_extensions')));
foreach ($extension_list as $extension) {
$mime_type = $extension_mime_type_guesser->guessMimeType('fakedFile.' . $extension);
if (static::mimeTypeApplies($mime_type)) {
return TRUE;
}
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$summary[] = $this->t('Playback controls: %controls', ['%controls' => $this->getSetting('controls') ? $this->t('visible') : $this->t('hidden')]);
$summary[] = $this->t('Autoplay: %autoplay', ['%autoplay' => $this->getSetting('autoplay') ? $this->t('yes') : $this->t('no')]);
$summary[] = $this->t('Loop: %loop', ['%loop' => $this->getSetting('loop') ? $this->t('yes') : $this->t('no')]);
switch ($this->getSetting('multiple_file_display_type')) {
case 'tags':
$summary[] = $this->t('Multiple file display: Multiple HTML tags');
break;
case 'sources':
$summary[] = $this->t('Multiple file display: One HTML tag with multiple sources');
break;
}
return $summary;
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
$source_files = $this->getSourceFiles($items, $langcode);
if (empty($source_files)) {
return $elements;
}
$attributes = $this->prepareAttributes();
foreach ($source_files as $delta => $files) {
$elements[$delta] = [
'#theme' => $this->getPluginId(),
'#attributes' => $attributes,
'#files' => $files,
'#cache' => ['tags' => []],
];
$cache_tags = [];
foreach ($files as $file) {
$cache_tags = Cache::mergeTags($cache_tags, $file['file']->getCacheTags());
}
$elements[$delta]['#cache']['tags'] = $cache_tags;
}
return $elements;
}
/**
* Prepare the attributes according to the settings.
*
* @param string[] $additional_attributes
* Additional attributes to be applied to the HTML element. Attribute names
* will be used as key and value in the HTML element.
*
* @return \Drupal\Core\Template\Attribute
* Container with all the attributes for the HTML tag.
*/
protected function prepareAttributes(array $additional_attributes = []) {
$attributes = new Attribute();
foreach (array_merge(['controls', 'autoplay', 'loop'], $additional_attributes) as $attribute) {
if ($this->getSetting($attribute)) {
$attributes->setAttribute($attribute, $attribute);
}
}
return $attributes;
}
/**
* Check if given MIME type applies to the media type of the formatter.
*
* @param string $mime_type
* The complete MIME type.
*
* @return bool
* TRUE if the MIME type applies, FALSE otherwise.
*/
protected static function mimeTypeApplies($mime_type) {
[$type] = explode('/', $mime_type, 2);
return $type === static::getMediaType();
}
/**
* Gets source files with attributes.
*
* @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items
* The item list.
* @param string $langcode
* The language code of the referenced entities to display.
*
* @return array
* Numerically indexed array, which again contains an associative array with
* the following key/values:
* - file => \Drupal\file\Entity\File
* - source_attributes => \Drupal\Core\Template\Attribute
*/
protected function getSourceFiles(EntityReferenceFieldItemListInterface $items, $langcode) {
$source_files = [];
// Because we can have the files grouped in a single media tag, we do a
// grouping in case the multiple file behavior is not 'tags'.
/** @var \Drupal\file\Entity\File $file */
foreach ($this->getEntitiesToView($items, $langcode) as $file) {
if (static::mimeTypeApplies($file->getMimeType())) {
$source_attributes = new Attribute();
$source_attributes
->setAttribute('src', $file->createFileUrl())
->setAttribute('type', $file->getMimeType());
if ($this->getSetting('multiple_file_display_type') === 'tags') {
$source_files[] = [
[
'file' => $file,
'source_attributes' => $source_attributes,
],
];
}
else {
$source_files[0][] = [
'file' => $file,
'source_attributes' => $source_attributes,
];
}
}
}
return $source_files;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
/**
* Defines getter methods for FileMediaFormatterBase.
*
* This interface is used on the FileMediaFormatterBase class to ensure that
* each file media formatter will be based on a media type.
*
* Abstract classes are not able to implement abstract static methods,
* this interface will work around that.
*
* @see \Drupal\file\Plugin\Field\FieldFormatter\FileMediaFormatterBase
*/
interface FileMediaFormatterInterface {
/**
* Gets the applicable media type for a formatter.
*
* @return string
* The media type of this formatter.
*/
public static function getMediaType();
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Formatter that shows the file byte size in a human-readable way.
*/
#[FieldFormatter(
id: 'file_size',
label: new TranslatableMarkup('Bytes (KB, MB, ...)'),
field_types: [
'integer',
],
)]
class FileSize extends FormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
$elements[$delta] = ['#markup' => ByteSizeMarkup::create((int) $item->value)];
}
return $elements;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Formatter to render the file URI to its download path.
*/
#[FieldFormatter(
id: 'file_uri',
label: new TranslatableMarkup('File URI'),
field_types: [
'uri',
'file_uri',
],
)]
class FileUriFormatter extends BaseFieldFileFormatterBase {
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
$settings = parent::defaultSettings();
$settings['file_download_path'] = FALSE;
return $settings;
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$form = parent::settingsForm($form, $form_state);
$form['file_download_path'] = [
'#title' => $this->t('Display the file download URI'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('file_download_path'),
];
return $form;
}
/**
* {@inheritdoc}
*/
protected function viewValue(FieldItemInterface $item) {
$value = $item->value;
if ($this->getSetting('file_download_path')) {
$value = $this->fileUrlGenerator->generateString($value);
}
return $value;
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return parent::isApplicable($field_definition) && $field_definition->getName() === 'uri';
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Plugin implementation of the 'file_video' formatter.
*/
#[FieldFormatter(
id: 'file_video',
label: new TranslatableMarkup('Video'),
description: new TranslatableMarkup('Display the file using an HTML5 video tag.'),
field_types: [
'file',
],
)]
class FileVideoFormatter extends FileMediaFormatterBase {
/**
* {@inheritdoc}
*/
public static function getMediaType() {
return 'video';
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'muted' => FALSE,
'width' => 640,
'height' => 480,
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
return parent::settingsForm($form, $form_state) + [
'muted' => [
'#title' => $this->t('Muted'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('muted'),
],
'width' => [
'#type' => 'number',
'#title' => $this->t('Width'),
'#default_value' => $this->getSetting('width'),
'#size' => 5,
'#maxlength' => 5,
'#field_suffix' => $this->t('pixels'),
// A width of zero pixels would make this video invisible.
'#min' => 1,
],
'height' => [
'#type' => 'number',
'#title' => $this->t('Height'),
'#default_value' => $this->getSetting('height'),
'#size' => 5,
'#maxlength' => 5,
'#field_suffix' => $this->t('pixels'),
// A height of zero pixels would make this video invisible.
'#min' => 1,
],
];
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = parent::settingsSummary();
$summary[] = $this->t('Muted: %muted', ['%muted' => $this->getSetting('muted') ? $this->t('yes') : $this->t('no')]);
if ($width = $this->getSetting('width')) {
$summary[] = $this->t('Width: %width pixels', [
'%width' => $width,
]);
}
if ($height = $this->getSetting('height')) {
$summary[] = $this->t('Height: %height pixels', [
'%height' => $height,
]);
}
return $summary;
}
/**
* {@inheritdoc}
*/
protected function prepareAttributes(array $additional_attributes = []) {
$attributes = parent::prepareAttributes(['muted']);
if (($width = $this->getSetting('width'))) {
$attributes->setAttribute('width', $width);
}
if (($height = $this->getSetting('height'))) {
$attributes->setAttribute('height', $height);
}
return $attributes;
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Formatter to render the file MIME type, with an optional icon.
*/
#[FieldFormatter(
id: 'file_filemime',
label: new TranslatableMarkup('File MIME'),
field_types: [
'string',
],
)]
class FilemimeFormatter extends BaseFieldFileFormatterBase {
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return parent::isApplicable($field_definition) && $field_definition->getName() === 'filemime';
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
$settings = parent::defaultSettings();
$settings['filemime_image'] = FALSE;
return $settings;
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$form = parent::settingsForm($form, $form_state);
$form['filemime_image'] = [
'#title' => $this->t('Display an icon'),
'#description' => $this->t('The icon is representing the file type, instead of the MIME text (such as "image/jpeg")'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('filemime_image'),
];
return $form;
}
/**
* {@inheritdoc}
*/
protected function viewValue(FieldItemInterface $item) {
$value = $item->value;
if ($this->getSetting('filemime_image') && $value) {
$file_icon = [
'#theme' => 'image__file_icon',
'#file' => $item->getEntity(),
];
return $file_icon;
}
return $value;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Plugin implementation of the 'file_default' formatter.
*/
#[FieldFormatter(
id: 'file_default',
label: new TranslatableMarkup('Generic file'),
field_types: [
'file',
],
)]
class GenericFileFormatter extends DescriptionAwareFileFormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($this->getEntitiesToView($items, $langcode) as $delta => $file) {
$item = $file->_referringItem;
$elements[$delta] = [
'#theme' => 'file_link',
'#file' => $file,
'#description' => $this->getSetting('use_description_as_link_text') ? $item->description : NULL,
'#cache' => [
'tags' => $file->getCacheTags(),
],
];
// Pass field item attributes to the theme function.
if (isset($item->_attributes)) {
$elements[$delta] += ['#attributes' => []];
$elements[$delta]['#attributes'] += $item->_attributes;
// Unset field item attributes since they have been included in the
// formatter output and should not be rendered in the field template.
unset($item->_attributes);
}
}
return $elements;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Plugin implementation of the 'file_rss_enclosure' formatter.
*/
#[FieldFormatter(
id: 'file_rss_enclosure',
label: new TranslatableMarkup('RSS enclosure'),
field_types: [
'file',
],
)]
class RSSEnclosureFormatter extends FileFormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$entity = $items->getEntity();
// Add the first file as an enclosure to the RSS item. RSS allows only one
// enclosure per item. See: http://wikipedia.org/wiki/RSS_enclosure
foreach ($this->getEntitiesToView($items, $langcode) as $file) {
/** @var \Drupal\file\FileInterface $file */
$entity->rss_elements[] = [
'key' => 'enclosure',
'attributes' => [
// In RSS feeds, it is necessary to use absolute URLs. The 'url.site'
// cache context is already associated with RSS feed responses, so it
// does not need to be specified here.
'url' => $file->createFileUrl(FALSE),
'length' => $file->getSize(),
'type' => $file->getMimeType(),
],
];
}
return [];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Plugin implementation of the 'file_table' formatter.
*/
#[FieldFormatter(
id: 'file_table',
label: new TranslatableMarkup('Table of files'),
field_types: [
'file',
],
)]
class TableFormatter extends DescriptionAwareFileFormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
if ($files = $this->getEntitiesToView($items, $langcode)) {
$header = [$this->t('Attachment'), $this->t('Size')];
$rows = [];
foreach ($files as $file) {
$item = $file->_referringItem;
$rows[] = [
[
'data' => [
'#theme' => 'file_link',
'#file' => $file,
'#description' => $this->getSetting('use_description_as_link_text') ? $item->description : NULL,
'#cache' => [
'tags' => $file->getCacheTags(),
],
],
],
['data' => $file->getSize() !== NULL ? ByteSizeMarkup::create($file->getSize()) : $this->t('Unknown')],
];
}
$elements[0] = [];
if (!empty($rows)) {
$elements[0] = [
'#theme' => 'table__file_formatter_table',
'#header' => $header,
'#rows' => $rows,
];
}
}
return $elements;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Drupal\file\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\file\FileInterface;
/**
* Plugin implementation of the 'file_url_plain' formatter.
*/
#[FieldFormatter(
id: 'file_url_plain',
label: new TranslatableMarkup('URL to file'),
field_types: [
'file',
],
)]
class UrlPlainFormatter extends FileFormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($this->getEntitiesToView($items, $langcode) as $delta => $file) {
assert($file instanceof FileInterface);
$elements[$delta] = [
'#markup' => $file->createFileUrl(),
'#cache' => [
'tags' => $file->getCacheTags(),
],
];
}
return $elements;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Drupal\file\Plugin\Field\FieldType;
use Drupal\Core\Field\EntityReferenceFieldItemList;
use Drupal\Core\Form\FormStateInterface;
/**
* Represents a configurable entity file field.
*/
class FileFieldItemList extends EntityReferenceFieldItemList {
/**
* {@inheritdoc}
*/
public function defaultValuesForm(array &$form, FormStateInterface $form_state) {}
/**
* {@inheritdoc}
*/
public function postSave($update) {
$entity = $this->getEntity();
if (!$update) {
// Add a new usage for newly uploaded files.
foreach ($this->referencedEntities() as $file) {
\Drupal::service('file.usage')->add($file, 'file', $entity->getEntityTypeId(), $entity->id());
}
}
else {
// Get current target file entities and file IDs.
$files = $this->referencedEntities();
$ids = [];
/** @var \Drupal\file\FileInterface $file */
foreach ($files as $file) {
$ids[] = $file->id();
}
// On new revisions, all files are considered to be a new usage and no
// deletion of previous file usages are necessary.
if (!empty($entity->original) && $entity->getRevisionId() != $entity->original->getRevisionId()) {
foreach ($files as $file) {
\Drupal::service('file.usage')->add($file, 'file', $entity->getEntityTypeId(), $entity->id());
}
return;
}
// Get the file IDs attached to the field before this update.
$field_name = $this->getFieldDefinition()->getName();
$original_ids = [];
$langcode = $this->getLangcode();
$original = $entity->original;
if ($original->hasTranslation($langcode)) {
$original_items = $original->getTranslation($langcode)->{$field_name};
foreach ($original_items as $item) {
$original_ids[] = $item->target_id;
}
}
// Decrement file usage by 1 for files that were removed from the field.
$removed_ids = array_filter(array_diff($original_ids, $ids));
$removed_files = \Drupal::entityTypeManager()->getStorage('file')->loadMultiple($removed_ids);
foreach ($removed_files as $file) {
\Drupal::service('file.usage')->delete($file, 'file', $entity->getEntityTypeId(), $entity->id());
}
// Add new usage entries for newly added files.
foreach ($files as $file) {
if (!in_array($file->id(), $original_ids)) {
\Drupal::service('file.usage')->add($file, 'file', $entity->getEntityTypeId(), $entity->id());
}
}
}
}
/**
* {@inheritdoc}
*/
public function delete() {
parent::delete();
$entity = $this->getEntity();
// If a translation is deleted only decrement the file usage by one. If the
// default translation is deleted remove all file usages within this entity.
$count = $entity->isDefaultTranslation() ? 0 : 1;
foreach ($this->referencedEntities() as $file) {
\Drupal::service('file.usage')->delete($file, 'file', $entity->getEntityTypeId(), $entity->id(), $count);
}
}
/**
* {@inheritdoc}
*/
public function deleteRevision() {
parent::deleteRevision();
$entity = $this->getEntity();
// Decrement the file usage by 1.
foreach ($this->referencedEntities() as $file) {
\Drupal::service('file.usage')->delete($file, 'file', $entity->getEntityTypeId(), $entity->id());
}
}
}

View File

@@ -0,0 +1,396 @@
<?php
namespace Drupal\file\Plugin\Field\FieldType;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\Bytes;
use Drupal\Component\Utility\Environment;
use Drupal\Component\Utility\Random;
use Drupal\Core\Field\Attribute\FieldType;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\file\Validation\FileValidatorSettingsTrait;
/**
* Plugin implementation of the 'file' field type.
*/
#[FieldType(
id: "file",
label: new TranslatableMarkup("File"),
description: [
new TranslatableMarkup("For uploading files"),
new TranslatableMarkup("Can be configured with options such as allowed file extensions and maximum upload size"),
],
category: "file_upload",
default_widget: "file_generic",
default_formatter: "file_default",
list_class: FileFieldItemList::class,
constraints: ["ReferenceAccess" => [], "FileValidation" => []],
column_groups: [
'target_id' => [
'label' => new TranslatableMarkup('File'),
'translatable' => TRUE,
],
'display' => [
'label' => new TranslatableMarkup('Display'),
'translatable' => TRUE,
],
'description' => [
'label' => new TranslatableMarkup('Description'),
'translatable' => TRUE,
],
],
)]
class FileItem extends EntityReferenceItem {
use FileValidatorSettingsTrait;
/**
* {@inheritdoc}
*/
public static function defaultStorageSettings() {
return [
'target_type' => 'file',
'display_field' => FALSE,
'display_default' => FALSE,
'uri_scheme' => \Drupal::config('system.file')->get('default_scheme'),
] + parent::defaultStorageSettings();
}
/**
* {@inheritdoc}
*/
public static function defaultFieldSettings() {
return [
'file_extensions' => 'txt',
'file_directory' => '[date:custom:Y]-[date:custom:m]',
'max_filesize' => '',
'description_field' => 0,
] + parent::defaultFieldSettings();
}
/**
* {@inheritdoc}
*/
public static function schema(FieldStorageDefinitionInterface $field_definition) {
return [
'columns' => [
'target_id' => [
'description' => 'The ID of the file entity.',
'type' => 'int',
'unsigned' => TRUE,
],
'display' => [
'description' => 'Flag to control whether this file should be displayed when viewing content.',
'type' => 'int',
'size' => 'tiny',
'unsigned' => TRUE,
'default' => 1,
],
'description' => [
'description' => 'A description of the file.',
'type' => 'text',
],
],
'indexes' => [
'target_id' => ['target_id'],
],
'foreign keys' => [
'target_id' => [
'table' => 'file_managed',
'columns' => ['target_id' => 'fid'],
],
],
];
}
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
$properties = parent::propertyDefinitions($field_definition);
$properties['display'] = DataDefinition::create('boolean')
->setLabel(new TranslatableMarkup('Display'))
->setDescription(new TranslatableMarkup('Flag to control whether this file should be displayed when viewing content'));
$properties['description'] = DataDefinition::create('string')
->setLabel(new TranslatableMarkup('Description'));
return $properties;
}
/**
* {@inheritdoc}
*/
public static function storageSettingsSummary(FieldStorageDefinitionInterface $storage_definition): array {
// Bypass the parent setting summary as it produces redundant information.
return [];
}
/**
* {@inheritdoc}
*/
public function storageSettingsForm(array &$form, FormStateInterface $form_state, $has_data) {
$element = [];
$element['#attached']['library'][] = 'file/drupal.file';
$element['display_field'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable <em>Display</em> field'),
'#default_value' => $this->getSetting('display_field'),
'#description' => $this->t('The display option allows users to choose if a file should be shown when viewing the content.'),
];
$element['display_default'] = [
'#type' => 'checkbox',
'#title' => $this->t('Files displayed by default'),
'#default_value' => $this->getSetting('display_default'),
'#description' => $this->t('This setting only has an effect if the display option is enabled.'),
'#states' => [
'visible' => [
':input[name="field_storage[subform][settings][display_field]"]' => ['checked' => TRUE],
],
],
];
$scheme_options = \Drupal::service('stream_wrapper_manager')->getNames(StreamWrapperInterface::WRITE_VISIBLE);
$element['uri_scheme'] = [
'#type' => 'radios',
'#title' => $this->t('Upload destination'),
'#options' => $scheme_options,
'#default_value' => $this->getSetting('uri_scheme'),
'#description' => $this->t('Select where the final files should be stored. Private file storage has significantly more overhead than public files, but allows restricted access to files within this field.'),
'#disabled' => $has_data,
];
return $element;
}
/**
* {@inheritdoc}
*/
public function fieldSettingsForm(array $form, FormStateInterface $form_state) {
$element = [];
$settings = $this->getSettings();
$element['file_directory'] = [
'#type' => 'textfield',
'#title' => $this->t('File directory'),
'#default_value' => $settings['file_directory'],
'#description' => $this->t('Optional subdirectory within the upload destination where files will be stored. Do not include preceding or trailing slashes.'),
'#element_validate' => [[static::class, 'validateDirectory']],
'#weight' => 3,
];
// Make the extension list a little more human-friendly by comma-separation.
$extensions = str_replace(' ', ', ', $settings['file_extensions']);
$element['file_extensions'] = [
'#type' => 'textfield',
'#title' => $this->t('Allowed file extensions'),
'#default_value' => $extensions,
'#description' => $this->t("Separate extensions with a comma or space. Each extension can contain alphanumeric characters, '.', and '_', and should start and end with an alphanumeric character."),
'#element_validate' => [[static::class, 'validateExtensions']],
'#weight' => 1,
'#maxlength' => 256,
// By making this field required, we prevent a potential security issue
// that would allow files of any type to be uploaded.
'#required' => TRUE,
];
$element['max_filesize'] = [
'#type' => 'textfield',
'#title' => $this->t('Maximum upload size'),
'#default_value' => $settings['max_filesize'],
'#description' => $this->t('Enter a value like "512" (bytes), "80 KB" (kilobytes) or "50 MB" (megabytes) in order to restrict the allowed file size. If left empty the file sizes could be limited only by PHP\'s maximum post and file upload sizes (current limit <strong>%limit</strong>).', [
'%limit' => ByteSizeMarkup::create(Environment::getUploadMaxSize()),
]),
'#size' => 10,
'#element_validate' => [[static::class, 'validateMaxFilesize']],
'#weight' => 5,
];
$element['description_field'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable <em>Description</em> field'),
'#default_value' => $settings['description_field'] ?? '',
'#description' => $this->t('The description field allows users to enter a description about the uploaded file.'),
'#weight' => 11,
];
return $element;
}
/**
* Form API callback.
*
* Removes slashes from the beginning and end of the destination value and
* ensures that the file directory path is not included at the beginning of the
* value.
*
* This function is assigned as an #element_validate callback in
* fieldSettingsForm().
*/
public static function validateDirectory($element, FormStateInterface $form_state) {
// Strip slashes from the beginning and end of $element['file_directory'].
$value = trim($element['#value'], '\\/');
$form_state->setValueForElement($element, $value);
}
/**
* Form API callback.
*
* This function is assigned as an #element_validate callback in
* fieldSettingsForm().
*
* This doubles as a convenience clean-up function and a validation routine.
* Commas are allowed by the end-user, but ultimately the value will be stored
* as a space-separated list for compatibility with file_validate_extensions().
*/
public static function validateExtensions($element, FormStateInterface $form_state) {
if (!empty($element['#value'])) {
$extensions = preg_replace('/([, ]+\.?)/', ' ', trim(strtolower($element['#value'])));
$extension_array = array_unique(array_filter(explode(' ', $extensions)));
$extensions = implode(' ', $extension_array);
if (!preg_match('/^([a-z0-9]+([._][a-z0-9])* ?)+$/', $extensions)) {
$form_state->setError($element, new TranslatableMarkup("The list of allowed extensions is not valid. Allowed characters are a-z, 0-9, '.', and '_'. The first and last characters cannot be '.' or '_', and these two characters cannot appear next to each other. Separate extensions with a comma or space."));
}
else {
$form_state->setValueForElement($element, $extensions);
}
// If insecure uploads are not allowed and txt is not in the list of
// allowed extensions, ensure that no insecure extensions are allowed.
if (!in_array('txt', $extension_array, TRUE) && !\Drupal::config('system.file')->get('allow_insecure_uploads')) {
foreach ($extension_array as $extension) {
if (preg_match(FileSystemInterface::INSECURE_EXTENSION_REGEX, 'test.' . $extension)) {
$form_state->setError($element, new TranslatableMarkup('Add %txt_extension to the list of allowed extensions to securely upload files with a %extension extension. The %txt_extension extension will then be added automatically.', ['%extension' => $extension, '%txt_extension' => 'txt']));
break;
}
}
}
}
}
/**
* Form API callback.
*
* Ensures that a size has been entered and that it can be parsed by
* \Drupal\Component\Utility\Bytes::toNumber().
*
* This function is assigned as an #element_validate callback in
* fieldSettingsForm().
*/
public static function validateMaxFilesize($element, FormStateInterface $form_state) {
$element['#value'] = trim($element['#value']);
$form_state->setValue(['settings', 'max_filesize'], $element['#value']);
if (!empty($element['#value']) && !Bytes::validate($element['#value'])) {
$form_state->setError($element, new TranslatableMarkup('The "@name" option must contain a valid value. You may either leave the text field empty or enter a string like "512" (bytes), "80 KB" (kilobytes) or "50 MB" (megabytes).', ['@name' => $element['#title']]));
}
}
/**
* Determines the URI for a file field.
*
* @param array $data
* An array of token objects to pass to Token::replace().
*
* @return string
* An unsanitized file directory URI with tokens replaced. The result of
* the token replacement is then converted to plain text and returned.
*
* @see \Drupal\Core\Utility\Token::replace()
*/
public function getUploadLocation($data = []) {
return static::doGetUploadLocation($this->getSettings(), $data);
}
/**
* Determines the URI for a file field.
*
* @param array $settings
* The array of field settings.
* @param array $data
* An array of token objects to pass to Token::replace().
*
* @return string
* An unsanitized file directory URI with tokens replaced. The result of
* the token replacement is then converted to plain text and returned.
*
* @see \Drupal\Core\Utility\Token::replace()
*/
protected static function doGetUploadLocation(array $settings, $data = []) {
$destination = trim($settings['file_directory'], '/');
// Replace tokens. As the tokens might contain HTML we convert it to plain
// text.
$destination = PlainTextOutput::renderFromHtml(\Drupal::token()->replace($destination, $data));
return $settings['uri_scheme'] . '://' . $destination;
}
/**
* Retrieves the upload validators for a file field.
*
* @return array
* An array suitable for passing to file_save_upload() or the file field
* element's '#upload_validators' property.
*/
public function getUploadValidators() {
return $this->getFileUploadValidators($this->getSettings());
}
/**
* {@inheritdoc}
*/
public static function generateSampleValue(FieldDefinitionInterface $field_definition) {
$random = new Random();
$settings = $field_definition->getSettings();
// Prepare destination.
$dirname = static::doGetUploadLocation($settings);
\Drupal::service('file_system')->prepareDirectory($dirname, FileSystemInterface::CREATE_DIRECTORY);
// Generate a file entity.
$destination = $dirname . '/' . $random->name(10, TRUE) . '.txt';
$data = $random->paragraphs(3);
/** @var \Drupal\file\FileRepositoryInterface $file_repository */
$file_repository = \Drupal::service('file.repository');
$file = $file_repository->writeData($data, $destination, FileExists::Error);
$values = [
'target_id' => $file->id(),
'display' => (int) $settings['display_default'],
'description' => $random->sentences(10),
];
return $values;
}
/**
* Determines whether an item should be displayed when rendering the field.
*
* @return bool
* TRUE if the item should be displayed, FALSE if not.
*/
public function isDisplayed() {
if ($this->getSetting('display_field')) {
return (bool) $this->display;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public static function getPreconfiguredOptions() {
return [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\file\Plugin\Field\FieldType;
use Drupal\Core\Field\Attribute\FieldType;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\UriItem;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\file\ComputedFileUrl;
/**
* File-specific plugin implementation of a URI item to provide a full URL.
*/
#[FieldType(
id: "file_uri",
label: new TranslatableMarkup("File URI"),
description: new TranslatableMarkup("An entity field containing a file URI, and a computed root-relative file URL."),
default_widget: "uri",
default_formatter: "file_uri",
no_ui: TRUE,
)]
class FileUriItem extends UriItem {
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
$properties = parent::propertyDefinitions($field_definition);
$properties['url'] = DataDefinition::create('string')
->setLabel(t('Root-relative file URL'))
->setComputed(TRUE)
->setInternal(FALSE)
->setClass(ComputedFileUrl::class);
return $properties;
}
}

View File

@@ -0,0 +1,609 @@
<?php
namespace Drupal\file\Plugin\Field\FieldWidget;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Field\Attribute\FieldWidget;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\ElementInfoManagerInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\file\Element\ManagedFile;
use Drupal\file\Entity\File;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Plugin implementation of the 'file_generic' widget.
*/
#[FieldWidget(
id: 'file_generic',
label: new TranslatableMarkup('File'),
field_types: ['file'],
)]
class FileWidget extends WidgetBase {
/**
* The element info manager.
*/
protected ElementInfoManagerInterface $elementInfo;
/**
* {@inheritdoc}
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $element_info) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
$this->elementInfo = $element_info;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($plugin_id, $plugin_definition, $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], $container->get('element_info'));
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'progress_indicator' => 'throbber',
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$element['progress_indicator'] = [
'#type' => 'radios',
'#title' => $this->t('Progress indicator'),
'#options' => [
'throbber' => $this->t('Throbber'),
'bar' => $this->t('Bar with progress meter'),
],
'#default_value' => $this->getSetting('progress_indicator'),
'#description' => $this->t('The throbber display does not show the status of uploads but takes up less space. The progress bar is helpful for monitoring progress on large uploads.'),
'#weight' => 16,
'#access' => extension_loaded('uploadprogress'),
];
return $element;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$summary[] = $this->t('Progress indicator: @progress_indicator', ['@progress_indicator' => $this->getSetting('progress_indicator')]);
return $summary;
}
/**
* Overrides \Drupal\Core\Field\WidgetBase::formMultipleElements().
*
* Special handling for draggable multiple widgets and 'add more' button.
*/
protected function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) {
$field_name = $this->fieldDefinition->getName();
$parents = $form['#parents'];
// Load the items for form rebuilds from the field state as they might not
// be in $form_state->getValues() because of validation limitations. Also,
// they are only passed in as $items when editing existing entities.
$field_state = static::getWidgetState($parents, $field_name, $form_state);
if (isset($field_state['items'])) {
$items->setValue($field_state['items']);
}
// Determine the number of widgets to display.
$cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality();
switch ($cardinality) {
case FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED:
$max = count($items);
$is_multiple = TRUE;
break;
default:
$max = $cardinality - 1;
$is_multiple = ($cardinality > 1);
break;
}
$title = $this->fieldDefinition->getLabel();
$description = $this->getFilteredDescription();
$elements = [];
$delta = 0;
// Add an element for every existing item.
foreach ($items as $item) {
$element = [
'#title' => $title,
'#description' => $description,
];
$element = $this->formSingleElement($items, $delta, $element, $form, $form_state);
if ($element) {
// Input field for the delta (drag-n-drop reordering).
if ($is_multiple) {
// We name the element '_weight' to avoid clashing with elements
// defined by widget.
$element['_weight'] = [
'#type' => 'weight',
'#title' => $this->t('Weight for row @number', ['@number' => $delta + 1]),
'#title_display' => 'invisible',
// Note: this 'delta' is the FAPI #type 'weight' element's property.
'#delta' => $max,
'#default_value' => $item->_weight ?: $delta,
'#weight' => 100,
];
}
$elements[$delta] = $element;
$delta++;
}
}
$empty_single_allowed = ($cardinality == 1 && $delta == 0);
$empty_multiple_allowed = ($cardinality == FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED || $delta < $cardinality) && !$form_state->isProgrammed();
// Add one more empty row for new uploads except when this is a programmed
// multiple form as it is not necessary.
if ($empty_single_allowed || $empty_multiple_allowed) {
// Create a new empty item.
$items->appendItem();
$element = [
'#title' => $title,
'#description' => $description,
];
$element = $this->formSingleElement($items, $delta, $element, $form, $form_state);
if ($element) {
$element['#required'] = ($element['#required'] && $delta == 0);
$elements[$delta] = $element;
}
}
if ($is_multiple) {
// The group of elements all-together need some extra functionality after
// building up the full list (like draggable table rows).
$elements['#file_upload_delta'] = $delta;
$elements['#type'] = 'details';
$elements['#open'] = TRUE;
$elements['#theme'] = 'file_widget_multiple';
$elements['#theme_wrappers'] = ['details'];
$elements['#process'] = [[static::class, 'processMultiple']];
$elements['#title'] = $title;
$elements['#description'] = $description;
$elements['#field_name'] = $field_name;
$elements['#language'] = $items->getLangcode();
// The field settings include defaults for the field type. However, this
// widget is a base class for other widgets (e.g., ImageWidget) that may
// act on field types without these expected settings.
$field_settings = $this->getFieldSettings() + ['display_field' => NULL];
$elements['#display_field'] = (bool) $field_settings['display_field'];
// Add some properties that will eventually be added to the file upload
// field. These are added here so that they may be referenced easily
// through a hook_form_alter().
$elements['#file_upload_title'] = $this->t('Add a new file');
$elements['#file_upload_description'] = [
'#theme' => 'file_upload_help',
'#description' => '',
'#upload_validators' => $elements[0]['#upload_validators'],
'#cardinality' => $cardinality,
];
}
return $elements;
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$field_settings = $this->getFieldSettings();
// The field settings include defaults for the field type. However, this
// widget is a base class for other widgets (e.g., ImageWidget) that may act
// on field types without these expected settings.
$field_settings += [
'display_default' => NULL,
'display_field' => NULL,
'description_field' => NULL,
];
$cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality();
$defaults = [
'fids' => [],
'display' => (bool) $field_settings['display_default'],
'description' => '',
];
// Essentially we use the managed_file type, extended with some
// enhancements.
$element_info = $this->elementInfo->getInfo('managed_file');
$element += [
'#type' => 'managed_file',
'#upload_location' => $items[$delta]->getUploadLocation(),
'#upload_validators' => $items[$delta]->getUploadValidators(),
'#value_callback' => [static::class, 'value'],
'#process' => array_merge($element_info['#process'], [[static::class, 'process']]),
'#progress_indicator' => $this->getSetting('progress_indicator'),
// Allows this field to return an array instead of a single value.
'#extended' => TRUE,
// Add properties needed by value() and process() methods.
'#field_name' => $this->fieldDefinition->getName(),
'#entity_type' => $items->getEntity()->getEntityTypeId(),
'#display_field' => (bool) $field_settings['display_field'],
'#display_default' => $field_settings['display_default'],
'#description_field' => $field_settings['description_field'],
'#cardinality' => $cardinality,
];
$element['#weight'] = $delta;
// Field stores FID value in a single mode, so we need to transform it for
// form element to recognize it correctly.
if (!isset($items[$delta]->fids) && isset($items[$delta]->target_id)) {
$items[$delta]->fids = [$items[$delta]->target_id];
}
$element['#default_value'] = $items[$delta]->getValue() + $defaults;
$default_fids = $element['#extended'] ? $element['#default_value']['fids'] : $element['#default_value'];
if (empty($default_fids)) {
$file_upload_help = [
'#theme' => 'file_upload_help',
'#description' => $element['#description'],
'#upload_validators' => $element['#upload_validators'],
'#cardinality' => $cardinality,
];
$element['#description'] = \Drupal::service('renderer')->renderInIsolation($file_upload_help);
$element['#multiple'] = $cardinality != 1 ? TRUE : FALSE;
if ($cardinality != 1 && $cardinality != -1) {
$element['#element_validate'] = [[static::class, 'validateMultipleCount']];
}
}
return $element;
}
/**
* {@inheritdoc}
*/
public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
// Since file upload widget now supports uploads of more than one file at a
// time it always returns an array of fids. We have to translate this to a
// single fid, as field expects single value.
$new_values = [];
foreach ($values as &$value) {
foreach ($value['fids'] as $fid) {
$new_value = $value;
$new_value['target_id'] = $fid;
unset($new_value['fids']);
$new_values[] = $new_value;
}
}
return $new_values;
}
/**
* {@inheritdoc}
*/
public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state) {
parent::extractFormValues($items, $form, $form_state);
// Update reference to 'items' stored during upload to take into account
// changes to values like 'alt' etc.
// @see \Drupal\file\Plugin\Field\FieldWidget\FileWidget::submit()
$field_name = $this->fieldDefinition->getName();
$field_state = static::getWidgetState($form['#parents'], $field_name, $form_state);
$field_state['items'] = $items->getValue();
static::setWidgetState($form['#parents'], $field_name, $form_state, $field_state);
}
/**
* Form API callback. Retrieves the value for the file_generic field element.
*
* This method is assigned as a #value_callback in formElement() method.
*/
public static function value($element, $input, FormStateInterface $form_state) {
if ($input) {
if (empty($input['display'])) {
// Updates the display field with the default value because
// #display_field is invisible.
if (empty($input['fids'])) {
$input['display'] = $element['#display_default'];
}
// Checkboxes lose their value when empty.
// If the display field is present, make sure its unchecked value is
// saved.
else {
$input['display'] = $element['#display_field'] ? 0 : 1;
}
}
}
// We depend on the managed file element to handle uploads.
$return = ManagedFile::valueCallback($element, $input, $form_state);
// Ensure that all the required properties are returned even if empty.
$return += [
'fids' => [],
'display' => 1,
'description' => '',
];
return $return;
}
/**
* Validates the number of uploaded files.
*
* This validator is used only when cardinality not set to 1 or unlimited.
*/
public static function validateMultipleCount($element, FormStateInterface $form_state, $form) {
$values = NestedArray::getValue($form_state->getValues(), $element['#parents']);
$array_parents = $element['#array_parents'];
array_pop($array_parents);
$previously_uploaded_count = count(Element::children(NestedArray::getValue($form, $array_parents))) - 1;
$field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions($element['#entity_type']);
$field_storage = $field_storage_definitions[$element['#field_name']];
$newly_uploaded_count = count($values['fids']);
$total_uploaded_count = $newly_uploaded_count + $previously_uploaded_count;
if ($total_uploaded_count > $field_storage->getCardinality()) {
$keep = $newly_uploaded_count - $total_uploaded_count + $field_storage->getCardinality();
$removed_files = array_slice($values['fids'], $keep);
$removed_names = [];
foreach ($removed_files as $fid) {
$file = File::load($fid);
$removed_names[] = $file->getFilename();
}
$args = [
'%field' => $field_storage->getName(),
'@max' => $field_storage->getCardinality(),
'@count' => $total_uploaded_count,
'%list' => implode(', ', $removed_names),
];
$message = new TranslatableMarkup('Field %field can only hold @max values but there were @count uploaded. The following files have been omitted as a result: %list.', $args);
\Drupal::messenger()->addWarning($message);
$values['fids'] = array_slice($values['fids'], 0, $keep);
NestedArray::setValue($form_state->getValues(), $element['#parents'], $values);
}
}
/**
* Form API callback: Processes a file_generic field element.
*
* Expands the file_generic type to include the description and display
* fields.
*
* This method is assigned as a #process callback in formElement() method.
*/
public static function process($element, FormStateInterface $form_state, $form) {
$item = $element['#value'];
$item['fids'] = $element['fids']['#value'];
// Add the display field if enabled.
if ($element['#display_field']) {
$element['display'] = [
'#type' => empty($item['fids']) ? 'hidden' : 'checkbox',
'#title' => new TranslatableMarkup('Include file in display'),
'#attributes' => ['class' => ['file-display']],
];
if (isset($item['display'])) {
$element['display']['#value'] = $item['display'] ? '1' : '';
}
else {
$element['display']['#value'] = $element['#display_default'];
}
}
else {
$element['display'] = [
'#type' => 'hidden',
'#value' => '1',
];
}
// Add the description field if enabled.
if ($element['#description_field'] && $item['fids']) {
$config = \Drupal::config('file.settings');
$element['description'] = [
'#type' => $config->get('description.type'),
'#title' => new TranslatableMarkup('Description'),
'#value' => $item['description'] ?? '',
'#maxlength' => $config->get('description.length'),
'#description' => new TranslatableMarkup('The description may be used as the label of the link to the file.'),
];
}
// Adjust the Ajax settings so that on upload and remove of any individual
// file, the entire group of file fields is updated together.
if ($element['#cardinality'] != 1) {
$parents = array_slice($element['#array_parents'], 0, -1);
$new_options = [
'query' => [
'element_parents' => implode('/', $parents),
],
];
$field_element = NestedArray::getValue($form, $parents);
$new_wrapper = $field_element['#id'] . '-ajax-wrapper';
foreach (Element::children($element) as $key) {
if (isset($element[$key]['#ajax'])) {
$element[$key]['#ajax']['options'] = $new_options;
$element[$key]['#ajax']['wrapper'] = $new_wrapper;
}
}
unset($element['#prefix'], $element['#suffix']);
}
// Add another submit handler to the upload and remove buttons, to implement
// functionality needed by the field widget. This submit handler, along with
// the rebuild logic in file_field_widget_form() requires the entire field,
// not just the individual item, to be valid.
foreach (['upload_button', 'remove_button'] as $key) {
$element[$key]['#submit'][] = [static::class, 'submit'];
$element[$key]['#limit_validation_errors'] = [array_slice($element['#parents'], 0, -1)];
}
return $element;
}
/**
* Form API callback: Processes a group of file_generic field elements.
*
* Adds the weight field to each row so it can be ordered and adds a new Ajax
* wrapper around the entire group so it can be replaced all at once.
*
* This method on is assigned as a #process callback in formMultipleElements()
* method.
*/
public static function processMultiple($element, FormStateInterface $form_state, $form) {
$element_children = Element::children($element, TRUE);
$count = count($element_children);
// Count the number of already uploaded files, in order to display new
// items in \Drupal\file\Element\ManagedFile::uploadAjaxCallback().
if (!$form_state->isRebuilding()) {
$count_items_before = 0;
foreach ($element_children as $children) {
if (!empty($element[$children]['#default_value']['fids'])) {
$count_items_before++;
}
}
$form_state->set('file_upload_delta_initial', $count_items_before);
}
foreach ($element_children as $delta => $key) {
if ($key != $element['#file_upload_delta']) {
$description = static::getDescriptionFromElement($element[$key]);
$element[$key]['_weight'] = [
'#type' => 'weight',
'#title' => $description ? new TranslatableMarkup('Weight for @title', ['@title' => $description]) : new TranslatableMarkup('Weight for new file'),
'#title_display' => 'invisible',
'#delta' => $count,
'#default_value' => $delta,
];
}
else {
// The title needs to be assigned to the upload field so that validation
// errors include the correct widget label.
$element[$key]['#title'] = $element['#title'];
$element[$key]['_weight'] = [
'#type' => 'hidden',
'#default_value' => $delta,
];
}
}
// Add a new wrapper around all the elements for Ajax replacement.
$element['#prefix'] = '<div id="' . $element['#id'] . '-ajax-wrapper">';
$element['#suffix'] = '</div>';
return $element;
}
/**
* Retrieves the file description from a field element.
*
* This helper static method is used by processMultiple() method.
*
* @param array $element
* An associative array with the element being processed.
*
* @return array|false
* A description of the file suitable for use in the administrative
* interface.
*/
protected static function getDescriptionFromElement($element) {
// Use the actual file description, if it's available.
if (!empty($element['#default_value']['description'])) {
return $element['#default_value']['description'];
}
// Otherwise, fall back to the filename.
if (!empty($element['#default_value']['filename'])) {
return $element['#default_value']['filename'];
}
// This is probably a newly uploaded file; no description is available.
return FALSE;
}
/**
* Form submission handler for upload/remove button of formElement().
*
* This runs in addition to and after file_managed_file_submit().
*
* @see file_managed_file_submit()
*/
public static function submit($form, FormStateInterface $form_state) {
// During the form rebuild, formElement() will create field item widget
// elements using re-indexed deltas, so clear out FormState::$input to
// avoid a mismatch between old and new deltas. The rebuilt elements will
// have #default_value set appropriately for the current state of the field,
// so nothing is lost in doing this.
$button = $form_state->getTriggeringElement();
$parents = array_slice($button['#parents'], 0, -2);
NestedArray::setValue($form_state->getUserInput(), $parents, NULL);
// Go one level up in the form, to the widgets container.
$element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
$field_name = $element['#field_name'];
$parents = $element['#field_parents'];
$submitted_values = NestedArray::getValue($form_state->getValues(), array_slice($button['#parents'], 0, -2));
foreach ($submitted_values as $delta => $submitted_value) {
if (empty($submitted_value['fids'])) {
unset($submitted_values[$delta]);
}
}
// If there are more files uploaded via the same widget, we have to separate
// them, as we display each file in its own widget.
$new_values = [];
foreach ($submitted_values as $delta => $submitted_value) {
if (is_array($submitted_value['fids'])) {
foreach ($submitted_value['fids'] as $fid) {
$new_value = $submitted_value;
$new_value['fids'] = [$fid];
$new_values[] = $new_value;
}
}
else {
$new_value = $submitted_value;
}
}
// Re-index deltas after removing empty items.
$submitted_values = array_values($new_values);
// Update form_state values.
NestedArray::setValue($form_state->getValues(), array_slice($button['#parents'], 0, -2), $submitted_values);
// Update items.
$field_state = static::getWidgetState($parents, $field_name, $form_state);
$field_state['items'] = $submitted_values;
static::setWidgetState($parents, $field_name, $form_state, $field_state);
}
/**
* {@inheritdoc}
*/
public function flagErrors(FieldItemListInterface $items, ConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
// Never flag validation errors for the remove button.
$clicked_button = end($form_state->getTriggeringElement()['#parents']);
if ($clicked_button !== 'remove_button') {
parent::flagErrors($items, $violations, $form, $form_state);
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\file\FileInterface;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Provides a base class for file constraint validators.
*/
abstract class BaseFileConstraintValidator extends ConstraintValidator {
/**
* Checks the value is of type FileInterface.
*
* @param mixed $value
* The value to check.
*
* @return \Drupal\file\FileInterface
* The file.
*
* @throw Symfony\Component\Validator\Exception\UnexpectedTypeException
* Thrown if the value is not a FileInterface.
*/
protected function assertValueIsFile(mixed $value): FileInterface {
if (!$value instanceof FileInterface) {
throw new UnexpectedTypeException($value, FileInterface::class);
}
return $value;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* File extension constraint.
*/
#[Constraint(
id: 'FileExtension',
label: new TranslatableMarkup('File Extension', [], ['context' => 'Validation']),
type: 'file'
)]
class FileExtensionConstraint extends SymfonyConstraint {
/**
* The error message.
*
* @var string
*/
public string $message = 'Only files with the following extensions are allowed: %files-allowed.';
/**
* The allowed file extensions.
*
* @var string
*/
public string $extensions;
/**
* {@inheritdoc}
*/
public function getDefaultOption(): string {
return 'extensions';
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validates the file extension constraint.
*/
class FileExtensionConstraintValidator extends BaseFileConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint) {
$file = $this->assertValueIsFile($value);
if (!$constraint instanceof FileExtensionConstraint) {
throw new UnexpectedTypeException($constraint, FileExtensionConstraint::class);
}
$extensions = $constraint->extensions;
$regex = '/\.(' . preg_replace('/ +/', '|', preg_quote($extensions)) . ')$/i';
// Filename may differ from the basename, for instance in case files
// migrated from D7 file entities. Because of that new files are saved
// temporarily with a generated file name, without the original extension,
// we will use the generated filename property for extension validation only
// in case of temporary files; and use the file system file name in case of
// permanent files.
$subject = $file->isTemporary() ? $file->getFilename() : $file->getFileUri();
if (!preg_match($regex, $subject)) {
$this->context->addViolation($constraint->message, ['%files-allowed' => $extensions]);
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* File extension secure constraint.
*/
#[Constraint(
id: 'FileExtensionSecure',
label: new TranslatableMarkup('File Extension Secure', [], ['context' => 'Validation']),
type: 'file'
)]
class FileExtensionSecureConstraint extends SymfonyConstraint {
/**
* The error message.
*
* @var string
*/
public string $message = 'For security reasons, your upload has been rejected.';
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\File\FileSystemInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validator for the FileExtensionSecureConstraint.
*/
class FileExtensionSecureConstraintValidator extends BaseFileConstraintValidator implements ContainerInjectionInterface {
/**
* Creates a new FileExtensionSecureConstraintValidator.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('config.factory'));
}
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint) {
$file = $this->assertValueIsFile($value);
if (!$constraint instanceof FileExtensionSecureConstraint) {
throw new UnexpectedTypeException($constraint, FileExtensionSecureConstraint::class);
}
$allowInsecureUploads = $this->configFactory->get('system.file')->get('allow_insecure_uploads');
if (!$allowInsecureUploads && preg_match(FileSystemInterface::INSECURE_EXTENSION_REGEX, $file->getFilename())) {
$this->context->addViolation($constraint->message);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* File extension dimensions constraint.
*/
#[Constraint(
id: 'FileImageDimensions',
label: new TranslatableMarkup('File Image Dimensions', [], ['context' => 'Validation']),
type: 'file'
)]
class FileImageDimensionsConstraint extends SymfonyConstraint {
/**
* The minimum dimensions.
*
* @var string|int
*/
public string | int $minDimensions = 0;
/**
* The maximum dimensions.
*
* @var string|int
*/
public string | int $maxDimensions = 0;
/**
* The resized image too small message.
*
* @var string
*/
public string $messageResizedImageTooSmall = 'The resized image is too small. The minimum dimensions are %dimensions pixels and after resizing, the image size will be %widthx%height pixels.';
/**
* The image too small message.
*
* @var string
*/
public string $messageImageTooSmall = 'The image is too small. The minimum dimensions are %dimensions pixels and the image size is %widthx%height pixels.';
/**
* The resize failed message.
*
* @var string
*/
public string $messageResizeFailed = 'The image exceeds the maximum allowed dimensions and an attempt to resize it failed.';
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validator for the FileImageDimensionsConstraint.
*
* This validator will resize the image if exceeds the limits.
*/
class FileImageDimensionsConstraintValidator extends BaseFileConstraintValidator implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* Creates a new FileImageDimensionsConstraintValidator.
*
* @param \Drupal\Core\Image\ImageFactory $imageFactory
* The image factory.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger.
*/
public function __construct(
protected ImageFactory $imageFactory,
protected MessengerInterface $messenger,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('image.factory'),
$container->get('messenger'),
);
}
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint) {
$file = $this->assertValueIsFile($value);
if (!$constraint instanceof FileImageDimensionsConstraint) {
throw new UnexpectedTypeException($constraint, FileImageDimensionsConstraint::class);
}
$image = $this->imageFactory->get($file->getFileUri());
if (!$image->isValid()) {
return;
}
$scaling = FALSE;
$maxDimensions = $constraint->maxDimensions;
if ($maxDimensions) {
// Check that it is smaller than the given dimensions.
[$width, $height] = explode('x', $maxDimensions);
if ($image->getWidth() > $width || $image->getHeight() > $height) {
// Try to resize the image to fit the dimensions.
if ($image->scale($width, $height)) {
$scaling = TRUE;
$image->save();
if (!empty($width) && !empty($height)) {
$this->messenger->addStatus($this->t('The image was resized to fit within the maximum allowed dimensions of %dimensions pixels. The new dimensions of the resized image are %new_widthx%new_height pixels.',
[
'%dimensions' => $maxDimensions,
'%new_width' => $image->getWidth(),
'%new_height' => $image->getHeight(),
]));
}
elseif (empty($width)) {
$this->messenger->addStatus($this->t('The image was resized to fit within the maximum allowed height of %height pixels. The new dimensions of the resized image are %new_widthx%new_height pixels.',
[
'%height' => $height,
'%new_width' => $image->getWidth(),
'%new_height' => $image->getHeight(),
]));
}
elseif (empty($height)) {
$this->messenger->addStatus($this->t('The image was resized to fit within the maximum allowed width of %width pixels. The new dimensions of the resized image are %new_widthx%new_height pixels.',
[
'%width' => $width,
'%new_width' => $image->getWidth(),
'%new_height' => $image->getHeight(),
]));
}
}
else {
$this->context->addViolation($constraint->messageResizeFailed);
}
}
}
$minDimensions = $constraint->minDimensions;
if ($minDimensions) {
// Check that it is larger than the given dimensions.
[$width, $height] = explode('x', $minDimensions);
if ($image->getWidth() < $width || $image->getHeight() < $height) {
if ($scaling) {
$this->context->addViolation($constraint->messageResizedImageTooSmall,
[
'%dimensions' => $minDimensions,
'%width' => $image->getWidth(),
'%height' => $image->getHeight(),
]);
return;
}
$this->context->addViolation($constraint->messageImageTooSmall,
[
'%dimensions' => $minDimensions,
'%width' => $image->getWidth(),
'%height' => $image->getHeight(),
]);
}
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* File is image constraint.
*/
#[Constraint(
id: 'FileIsImage',
label: new TranslatableMarkup('File Is Image', [], ['context' => 'Validation']),
type: 'file'
)]
class FileIsImageConstraint extends SymfonyConstraint {
/**
* The error message.
*
* @var string
*/
public string $message = 'The image file is invalid or the image type is not allowed. Allowed types: %types';
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Image\ImageFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validator for the FileIsImageConstraint.
*/
class FileIsImageConstraintValidator extends BaseFileConstraintValidator implements ContainerInjectionInterface {
/**
* Creates a new FileIsImageConstraintValidator.
*
* @param \Drupal\Core\Image\ImageFactory $imageFactory
* The image factory.
*/
public function __construct(
protected ImageFactory $imageFactory,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('image.factory'));
}
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint) {
$file = $this->assertValueIsFile($value);
if (!$constraint instanceof FileIsImageConstraint) {
throw new UnexpectedTypeException($constraint, FileIsImageConstraint::class);
}
$image = $this->imageFactory->get($file->getFileUri());
if (!$image->isValid()) {
$supportedExtensions = $this->imageFactory->getSupportedExtensions();
$this->context->addViolation($constraint->message, ['%types' => implode(', ', $supportedExtensions)]);
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* File name length constraint.
*/
#[Constraint(
id: 'FileNameLength',
label: new TranslatableMarkup('File Name Length', [], ['context' => 'Validation']),
type: 'file'
)]
class FileNameLengthConstraint extends SymfonyConstraint {
/**
* The maximum file name length.
*
* @var int
*/
public int $maxLength = 240;
/**
* The message when file name is empty.
*
* @var string
*/
public string $messageEmpty = "The file's name is empty. Enter a name for the file.";
/**
* The message when file name is too long.
*
* @var string
*/
public string $messageTooLong = "The file's name exceeds the %maxLength characters limit. Rename the file and try again.";
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validates the file name length constraint.
*/
class FileNameLengthConstraintValidator extends BaseFileConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint) {
$file = $this->assertValueIsFile($value);
if (!$constraint instanceof FileNameLengthConstraint) {
throw new UnexpectedTypeException($constraint, FileNameLengthConstraint::class);
}
if (!$file->getFilename()) {
$this->context->addViolation($constraint->messageEmpty);
}
if (mb_strlen($file->getFilename()) > $constraint->maxLength) {
$this->context->addViolation($constraint->messageTooLong, [
'%maxLength' => $constraint->maxLength,
]);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* File size max constraint.
*/
#[Constraint(
id: 'FileSizeLimit',
label: new TranslatableMarkup('File Size Limit', [], ['context' => 'Validation']),
type: 'file'
)]
class FileSizeLimitConstraint extends SymfonyConstraint {
/**
* The message for when file size limit is exceeded.
*
* @var string
*/
public string $maxFileSizeMessage = 'The file is %filesize exceeding the maximum file size of %maxsize.';
/**
* The message for when disk quota is exceeded.
*
* @var string
*/
public string $diskQuotaMessage = 'The file is %filesize which would exceed your disk quota of %quota.';
/**
* The file limit.
*
* @var int
*/
public int $fileLimit = 0;
/**
* The user limit.
*
* @var int
*/
public int $userLimit = 0;
/**
* {@inheritdoc}
*/
public function getDefaultOption(): ?string {
return 'fileLimit';
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validates the FileSizeLimitConstraint.
*/
class FileSizeLimitConstraintValidator extends BaseFileConstraintValidator implements ContainerInjectionInterface {
/**
* Creates a new FileSizeConstraintValidator.
*
* @param \Drupal\Core\Session\AccountInterface $currentUser
* The current user.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
*/
public function __construct(
protected AccountInterface $currentUser,
protected EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_user'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint): void {
$file = $this->assertValueIsFile($value);
if (!$constraint instanceof FileSizeLimitConstraint) {
throw new UnexpectedTypeException($constraint, FileSizeLimitConstraint::class);
}
$fileLimit = $constraint->fileLimit;
if ($fileLimit && $file->getSize() > $fileLimit) {
$this->context->addViolation($constraint->maxFileSizeMessage, [
'%filesize' => ByteSizeMarkup::create($file->getSize()),
'%maxsize' => ByteSizeMarkup::create($fileLimit),
]);
}
$userLimit = $constraint->userLimit;
// Save a query by only calling spaceUsed() when a limit is provided.
if ($userLimit) {
/** @var \Drupal\file\FileStorageInterface $fileStorage */
$fileStorage = $this->entityTypeManager->getStorage('file');
$spaceUsed = $fileStorage->spaceUsed($this->currentUser->id()) + $file->getSize();
if ($spaceUsed > $userLimit) {
$this->context->addViolation($constraint->diskQuotaMessage, [
'%filesize' => ByteSizeMarkup::create($file->getSize()),
'%quota' => ByteSizeMarkup::create($userLimit),
]);
}
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Supports validating file URIs.
*/
#[Constraint(
id: 'FileUriUnique',
label: new TranslatableMarkup('File URI', [], ['context' => 'Validation'])
)]
class FileUriUnique extends SymfonyConstraint {
public $message = 'The file %value already exists. Enter a unique file URI.';
/**
* {@inheritdoc}
*/
public function validatedBy() {
return '\Drupal\Core\Validation\Plugin\Validation\Constraint\UniqueFieldValueValidator';
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Validation File constraint.
*/
#[Constraint(
id: 'FileValidation',
label: new TranslatableMarkup('File Validation', [], ['context' => 'Validation'])
)]
class FileValidationConstraint extends SymfonyConstraint {
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Component\Utility\Bytes;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\file\Validation\FileValidatorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks that a file referenced in a file field is valid.
*/
class FileValidationConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* Creates a new FileValidationConstraintValidator.
*
* @param \Drupal\file\Validation\FileValidatorInterface $fileValidator
* The file validator.
*/
public function __construct(
protected FileValidatorInterface $fileValidator,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): self {
return new static($container->get('file.validator'));
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
// Get the file to execute validators.
$target = $value->get('entity')->getTarget();
if (!$target) {
return;
}
$file = $target->getValue();
// Get the validators.
$validators = $value->getUploadValidators();
// Always respect the configured maximum file size.
$field_settings = $value->getFieldDefinition()->getSettings();
if (array_key_exists('max_filesize', $field_settings)) {
$validators['FileSizeLimit'] = ['fileLimit' => Bytes::toNumber($field_settings['max_filesize'])];
}
else {
// Do not validate the file size if it is not set explicitly.
unset($validators['FileSizeLimit']);
}
// Checks that a file meets the criteria specified by the validators.
if ($violations = $this->fileValidator->validate($file, $validators)) {
foreach ($violations as $violation) {
$this->context->addViolation($violation->getMessage());
}
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Drupal\file\Plugin\migrate\destination;
use Drupal\Core\Field\Plugin\Field\FieldType\UriItem;
use Drupal\migrate\Attribute\MigrateDestination;
use Drupal\migrate\Row;
use Drupal\migrate\MigrateException;
use Drupal\migrate\Plugin\migrate\destination\EntityContentBase;
/**
* Provides migrate destination plugin for File entities.
*/
#[MigrateDestination('entity:file')]
class EntityFile extends EntityContentBase {
/**
* {@inheritdoc}
*/
protected function getEntity(Row $row, array $old_destination_id_values) {
// For stub rows, there is no real file to deal with, let the stubbing
// process take its default path.
if ($row->isStub()) {
return parent::getEntity($row, $old_destination_id_values);
}
// By default the entity key (fid) would be used, but we want to make sure
// we're loading the matching URI.
$destination = $row->getDestinationProperty('uri');
if (empty($destination)) {
throw new MigrateException('Destination property uri not provided');
}
$entity = $this->storage->loadByProperties(['uri' => $destination]);
if ($entity) {
return reset($entity);
}
else {
return parent::getEntity($row, $old_destination_id_values);
}
}
/**
* {@inheritdoc}
*/
protected function processStubRow(Row $row) {
// We stub the uri value ourselves so we can create a real stub file for it.
if (!$row->getDestinationProperty('uri')) {
$field_definitions = $this->entityFieldManager
->getFieldDefinitions($this->storage->getEntityTypeId(),
$this->getKey('bundle'));
$value = UriItem::generateSampleValue($field_definitions['uri']);
if (empty($value)) {
throw new MigrateException('Stubbing failed, unable to generate value for field uri');
}
// generateSampleValue() wraps the value in an array.
$value = reset($value);
// Make it into a proper public file uri, stripping off the existing
// scheme if present.
$value = 'public://' . preg_replace('|^[a-z]+://|i', '', $value);
$value = mb_substr($value, 0, $field_definitions['uri']->getSetting('max_length'));
// Create a real file, so File::preSave() can do filesize() on it.
touch($value);
$row->setDestinationProperty('uri', $value);
}
parent::processStubRow($row);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Drupal\file\Plugin\migrate\field\d6;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Drupal\migrate_drupal\Attribute\MigrateField;
use Drupal\migrate_drupal\Plugin\migrate\field\FieldPluginBase;
// cspell:ignore filefield imagefield imagelink nodelink
/**
* MigrateField Plugin for Drupal 6 file fields.
*/
#[MigrateField(
id: 'filefield',
core: [6],
source_module: 'filefield',
destination_module: 'file',
)]
class FileField extends FieldPluginBase {
/**
* {@inheritdoc}
*/
public function getFieldWidgetMap() {
return [
'filefield_widget' => 'file_generic',
];
}
/**
* {@inheritdoc}
*/
public function getFieldFormatterMap() {
return [
'default' => 'file_default',
'url_plain' => 'file_url_plain',
'path_plain' => 'file_url_plain',
'image_plain' => 'image',
'image_nodelink' => 'image',
'image_imagelink' => 'image',
];
}
/**
* {@inheritdoc}
*/
public function defineValueProcessPipeline(MigrationInterface $migration, $field_name, $data) {
$process = [
'plugin' => 'd6_field_file',
'source' => $field_name,
];
$migration->mergeProcessOfProperty($field_name, $process);
}
/**
* {@inheritdoc}
*/
public function getFieldType(Row $row) {
return $row->getSourceProperty('widget_type') == 'imagefield_widget' ? 'image' : 'file';
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Drupal\file\Plugin\migrate\field\d7;
use Drupal\file\Plugin\migrate\field\d6\FileField as D6FileField;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate_drupal\Attribute\MigrateField;
// cspell:ignore filefield
/**
* MigrateField Plugin for Drupal 7 file fields.
*/
#[MigrateField(
id: 'file',
core: [7],
source_module: 'file',
destination_module: 'file',
)]
class FileField extends D6FileField {
/**
* {@inheritdoc}
*/
public function getFieldWidgetMap() {
return [
'file_mfw' => 'file_generic',
'filefield_widget' => 'file_generic',
];
}
/**
* {@inheritdoc}
*/
public function defineValueProcessPipeline(MigrationInterface $migration, $field_name, $data) {
$process = [
'plugin' => 'sub_process',
'source' => $field_name,
'process' => [
'target_id' => 'fid',
'display' => 'display',
'description' => 'description',
],
];
$migration->mergeProcessOfProperty($field_name, $process);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Drupal\file\Plugin\migrate\process\d6;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateLookupInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Attribute\MigrateProcess;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
#[MigrateProcess('d6_field_file')]
class FieldFile extends ProcessPluginBase implements ContainerFactoryPluginInterface {
/**
* The current migration.
*/
protected MigrationInterface $migration;
/**
* The migrate lookup service.
*
* @var \Drupal\migrate\MigrateLookupInterface
*/
protected $migrateLookup;
/**
* Constructs a FieldFile plugin instance.
*
* @param array $configuration
* The plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\migrate\Plugin\MigrationInterface $migration
* The current migration.
* @param \Drupal\migrate\MigrateLookupInterface $migrate_lookup
* The migrate lookup service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, MigrateLookupInterface $migrate_lookup) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->migration = $migration;
$this->migrateLookup = $migrate_lookup;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, ?MigrationInterface $migration = NULL) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
$container->get('migrate.lookup')
);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$options = unserialize($value['data']);
// Try to look up the ID of the migrated file. If one cannot be found, it
// means the file referenced by the current field item did not migrate for
// some reason -- file migration is notoriously brittle -- and we do NOT
// want to send invalid file references into the field system (it causes
// fatal errors), so return an empty item instead.
$lookup_result = $this->migrateLookup->lookup('d6_file', [$value['fid']]);
if ($lookup_result) {
return [
'target_id' => $lookup_result[0]['fid'],
'display' => $value['list'],
'description' => $options['description'] ?? '',
'alt' => $options['alt'] ?? '',
'title' => $options['title'] ?? '',
];
}
else {
return [];
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\file\Plugin\migrate\process\d6;
use Drupal\migrate\Attribute\MigrateProcess;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Process the file URL into a D8 compatible URL.
*/
#[MigrateProcess('file_uri')]
class FileUri extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
// If we're stubbing a file entity, return a uri of NULL so it will get
// stubbed by the general process.
if ($row->isStub()) {
return NULL;
}
[$filepath, $file_directory_path, $temp_directory_path, $is_public] = $value;
// Specific handling using $temp_directory_path for temporary files.
if (str_starts_with($filepath, $temp_directory_path)) {
$uri = preg_replace('/^' . preg_quote($temp_directory_path, '/') . '/', '', $filepath);
return 'temporary://' . ltrim($uri, '/');
}
// Strip the files path from the uri instead of using basename
// so any additional folders in the path are preserved.
$uri = preg_replace('/^' . preg_quote($file_directory_path, '/') . '/', '', $filepath);
return ($is_public ? 'public' : 'private') . '://' . ltrim($uri, '/');
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace Drupal\file\Plugin\migrate\source\d6;
use Drupal\migrate\Row;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
/**
* Drupal 6 file source from database.
*
* Available configuration keys:
* - site_path: (optional) The path to the site directory relative to Drupal
* root. Defaults to 'sites/default'. This value is ignored if the
* 'file_directory_path' variable is set in the source Drupal database.
*
* Example:
*
* @code
* source:
* plugin: d6_file
* site_path: sites/example
* @endcode
*
* In this example, public file values are retrieved from the source database.
* The site path is specified because it's not the default one (sites/default).
* The final path to the public files will be "sites/example/files/", assuming
* the 'file_directory_path' variable is not set in the source database.
*
* For complete example, refer to the d6_file.yml migration.
*
* For additional configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
* @see d6_file.yml
*
* @MigrateSource(
* id = "d6_file",
* source_module = "system"
* )
*/
class File extends DrupalSqlBase {
/**
* The file directory path.
*
* @var string
*/
protected $filePath;
/**
* The temporary file path.
*
* @var string
*/
protected $tempFilePath;
/**
* Flag for private or public file storage.
*
* @var bool
*/
protected $isPublic;
/**
* {@inheritdoc}
*/
public function query() {
return $this->select('files', 'f')
->fields('f')
->condition('f.filepath', '/tmp%', 'NOT LIKE')
->orderBy('f.timestamp')
// If two or more files have the same timestamp, they'll end up in a
// non-deterministic order. Ordering by fid (or any other unique field)
// will prevent this.
->orderBy('f.fid');
}
/**
* {@inheritdoc}
*/
protected function initializeIterator() {
$site_path = $this->configuration['site_path'] ?? 'sites/default';
$this->filePath = $this->variableGet('file_directory_path', $site_path . '/files') . '/';
$this->tempFilePath = $this->variableGet('file_directory_temp', '/tmp') . '/';
// FILE_DOWNLOADS_PUBLIC == 1 and FILE_DOWNLOADS_PRIVATE == 2.
$this->isPublic = $this->variableGet('file_downloads', 1) == 1;
return parent::initializeIterator();
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
$row->setSourceProperty('file_directory_path', $this->filePath);
$row->setSourceProperty('temp_directory_path', $this->tempFilePath);
$row->setSourceProperty('is_public', $this->isPublic);
return parent::prepareRow($row);
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'fid' => $this->t('File ID'),
'uid' => $this->t('The {users}.uid who added the file. If set to 0, this file was added by an anonymous user.'),
'filename' => $this->t('File name'),
'filepath' => $this->t('File path'),
'filemime' => $this->t('File MIME Type'),
'status' => $this->t('The published status of a file.'),
'timestamp' => $this->t('The time that the file was added.'),
'file_directory_path' => $this->t('The Drupal files path.'),
'is_public' => $this->t('TRUE if the files directory is public otherwise FALSE.'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['fid']['type'] = 'integer';
$ids['fid']['alias'] = 'f';
return $ids;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Drupal\file\Plugin\migrate\source\d6;
use Drupal\migrate\Row;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
/**
* Drupal 6 upload source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "d6_upload",
* source_module = "upload"
* )
*/
class Upload extends DrupalSqlBase {
/**
* The join options between the node and the upload table.
*/
const JOIN = '[n].[nid] = [u].[nid] AND [n].[vid] = [u].[vid]';
/**
* {@inheritdoc}
*/
public function query() {
$query = $this->select('upload', 'u')
->distinct()
->fields('u', ['nid', 'vid']);
$query->innerJoin('node', 'n', static::JOIN);
$query->addField('n', 'type');
$query->addField('n', 'language');
return $query;
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
$query = $this->select('upload', 'u')
->fields('u', ['fid', 'description', 'list'])
->condition('u.nid', $row->getSourceProperty('nid'))
->orderBy('u.weight');
$query->innerJoin('node', 'n', static::JOIN);
$row->setSourceProperty('upload', $query->execute()->fetchAll());
return parent::prepareRow($row);
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'fid' => $this->t('The file Id.'),
'nid' => $this->t('The node Id.'),
'vid' => $this->t('The version Id.'),
'type' => $this->t('The node type'),
'language' => $this->t('The node language.'),
'description' => $this->t('The file description.'),
'list' => $this->t('Whether the list should be visible on the node page.'),
'weight' => $this->t('The file weight.'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['vid']['type'] = 'integer';
$ids['vid']['alias'] = 'u';
return $ids;
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Drupal\file\Plugin\migrate\source\d6;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
use Drupal\migrate\Plugin\migrate\source\DummyQueryTrait;
// cspell:ignore uploadsize
/**
* Drupal 6 upload instance source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "d6_upload_instance",
* source_module = "upload"
* )
*/
class UploadInstance extends DrupalSqlBase {
use DummyQueryTrait;
/**
* {@inheritdoc}
*/
protected function initializeIterator() {
$node_types = $this->select('node_type', 'nt')
->fields('nt', ['type'])
->execute()
->fetchCol();
$variables = array_map(function ($type) {
return 'upload_' . $type;
}, $node_types);
$max_filesize = $this->variableGet('upload_uploadsize_default', 1);
$max_filesize = $max_filesize ? $max_filesize . 'MB' : '';
$file_extensions = $this->variableGet('upload_extensions_default', 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp');
$return = [];
$values = $this->select('variable', 'v')
->fields('v', ['name', 'value'])
->condition('v.name', $variables, 'IN')
->execute()
->fetchAllKeyed();
foreach ($node_types as $node_type) {
$name = 'upload_' . $node_type;
// By default, file attachments in D6 are enabled unless upload_<type> is
// false, so include types where the upload-variable is not set.
$enabled = !isset($values[$name]) || unserialize($values[$name]);
if ($enabled) {
$return[$node_type]['node_type'] = $node_type;
$return[$node_type]['max_filesize'] = $max_filesize;
$return[$node_type]['file_extensions'] = $file_extensions;
}
}
return new \ArrayIterator($return);
}
/**
* {@inheritdoc}
*/
public function getIds() {
return [
'node_type' => [
'type' => 'string',
],
];
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'node_type' => $this->t('Node type'),
'max_filesize' => $this->t('Max filesize'),
'file_extensions' => $this->t('File extensions'),
];
}
/**
* {@inheritdoc}
*/
protected function doCount() {
return count($this->initializeIterator());
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Drupal\file\Plugin\migrate\source\d7;
use Drupal\migrate\Row;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
/**
* Drupal 7 file source from database.
*
* Available configuration keys:
* - scheme: (optional) The scheme of the files to get from the source, for
* example, 'public' or 'private'. Can be a string or an array of schemes.
* The 'temporary' scheme is not supported. If omitted, all files in
* supported schemes are retrieved.
*
* Example:
*
* @code
* source:
* plugin: d7_file
* scheme: public
* @endcode
*
* In this example, public file values are retrieved from the source database.
* For complete example, refer to the d7_file.yml migration.
*
* For additional configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
* @see d7_file.yml
*
* @MigrateSource(
* id = "d7_file",
* source_module = "file"
* )
*/
class File extends DrupalSqlBase {
/**
* The public file directory path.
*
* @var string
*/
protected $publicPath;
/**
* The private file directory path, if any.
*
* @var string
*/
protected $privatePath;
/**
* {@inheritdoc}
*/
public function query() {
$query = $this->select('file_managed', 'f')
->fields('f')
->condition('f.uri', 'temporary://%', 'NOT LIKE')
->orderBy('f.timestamp');
// Filter by scheme(s), if configured.
if (isset($this->configuration['scheme'])) {
$schemes = [];
// Remove 'temporary' scheme.
$valid_schemes = array_diff((array) $this->configuration['scheme'], ['temporary']);
// Accept either a single scheme, or a list.
foreach ((array) $valid_schemes as $scheme) {
$schemes[] = rtrim($scheme) . '://';
}
$schemes = array_map([$this->getDatabase(), 'escapeLike'], $schemes);
// Add conditions, uri LIKE 'public://%' OR uri LIKE 'private://%'.
$conditions = $this->getDatabase()->condition('OR');
foreach ($schemes as $scheme) {
$conditions->condition('f.uri', $scheme . '%', 'LIKE');
}
$query->condition($conditions);
}
return $query;
}
/**
* {@inheritdoc}
*/
protected function initializeIterator() {
$this->publicPath = $this->variableGet('file_public_path', 'sites/default/files');
$this->privatePath = $this->variableGet('file_private_path', NULL);
return parent::initializeIterator();
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
// Compute the filepath property, which is a physical representation of
// the URI relative to the Drupal root.
$path = str_replace(['public:/', 'private:/'], [$this->publicPath, $this->privatePath], $row->getSourceProperty('uri'));
// At this point, $path could be an absolute path or a relative path,
// depending on how the scheme's variable was set. So we need to shear out
// the source_base_path in order to make them all relative.
$path = preg_replace('#' . preg_quote($this->configuration['constants']['source_base_path']) . '#', '', $path, 1);
$row->setSourceProperty('filepath', $path);
return parent::prepareRow($row);
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'fid' => $this->t('File ID'),
'uid' => $this->t('The {users}.uid who added the file. If set to 0, this file was added by an anonymous user.'),
'filename' => $this->t('File name'),
'filepath' => $this->t('File path'),
'filemime' => $this->t('File MIME Type'),
'status' => $this->t('The published status of a file.'),
'timestamp' => $this->t('The time that the file was added.'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['fid']['type'] = 'integer';
$ids['fid']['alias'] = 'f';
return $ids;
}
}

View File

@@ -0,0 +1,450 @@
<?php
namespace Drupal\file\Plugin\rest\resource;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\Exception\FileExistsException;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\File\MimeType\MimeTypeGuesser;
use Drupal\Core\Lock\LockAcquiringException;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Utility\Token;
use Drupal\file\Entity\File;
use Drupal\file\Upload\ContentDispositionFilenameParser;
use Drupal\file\Upload\FileUploadHandler;
use Drupal\file\Upload\FileUploadLocationTrait;
use Drupal\file\Upload\InputStreamFileWriterInterface;
use Drupal\file\Upload\InputStreamUploadedFile;
use Drupal\file\Validation\FileValidatorInterface;
use Drupal\file\Validation\FileValidatorSettingsTrait;
use Drupal\rest\Attribute\RestResource;
use Drupal\rest\ModifiedResourceResponse;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait;
use Drupal\rest\RequestHandler;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\File\Exception\CannotWriteFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
use Symfony\Component\HttpFoundation\File\Exception\UploadException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Route;
/**
* File upload resource.
*
* This is implemented as a field-level resource for the following reasons:
* - Validation for uploaded files is tied to fields (allowed extensions, max
* size, etc..).
* - The actual files do not need to be stored in another temporary location,
* to be later moved when they are referenced from a file field.
* - Permission to upload a file can be determined by a users field level
* create access to the file field.
*/
#[RestResource(
id: "file:upload",
label: new TranslatableMarkup("File Upload"),
serialization_class: File::class,
uri_paths: [
"create" => "/file/upload/{entity_type_id}/{bundle}/{field_name}",
]
)]
class FileUploadResource extends ResourceBase {
use DeprecatedServicePropertyTrait;
use FileValidatorSettingsTrait;
use EntityResourceValidationTrait {
validate as resourceValidate;
}
use FileUploadLocationTrait {
getUploadLocation as getUploadDestination;
}
/**
* The regex used to extract the filename from the content disposition header.
*
* @var string
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\ContentDispositionFilenameParser::REQUEST_HEADER_FILENAME_REGEX
* instead.
*
* @see https://www.drupal.org/node/3380380
*/
const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
/**
* The amount of bytes to read in each iteration when streaming file data.
*
* @var int
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\InputStreamFileWriterInterface::DEFAULT_BYTES_TO_READ
* instead.
*
* @see https://www.drupal.org/node/3380607
*/
const BYTES_TO_READ = 8192;
/**
* {@inheritdoc}
*/
protected array $deprecatedProperties = [
'currentUser' => 'current_user',
'mimeTypeGuesser' => 'mime_type.guesser',
'token' => 'token',
'lock' => 'lock',
'eventDispatcher' => 'event_dispatcher',
];
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
$serializer_formats,
LoggerInterface $logger,
protected FileSystemInterface $fileSystem,
protected EntityTypeManagerInterface $entityTypeManager,
protected EntityFieldManagerInterface $entityFieldManager,
protected FileValidatorInterface | AccountInterface $fileValidator,
protected InputStreamFileWriterInterface | MimeTypeGuesser $inputStreamFileWriter,
protected FileUploadHandler | Token $fileUploadHandler,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
if (!$fileValidator instanceof FileValidatorInterface) {
@trigger_error('Passing a \Drupal\Core\Session\AccountInterface to ' . __METHOD__ . '() as argument 9 is deprecated in drupal:10.3.0 and will be removed before drupal:11.0.0. Pass a \Drupal\file\Validation\FileValidatorInterface instead. See https://www.drupal.org/node/3402032', E_USER_DEPRECATED);
$this->fileValidator = \Drupal::service('file.validator');
}
if (!$inputStreamFileWriter instanceof InputStreamFileWriterInterface) {
@trigger_error('Passing a \Drupal\Core\File\MimeType\MimeTypeGuesser to ' . __METHOD__ . '() as argument 10 is deprecated in drupal:10.3.0 and will be removed before drupal:11.0.0. Pass an \Drupal\file\Upload\InputStreamFileWriterInterface instead. See https://www.drupal.org/node/3402032', E_USER_DEPRECATED);
$this->inputStreamFileWriter = \Drupal::service('file.input_stream_file_writer');
}
if (!$fileUploadHandler instanceof FileUploadHandler) {
@trigger_error('Passing a \Drupal\Core\Utility\Token to ' . __METHOD__ . '() as argument 11 is deprecated in drupal:10.3.0 and will be removed before drupal:11.0.0. Pass an \Drupal\file\Upload\FileUploadHandler instead. See https://www.drupal.org/node/3402032', E_USER_DEPRECATED);
$this->fileUploadHandler = \Drupal::service('file.upload_handler');
}
}
/**
* {@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('file.validator'),
$container->get('file.input_stream_file_writer'),
$container->get('file.upload_handler'),
);
}
/**
* {@inheritdoc}
*/
public function permissions() {
// Access to this resource depends on field-level access so no explicit
// permissions are required.
// @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validateAndLoadFieldDefinition()
// @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
return [];
}
/**
* Creates a file from an endpoint.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
* @param string $entity_type_id
* The entity type ID.
* @param string $bundle
* The entity bundle. This will be the same as $entity_type_id for entity
* types that don't support bundles.
* @param string $field_name
* The field name.
*
* @return \Drupal\rest\ModifiedResourceResponse
* A 201 response, on success.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
* Thrown when temporary files cannot be written, a lock cannot be acquired,
* or when temporary files cannot be moved to their new location.
*/
public function post(Request $request, $entity_type_id, $bundle, $field_name) {
$field_definition = $this->validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name);
$destination = $this->getUploadDestination($field_definition);
// Check the destination file path is writable.
if (!$this->fileSystem->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY)) {
throw new HttpException(500, 'Destination file path is not writable');
}
$settings = $field_definition->getSettings();
$validators = $this->getFileUploadValidators($settings);
if (!array_key_exists('FileExtension', $validators) && $settings['file_extensions'] === '') {
// An empty string means 'all file extensions' but the FileUploadHandler
// needs the FileExtension entry to be present and empty in order for this
// to be respected. An empty array means 'all file extensions'.
// @see \Drupal\file\Upload\FileUploadHandler::handleExtensionValidation
$validators['FileExtension'] = [];
}
try {
$filename = ContentDispositionFilenameParser::parseFilename($request);
$tempPath = $this->inputStreamFileWriter->writeStreamToFile();
$uploadedFile = new InputStreamUploadedFile($filename, $filename, $tempPath, @filesize($tempPath));
$result = $this->fileUploadHandler->handleFileUpload($uploadedFile, $validators, $destination, FileExists::Rename, FALSE);
}
catch (LockAcquiringException $e) {
throw new HttpException(503, $e->getMessage(), NULL, ['Retry-After' => 1]);
}
catch (UploadException $e) {
$this->logger->error('Input data could not be read');
throw new HttpException(500, 'Input file data could not be read', $e);
}
catch (CannotWriteFileException $e) {
$this->logger->error('Temporary file data for could not be written');
throw new HttpException(500, 'Temporary file data could not be written', $e);
}
catch (NoFileException $e) {
$this->logger->error('Temporary file could not be opened for file upload');
throw new HttpException(500, 'Temporary file could not be opened', $e);
}
catch (FileExistsException $e) {
throw new HttpException(statusCode: 500, message: $e->getMessage(), previous: $e);
}
catch (FileException $e) {
throw new HttpException(500, 'Temporary file could not be moved to file location');
}
if ($result->hasViolations()) {
$message = "Unprocessable Entity: file validation failed.\n";
$errors = [];
foreach ($result->getViolations() as $violation) {
$errors[] = PlainTextOutput::renderFromHtml($violation->getMessage());
}
$message .= implode("\n", $errors);
throw new UnprocessableEntityHttpException($message);
}
// 201 Created responses return the newly created entity in the response
// body. These responses are not cacheable, so we add no cacheability
// metadata here.
return new ModifiedResourceResponse($result->getFile(), 201);
}
/**
* Streams file upload data to temporary file and moves to file destination.
*
* @return string
* The temp file path.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
* Thrown when input data cannot be read, the temporary file cannot be
* opened, or the temporary file cannot be written.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no
* replacement.
*
* @see https://www.drupal.org/node/3402032
*/
protected function streamUploadData(): string {
@\trigger_error('Calling ' . __METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3402032', E_USER_DEPRECATED);
// Catch and throw the exceptions that REST expects.
try {
$temp_file_path = $this->inputStreamFileWriter->writeStreamToFile();
}
catch (UploadException $e) {
$this->logger->error('Input data could not be read');
throw new HttpException(500, 'Input file data could not be read', $e);
}
catch (CannotWriteFileException $e) {
$this->logger->error('Temporary file data for could not be written');
throw new HttpException(500, 'Temporary file data could not be written', $e);
}
catch (NoFileException $e) {
$this->logger->error('Temporary file could not be opened for file upload');
throw new HttpException(500, 'Temporary file could not be opened', $e);
}
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
* Thrown when the 'Content-Disposition' request header is invalid.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename()
* instead.
*
* @see https://www.drupal.org/node/3380380
*/
protected function validateAndParseContentDispositionHeader(Request $request) {
@trigger_error('Calling ' . __METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename() instead. See https://www.drupal.org/node/3380380', E_USER_DEPRECATED);
return ContentDispositionFilenameParser::parseFilename($request);
}
/**
* 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
* The field definition.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Thrown when the field does not exist.
* @throws \Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException
* Thrown when the target type of the field is not a file, or the current
* user does not have 'edit' access for the field.
*/
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 NotFoundHttpException(sprintf('Field "%s" does not exist', $field_name));
}
/** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
$field_definition = $field_definitions[$field_name];
if ($field_definition->getSetting('target_type') !== 'file') {
throw new AccessDeniedHttpException(sprintf('"%s" is not a file field', $field_name));
}
$entity_access_control_handler = $this->entityTypeManager->getAccessControlHandler($entity_type_id);
$bundle = $this->entityTypeManager->getDefinition($entity_type_id)->hasKey('bundle') ? $bundle : NULL;
$access_result = $entity_access_control_handler->createAccess($bundle, NULL, [], TRUE)
->andIf($entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE));
if (!$access_result->isAllowed()) {
throw new AccessDeniedHttpException($access_result->getReason());
}
return $field_definition;
}
/**
* 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.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no
* replacement.
*
* @see https://www.drupal.org/node/3402032
* @see https://www.drupal.org/node/3402032
*/
protected function prepareFilename($filename, array &$validators) {
@\trigger_error('Calling ' . __METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3402032', E_USER_DEPRECATED);
$extensions = $validators['FileExtension']['extensions'] ?? '';
$event = new FileUploadSanitizeNameEvent($filename, $extensions);
// @phpstan-ignore-next-line
$this->eventDispatcher->dispatch($event);
return $event->getFilename();
}
/**
* 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.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\FileUploadLocationTrait::getUploadLocation() instead.
*
* @see https://www.drupal.org/node/3406099
*/
protected function getUploadLocation(array $settings) {
@\trigger_error(__METHOD__ . ' is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\file\Upload\FileUploadLocationTrait::getUploadLocation() instead. See https://www.drupal.org/node/3406099', E_USER_DEPRECATED);
$destination = trim($settings['file_directory'], '/');
// Replace tokens. As the tokens might contain HTML we convert it to plain
// text.
// @phpstan-ignore-next-line
$destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, []));
return $settings['uri_scheme'] . '://' . $destination;
}
/**
* {@inheritdoc}
*/
protected function getBaseRoute($canonical_path, $method) {
return new Route($canonical_path, [
'_controller' => RequestHandler::class . '::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';
return $requirements;
}
/**
* Generates a lock ID based on the file URI.
*
* @param $file_uri
* The file URI.
*
* @return string
* The generated lock ID.
*/
protected static function generateLockIdFromFileUri($file_uri) {
return 'file:rest:' . Crypt::hashBase64($file_uri);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Drupal\file\Plugin\views\argument;
use Drupal\views\Attribute\ViewsArgument;
use Drupal\views\Plugin\views\argument\EntityArgument;
/**
* Argument handler to accept multiple file ids.
*
* @ingroup views_argument_handlers
*/
#[ViewsArgument(
id: 'file_fid',
)]
class Fid extends EntityArgument {}

View File

@@ -0,0 +1,114 @@
<?php
namespace Drupal\file\Plugin\views\field;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\views\Attribute\ViewsField;
use Drupal\views\ResultRow;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Field handler to provide simple renderer that allows linking to a file.
*
* @ingroup views_field_handlers
*/
#[ViewsField("file")]
class File extends FieldPluginBase {
/**
* The file URL generator.
*
* @var \Drupal\Core\File\FileUrlGeneratorInterface
*/
protected $fileUrlGenerator;
/**
* Constructs a File object.
*
* @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 \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator
* The file URL generator.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FileUrlGeneratorInterface $file_url_generator) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->fileUrlGenerator = $file_url_generator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('file_url_generator'));
}
/**
* {@inheritdoc}
*/
public function init(ViewExecutable $view, DisplayPluginBase $display, ?array &$options = NULL) {
parent::init($view, $display, $options);
if (!empty($options['link_to_file'])) {
$this->additional_fields['uri'] = 'uri';
}
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['link_to_file'] = ['default' => FALSE];
return $options;
}
/**
* Provide link to file option.
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
$form['link_to_file'] = [
'#title' => $this->t('Link this field to download the file'),
'#description' => $this->t("Enable to override this field's links."),
'#type' => 'checkbox',
'#default_value' => !empty($this->options['link_to_file']),
];
parent::buildOptionsForm($form, $form_state);
}
/**
* Prepares link to the file.
*
* @param string $data
* The XSS safe string for the link text.
* @param \Drupal\views\ResultRow $values
* The values retrieved from a single row of a view's query result.
*
* @return string
* Returns a string for the link text.
*/
protected function renderLink($data, ResultRow $values) {
if (!empty($this->options['link_to_file']) && $data !== NULL && $data !== '') {
$this->options['alter']['make_link'] = TRUE;
$this->options['alter']['url'] = $this->fileUrlGenerator->generate($this->getValue($values, 'uri'));
}
return $data;
}
/**
* {@inheritdoc}
*/
public function render(ResultRow $values) {
$value = $this->getValue($values);
return $this->renderLink($this->sanitizeValue($value), $values);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\file\Plugin\views\filter;
use Drupal\file\FileInterface;
use Drupal\views\Attribute\ViewsFilter;
use Drupal\views\Plugin\views\filter\InOperator;
/**
* Filter by file status.
*
* @ingroup views_filter_handlers
*/
#[ViewsFilter("file_status")]
class Status extends InOperator {
public function getValueOptions() {
if (!isset($this->valueOptions)) {
$this->valueOptions = [
0 => $this->t('Temporary'),
FileInterface::STATUS_PERMANENT => $this->t('Permanent'),
];
}
return $this->valueOptions;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\file\Plugin\views\wizard;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\views\Attribute\ViewsWizard;
use Drupal\views\Plugin\views\wizard\WizardPluginBase;
/**
* Tests creating managed files views with the wizard.
*/
#[ViewsWizard(
id: 'file_managed',
title: new TranslatableMarkup('Files'),
base_table: 'file_managed'
)]
class File extends WizardPluginBase {
/**
* Set the created column.
*
* @var string
*/
protected $createdColumn = 'created';
/**
* {@inheritdoc}
*/
protected function defaultDisplayOptions() {
$display_options = parent::defaultDisplayOptions();
// Add permission-based access control.
$display_options['access']['type'] = 'perm';
// Remove the default fields, since we are customizing them here.
unset($display_options['fields']);
/* Field: File: Name */
$display_options['fields']['filename']['id'] = 'filename';
$display_options['fields']['filename']['table'] = 'file_managed';
$display_options['fields']['filename']['field'] = 'filename';
$display_options['fields']['filename']['entity_type'] = 'file';
$display_options['fields']['filename']['entity_field'] = 'filename';
$display_options['fields']['filename']['label'] = '';
$display_options['fields']['filename']['alter']['alter_text'] = 0;
$display_options['fields']['filename']['alter']['make_link'] = 0;
$display_options['fields']['filename']['alter']['absolute'] = 0;
$display_options['fields']['filename']['alter']['trim'] = 0;
$display_options['fields']['filename']['alter']['word_boundary'] = 0;
$display_options['fields']['filename']['alter']['ellipsis'] = 0;
$display_options['fields']['filename']['alter']['strip_tags'] = 0;
$display_options['fields']['filename']['alter']['html'] = 0;
$display_options['fields']['filename']['hide_empty'] = 0;
$display_options['fields']['filename']['empty_zero'] = 0;
$display_options['fields']['filename']['plugin_id'] = 'field';
$display_options['fields']['filename']['type'] = 'file_link';
return $display_options;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Drupal\file\Upload;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Parses the content-disposition header to extract the client filename.
*/
final class ContentDispositionFilenameParser {
/**
* The regex used to extract the filename from the content disposition header.
*/
const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
/**
* Private constructor to prevent instantiation.
*/
private function __construct() {}
/**
* Parse the content disposition header and return the filename.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return string
* The filename.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Thrown when the 'Content-Disposition' request header is invalid.
*/
public static function parseFilename(Request $request): string {
// 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::REQUEST_HEADER_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 \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
$filename = $matches['filename'];
// Make sure only the filename component is returned. Path information is
// stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
// We do not need to use Drupal's FileSystem service here as we are not
// dealing with StreamWrappers.
return \basename($filename);
}
}

View File

@@ -0,0 +1,480 @@
<?php
namespace Drupal\file\Upload;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
use Drupal\Core\File\Exception\FileExistsException;
use Drupal\Core\File\Exception\FileWriteException;
use Drupal\Core\File\Exception\InvalidStreamWrapperException;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Lock\LockAcquiringException;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\Core\Validation\BasicRecursiveValidatorFactory;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\file\FileRepositoryInterface;
use Drupal\file\Validation\FileValidatorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\Exception\CannotWriteFileException;
use Symfony\Component\HttpFoundation\File\Exception\ExtensionFileException;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\File\Exception\IniSizeFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoTmpDirFileException;
use Symfony\Component\HttpFoundation\File\Exception\PartialFileException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mime\MimeTypeGuesserInterface;
/**
* Handles validating and creating file entities from file uploads.
*/
class FileUploadHandler {
/**
* The default extensions if none are provided.
*/
const DEFAULT_EXTENSIONS = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp';
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The stream wrapper manager.
*
* @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
*/
protected $streamWrapperManager;
/**
* The event dispatcher.
*
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The MIME type guesser.
*
* @var \Symfony\Component\Mime\MimeTypeGuesserInterface
*/
protected $mimeTypeGuesser;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The file Repository.
*
* @var \Drupal\file\FileRepositoryInterface
*/
protected $fileRepository;
/**
* The file validator.
*
* @var \Drupal\file\Validation\FileValidatorInterface
*/
protected FileValidatorInterface $fileValidator;
public function __construct(
FileSystemInterface $fileSystem,
EntityTypeManagerInterface $entityTypeManager,
StreamWrapperManagerInterface $streamWrapperManager,
EventDispatcherInterface $eventDispatcher,
MimeTypeGuesserInterface $mimeTypeGuesser,
AccountInterface $currentUser,
RequestStack $requestStack,
?FileRepositoryInterface $fileRepository = NULL,
?FileValidatorInterface $file_validator = NULL,
protected ?LockBackendInterface $lock = NULL,
protected ?BasicRecursiveValidatorFactory $validatorFactory = NULL,
) {
$this->fileSystem = $fileSystem;
$this->entityTypeManager = $entityTypeManager;
$this->streamWrapperManager = $streamWrapperManager;
$this->eventDispatcher = $eventDispatcher;
$this->mimeTypeGuesser = $mimeTypeGuesser;
$this->currentUser = $currentUser;
$this->requestStack = $requestStack;
if ($fileRepository === NULL) {
@trigger_error('Calling ' . __METHOD__ . ' without the $fileRepository argument is deprecated in drupal:10.1.0 and will be required in drupal:11.0.0. See https://www.drupal.org/node/3346839', E_USER_DEPRECATED);
$fileRepository = \Drupal::service('file.repository');
}
$this->fileRepository = $fileRepository;
if (!$file_validator) {
@trigger_error('Calling ' . __METHOD__ . '() without the $file_validator argument is deprecated in drupal:10.2.0 and is required in drupal:11.0.0. See https://www.drupal.org/node/3363700', E_USER_DEPRECATED);
$file_validator = \Drupal::service('file.validator');
}
$this->fileValidator = $file_validator;
if (!$this->lock) {
@trigger_error('Calling ' . __METHOD__ . '() without the $lock argument is deprecated in drupal:10.3.0 and is required in drupal:11.0.0. See https://www.drupal.org/node/3389017', E_USER_DEPRECATED);
$this->lock = \Drupal::service('lock');
}
if (!$validatorFactory) {
@trigger_error('Calling ' . __METHOD__ . '() without the $validatorFactory argument is deprecated in drupal:10.3.0 and is required in drupal:11.0.0. See https://www.drupal.org/node/3375456', E_USER_DEPRECATED);
$this->validatorFactory = \Drupal::service('validation.basic_recursive_validator_factory');
}
}
/**
* Creates a file from an upload.
*
* @param \Drupal\file\Upload\UploadedFileInterface $uploadedFile
* The uploaded file object.
* @param array $validators
* The validators to run against the uploaded file.
* @param string $destination
* The destination directory.
* @param \Drupal\Core\File\FileExists|int $fileExists
* The behavior when the destination file already exists.
* @param bool $throw
* (optional) Whether to throw an exception if the file is invalid.
*
* @return \Drupal\file\Upload\FileUploadResult
* The created file entity.
*
* @throws \Symfony\Component\HttpFoundation\File\Exception\FileException
* Thrown when a file upload error occurred and $throws is TRUE.
* @throws \Drupal\Core\File\Exception\FileWriteException
* Thrown when there is an error moving the file and $throws is TRUE.
* @throws \Drupal\Core\File\Exception\FileException
* Thrown when a file system error occurs and $throws is TRUE.
* @throws \Drupal\file\Upload\FileValidationException
* Thrown when file validation fails and $throws is TRUE.
* @throws \Drupal\Core\Lock\LockAcquiringException
* Thrown when a lock cannot be acquired.
* @throws \ValueError
* Thrown if $fileExists is a legacy int and not a valid value.
*/
public function handleFileUpload(UploadedFileInterface $uploadedFile, array $validators = [], string $destination = 'temporary://', /*FileExists*/$fileExists = FileExists::Replace, bool $throw = TRUE): FileUploadResult {
if (!$fileExists instanceof FileExists) {
// @phpstan-ignore-next-line
$fileExists = FileExists::fromLegacyInt($fileExists, __METHOD__);
}
$result = new FileUploadResult();
if ($throw) {
@trigger_error('Calling ' . __METHOD__ . '() with the $throw argument as TRUE is deprecated in drupal:10.3.0 and will be removed in drupal:11.0.0. Use \Drupal\file\Upload\FileUploadResult::getViolations() instead. See https://www.drupal.org/node/3375456', E_USER_DEPRECATED);
// @phpstan-ignore-next-line
if (!$uploadedFile->isValid()) {
// @phpstan-ignore-next-line
switch ($uploadedFile->getError()) {
case \UPLOAD_ERR_INI_SIZE:
// @phpstan-ignore-next-line
throw new IniSizeFileException($uploadedFile->getErrorMessage());
case \UPLOAD_ERR_FORM_SIZE:
// @phpstan-ignore-next-line
throw new FormSizeFileException($uploadedFile->getErrorMessage());
case \UPLOAD_ERR_PARTIAL:
// @phpstan-ignore-next-line
throw new PartialFileException($uploadedFile->getErrorMessage());
case \UPLOAD_ERR_NO_FILE:
// @phpstan-ignore-next-line
throw new NoFileException($uploadedFile->getErrorMessage());
case \UPLOAD_ERR_CANT_WRITE:
// @phpstan-ignore-next-line
throw new CannotWriteFileException($uploadedFile->getErrorMessage());
case \UPLOAD_ERR_NO_TMP_DIR:
// @phpstan-ignore-next-line
throw new NoTmpDirFileException($uploadedFile->getErrorMessage());
case \UPLOAD_ERR_EXTENSION:
// @phpstan-ignore-next-line
throw new ExtensionFileException($uploadedFile->getErrorMessage());
}
// @phpstan-ignore-next-line
throw new FileException($uploadedFile->getErrorMessage());
}
}
else {
$violations = $uploadedFile->validate($this->validatorFactory->createValidator());
if (count($violations) > 0) {
$result->addViolations($violations);
return $result;
}
}
$originalName = $uploadedFile->getClientOriginalName();
$extensions = $this->handleExtensionValidation($validators);
// Assert that the destination contains a valid stream.
$destinationScheme = $this->streamWrapperManager::getScheme($destination);
if (!$this->streamWrapperManager->isValidScheme($destinationScheme)) {
throw new InvalidStreamWrapperException(sprintf('The file could not be uploaded because the destination "%s" is invalid.', $destination));
}
// A file URI may already have a trailing slash or look like "public://".
if (!str_ends_with($destination, '/')) {
$destination .= '/';
}
// Call an event to sanitize the filename and to attempt to address security
// issues caused by common server setups.
$event = new FileUploadSanitizeNameEvent($originalName, $extensions);
$this->eventDispatcher->dispatch($event);
$filename = $event->getFilename();
$mimeType = $this->mimeTypeGuesser->guessMimeType($filename);
$destinationFilename = $this->fileSystem->getDestinationFilename($destination . $filename, $fileExists);
if ($destinationFilename === FALSE) {
throw new FileExistsException(sprintf('Destination file "%s" exists', $destinationFilename));
}
// Lock based on the prepared file URI.
$lock_id = $this->generateLockId($destinationFilename);
try {
if (!$this->lock->acquire($lock_id)) {
throw new LockAcquiringException(
sprintf(
'File "%s" is already locked for writing.',
$destinationFilename
)
);
}
$file = File::create([
'uid' => $this->currentUser->id(),
'status' => 0,
'uri' => $uploadedFile->getRealPath(),
]);
// This will be replaced later with a filename based on the destination.
$file->setFilename($filename);
$file->setMimeType($mimeType);
$file->setSize($uploadedFile->getSize());
// Add in our check of the file name length.
$validators['FileNameLength'] = [];
// Call the validation functions specified by this function's caller.
$violations = $this->fileValidator->validate($file, $validators);
if (count($violations) > 0) {
$result->addViolations($violations);
return $result;
}
if ($throw) {
$errors = [];
foreach ($violations as $violation) {
$errors[] = $violation->getMessage();
}
if (!empty($errors)) {
throw new FileValidationException(
'File validation failed',
$filename,
$errors
);
}
}
$file->setFileUri($destinationFilename);
if (!$this->moveUploadedFile($uploadedFile, $file->getFileUri())) {
throw new FileWriteException(
'File upload error. Could not move uploaded file.'
);
}
// Update the filename with any changes as a result of security or
// renaming due to an existing file.
$file->setFilename($this->fileSystem->basename($file->getFileUri()));
if ($fileExists === FileExists::Replace) {
$existingFile = $this->fileRepository->loadByUri($file->getFileUri());
if ($existingFile) {
$file->fid = $existingFile->id();
$file->setOriginalId($existingFile->id());
}
}
$result->setOriginalFilename($originalName)
->setSanitizedFilename($filename)
->setFile($file);
// If the filename has been modified, let the user know.
if ($event->isSecurityRename()) {
$result->setSecurityRename();
}
// Set the permissions on the new file.
$this->fileSystem->chmod($file->getFileUri());
// We can now validate the file object itself before it's saved.
$violations = $file->validate();
if ($throw) {
foreach ($violations as $violation) {
$errors[] = $violation->getMessage();
}
if (!empty($errors)) {
throw new FileValidationException(
'File validation failed',
$filename,
$errors
);
}
}
if (count($violations) > 0) {
$result->addViolations($violations);
return $result;
}
// If we made it this far it's safe to record this file in the database.
$file->save();
// Allow an anonymous user who creates a non-public file to see it. See
// \Drupal\file\FileAccessControlHandler::checkAccess().
if ($this->currentUser->isAnonymous() && $destinationScheme !== 'public') {
$session = $this->requestStack->getCurrentRequest()->getSession();
$allowed_temp_files = $session->get('anonymous_allowed_file_ids', []);
$allowed_temp_files[$file->id()] = $file->id();
$session->set('anonymous_allowed_file_ids', $allowed_temp_files);
}
}
finally {
$this->lock->release($lock_id);
}
return $result;
}
/**
* Move the uploaded file from the temporary path to the destination.
*
* @param \Drupal\file\Upload\UploadedFileInterface $uploadedFile
* The uploaded file.
* @param string $uri
* The destination URI.
*
* @return bool
* Returns FALSE if moving failed.
*
* @see https://www.drupal.org/project/drupal/issues/2940383
*/
protected function moveUploadedFile(UploadedFileInterface $uploadedFile, string $uri): bool {
if ($uploadedFile instanceof FormUploadedFile) {
return $this->fileSystem->moveUploadedFile($uploadedFile->getRealPath(), $uri);
}
// We use FileExists::Error) as the file location has already
// been determined above in FileSystem::getDestinationFilename().
return $this->fileSystem->move($uploadedFile->getRealPath(), $uri, FileExists::Error);
}
/**
* Gets the list of allowed extensions and updates the validators.
*
* This will add an extension validator to the list of validators if one is
* not set.
*
* If the extension validator is set, but no extensions are specified, it
* means all extensions are allowed, so the validator is removed from the list
* of validators.
*
* @param array $validators
* The file validators in use.
*
* @return string
* The space delimited list of allowed file extensions.
*/
protected function handleExtensionValidation(array &$validators): string {
// Handle legacy extension validation.
if (isset($validators['file_validate_extensions'])) {
@trigger_error(
'\'file_validate_extensions\' is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use the \'FileExtension\' constraint instead. See https://www.drupal.org/node/3363700',
E_USER_DEPRECATED
);
// Empty string means all extensions are allowed so we should remove the
// validator.
if (\is_string($validators['file_validate_extensions']) && empty($validators['file_validate_extensions'])) {
unset($validators['file_validate_extensions']);
return '';
}
// The deprecated 'file_validate_extensions' has configuration, so that
// should be used.
$validators['FileExtension']['extensions'] = $validators['file_validate_extensions'][0];
unset($validators['file_validate_extensions']);
return $validators['FileExtension']['extensions'];
}
// No validator was provided, so add one using the default list.
// Build a default non-munged safe list for
// \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber::sanitizeName().
if (!isset($validators['FileExtension'])) {
$validators['FileExtension'] = ['extensions' => self::DEFAULT_EXTENSIONS];
return self::DEFAULT_EXTENSIONS;
}
// Check if we want to allow all extensions.
if (!isset($validators['FileExtension']['extensions'])) {
// If 'FileExtension' is set and the list is empty then the caller wants
// to allow any extension. In this case we have to remove the validator
// or else it will reject all extensions.
unset($validators['FileExtension']);
return '';
}
return $validators['FileExtension']['extensions'];
}
/**
* Loads the first File entity found with the specified URI.
*
* @param string $uri
* The file URI.
*
* @return \Drupal\file\FileInterface|null
* The first file with the matched URI if found, NULL otherwise.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0.
* Use \Drupal\file\FileRepositoryInterface::loadByUri().
*
* @see https://www.drupal.org/node/3409326
*/
protected function loadByUri(string $uri): ?FileInterface {
@trigger_error('FileUploadHandler::loadByUri() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\file\FileRepositoryInterface::loadByUri(). See https://www.drupal.org/node/3409326', E_USER_DEPRECATED);
return $this->fileRepository->loadByUri($uri);
}
/**
* Generates a lock ID based on the file URI.
*/
protected static function generateLockId(string $fileUri): string {
return 'file:upload:' . Crypt::hashBase64($fileUri);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Drupal\file\Upload;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
use Drupal\file\Plugin\Field\FieldType\FileItem;
/**
* Resolves the file upload location from a file field definition.
*/
trait FileUploadLocationTrait {
/**
* Resolves the file upload location from a file field definition.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface $fieldDefinition
* The file field definition.
*
* @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.
*/
public function getUploadLocation(FieldDefinitionInterface $fieldDefinition): string {
assert(is_a($fieldDefinition->getClass(), FileFieldItemList::class, TRUE));
$fieldItemDataDefinition = FieldItemDataDefinition::create($fieldDefinition);
$fileItem = new FileItem($fieldItemDataDefinition);
return $fileItem->getUploadLocation();
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace Drupal\file\Upload;
use Drupal\file\FileInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Value object for a file upload result.
*/
class FileUploadResult {
/**
* If the filename was renamed for security reasons.
*
* @var bool
*/
protected $securityRename = FALSE;
/**
* The sanitized filename.
*
* @var string
*/
protected $sanitizedFilename;
/**
* The original filename.
*
* @var string
*/
protected $originalFilename;
/**
* The File entity.
*
* @var \Drupal\file\FileInterface
*/
protected $file;
/**
* The constraint violations.
*
* @var \Symfony\Component\Validator\ConstraintViolationListInterface
*/
protected ConstraintViolationListInterface $violations;
/**
* Creates a new FileUploadResult.
*/
public function __construct() {
$this->violations = new ConstraintViolationList();
}
/**
* Flags the result as having had a security rename.
*
* @return $this
*/
public function setSecurityRename(): FileUploadResult {
$this->securityRename = TRUE;
return $this;
}
/**
* Sets the sanitized filename.
*
* @param string $sanitizedFilename
* The sanitized filename.
*
* @return $this
*/
public function setSanitizedFilename(string $sanitizedFilename): FileUploadResult {
$this->sanitizedFilename = $sanitizedFilename;
return $this;
}
/**
* Gets the original filename.
*
* @return string
*/
public function getOriginalFilename(): string {
return $this->originalFilename;
}
/**
* Sets the original filename.
*
* @param string $originalFilename
* The original filename.
*
* @return $this
*/
public function setOriginalFilename(string $originalFilename): FileUploadResult {
$this->originalFilename = $originalFilename;
return $this;
}
/**
* Sets the File entity.
*
* @param \Drupal\file\FileInterface $file
* A file entity.
*
* @return $this
*/
public function setFile(FileInterface $file): FileUploadResult {
$this->file = $file;
return $this;
}
/**
* Returns if there was a security rename.
*
* @return bool
*/
public function isSecurityRename(): bool {
return $this->securityRename;
}
/**
* Returns if there was a file rename.
*
* @return bool
*/
public function isRenamed(): bool {
return $this->originalFilename !== $this->sanitizedFilename;
}
/**
* Gets the sanitized filename.
*
* @return string
*/
public function getSanitizedFilename(): string {
return $this->sanitizedFilename;
}
/**
* Gets the File entity.
*
* @return \Drupal\file\FileInterface
*/
public function getFile(): FileInterface {
return $this->file;
}
/**
* Adds a constraint violation.
*
* @param \Symfony\Component\Validator\ConstraintViolationInterface $violation
* The constraint violation.
*/
public function addViolation(ConstraintViolationInterface $violation): void {
$this->violations->add($violation);
}
/**
* Adds constraint violations.
*
* @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations
* The constraint violations.
*/
public function addViolations(ConstraintViolationListInterface $violations): void {
$this->violations->addAll($violations);
}
/**
* Gets the constraint violations.
*
* @return \Symfony\Component\Validator\ConstraintViolationListInterface
* The constraint violations.
*/
public function getViolations(): ConstraintViolationListInterface {
return $this->violations;
}
/**
* Returns TRUE if there are constraint violations.
*/
public function hasViolations(): bool {
return $this->violations->count() > 0;
}
}

Some files were not shown because too many files have changed in this diff Show More