Commit 7b3f57c6 authored by catch's avatar catch
Browse files

Merge 9.1.13

parents caf0cd74 bdafd020
......@@ -80,7 +80,7 @@ class Drupal {
/**
* The current system version.
*/
const VERSION = '9.1.13-dev';
const VERSION = '9.1.14-dev';
/**
* Core API compatibility.
......
......@@ -180,6 +180,11 @@ public function testMetadata() {
// Verify metadata.
$items = $entity->get($this->fieldName);
\Drupal::state()->set('quickedit_test_field_access', 'forbidden');
$this->assertSame(['access' => FALSE], $this->metadataGenerator->generateFieldMetadata($items, 'default'));
\Drupal::state()->set('quickedit_test_field_access', 'neutral');
$this->assertSame(['access' => FALSE], $this->metadataGenerator->generateFieldMetadata($items, 'default'));
\Drupal::state()->set('quickedit_test_field_access', 'allowed');
$metadata = $this->metadataGenerator->generateFieldMetadata($items, 'default');
$expected = [
'access' => TRUE,
......
......@@ -261,15 +261,28 @@ public function post(Request $request, $entity_type_id, $bundle, $field_name) {
$file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename));
@trigger_error('\Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Implement \Symfony\Component\Mime\MimeTypeGuesserInterface instead. See https://www.drupal.org/node/3133341', E_USER_DEPRECATED);
}
$file->setFileUri($file_uri);
$file->setFileUri($temp_file_path);
// Set the size. This is done in File::preSave() but we validate the file
// before it is saved.
$file->setSize(@filesize($temp_file_path));
// Validate the file entity against entity-level validation and field-level
// validators.
$this->validate($file, $validators);
// Validate the file against field-level validators first while the file is
// still a temporary file. Validation is split up in 2 steps to be the same
// as in _file_save_upload_single().
// For backwards compatibility this part is copied from ::validate() to
// leave that method behavior unchanged.
// @todo Improve this with a file uploader service in
// https://www.drupal.org/project/drupal/issues/2940383
$errors = file_validate($file, $validators);
if (!empty($errors)) {
$message = "Unprocessable Entity: file validation failed.\n";
$message .= implode("\n", array_map([PlainTextOutput::class, 'renderFromHtml'], $errors));
throw new UnprocessableEntityHttpException($message);
}
$file->setFileUri($file_uri);
// Move the file to the correct location after validation. Use
// FileSystemInterface::EXISTS_ERROR as the file location has already been
// determined above in FileSystem::getDestinationFilename().
......@@ -280,6 +293,9 @@ public function post(Request $request, $entity_type_id, $bundle, $field_name) {
throw new HttpException(500, 'Temporary file could not be moved to file location');
}
// Second step of the validation on the file object itself now.
$this->resourceValidate($file);
$file->save();
$this->lock->release($lock_id);
......@@ -432,6 +448,11 @@ protected function validateAndLoadFieldDefinition($entity_type_id, $bundle, $fie
/**
* Validates the file.
*
* @todo this method is unused in this class because file validation needs to
* be split up in 2 steps in ::post(). Add a deprecation notice as soon as a
* central core file upload service can be used in this class.
* See https://www.drupal.org/project/drupal/issues/2940383
*
* @param \Drupal\file\FileInterface $file
* The file entity to validate.
* @param array $validators
......
......@@ -42,9 +42,19 @@ protected function setUp(): void {
// Create the Article node type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
}
/**
* Tests that quick editor works correctly with images.
*
* @covers ::isCompatible
* @covers ::getAttachments
*
* @dataProvider providerTestImageInPlaceEditor
*/
public function testImageInPlaceEditor($admin_permission = FALSE) {
// Log in as a content author who can use Quick Edit and edit Articles.
$this->contentAuthorUser = $this->drupalCreateUser([
$permissions = [
'access contextual links',
'access toolbar',
'access in-place editing',
......@@ -52,17 +62,13 @@ protected function setUp(): void {
'create article content',
'edit any article content',
'delete any article content',
]);
];
if ($admin_permission) {
$permissions[] = 'administer nodes';
}
$this->contentAuthorUser = $this->drupalCreateUser($permissions);
$this->drupalLogin($this->contentAuthorUser);
}
/**
* Test that quick editor works correctly with images.
*
* @covers ::isCompatible
* @covers ::getAttachments
*/
public function testImageInPlaceEditor() {
// Create a field with a basic filetype restriction.
$field_name = strtolower($this->randomMachineName());
$field_settings = [
......@@ -127,13 +133,25 @@ public function testImageInPlaceEditor() {
$this->assertEntityInstanceStates([
'node/1[0]' => 'closed',
]);
$admin_inactive = [];
$admin_candidate = [];
if ($admin_permission) {
$admin_inactive = [
'node/1/uid/en/full' => 'inactive',
'node/1/created/en/full' => 'inactive',
];
$admin_candidate = [
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
];
}
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'inactive',
'node/1/uid/en/full' => 'inactive',
'node/1/created/en/full' => 'inactive',
'node/1/body/en/full' => 'inactive',
'node/1/' . $field_name . '/en/full' => 'inactive',
]);
] + $admin_inactive);
// Start in-place editing of the article node.
$this->startQuickEditViaToolbar('node', 1, 0);
......@@ -143,11 +161,9 @@ public function testImageInPlaceEditor() {
$this->assertQuickEditEntityToolbar((string) $node->label(), NULL);
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'candidate',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/' . $field_name . '/en/full' => 'candidate',
]);
] + $admin_candidate);
// Click the image field.
$this->click($field_selector);
......@@ -155,21 +171,17 @@ public function testImageInPlaceEditor() {
$this->assertSession()->elementExists('css', $field_selector . ' .quickedit-image-dropzone');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'candidate',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/' . $field_name . '/en/full' => 'active',
]);
] + $admin_candidate);
// Type new 'alt' text.
$this->typeInImageEditorAltTextInput('New text');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'candidate',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/' . $field_name . '/en/full' => 'changed',
]);
] + $admin_candidate);
// Drag and drop an image.
$this->dropImageOnImageEditor($valid_images[1]->uri);
......@@ -185,11 +197,9 @@ public function testImageInPlaceEditor() {
]);
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'candidate',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/' . $field_name . '/en/full' => 'saving',
]);
] + $admin_candidate);
$this->assertEntityInstanceFieldMarkup([
'node/1/' . $field_name . '/en/full' => '.quickedit-changed',
]);
......@@ -209,4 +219,17 @@ public function testImageInPlaceEditor() {
$this->assertSession()->elementExists('css', $entity_selector . ' ' . $field_selector . ' ' . $new_image_selector);
}
/**
* Data provider for ::testImageInPlaceEditor().
*
* @return array
* Test cases.
*/
public function providerTestImageInPlaceEditor(): array {
return [
'with permission' => [TRUE],
'without permission' => [FALSE],
];
}
}
......@@ -18,6 +18,7 @@
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Utility\Token;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Core\Entity\EntityConstraintViolationList;
use Drupal\file\Entity\File;
use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
use Psr\Log\LoggerInterface;
......@@ -180,18 +181,37 @@ public function handleFileUploadForField(FieldDefinitionInterface $field_definit
@trigger_error('\Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Implement \Symfony\Component\Mime\MimeTypeGuesserInterface instead. See https://www.drupal.org/node/3133341', E_USER_DEPRECATED);
$file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename));
}
$file->setFileUri($file_uri);
$file->setFileUri($temp_file_path);
// Set the size. This is done in File::preSave() but we validate the file
// before it is saved.
$file->setSize(@filesize($temp_file_path));
// Validate the file entity against entity-level validation and field-level
// validators.
$violations = $this->validate($file, $validators);
if ($violations->count() > 0) {
// Validate the file against field-level validators first while the file is
// still a temporary file. Validation is split up in 2 steps to be the same
// as in _file_save_upload_single().
// For backwards compatibility this part is copied from ::validate() to
// leave that method behavior unchanged.
// @todo Improve this with a file uploader service in
// https://www.drupal.org/project/drupal/issues/2940383
$errors = file_validate($file, $validators);
if (!empty($errors)) {
$violations = new EntityConstraintViolationList($file);
$translator = new DrupalTranslator();
$entity = EntityAdapter::createFromEntity($file);
foreach ($errors as $error) {
$violation = new ConstraintViolation($translator->trans($error),
$error,
[],
$entity,
'',
NULL
);
$violations->add($violation);
}
return $violations;
}
$file->setFileUri($file_uri);
// Move the file to the correct location after validation. Use
// FileSystemInterface::EXISTS_ERROR as the file location has already been
// determined above in FileSystem::getDestinationFilename().
......@@ -202,6 +222,16 @@ public function handleFileUploadForField(FieldDefinitionInterface $field_definit
throw new HttpException(500, 'Temporary file could not be moved to file location');
}
// Second step of the validation on the file object itself now.
$violations = $file->validate();
// Remove violations of inaccessible fields as they cannot stem from our
// changes.
$violations->filterByFieldAccess();
if ($violations->count() > 0) {
return $violations;
}
$file->save();
$this->lock->release($lock_id);
......@@ -340,6 +370,11 @@ protected function streamUploadData() {
/**
* Validates the file.
*
* @todo this method is unused in this class because file validation needs to
* be split up in 2 steps in ::handleFileUploadForField(). Add a deprecation
* notice as soon as a central core file upload service can be used in this
* class. See https://www.drupal.org/project/drupal/issues/2940383
*
* @param \Drupal\file\FileInterface $file
* The file entity to validate.
* @param array $validators
......
......@@ -393,7 +393,7 @@ public function getRelatableResourceTypes() {
$this->relatableResourceTypesByField = array_reduce(array_map(function (ResourceTypeRelationship $field) {
return [$field->getPublicName() => $field->getRelatableResourceTypes()];
}, array_filter($this->fields, function (ResourceTypeField $field) {
return $field instanceof ResourceTypeRelationship;
return $field instanceof ResourceTypeRelationship && $field->isFieldEnabled();
})), 'array_merge', []);
}
return $this->relatableResourceTypesByField;
......@@ -411,7 +411,7 @@ public function getRelatableResourceTypes() {
* @see self::getRelatableResourceTypes()
*/
public function getRelatableResourceTypesByField($field_name) {
return ($field = $this->getFieldByPublicName($field_name)) && $field instanceof ResourceTypeRelationship
return ($field = $this->getFieldByPublicName($field_name)) && $field instanceof ResourceTypeRelationship && $field->isFieldEnabled()
? $field->getRelatableResourceTypes()
: [];
}
......
......@@ -113,6 +113,14 @@ public function testQuickEditIgnoresDuplicateFields() {
$this->drupalLogin($this->contentAuthorUser);
$this->usingLayoutBuilder = TRUE;
$this->assertQuickEditInit(['title']);
$this->drupalLogin($this->drupalCreateUser([
'access contextual links',
'access in-place editing',
'access content',
'edit any article content',
'administer nodes',
]));
$this->assertQuickEditInit(['title', 'uid', 'created']);
}
......@@ -124,7 +132,7 @@ public function testQuickEditIgnoresDuplicateFields() {
*
* @dataProvider providerEnableDisableLayoutBuilder
*/
public function testEnableDisableLayoutBuilder($use_revisions) {
public function testEnableDisableLayoutBuilder($use_revisions, $admin_permission = FALSE) {
if (!$use_revisions) {
$content_type = NodeType::load('article');
$content_type->setNewRevision(FALSE);
......@@ -132,10 +140,18 @@ public function testEnableDisableLayoutBuilder($use_revisions) {
}
$fields = [
'title',
'uid',
'created',
'body',
];
if ($admin_permission) {
$fields = array_merge($fields, ['uid', 'created']);
$this->drupalLogin($this->drupalCreateUser([
'access contextual links',
'access in-place editing',
'access content',
'edit any article content',
'administer nodes',
]));
}
// Test article with Layout Builder disabled.
$this->assertQuickEditInit($fields);
......@@ -169,8 +185,10 @@ public function testEnableDisableLayoutBuilder($use_revisions) {
*/
public function providerEnableDisableLayoutBuilder() {
return [
'use revisions' => [TRUE],
'do not use revisions' => [FALSE],
'use revisions, not admin' => [TRUE],
'do not use revisions, not admin' => [FALSE],
'use revisions, admin' => [TRUE, TRUE],
'do not use revisions, admin' => [FALSE, TRUE],
];
}
......
......@@ -472,6 +472,10 @@
uuid: this.data.attributes['data-entity-uuid'],
},
dataType: 'html',
headers: {
'X-Drupal-MediaPreview-CSRF-Token':
editor.config.drupalMedia_previewCsrfToken,
},
success: (previewHtml, textStatus, jqXhr) => {
this.element.setHtml(previewHtml);
this.setData(
......
......@@ -311,6 +311,9 @@
uuid: this.data.attributes['data-entity-uuid']
},
dataType: 'html',
headers: {
'X-Drupal-MediaPreview-CSRF-Token': editor.config.drupalMedia_previewCsrfToken
},
success: function success(previewHtml, textStatus, jqXhr) {
_this3.element.setHtml(previewHtml);
......
......@@ -7,10 +7,12 @@
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\filter\FilterFormatInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
......@@ -93,6 +95,8 @@ public static function create(ContainerInterface $container) {
* @see \Drupal\editor\EditorController::getUntransformedText
*/
public function preview(Request $request, FilterFormatInterface $filter_format) {
self::checkCsrf($request, \Drupal::currentUser());
$text = $request->query->get('text');
$uuid = $request->query->get('uuid');
if ($text == '' || $uuid == '') {
......@@ -140,4 +144,30 @@ public static function formatUsesMediaEmbedFilter(FilterFormatInterface $filter_
->addCacheableDependency($filter_format);
}
/**
* Throws an AccessDeniedHttpException if the request fails CSRF validation.
*
* This is used instead of \Drupal\Core\Access\CsrfAccessCheck, in order to
* allow access for anonymous users.
*
* @todo Refactor this to an access checker.
*/
private static function checkCsrf(Request $request, AccountInterface $account) {
$header = 'X-Drupal-MediaPreview-CSRF-Token';
if (!$request->headers->has($header)) {
throw new AccessDeniedHttpException();
}
if ($account->isAnonymous()) {
// For anonymous users, just the presence of the custom header is
// sufficient protection.
return;
}
// For authenticated users, validate the token value.
$token = $request->headers->get($header);
if (!\Drupal::csrfToken()->validate($token, $header)) {
throw new AccessDeniedHttpException();
}
}
}
......@@ -98,7 +98,9 @@ public function getFile() {
* {@inheritdoc}
*/
public function getConfig(Editor $editor) {
return [];
return [
'drupalMedia_previewCsrfToken' => \Drupal::csrfToken()->get('X-Drupal-MediaPreview-CSRF-Token'),
];
}
/**
......
......@@ -3,7 +3,6 @@
namespace Drupal\Tests\media\FunctionalJavascript;
use Drupal\Component\Utility\Html;
use Drupal\Core\Url;
use Drupal\editor\Entity\Editor;
use Drupal\field\Entity\FieldConfig;
use Drupal\file\Entity\File;
......@@ -1034,14 +1033,13 @@ public function linkabilityProvider() {
* @dataProvider previewAccessProvider
*/
public function testEmbedPreviewAccess($media_embed_enabled, $can_use_format) {
$format = FilterFormat::create([
'format' => $this->randomMachineName(),
'name' => $this->randomString(),
'filters' => [
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => ['status' => $media_embed_enabled],
],
// Reconfigure the host entity's text format to suit our needs.
/** @var \Drupal\filter\FilterFormatInterface $format */
$format = FilterFormat::load($this->host->body->format);
$format->set('filters', [
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => ['status' => $media_embed_enabled],
]);
$format->save();
......@@ -1052,24 +1050,23 @@ public function testEmbedPreviewAccess($media_embed_enabled, $can_use_format) {
$permissions[] = $format->getPermissionName();
}
$this->drupalLogin($this->drupalCreateUser($permissions));
$text = '<drupal-media data-caption="baz" data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '"></drupal-media>';
$route_parameters = ['filter_format' => $format->id()];
$options = [
'query' => [
'text' => $text,
'uuid' => $this->media->uuid(),
],
];
$this->drupalGet(Url::fromRoute('media.filter.preview', $route_parameters, $options));
$this->drupalGet($this->host->toUrl('edit-form'));
$assert_session = $this->assertSession();
if ($media_embed_enabled && $can_use_format) {
$assert_session->elementExists('css', 'img');
$assert_session->responseContains('baz');
if ($can_use_format) {
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
if ($media_embed_enabled) {
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'article.media'));
}
else {
$assert_session->assertWaitOnAjaxRequest();
$assert_session->elementNotExists('css', 'article.media');
}
}
else {
$assert_session->responseContains('You are not authorized to access this page.');
$assert_session->pageTextContains('This field has been disabled because you do not have sufficient permissions to edit it.');
}
}
......
......@@ -526,6 +526,9 @@
options.success.call(entityModel);
}
};
entitySaverAjax.options.headers = entitySaverAjax.options.headers || {};
entitySaverAjax.options.headers['X-Drupal-Quickedit-CSRF-Token'] =
drupalSettings.quickedit.csrf_token;
// Trigger the AJAX request, which will return the quickeditEntitySaved
// AJAX command to which we then react.
entitySaverAjax.execute();
......
......@@ -235,6 +235,8 @@
}
};
entitySaverAjax.options.headers = entitySaverAjax.options.headers || {};
entitySaverAjax.options.headers['X-Drupal-Quickedit-CSRF-Token'] = drupalSettings.quickedit.csrf_token;
entitySaverAjax.execute();
},
validate: function validate(attrs, options) {
......
......@@ -53,6 +53,7 @@ function quickedit_page_attachments(array &$page) {
return;
}
$page['#attached']['drupalSettings']['quickedit']['csrf_token'] = \Drupal::csrfToken()->get('X-Drupal-Quickedit-CSRF-Token');
$page['#attached']['library'][] = 'quickedit/quickedit';
}
......
......@@ -68,7 +68,7 @@ public function generateFieldMetadata(FieldItemListInterface $items, $view_mode)
// Early-return if user does not have access.
$access = $this->accessChecker->accessEditEntityField($entity, $field_name);
if (!$access) {
if (!$access->isAllowed()) {
return ['access' => FALSE];
}
......
......@@ -6,10 +6,12 @@
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Entity\EntityInterface;
......@@ -157,6 +159,32 @@ public function metadata(Request $request) {
return new JsonResponse($metadata);
}
/**
* Throws an AccessDeniedHttpException if the request fails CSRF validation.
*
* This is used instead of \Drupal\Core\Access\CsrfAccessCheck, in order to
* allow access for anonymous users.
*
* @todo Refactor this to an access checker.