Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1884,12 +1884,6 @@ parameters:
count: 1
path: src/lib/Form/EventSubscriber/UserFieldsSubscriber.php

-
message: '#^Method Ibexa\\ContentForms\\Form\\EventSubscriber\\UserFieldsSubscriber\:\:handleUserUpdateData\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/lib/Form/EventSubscriber/UserFieldsSubscriber.php

-
message: '#^Method Ibexa\\ContentForms\\Form\\EventSubscriber\\UserFieldsSubscriber\:\:handleUserUpdateData\(\) has parameter \$languageCode with no type specified\.$#'
identifier: missingType.parameter
Expand Down
4 changes: 2 additions & 2 deletions src/bundle/Controller/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ public function editAction(
string $language,
Request $request
) {
$user = $this->userService->loadUser($contentId);
$user = $this->userService->loadUser($contentId, [$language]);
if (!$this->permissionResolver->canUser('content', 'edit', $user)) {
throw new CoreUnauthorizedException('content', 'edit', ['userId' => $contentId]);
}
Expand Down Expand Up @@ -199,7 +199,7 @@ public function editAction(
$userUpdate,
[
'location' => $location,
'content' => $this->contentService->loadContent($contentId),
'content' => $this->contentService->loadContent($contentId, [$language]),
'languageCode' => $language,
'mainLanguageCode' => $user->contentInfo->mainLanguageCode,
'struct' => $userUpdate,
Expand Down
7 changes: 7 additions & 0 deletions src/bundle/Resources/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ services:
tags:
- { name: kernel.event_subscriber }

Ibexa\ContentForms\Form\Processor\User\UserTranslationSecureListener:
arguments:
- '@ibexa.api.service.user'
- '@ibexa.api.service.content'
tags:
- { name: kernel.event_subscriber }

Ibexa\ContentForms\Form\Processor\SystemUrlRedirectProcessor:
autowire: true
autoconfigure: true
Expand Down
3 changes: 2 additions & 1 deletion src/lib/Data/Mapper/UserUpdateMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ public function mapToFormData(User $user, ContentType $contentType, array $param
$filter = $params['filter'];

$fields = $user->getFieldsByLanguage($params['languageCode']);
$mainLanguageCode = $user->contentInfo->mainLanguageCode;
foreach ($contentType->fieldDefinitions as $fieldDef) {
$field = $fields[$fieldDef->identifier];
$field = $fields[$fieldDef->identifier] ?? $user->getField($fieldDef->identifier, $mainLanguageCode);

if (is_callable($filter) && !($filter)($field)) {
continue;
Expand Down
23 changes: 17 additions & 6 deletions src/lib/FieldType/Mapper/UserAccountFieldValueFormMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

namespace Ibexa\ContentForms\FieldType\Mapper;

use Ibexa\ContentForms\Data\ContentTranslationData;
use Ibexa\ContentForms\Data\User\UserAccountFieldData;
use Ibexa\ContentForms\Data\User\UserCreateData;
use Ibexa\ContentForms\Data\User\UserUpdateData;
use Ibexa\ContentForms\Form\Type\FieldType\UserAccountFieldType;
use Ibexa\Contracts\ContentForms\Data\Content\FieldData;
use Ibexa\Contracts\ContentForms\FieldType\FieldValueFormMapperInterface;
Expand Down Expand Up @@ -41,12 +42,18 @@
$formConfig = $fieldForm->getConfig();
$rootForm = $fieldForm->getRoot()->getRoot();
$formIntent = $rootForm->getConfig()->getOption('intent');
$isTranslation = $rootForm->getData() instanceof ContentTranslationData;
$rootData = $rootForm->getData();
$isTranslation = !$rootData instanceof UserCreateData && !$rootData instanceof UserUpdateData;
$isNonMainLanguageTranslation = $isTranslation
&& !$fieldDefinition->isTranslatable
&& $formConfig->getOption('languageCode') !== $formConfig->getOption('mainLanguageCode');

$formBuilder = $formConfig->getFormFactory()->createBuilder()
->create('value', UserAccountFieldType::class, [
'required' => true,
'label' => $fieldDefinition->getName(),
'intent' => $formIntent,
'disabled' => $isNonMainLanguageTranslation,
]);

if ($isTranslation) {
Expand Down Expand Up @@ -82,13 +89,17 @@
*/
public function getModelTransformerForTranslation(FieldDefinition $fieldDefinition): CallbackTransformer
{
/** @var \Ibexa\Core\FieldType\User\Value $currentValue */
$currentValue = clone $fieldDefinition->defaultValue;

return new CallbackTransformer(
static function (ApiUserValue $data) {
static function (ApiUserValue $data) use (&$currentValue) {

Check warning on line 96 in src/lib/FieldType/Mapper/UserAccountFieldValueFormMapper.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused "$currentValue" local variable.

See more on https://sonarcloud.io/project/issues?id=ibexa_content-forms&issues=AaA34a3TiNYzrpZF71Bv&open=AaA34a3TiNYzrpZF71Bv&pullRequest=114
$currentValue = $data;

return new UserAccountFieldData($data->login, null, $data->email, $data->enabled);
},
static function (UserAccountFieldData $submittedData) use ($fieldDefinition) {
/** @var \Ibexa\Core\FieldType\User\Value $userValue */
$userValue = clone $fieldDefinition->defaultValue;
static function (UserAccountFieldData $submittedData) use (&$currentValue) {
$userValue = clone $currentValue;

$userValue->login = $submittedData->username;
$userValue->email = $submittedData->email;
Expand Down
12 changes: 9 additions & 3 deletions src/lib/Form/EventSubscriber/UserFieldsSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

use Ibexa\ContentForms\Data\User\UserCreateData;
use Ibexa\ContentForms\Data\User\UserUpdateData;
use Ibexa\Core\FieldType\User\Value;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
Expand Down Expand Up @@ -84,7 +83,7 @@ private function handleUserCreateData(UserCreateData $data)
* @param \Ibexa\ContentForms\Data\User\UserUpdateData $data
* @param $languageCode
*/
private function handleUserUpdateData(UserUpdateData $data, $languageCode)
private function handleUserUpdateData(UserUpdateData $data, $languageCode): void
{
foreach ($data->fieldsData as $fieldData) {
if ('ezuser' !== $fieldData->getFieldTypeIdentifier()) {
Expand All @@ -96,9 +95,16 @@ private function handleUserUpdateData(UserUpdateData $data, $languageCode)
$data->email = $userAccountFieldData->email;
$data->password = $userAccountFieldData->password;
$data->enabled = $userAccountFieldData->enabled;
$fieldIdentifier = $fieldData->field->fieldDefIdentifier;

// The "user_account" field is non-translatable and therefore
// never stored under a language other than the main one: fall
// back to it when editing/translating in a different language.
$userField = $data->user->getField($fieldIdentifier, $languageCode)
?? $data->user->getField($fieldIdentifier, $data->user->contentInfo->mainLanguageCode);

/** @var \Ibexa\Core\FieldType\User\Value $userValue */
$userValue = clone $data->user->getField($fieldData->field->fieldDefIdentifier, $languageCode)->value;
$userValue = clone $userField->value;
$userValue->email = $data->email;
$userValue->enabled = $data->enabled;
$userValue->plainPassword = $data->password;
Expand Down
80 changes: 80 additions & 0 deletions src/lib/Form/Processor/User/UserTranslationSecureListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\ContentForms\Form\Processor\User;

use Ibexa\ContentForms\Event\ContentFormEvents;
use Ibexa\ContentForms\Event\FormActionEvent;
use Ibexa\Contracts\Core\Repository\ContentService;
use Ibexa\Contracts\Core\Repository\UserService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
* Re-applies the User data after publishing a non-main-language translation.
*
* The "user_account" field (ezuser) is non-translatable and its form is disabled while
* translating (see UserAccountFieldValueFormMapper::mapFieldValueForm()), so it is never
* submitted as part of the translation's content update. Without this listener re-running
* UserService::updateUser() for the translated language after publish, translating User
* content throws a content field validation error / leaves the user_account field broken
* for that language.
*
* It also prevents data loss on translation removal: without re-persisting the user_account
* field for the translation's language here, ContentService::deleteTranslation() on that
* language would delete the User itself (login, password, etc.), even though the underlying
* content object survives.
*/
final class UserTranslationSecureListener implements EventSubscriberInterface
{
private UserService $userService;

private ContentService $contentService;

public function __construct(
UserService $userService,
ContentService $contentService
) {
$this->userService = $userService;
$this->contentService = $contentService;
}

public static function getSubscribedEvents(): array
{
return [
ContentFormEvents::CONTENT_PUBLISH => ['onPublish', 5],
Comment thread
ViniTou marked this conversation as resolved.
ContentFormEvents::CONTENT_PUBLISH_AND_EDIT => ['onPublish', 5],
];
}

/**
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentValidationException
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentFieldValidationException
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException
*/
public function onPublish(FormActionEvent $event): void
{
$content = $event->getPayload('content');
if (null === $content || !$this->userService->isUser($content)) {
return;
}

$languageCode = $event->getForm()->getConfig()->getOption('languageCode');
if ($languageCode === $content->contentInfo->mainLanguageCode) {
return;
}

$user = $this->userService->loadUser($content->id, [$languageCode]);
$userStruct = $this->userService->newUserUpdateStruct();
$userStruct->contentUpdateStruct = $this->contentService->newContentUpdateStruct();
$userStruct->contentUpdateStruct->initialLanguageCode = $languageCode;

$this->userService->updateUser($user, $userStruct);
}
}
6 changes: 6 additions & 0 deletions src/lib/Form/Processor/User/UserUpdateFormProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,14 @@ public function processUpdate(FormActionEvent $event)
private function setContentFields(UserUpdateData $data, string $languageCode): void
{
$data->contentUpdateStruct = $this->contentService->newContentUpdateStruct();
$data->contentUpdateStruct->initialLanguageCode = $languageCode;
$mainLanguageCode = $data->user->contentInfo->mainLanguageCode;

foreach ($data->fieldsData as $fieldDefIdentifier => $fieldData) {
if ($mainLanguageCode !== $languageCode && !$fieldData->fieldDefinition->isTranslatable) {
continue;
}

$data->contentUpdateStruct->setField($fieldDefIdentifier, $fieldData->value, $languageCode);
}
}
Expand Down
8 changes: 1 addition & 7 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,9 @@
declare(strict_types=1);

use Ibexa\Contracts\Test\Core\Bootstrapper\Bootstrapper;
use Ibexa\Contracts\Test\Core\Bootstrapper\DatabaseSchemaHook;
use Ibexa\Contracts\Test\Core\Bootstrapper\FixtureHook;

require_once dirname(__DIR__) . '/vendor/autoload.php';

chdir(dirname(__DIR__));

(new Bootstrapper())->bootstrap(null, [
Bootstrapper::class => [Bootstrapper::OPTION_PREPARE_DATABASE => false],
DatabaseSchemaHook::class => [DatabaseSchemaHook::OPTION_LOAD_SCHEMA => false],
FixtureHook::class => [FixtureHook::OPTION_LOAD_FIXTURES => false],
]);
(new Bootstrapper())->bootstrap();
Comment thread
ViniTou marked this conversation as resolved.
110 changes: 110 additions & 0 deletions tests/integration/UserTranslationSecureListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\Integration\ContentForms;

use Ibexa\ContentForms\Event\ContentFormEvents;
use Ibexa\ContentForms\Event\FormActionEvent;
use Ibexa\ContentForms\Form\Processor\User\UserTranslationSecureListener;
use Ibexa\Tests\Integration\Core\RepositoryTestCase;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormConfigBuilder;

final class UserTranslationSecureListenerTest extends RepositoryTestCase
{
private const NEW_LANGUAGE = 'ger-DE';

/**
* @dataProvider providePublishEventNames
*/
public function testTranslationFlowDoesNotRemoveUserData(string $eventName, string $loginSuffix): void
{
$login = 'jdoe_' . $loginSuffix;
$email = $login . '@mail.invalid';

$ibexaTestCore = $this->getIbexaTestCore();
$userService = $ibexaTestCore->getUserService();
$contentService = $ibexaTestCore->getContentService();
$contentTypeService = $ibexaTestCore->getContentTypeService();

$user = $this->createUser($login, 'John', 'Doe');
$mainLanguageCode = $user->contentInfo->mainLanguageCode;

$userContentType = $contentTypeService->loadContentTypeByIdentifier('user');
$translatableFieldDefinitions = [];
foreach ($userContentType->fieldDefinitions as $fieldDefinition) {
if (
$fieldDefinition->identifier !== 'user_account'
&& $fieldDefinition->isTranslatable
&& \in_array($fieldDefinition->fieldTypeIdentifier, ['ezstring', 'eztext'], true)
) {
$translatableFieldDefinitions[] = $fieldDefinition;
}
}
self::assertNotEmpty($translatableFieldDefinitions, 'Expected the "user" content type to have translatable fields besides "user_account".');

$draft = $contentService->createContentDraft($user->contentInfo);
$updateStruct = $contentService->newContentUpdateStruct();
$updateStruct->initialLanguageCode = self::NEW_LANGUAGE;
foreach ($translatableFieldDefinitions as $fieldDefinition) {
$updateStruct->setField($fieldDefinition->identifier, 'German value', self::NEW_LANGUAGE);
}
$updatedDraft = $contentService->updateContent($draft->versionInfo, $updateStruct);
$publishedContent = $contentService->publishVersion($updatedDraft->versionInfo);

$form = new Form(
(new FormConfigBuilder('form', null, new EventDispatcher(), [
'languageCode' => self::NEW_LANGUAGE,
]))->getFormConfig()
);

$eventDispatcher = new EventDispatcher();
$eventDispatcher->addSubscriber(new UserTranslationSecureListener($userService, $contentService));
$eventDispatcher->dispatch(
new FormActionEvent(
$form,
null,
'publish',
[],
['content' => $publishedContent]
),
$eventName
);

$userAfterTranslation = $userService->loadUserByLogin($login);
self::assertSame($login, $userAfterTranslation->login);
self::assertSame($email, $userAfterTranslation->email);
self::assertTrue($userAfterTranslation->enabled);

$contentService->deleteTranslation($publishedContent->contentInfo, self::NEW_LANGUAGE);
$userAfterDeletion = $userService->loadUserByLogin($login);

self::assertSame($login, $userAfterDeletion->login);
self::assertSame($email, $userAfterDeletion->email);
self::assertTrue($userAfterDeletion->enabled);
self::assertSame($userAfterTranslation->passwordHash, $userAfterDeletion->passwordHash);

$contentAfterDeletion = $contentService->loadContent($publishedContent->id, [$mainLanguageCode]);
self::assertSame(
[$mainLanguageCode],
$contentAfterDeletion->versionInfo->languageCodes
);
}

/**
* @return iterable<string, array{0: string}>
*/
public static function providePublishEventNames(): iterable
{
return [
'publish' => [ContentFormEvents::CONTENT_PUBLISH, 'publish'],
'publish and edit' => [ContentFormEvents::CONTENT_PUBLISH_AND_EDIT, 'publish_and_edit'],
];
}
}
Loading