Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

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'] = $this->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,474 @@
<?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() {
return [
'#input' => TRUE,
'#process' => [
[static::class, 'processManagedFile'],
],
'#element_validate' => [
[static::class, 'validateManagedFile'],
],
'#pre_render' => [
[static::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;
}
/**
* The #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['#description'])) {
$element['upload']['#attributes']['aria-describedby'] = $element['#id'] . '--description';
}
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']['FileExtension']['extensions'])) {
$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
* The file usage service.
*/
protected static function fileUsage() {
return \Drupal::service('file.usage');
}
}

View File

@ -0,0 +1,314 @@
<?php
namespace Drupal\file\Entity;
use Drupal\Core\Entity\Attribute\ContentEntityType;
use Drupal\Core\Entity\ContentEntityDeleteForm;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\StringTranslation\TranslatableMarkup;
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\FileAccessControlHandler;
use Drupal\file\FileInterface;
use Drupal\file\FileStorage;
use Drupal\file\FileStorageSchema;
use Drupal\file\FileViewsData;
use Drupal\user\EntityOwnerTrait;
/**
* Defines the file entity class.
*
* @ingroup file
*/
#[ContentEntityType(
id: 'file',
label: new TranslatableMarkup('File'),
label_collection: new TranslatableMarkup('Files'),
label_singular: new TranslatableMarkup('file'),
label_plural: new TranslatableMarkup('files'),
entity_keys: [
'id' => 'fid',
'label' => 'filename',
'langcode' => 'langcode',
'uuid' => 'uuid',
'owner' => 'uid',
],
handlers: [
'storage' => FileStorage::class,
'storage_schema' => FileStorageSchema::class,
'access' => FileAccessControlHandler::class,
'views_data' => FileViewsData::class,
'list_builder' => EntityListBuilder::class,
'form' => ['delete' => ContentEntityDeleteForm::class],
'route_provider' => ['html' => FileRouteProvider::class],
],
links: [
'delete-form' => '/file/{file}/delete',
],
base_table: 'file_managed',
label_count: [
'singular' => '@count file',
'plural' => '@count files',
],
)]
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) {
// 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);
}
/**
* {@inheritdoc}
*/
public function getDownloadHeaders(): array {
return [
'Content-Type' => $this->getMimeType(),
'Content-Length' => $this->getSize(),
'Cache-Control' => 'private',
];
}
}

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, NULL) . '[' . preg_quote($replacement, NULL) . ']*/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 which runs 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,154 @@
<?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();
/**
* Examines a file entity and returns content headers for download.
*
* @return array
* An associative array of headers, as expected by
* \Symfony\Component\HttpFoundation\StreamedResponse.
*/
public function getDownloadHeaders(): array;
}

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 staticMethod.deprecated
$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 staticMethod.deprecated
$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 staticMethod.deprecated
$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,132 @@
<?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.
*
* If a file entity already exists for the URI, it is updated; otherwise,
* a new file entity is created.
*
* @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,70 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Hook;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\file\FileInterface;
use Drupal\file\FileUsage\FileUsageInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Implements hook_cron().
*/
#[Hook('cron')]
class CronHook {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
private readonly StreamWrapperManagerInterface $streamWrapperManager,
private readonly ConfigFactoryInterface $configFactory,
private readonly FileUsageInterface $fileUsage,
private readonly TimeInterface $time,
#[Autowire('@logger.channel.file')]
private readonly LoggerInterface $logger,
) {}
/**
* Implements hook_cron().
*/
public function __invoke(): void {
$age = $this->configFactory->get('system.file')->get('temporary_maximum_age');
$fileStorage = $this->entityTypeManager->getStorage('file');
// Only delete temporary files if older than $age. Note that automatic
// cleanup is disabled if $age set to 0.
if ($age) {
$fids = $fileStorage->getQuery()->accessCheck(FALSE)->condition('status', FileInterface::STATUS_PERMANENT, '<>')->condition('changed', $this->time->getRequestTime() - $age, '<')->range(0, 100)->execute();
/** @var \Drupal\file\FileInterface[] $files */
$files = $fileStorage->loadMultiple($fids);
foreach ($files as $file) {
$references = $this->fileUsage->listUsage($file);
if (empty($references)) {
if (!file_exists($file->getFileUri())) {
if (!$this->streamWrapperManager->isValidUri($file->getFileUri())) {
$this->logger->warning('Temporary file "%path" that was deleted during garbage collection did not exist on the filesystem. This could be caused by a missing stream wrapper.', ['%path' => $file->getFileUri()]);
}
else {
$this->logger->warning('Temporary file "%path" that was deleted during garbage collection did not exist on the filesystem.', ['%path' => $file->getFileUri()]);
}
}
// Delete the file entity. If the file does not exist, this will
// generate a second notice in the watchdog.
$file->delete();
}
else {
$this->logger->info('Did not delete temporary file "%path" during garbage collection because it is in use by the following modules: %modules.', [
'%path' => $file->getFileUri(),
'%modules' => implode(', ', array_keys($references)),
]);
}
}
}
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Hook;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\file\FileRepositoryInterface;
use Drupal\file\FileUsage\FileUsageInterface;
/**
* Implements hook_file_download().
*/
#[Hook('file_download')]
class FileDownloadHook {
public function __construct(
private readonly FileRepositoryInterface $fileRepository,
private readonly FileUsageInterface $fileUsage,
private readonly AccountInterface $currentUser,
) {}
/**
* Implements hook_file_download().
*/
public function __invoke($uri): array|int|null {
// Get the file record based on the URI. If not in the database just return.
$file = $this->fileRepository->loadByUri($uri);
if (!$file) {
return NULL;
}
// Find out if a temporary file is still used in the system.
if ($file->isTemporary()) {
$usage = $this->fileUsage->listUsage($file);
if (empty($usage) && $file->getOwnerId() != $this->currentUser->id()) {
// Deny access to temporary files without usage that are not owned by
// the same user. This prevents the security issue that a private file
// that was protected by field permissions becomes available after its
// usage was removed and before it is actually deleted from the file
// system. Modules that depend on this behavior should make the file
// permanent instead.
return -1;
}
}
// Find out which (if any) fields of this type contain the file.
$references = file_get_file_references($file, NULL, EntityStorageInterface::FIELD_LOAD_CURRENT, NULL);
// Stop processing if there are no references in order to avoid returning
// headers for files controlled by other modules. Make an exception for
// temporary files where the host entity has not yet been saved (for
// example, an image preview on a node/add form) in which case, allow
// download by the file's owner.
if (empty($references) && ($file->isPermanent() || $file->getOwnerId() != $this->currentUser->id())) {
return NULL;
}
if (!$file->access('download')) {
return -1;
}
// Access is granted.
return $file->getDownloadHeaders();
}
}

View File

@ -0,0 +1,188 @@
<?php
namespace Drupal\file\Hook;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\file\Entity\File;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for file.
*/
class FileHooks {
// cspell:ignore widthx
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): string|array|null {
switch ($route_name) {
case 'help.page.file':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The File module allows you to create fields that contain files. See the <a href=":field">Field module help</a> and the <a href=":field_ui">Field UI help</a> pages for general information on fields and how to create and manage them. For more information, see the <a href=":file_documentation">online documentation for the File module</a>.', [
':field' => Url::fromRoute('help.page', [
'name' => 'field',
])->toString(),
':field_ui' => \Drupal::moduleHandler()->moduleExists('field_ui') ? Url::fromRoute('help.page', [
'name' => 'field_ui',
])->toString() : '#',
':file_documentation' => 'https://www.drupal.org/documentation/modules/file',
]) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Managing and displaying file fields') . '</dt>';
$output .= '<dd>' . $this->t('The <em>settings</em> and the <em>display</em> of the file field can be configured separately. See the <a href=":field_ui">Field UI help</a> for more information on how to manage fields and their display.', [
':field_ui' => \Drupal::moduleHandler()->moduleExists('field_ui') ? Url::fromRoute('help.page', [
'name' => 'field_ui',
])->toString() : '#',
]) . '</dd>';
$output .= '<dt>' . $this->t('Allowing file extensions') . '</dt>';
$output .= '<dd>' . $this->t('In the field settings, you can define the allowed file extensions (for example <em>pdf docx psd</em>) for the files that will be uploaded with the file field.') . '</dd>';
$output .= '<dt>' . $this->t('Storing files') . '</dt>';
$output .= '<dd>' . $this->t('Uploaded files can either be stored as <em>public</em> or <em>private</em>, depending on the <a href=":file-system">File system settings</a>. For more information, see the <a href=":system-help">System module help page</a>.', [
':file-system' => Url::fromRoute('system.file_system_settings')->toString(),
':system-help' => Url::fromRoute('help.page', [
'name' => 'system',
])->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Restricting the maximum file size') . '</dt>';
$output .= '<dd>' . $this->t('The maximum file size that users can upload is limited by PHP settings of the server, but you can restrict by entering the desired value as the <em>Maximum upload size</em> setting. The maximum file size is automatically displayed to users in the help text of the file field.') . '</dd>';
$output .= '<dt>' . $this->t('Displaying files and descriptions') . '<dt>';
$output .= '<dd>' . $this->t('In the field settings, you can allow users to toggle whether individual files are displayed. In the display settings, you can then choose one of the following formats: <ul><li><em>Generic file</em> displays links to the files and adds icons that symbolize the file extensions. If <em>descriptions</em> are enabled and have been submitted, then the description is displayed instead of the file name.</li><li><em>URL to file</em> displays the full path to the file as plain text.</li><li><em>Table of files</em> lists links to the files and the file sizes in a table.</li><li><em>RSS enclosure</em> only displays the first file, and only in a RSS feed, formatted according to the RSS 2.0 syntax for enclosures.</li></ul> A file can still be linked to directly by its URI even if it is not displayed.') . '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_field_widget_info_alter().
*/
#[Hook('field_widget_info_alter')]
public function fieldWidgetInfoAlter(array &$info): void {
// Allows using the 'uri' widget for the 'file_uri' field type, which uses
// it as the default widget.
// @see \Drupal\file\Plugin\Field\FieldType\FileUriItem
$info['uri']['field_types'][] = 'file_uri';
}
/**
* Implements hook_theme().
*/
#[Hook('theme')]
public function theme(): array {
return [
// From file.module.
'file_link' => [
'variables' => [
'file' => NULL,
'description' => NULL,
'attributes' => [],
],
],
'file_managed_file' => [
'render element' => 'element',
],
'file_audio' => [
'variables' => [
'files' => [],
'attributes' => NULL,
],
],
'file_video' => [
'variables' => [
'files' => [],
'attributes' => NULL,
],
],
'file_widget_multiple' => [
'render element' => 'element',
],
'file_upload_help' => [
'variables' => [
'description' => NULL,
'upload_validators' => NULL,
'cardinality' => NULL,
],
],
];
}
/**
* Implements hook_ENTITY_TYPE_predelete() for file entities.
*/
#[Hook('file_predelete')]
public function filePredelete(File $file): void {
// @todo Remove references to a file that is in-use. See https://www.drupal.org/project/drupal/issues/1506314
}
/**
* Implements hook_form_FORM_ID_alter().
*
* Injects the file sanitization options into /admin/config/media/file-system.
*
* These settings are enforced during upload by the FileEventSubscriber that
* listens to the FileUploadSanitizeNameEvent event.
*
* @see \Drupal\system\Form\FileSystemForm
* @see \Drupal\Core\File\Event\FileUploadSanitizeNameEvent
* @see \Drupal\file\EventSubscriber\FileEventSubscriber
*/
#[Hook('form_system_file_system_settings_alter')]
public function formSystemFileSystemSettingsAlter(array &$form, FormStateInterface $form_state): void {
$config = \Drupal::config('file.settings');
$form['filename_sanitization'] = [
'#type' => 'details',
'#title' => $this->t('Sanitize filenames'),
'#description' => $this->t('These settings only apply to new files as they are uploaded. Changes here do not affect existing file names.'),
'#open' => TRUE,
'#tree' => TRUE,
];
$form['filename_sanitization']['replacement_character'] = [
'#type' => 'select',
'#title' => $this->t('Replacement character'),
'#default_value' => $config->get('filename_sanitization.replacement_character'),
'#options' => [
'-' => $this->t('Dash (-)'),
'_' => $this->t('Underscore (_)'),
],
'#description' => $this->t('Used when replacing whitespace, replacing non-alphanumeric characters or transliterating unknown characters.'),
];
$form['filename_sanitization']['transliterate'] = [
'#type' => 'checkbox',
'#title' => $this->t('Transliterate'),
'#default_value' => $config->get('filename_sanitization.transliterate'),
'#description' => $this->t('Transliteration replaces any characters that are not alphanumeric, underscores, periods or hyphens with the replacement character. It ensures filenames only contain ASCII characters. It is recommended to keep transliteration enabled.'),
];
$form['filename_sanitization']['replace_whitespace'] = [
'#type' => 'checkbox',
'#title' => $this->t('Replace whitespace with the replacement character'),
'#default_value' => $config->get('filename_sanitization.replace_whitespace'),
];
$form['filename_sanitization']['replace_non_alphanumeric'] = [
'#type' => 'checkbox',
'#title' => $this->t('Replace non-alphanumeric characters with the replacement character'),
'#default_value' => $config->get('filename_sanitization.replace_non_alphanumeric'),
'#description' => $this->t('Alphanumeric characters, dots <span aria-hidden="true">(.)</span>, underscores <span aria-hidden="true">(_)</span> and dashes <span aria-hidden="true">(-)</span> are preserved.'),
];
$form['filename_sanitization']['deduplicate_separators'] = [
'#type' => 'checkbox',
'#title' => $this->t('Replace sequences of dots, underscores and/or dashes with the replacement character'),
'#default_value' => $config->get('filename_sanitization.deduplicate_separators'),
];
$form['filename_sanitization']['lowercase'] = [
'#type' => 'checkbox',
'#title' => $this->t('Convert to lowercase'),
'#default_value' => $config->get('filename_sanitization.lowercase'),
];
$form['#submit'][] = 'file_system_settings_submit';
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace Drupal\file\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Requirements for the File module.
*/
class FileRequirements {
use StringTranslationTrait;
/**
* Implements hook_runtime_requirements().
*/
#[Hook('runtime_requirements')]
public function runtime(): array {
$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 = $this->t('Enabled (<a href="https://github.com/php/pecl-php-uploadprogress#uploadprogress">PECL uploadprogress</a>)');
$description = NULL;
}
else {
$value = $this->t('Not enabled');
$description = $this->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="https://github.com/php/pecl-php-uploadprogress#installation">PECL uploadprogress library</a>.');
}
// Adjust the requirement depending on what the server supports.
if (!$is_apache && !$is_nginx) {
$value = $this->t('Not enabled');
$description = $this->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 = $this->t('Not enabled');
$description = $this->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' => $this->t('Upload progress'),
'value' => $value,
'description' => $description,
];
return $requirements;
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Hook;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\field\FieldStorageConfigInterface;
use Drupal\views\FieldViewsDataProvider;
/**
* Hook implementations for file.
*/
class FileViewsHooks {
use StringTranslationTrait;
public function __construct(
protected readonly EntityTypeManagerInterface $entityTypeManager,
protected readonly EntityFieldManagerInterface $entityFieldManager,
protected readonly ?FieldViewsDataProvider $fieldViewsDataProvider,
) {}
/**
* Implements hook_field_views_data().
*
* Views integration for file fields. Adds a file relationship to the default
* field data.
*
* @see FieldViewsDataProvider::defaultFieldImplementation()
*/
#[Hook('field_views_data')]
public function fieldViewsData(FieldStorageConfigInterface $field_storage): array {
$data = $this->fieldViewsDataProvider->defaultFieldImplementation($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' => $this->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.
*/
#[Hook('field_views_data_views_data_alter')]
public function fieldViewsDataViewsDataAlter(array &$data, FieldStorageConfigInterface $field_storage): void {
$entity_type_id = $field_storage->getTargetEntityTypeId();
$entity_type = $this->entityTypeManager->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 = $this->entityTypeManager->getStorage($entity_type_id)->getTableMapping();
[$label] = $this->entityFieldManager->getFieldLabels($entity_type_id, $field_name);
$data['file_managed'][$pseudo_field_name]['relationship'] = [
'title' => $this->t('@entity using @field', [
'@entity' => $entity_type->getLabel(),
'@field' => $label,
]),
'label' => $this->t('@field_name', [
'@field_name' => $field_name,
]),
'group' => $entity_type->getLabel(),
'help' => $this->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,153 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Hook;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Utility\Token;
/**
* Hook implementations for file tokens.
*/
class TokenHooks {
use StringTranslationTrait;
public function __construct(
private readonly Token $token,
private readonly DateFormatterInterface $dateFormatter,
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* Implements hook_tokens().
*/
#[Hook('tokens')]
public function tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata): array {
$langcode = $options['langcode'] ?? NULL;
$replacements = [];
if ($type == 'file' && !empty($data['file'])) {
$dateFormatStorage = $this->entityTypeManager->getStorage('date_format');
/** @var \Drupal\file\FileInterface $file */
$file = $data['file'];
foreach ($tokens as $name => $original) {
switch ($name) {
// Basic keys and values.
case 'fid':
$replacements[$original] = $file->id();
break;
case 'uuid':
$replacements[$original] = $file->uuid();
break;
// Essential file data
case 'name':
$replacements[$original] = $file->getFilename();
break;
case 'path':
$replacements[$original] = $file->getFileUri();
break;
case 'mime':
$replacements[$original] = $file->getMimeType();
break;
case 'size':
$replacements[$original] = ByteSizeMarkup::create($file->getSize());
break;
case 'url':
// Ideally, this would use return a relative URL, but because tokens
// are also often used in emails, it's better to keep absolute file
// URLs. The 'url.site' cache context is associated to ensure the
// correct absolute URL is used in case of a multisite setup.
$replacements[$original] = $file->createFileUrl(FALSE);
$bubbleable_metadata->addCacheContexts(['url.site']);
break;
// These tokens are default variations on the chained tokens handled
// below.
case 'created':
$date_format = $dateFormatStorage->load('medium');
$bubbleable_metadata->addCacheableDependency($date_format);
$replacements[$original] = $this->dateFormatter->format($file->getCreatedTime(), 'medium', '', NULL, $langcode);
break;
case 'changed':
$date_format = $dateFormatStorage->load('medium');
$bubbleable_metadata = $bubbleable_metadata->addCacheableDependency($date_format);
$replacements[$original] = $this->dateFormatter->format($file->getChangedTime(), 'medium', '', NULL, $langcode);
break;
case 'owner':
$owner = $file->getOwner();
$bubbleable_metadata->addCacheableDependency($owner);
$name = $owner->label();
$replacements[$original] = $name;
break;
}
}
if ($date_tokens = $this->token->findWithPrefix($tokens, 'created')) {
$replacements += $this->token->generate('date', $date_tokens, ['date' => $file->getCreatedTime()], $options, $bubbleable_metadata);
}
if ($date_tokens = $this->token->findWithPrefix($tokens, 'changed')) {
$replacements += $this->token->generate('date', $date_tokens, ['date' => $file->getChangedTime()], $options, $bubbleable_metadata);
}
if (($owner_tokens = $this->token->findWithPrefix($tokens, 'owner')) && $file->getOwner()) {
$replacements += $this->token->generate('user', $owner_tokens, ['user' => $file->getOwner()], $options, $bubbleable_metadata);
}
}
return $replacements;
}
/**
* Implements hook_token_info().
*/
#[Hook('token_info')]
public function tokenInfo(): array {
$types['file'] = [
'name' => $this->t("Files"),
'description' => $this->t("Tokens related to uploaded files."),
'needs-data' => 'file',
];
// File related tokens.
$file['fid'] = [
'name' => $this->t("File ID"),
'description' => $this->t("The unique ID of the uploaded file."),
];
$file['uuid'] = ['name' => $this->t('UUID'), 'description' => $this->t('The UUID of the uploaded file.')];
$file['name'] = ['name' => $this->t("File name"), 'description' => $this->t("The name of the file on disk.")];
$file['path'] = [
'name' => $this->t("Path"),
'description' => $this->t("The location of the file relative to Drupal root."),
];
$file['mime'] = ['name' => $this->t("MIME type"), 'description' => $this->t("The MIME type of the file.")];
$file['size'] = ['name' => $this->t("File size"), 'description' => $this->t("The size of the file.")];
$file['url'] = ['name' => $this->t("URL"), 'description' => $this->t("The web-accessible URL for the file.")];
$file['created'] = [
'name' => $this->t("Created"),
'description' => $this->t("The date the file created."),
'type' => 'date',
];
$file['changed'] = [
'name' => $this->t("Changed"),
'description' => $this->t("The date the file was most recently changed."),
'type' => 'date',
];
$file['owner'] = [
'name' => $this->t("Owner"),
'description' => $this->t("The user who originally uploaded the file."),
'type' => 'user',
];
return ['types' => $types, 'tokens' => ['file' => $file]];
}
}

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,221 @@
<?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\File\MimeType\MimeTypeMapInterface;
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 \Drupal\Core\File\MimeType\MimeTypeMapInterface $mime_type_map */
$mime_type_map = \Drupal::service(MimeTypeMapInterface::class);
$extension_list = array_filter(preg_split('/\s+/', $field_definition->getSetting('file_extensions')));
foreach ($extension_list as $extension) {
$mime_type = $mime_type_map->getMimeTypeForExtension($extension);
if ($mime_type !== NULL && 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) {
$mime_type = $file->getMimeType();
if ($mime_type !== NULL && static::mimeTypeApplies($mime_type)) {
$source_attributes = new Attribute();
$source_attributes
->setAttribute('src', $file->createFileUrl())
->setAttribute('type', $mime_type);
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->generateAbsoluteString($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,116 @@
<?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,
'playsinline' => 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'),
],
'playsinline' => [
'#title' => $this->t('Plays Inline'),
'#type' => 'checkbox',
'#default_value' => $this->getSetting('playsinline'),
],
'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')]);
$summary[] = $this->t('Plays Inline: %playsinline', ['%playsinline' => $this->getSetting('playsinline') ? $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', 'playsinline']);
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,104 @@
<?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 ($entity->getRevisionId() != $entity->getOriginal()?->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->getOriginal();
if ($original->hasTranslation($langcode)) {
foreach ($original->getTranslation($langcode)->{$field_name} 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,400 @@
<?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 the 'FileExtension'
* constraint.
*/
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);
// Ensure directory ends with a slash.
$dirname .= str_ends_with($dirname, '/') ? '' : '/';
// Generate a file entity.
$destination = $dirname . $random->name(10) . '.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,619 @@
<?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\Core\Url;
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['notice'] = [
'#type' => 'container',
'#markup' => $this->t('The UploadProgress PHP extension must be enabled to configure the progress indicator. Check the <a href=":status">status report</a> for more information.', [':status' => Url::fromRoute('system.status')->toString()]),
'#weight' => 16,
'#access' => !extension_loaded('uploadprogress'),
'#attributes' => [
'role' => 'status',
],
];
$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,
'#disabled' => !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,34 @@
<?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;
/**
* Defines an encoding constraint for files.
*/
#[Constraint(
id: 'FileEncoding',
label: new TranslatableMarkup('File encoding', [], ['context' => 'Validation'])
)]
class FileEncodingConstraint extends SymfonyConstraint {
/**
* The error message.
*
* @var string
*/
public string $message = "The file is encoded with %detected. It must be encoded with %encoding";
/**
* The allowed file encodings.
*
* @var array
*/
public array $encodings;
}

View File

@ -0,0 +1,53 @@
<?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 encoding constraint.
*/
class FileEncodingConstraintValidator extends BaseFileConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint): void {
/** @var \Drupal\file\Entity\FileInterface $file */
$file = $this->assertValueIsFile($value);
if (!$constraint instanceof FileEncodingConstraint) {
throw new UnexpectedTypeException($constraint, FileEncodingConstraint::class);
}
$encodings = $constraint->encodings;
$data = file_get_contents($file->getFileUri());
foreach ($encodings as $encoding) {
$this->validateEncoding($data, $encoding, $constraint);
}
}
/**
* Validates the encoding of the file.
*
* @param string $data
* The file data.
* @param string $encoding
* The encoding to validate.
* @param \Drupal\file\Plugin\Validation\Constraint\FileEncodingConstraint $constraint
* The constraint.
*/
protected function validateEncoding(string $data, string $encoding, FileEncodingConstraint $constraint): void {
if (mb_check_encoding($data, $encoding)) {
return;
}
$this->context->addViolation($constraint->message, [
'%encoding' => $encoding,
'%detected' => mb_detect_encoding($data),
]);
}
}

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): void {
$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): void {
$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,125 @@
<?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): void {
$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();
$file->setSize($image->getFileSize());
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): void {
$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): void {
$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 ($file->isNew() && $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,43 @@
<?php
namespace Drupal\file\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Drupal\Core\Validation\Plugin\Validation\Constraint\UniqueFieldConstraint;
use Drupal\Core\Validation\Plugin\Validation\Constraint\UniqueFieldValueValidator;
/**
* Supports validating file URIs.
*/
#[Constraint(
id: 'FileUriUnique',
label: new TranslatableMarkup('File URI', [], ['context' => 'Validation'])
)]
class FileUriUnique extends UniqueFieldConstraint {
/**
* The error message.
*
* @var string
*/
public $message = 'The file %value already exists. Enter a unique file URI.';
/**
* This constraint is case-sensitive.
*
* For example "public://foo.txt" and "public://FOO.txt" are treated as
* different values, and can co-exist.
*
* @var bool
*/
public $caseSensitive = TRUE;
/**
* {@inheritdoc}
*/
public function validatedBy(): string {
return UniqueFieldValueValidator::class;
}
}

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): void {
// 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,92 @@
<?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;
/**
* Determines the settings for a Drupal 6 file field.
*/
#[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,284 @@
<?php
namespace Drupal\file\Plugin\rest\resource;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
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\Lock\LockAcquiringException;
use Drupal\Core\StringTranslation\TranslatableMarkup;
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 FileValidatorSettingsTrait;
use EntityResourceValidationTrait {
validate as resourceValidate;
}
use FileUploadLocationTrait {
getUploadLocation as getUploadDestination;
}
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
$serializer_formats,
LoggerInterface $logger,
protected FileSystemInterface $fileSystem,
protected EntityTypeManagerInterface $entityTypeManager,
protected EntityFieldManagerInterface $entityFieldManager,
protected FileValidatorInterface $fileValidator,
protected InputStreamFileWriterInterface $inputStreamFileWriter,
protected FileUploadHandler $fileUploadHandler,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
}
/**
* {@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) {
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);
}
/**
* 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;
}
/**
* {@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 string $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,30 @@
<?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 {
/**
* {@inheritdoc}
*/
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,323 @@
<?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\FileRepositoryInterface;
use Drupal\file\Validation\FileValidatorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mime\MimeTypeGuesserInterface;
/**
* Handles validating and creating file entities from file uploads.
*/
class FileUploadHandler implements FileUploadHandlerInterface {
/**
* 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,
FileValidatorInterface $file_validator,
protected LockBackendInterface $lock,
protected BasicRecursiveValidatorFactory $validatorFactory,
) {
$this->fileSystem = $fileSystem;
$this->entityTypeManager = $entityTypeManager;
$this->streamWrapperManager = $streamWrapperManager;
$this->eventDispatcher = $eventDispatcher;
$this->mimeTypeGuesser = $mimeTypeGuesser;
$this->currentUser = $currentUser;
$this->requestStack = $requestStack;
$this->fileRepository = $fileRepository;
$this->fileValidator = $file_validator;
}
/**
* {@inheritdoc}
*/
public function handleFileUpload(UploadedFileInterface $uploadedFile, array $validators = [], string $destination = 'temporary://', /*FileExists*/$fileExists = FileExists::Replace): FileUploadResult {
if (!$fileExists instanceof FileExists) {
// @phpstan-ignore staticMethod.deprecated
$fileExists = FileExists::fromLegacyInt($fileExists, __METHOD__);
}
$result = new FileUploadResult();
$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;
}
$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 (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 {
// 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'];
}
/**
* 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,44 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Upload;
use Drupal\Core\File\FileExists;
/**
* Handles validating and creating file entities from file uploads.
*/
interface FileUploadHandlerInterface {
/**
* 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.
*
* @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::Replace): FileUploadResult;
}

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,193 @@
<?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
* The original filename.
*/
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
* TRUE when the file was renamed for a security reason, FALSE otherwise.
*/
public function isSecurityRename(): bool {
return $this->securityRename;
}
/**
* Returns if there was a file rename.
*
* @return bool
* TRUE when the file was renamed, FALSE otherwise.
*/
public function isRenamed(): bool {
return $this->originalFilename !== $this->sanitizedFilename;
}
/**
* Gets the sanitized filename.
*
* @return string
* The sanitized filename.
*/
public function getSanitizedFilename(): string {
return $this->sanitizedFilename;
}
/**
* Gets the File entity.
*
* @return \Drupal\file\FileInterface
* The file entity.
*/
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;
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Drupal\file\Upload;
/**
* Provides an exception for upload validation errors.
*/
class FileValidationException extends \RuntimeException {
/**
* The validation errors.
*
* @var array
*/
protected $errors;
/**
* The file name.
*
* @var string
*/
protected $fileName;
/**
* Constructs a new FileValidationException.
*
* @param string $message
* The message.
* @param string $file_name
* The file name.
* @param array $errors
* The validation errors.
*/
public function __construct(string $message, string $file_name, array $errors) {
parent::__construct($message, 0, NULL);
$this->fileName = $file_name;
$this->errors = $errors;
}
/**
* Gets the file name.
*
* @return string
* The file name.
*/
public function getFilename(): string {
return $this->fileName;
}
/**
* Gets the errors.
*
* @return array
* The errors.
*/
public function getErrors(): array {
return $this->errors;
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace Drupal\file\Upload;
use Drupal\file\Validation\Constraint\UploadedFileConstraint;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Provides a bridge to Symfony UploadedFile.
*/
class FormUploadedFile implements UploadedFileInterface {
/**
* The wrapped uploaded file.
*
* @var \Symfony\Component\HttpFoundation\File\UploadedFile
*/
protected $uploadedFile;
/**
* Creates a new FormUploadedFile.
*
* @param \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile
* The wrapped Symfony uploaded file.
*/
public function __construct(UploadedFile $uploadedFile) {
$this->uploadedFile = $uploadedFile;
}
/**
* {@inheritdoc}
*/
public function getClientOriginalName(): string {
return $this->uploadedFile->getClientOriginalName();
}
/**
* {@inheritdoc}
*/
public function getSize(): int {
return $this->uploadedFile->getSize();
}
/**
* {@inheritdoc}
*/
public function getRealPath() {
return $this->uploadedFile->getRealPath();
}
/**
* {@inheritdoc}
*/
public function getPathname(): string {
return $this->uploadedFile->getPathname();
}
/**
* {@inheritdoc}
*/
public function getFilename(): string {
return $this->uploadedFile->getFilename();
}
/**
* {@inheritdoc}
*/
public function validate(ValidatorInterface $validator, array $options = []): ConstraintViolationListInterface {
$constraint = new UploadedFileConstraint($options);
return $validator->validate($this->uploadedFile, $constraint);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace Drupal\file\Upload;
use Drupal\Core\File\FileSystemInterface;
use Symfony\Component\HttpFoundation\File\Exception\CannotWriteFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
use Symfony\Component\HttpFoundation\File\Exception\UploadException;
/**
* Writes files from a input stream to a temporary file.
*/
class InputStreamFileWriter implements InputStreamFileWriterInterface {
/**
* Creates a new InputStreamFileUploader.
*/
public function __construct(
protected FileSystemInterface $fileSystem,
) {}
/**
* {@inheritdoc}
*/
public function writeStreamToFile(string $stream = self::DEFAULT_STREAM, int $bytesToRead = self::DEFAULT_BYTES_TO_READ): string {
// 'rb' is needed so reading works correctly on Windows environments too.
$fileData = fopen($stream, 'rb');
$tempFilePath = $this->fileSystem->tempnam('temporary://', 'file');
$tempFile = fopen($tempFilePath, 'wb');
if ($tempFile) {
while (!feof($fileData)) {
$read = fread($fileData, $bytesToRead);
if ($read === FALSE) {
// Close the file streams.
fclose($tempFile);
fclose($fileData);
throw new UploadException('Input file data could not be read');
}
if (fwrite($tempFile, $read) === FALSE) {
// Close the file streams.
fclose($tempFile);
fclose($fileData);
throw new CannotWriteFileException(sprintf('Temporary file data for "%s" could not be written', $tempFilePath));
}
}
// Close the temp file stream.
fclose($tempFile);
}
else {
// Close the input file stream since we can't proceed with the upload.
// Don't try to close $tempFile since it's FALSE at this point.
fclose($fileData);
throw new NoFileException(sprintf('Temporary file "%s" could not be opened for file upload', $tempFilePath));
}
// Close the input stream.
fclose($fileData);
return $tempFilePath;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Drupal\file\Upload;
/**
* Uploads files from a stream.
*/
interface InputStreamFileWriterInterface {
/**
* The length of bytes to read in each iteration when streaming file data.
*/
const DEFAULT_BYTES_TO_READ = 8192;
/**
* The default stream.
*/
const DEFAULT_STREAM = "php://input";
/**
* Write the input stream to a temporary file.
*
* @param string $stream
* (optional) The input stream.
* @param int $bytesToRead
* (optional) The length of bytes to read in each iteration.
*
* @return string
* The temporary file path.
*/
public function writeStreamToFile(string $stream = self::DEFAULT_STREAM, int $bytesToRead = self::DEFAULT_BYTES_TO_READ): string;
}

View File

@ -0,0 +1,66 @@
<?php
namespace Drupal\file\Upload;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* An uploaded file from an input stream.
*/
final class InputStreamUploadedFile implements UploadedFileInterface {
/**
* Creates a new InputStreamUploadedFile.
*/
public function __construct(
protected readonly string $clientOriginalName,
protected readonly string $filename,
protected readonly string $realPath,
protected readonly int | false $size,
) {}
/**
* {@inheritdoc}
*/
public function getClientOriginalName(): string {
return $this->clientOriginalName;
}
/**
* {@inheritdoc}
*/
public function getSize(): int {
return $this->size;
}
/**
* {@inheritdoc}
*/
public function getRealPath(): string | false {
return $this->realPath;
}
/**
* {@inheritdoc}
*/
public function getFilename(): string {
return $this->filename;
}
/**
* {@inheritdoc}
*/
public function getPathname(): string {
throw new \BadMethodCallException(__METHOD__ . ' not implemented');
}
/**
* {@inheritdoc}
*/
public function validate(ValidatorInterface $validator, array $options = []): ConstraintViolationListInterface {
return new ConstraintViolationList();
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace Drupal\file\Upload;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Provides an interface for uploaded files.
*/
interface UploadedFileInterface {
/**
* Returns the original file name.
*
* The file name is extracted from the request that uploaded the file and as
* such should not be considered a safe value.
*
* @return string
* The original file name supplied by the client.
*/
public function getClientOriginalName(): string;
/**
* Gets file size.
*
* @return int
* The filesize in bytes.
*
* @see https://www.php.net/manual/en/splfileinfo.getsize.php
*/
public function getSize(): int;
/**
* Gets the absolute path to the file.
*
* @return string|false
* The path to the file, or FALSE if the file does not exist.
*
* @see https://php.net/manual/en/splfileinfo.getrealpath.php
*/
public function getRealPath();
/**
* Gets the path to the file.
*
* @return string
* The path to the file.
*
* @see https://php.net/manual/en/splfileinfo.getpathname.php
*/
public function getPathname(): string;
/**
* Gets the filename.
*
* @return string
* The filename.
*
* @see https://php.net/manual/en/splfileinfo.getfilename.php
*/
public function getFilename(): string;
/**
* Validates the uploaded file information.
*
* @param \Symfony\Component\Validator\Validator\ValidatorInterface $validator
* A validator object.
* @param array $options
* Options to pass to a constraint.
*
* @return \Symfony\Component\Validator\ConstraintViolationListInterface
* The list of violations.
*/
public function validate(ValidatorInterface $validator, array $options = []): ConstraintViolationListInterface;
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* A constraint for UploadedFile objects.
*/
class UploadedFileConstraint extends Constraint {
/**
* The upload max size. Defaults to checking the environment.
*
* @var int|null
*/
public ?int $maxSize;
/**
* The upload ini size error message.
*
* @var string
*/
public string $uploadIniSizeErrorMessage = 'The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.';
/**
* The upload form size error message.
*
* @var string
*/
public string $uploadFormSizeErrorMessage = 'The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.';
/**
* The upload partial error message.
*
* @var string
*/
public string $uploadPartialErrorMessage = 'The file %file could not be saved because the upload did not complete.';
/**
* The upload no file error message.
*
* @var string
*/
public string $uploadNoFileErrorMessage = 'The file %file could not be saved because the upload did not complete.';
/**
* The generic file upload error message.
*
* @var string
*/
public string $uploadErrorMessage = 'The file %file could not be saved. An unknown error has occurred.';
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Drupal\file\Validation\Constraint;
use Drupal\Component\Utility\Environment;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Constraint validator for uploaded files.
*
* Use FileValidatorInterface for validating file entities.
*
* @see \Drupal\Core\Validation\FileValidatorInterface
*/
class UploadedFileConstraintValidator extends ConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate(mixed $value, Constraint $constraint): void {
if (!$constraint instanceof UploadedFileConstraint) {
throw new UnexpectedTypeException($constraint, UploadedFileConstraint::class);
}
if (!$value instanceof UploadedFile) {
throw new UnexpectedTypeException($value, UploadedFile::class);
}
if ($value->isValid()) {
return;
}
$maxSize = $constraint->maxSize ?? Environment::getUploadMaxSize();
match ($value->getError()) {
\UPLOAD_ERR_INI_SIZE => $this->context->buildViolation($constraint->uploadIniSizeErrorMessage, [
'%file' => $value->getClientOriginalName(),
'%maxsize' => ByteSizeMarkup::create($maxSize),
])->setCode((string) \UPLOAD_ERR_INI_SIZE)
->addViolation(),
\UPLOAD_ERR_FORM_SIZE => $this->context->buildViolation($constraint->uploadFormSizeErrorMessage, [
'%file' => $value->getClientOriginalName(),
'%maxsize' => ByteSizeMarkup::create($maxSize),
])->setCode((string) \UPLOAD_ERR_FORM_SIZE)
->addViolation(),
\UPLOAD_ERR_PARTIAL => $this->context->buildViolation($constraint->uploadPartialErrorMessage, [
'%file' => $value->getClientOriginalName(),
])->setCode((string) \UPLOAD_ERR_PARTIAL)
->addViolation(),
\UPLOAD_ERR_NO_FILE => $this->context->buildViolation($constraint->uploadNoFileErrorMessage, [
'%file' => $value->getClientOriginalName(),
])->setCode((string) \UPLOAD_ERR_NO_FILE)
->addViolation(),
default => $this->context->buildViolation($constraint->uploadErrorMessage, [
'%file' => $value->getClientOriginalName(),
])->setCode((string) $value->getError())
->addViolation()
};
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Drupal\file\Validation;
use Drupal\file\FileInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Event for file validations.
*/
class FileValidationEvent extends Event {
/**
* Creates a new FileValidationEvent.
*
* @param \Drupal\file\FileInterface $file
* The file.
* @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations
* The violations.
*/
public function __construct(
public readonly FileInterface $file,
public readonly ConstraintViolationListInterface $violations,
) {}
}

View File

@ -0,0 +1,63 @@
<?php
namespace Drupal\file\Validation;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Validation\ConstraintManager;
use Drupal\file\FileInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Provides a class for file validation.
*/
class FileValidator implements FileValidatorInterface {
/**
* Creates a new FileValidator.
*
* @param \Symfony\Component\Validator\Validator\ValidatorInterface $validator
* The validator.
* @param \Drupal\Core\Validation\ConstraintManager $constraintManager
* The constraint factory.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher
* The event dispatcher.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler.
*/
public function __construct(
protected ValidatorInterface $validator,
protected ConstraintManager $constraintManager,
protected EventDispatcherInterface $eventDispatcher,
protected ModuleHandlerInterface $moduleHandler,
) {}
/**
* {@inheritdoc}
*/
public function validate(FileInterface $file, array $validators): ConstraintViolationListInterface {
$constraints = [];
foreach ($validators as $validator => $options) {
// Create the constraint.
// Options are an associative array of constraint properties and values.
$constraints[] = $this->constraintManager->create($validator, $options);
}
// Get the typed data.
$fileTypedData = $file->getTypedData();
$violations = $this->validator->validate($fileTypedData, $constraints);
$this->eventDispatcher->dispatch(new FileValidationEvent($file, $violations));
// Always check the insecure upload constraint.
if (count($violations) === 0) {
$insecureUploadConstraint = $this->constraintManager->create('FileExtensionSecure', []);
$violations = $this->validator->validate($fileTypedData, $insecureUploadConstraint);
}
return $violations;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Drupal\file\Validation;
use Drupal\file\FileInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Provides a file validator that supports a list of validations.
*/
interface FileValidatorInterface {
/**
* Validates a File with a list of validators.
*
* @param \Drupal\file\FileInterface $file
* The file to validate.
* @param array $validators
* An associative array of validators with:
* - key: the plugin ID of the file validation constraint.
* - value: an associative array of options to pass to the constraint.
*
* @return \Symfony\Component\Validator\ConstraintViolationListInterface
* The violations list.
*/
public function validate(FileInterface $file, array $validators): ConstraintViolationListInterface;
}

View File

@ -0,0 +1,51 @@
<?php
namespace Drupal\file\Validation;
use Drupal\Component\Utility\Bytes;
use Drupal\Component\Utility\Environment;
/**
* Provides a trait to create validators from settings.
*/
trait FileValidatorSettingsTrait {
/**
* Gets the upload validators for the specified settings.
*
* @param array $settings
* An associative array of settings. The following keys are supported:
* - max_filesize: The maximum file size in bytes. Defaults to the PHP max
* upload size.
* - file_extensions: A space-separated list of allowed file extensions.
*
* @return array
* An array suitable for passing to file_save_upload() or the file field
* element's '#upload_validators' property.
*/
public function getFileUploadValidators(array $settings): array {
$validators = [
// Add in our check of the file name length.
'FileNameLength' => [],
];
// Cap the upload size according to the PHP limit.
$maxFilesize = Bytes::toNumber(Environment::getUploadMaxSize());
if (!empty($settings['max_filesize'])) {
$maxFilesize = min($maxFilesize, Bytes::toNumber($settings['max_filesize']));
}
// There is always a file size limit due to the PHP server limit.
$validators['FileSizeLimit'] = ['fileLimit' => $maxFilesize];
// Add the extension check if necessary.
if (!empty($settings['file_extensions'])) {
$validators['FileExtension'] = [
'extensions' => $settings['file_extensions'],
];
}
return $validators;
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Drupal\file\Validation;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\TypedData\TypedDataManagerInterface;
use Drupal\Core\Validation\ExecutionContextFactory;
use Drupal\Core\TypedData\Validation\RecursiveValidator;
use Drupal\Core\Validation\ConstraintValidatorFactory;
use Drupal\Core\Validation\DrupalTranslator;
/**
* Factory for creating a new RecursiveValidator.
*/
class RecursiveValidatorFactory {
/**
* Constructs a new RecursiveValidatorFactory.
*
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $classResolver
* The class resolver.
* @param \Drupal\Core\TypedData\TypedDataManagerInterface $typedDataManager
* The typed data manager.
*/
public function __construct(
protected ClassResolverInterface $classResolver,
protected TypedDataManagerInterface $typedDataManager,
) {}
/**
* Creates a new RecursiveValidator.
*
* @return \Drupal\Core\TypedData\Validation\RecursiveValidator
* The validator.
*/
public function createValidator(): RecursiveValidator {
return new RecursiveValidator(
new ExecutionContextFactory(new DrupalTranslator()),
new ConstraintValidatorFactory($this->classResolver),
$this->typedDataManager,
);
}
}