Commit 342e7bff authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[BUGFIX] Resolve correct page in slug validation

The SlugHelper now receives an encapsulated RecordState object that
represents a record.

This allows fine-grained control over a record and helps resolving
related information, which is required to resolve slugs properly in a
case where e.g. the node ("parent") and language uid can occur multiple
times.

The RecordState contains:

- an EntityContext which describes a variant of a record by its language
  and workspace assignment

- a node object (EntityPointer) that points to the node (aka "parent") of
  the record

- a EntityUidPointer that describes the origin of the record by its table
  name and uid

The RecordStateFactory creates such RecordState objects and enriches them
with links (EntityPointerLink) that point to languages and versions, that
are also represented by EntityPointer implementations.

Resolves: #86195
Releases: master
Change-Id: If17a30e98f802825d80e95044572153f2426bea2
Reviewed-on: https://review.typo3.org/58229


Tested-by: default avatarTYPO3com <no-reply@typo3.com>
Reviewed-by: Andreas Wolf's avatarAndreas Wolf <andreas.wolf@typo3.org>
Tested-by: Daniel Goerz's avatarDaniel Goerz <daniel.goerz@posteo.de>
Reviewed-by: Susanne Moog's avatarSusanne Moog <susanne.moog@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 5178ce52
......@@ -18,6 +18,7 @@ namespace TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\DataHandling\SlugHelper;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -55,6 +56,7 @@ class FormSlugAjaxController extends AbstractFormEngineAjaxController
*
* @param ServerRequestInterface $request
* @return ResponseInterface
* @throws \RuntimeException
*/
public function suggestAction(ServerRequestInterface $request): ResponseInterface
{
......@@ -84,17 +86,17 @@ class FormSlugAjaxController extends AbstractFormEngineAjaxController
$hasConflict = false;
$recordData = $values;
$recordData['pid'] = $pid;
if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['languageField'])) {
$recordData[$GLOBALS['TCA'][$tableName]['ctrl']['languageField']] = $languageId;
}
$slug = GeneralUtility::makeInstance(SlugHelper::class, $tableName, $fieldName, $fieldConfig);
if ($mode === 'auto') {
// New page - Feed incoming values to generator
$recordData = $values;
$recordData['pid'] = $pid;
$recordData[$GLOBALS['TCA'][$tableName]['ctrl']['languageField']] = $languageId;
$proposal = $slug->generate($recordData, $pid);
} elseif ($mode === 'recreate') {
$recordData = $values;
$recordData['pid'] = $pid;
$recordData[$GLOBALS['TCA'][$tableName]['ctrl']['languageField']] = $languageId;
$proposal = $slug->generate($recordData, $parentPageId);
} elseif ($mode === 'manual') {
// Existing record - Fetch full record and only validate against the new "slug" field.
......@@ -103,13 +105,15 @@ class FormSlugAjaxController extends AbstractFormEngineAjaxController
throw new \RuntimeException('mode must be either "auto", "recreate" or "manual"', 1535835666);
}
if ($hasToBeUniqueInSite && !$slug->isUniqueInSite($proposal, $recordId, $pid, $languageId)) {
$state = RecordStateFactory::forName($tableName)
->fromArray($recordData, $pid, $recordId);
if ($hasToBeUniqueInSite && !$slug->isUniqueInSite($proposal, $state)) {
$hasConflict = true;
$proposal = $slug->buildSlugForUniqueInSite($proposal, $recordId, $pid, $languageId);
$proposal = $slug->buildSlugForUniqueInSite($proposal, $state);
}
if ($hasToBeUniqueInPid && !$slug->isUniqueInPid($proposal, $recordId, $pid, $languageId)) {
if ($hasToBeUniqueInPid && !$slug->isUniqueInPid($proposal, $state)) {
$hasConflict = true;
$proposal = $slug->buildSlugForUniqueInPid($proposal, $recordId, $pid, $languageId);
$proposal = $slug->buildSlugForUniqueInPid($proposal, $state);
}
return new JsonResponse([
......@@ -122,6 +126,7 @@ class FormSlugAjaxController extends AbstractFormEngineAjaxController
/**
* @param ServerRequestInterface $request
* @return bool
* @throws \InvalidArgumentException
*/
protected function checkRequest(ServerRequestInterface $request): bool
{
......
......@@ -47,6 +47,7 @@ use TYPO3\CMS\Core\Database\ReferenceIndex;
use TYPO3\CMS\Core\Database\RelationHandler;
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
use TYPO3\CMS\Core\DataHandling\Localization\DataMapProcessor;
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\Html\RteHtmlParser;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
......@@ -1976,9 +1977,6 @@ class DataHandler implements LoggerAwareInterface
$value = $helper->sanitize($value);
}
$languageId = (int)$fullRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']];
$evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
// In case a workspace is given, and the $realPid(!) still is negative
// this is most probably triggered by versionizeRecord() and a raw record
// copy - thus, uniqueness cannot be determined without having the
......@@ -1990,11 +1988,19 @@ class DataHandler implements LoggerAwareInterface
return ['value' => $value];
}
// Return directly in case no evaluations are defined
if (empty($tcaFieldConf['eval'])) {
return ['value' => $value];
}
$state = RecordStateFactory::forName($table)
->fromArray($fullRecord, $realPid, $id);
$evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
if (in_array('uniqueInSite', $evalCodesArray, true)) {
$value = $helper->buildSlugForUniqueInSite($value, $id, $realPid, $languageId);
$value = $helper->buildSlugForUniqueInSite($value, $state);
}
if (in_array('uniqueInPid', $evalCodesArray, true)) {
$value = $helper->buildSlugForUniqueInPid($value, $id, $realPid, $languageId);
$value = $helper->buildSlugForUniqueInPid($value, $state);
}
return ['value' => $value];
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
/**
* Represents the context of an entity
*
* A context defines a "variant" of an entity, currently by its language and workspace assignment. The EntityContext
* is bound to a RecordState.
*/
class EntityContext
{
/**
* @var int
*/
protected $workspaceId = 0;
/**
* @var int
*/
protected $languageId = 0;
/**
* @return int
*/
public function getWorkspaceId(): int
{
return $this->workspaceId;
}
/**
* @param int $workspaceId
* @return static
*/
public function withWorkspaceId(int $workspaceId): self
{
if ($this->workspaceId === $workspaceId) {
return $this;
}
$target = clone $this;
$target->workspaceId = $workspaceId;
return $target;
}
/**
* @return int
*/
public function getLanguageId(): int
{
return $this->languageId;
}
/**
* @param int $languageId
* @return static
*/
public function withLanguageId(int $languageId): self
{
if ($this->languageId === $languageId) {
return $this;
}
$target = clone $this;
$target->languageId = $languageId;
return $target;
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
/**
* Interface describing pointers to an entity
*/
interface EntityPointer
{
/**
* @return string
*/
public function getName(): string;
/**
* @return string
*/
public function getIdentifier(): string;
/**
* @return bool
*/
public function isNode(): bool;
/**
* @param EntityPointer $other
* @return bool
*/
public function isEqualTo(EntityPointer $other): bool;
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
/**
* An EntityPointerLink is used to connect EntityPointer instances
*/
class EntityPointerLink
{
/**
* @var EntityPointer
*/
protected $subject;
/**
* @var EntityPointerLink|null
*/
protected $ancestor;
/**
* @param EntityPointer $subject
*/
public function __construct(EntityPointer $subject)
{
$this->subject = $subject;
}
/**
* @return EntityPointer
*/
public function getSubject(): EntityPointer
{
return $this->subject;
}
/**
* @return EntityPointerLink
*/
public function getHead(): EntityPointerLink
{
$head = $this;
while ($head->ancestor !== null) {
$head = $head->ancestor;
}
return $head;
}
/**
* @return EntityPointerLink|null
*/
public function getAncestor(): ?EntityPointerLink
{
return $this->ancestor;
}
/**
* @param EntityPointerLink $ancestor
* @return EntityPointerLink
*/
public function withAncestor(EntityPointerLink $ancestor): self
{
if ($this->ancestor === $ancestor) {
return $this;
}
$target = clone $this;
$target->ancestor = $ancestor;
return $target;
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
/**
* The EntityUidPointer represents the concrete origin of the entity
*/
class EntityUidPointer implements EntityPointer
{
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $identifier;
/**
* @param string $name
* @param string $identifier
*/
public function __construct(string $name, string $identifier)
{
$this->name = $name;
$this->identifier = $identifier;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string
*/
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* @param string $identifier
* @return static
*/
public function withUid(string $identifier): self
{
if ($this->identifier === $identifier) {
return $this;
}
$target = clone $this;
$target->identifier = $identifier;
return $target;
}
/**
* @return bool
*/
public function isNode(): bool
{
return $this->name === 'pages';
}
/**
* @param EntityPointer $other
* @return bool
*/
public function isEqualTo(EntityPointer $other): bool
{
return $this->identifier === $other->getIdentifier()
&& $this->name === $other->getName();
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* A RecordState is an abstract description of a record that consists of
*
* - an EntityContext describing the "variant" of a record
* - an EntityPointer that describes the node where the record is stored
* - an EntityUidPointer of the record the RecordState instance represents
*
* Instances of this class are created by the RecordStateFactory.
*/
class RecordState
{
/**
* @var EntityContext
*/
protected $context;
/**
* @var EntityPointer
*/
protected $node;
/**
* @var EntityUidPointer
*/
protected $subject;
/**
* @var EntityPointerLink
*/
protected $languageLink;
/**
* @var EntityPointerLink
*/
protected $versionLink;
/**
* @param EntityContext $context
* @param EntityPointer $node
* @param EntityUidPointer $subject
*/
public function __construct(EntityContext $context, EntityPointer $node, EntityUidPointer $subject)
{
$this->context = $context;
$this->node = $node;
$this->subject = $subject;
}
/**
* @return EntityContext
*/
public function getContext(): EntityContext
{
return $this->context;
}
/**
* @return EntityPointer
*/
public function getNode(): EntityPointer
{
return $this->node;
}
/**
* @return EntityUidPointer
*/
public function getSubject(): EntityUidPointer
{
return $this->subject;
}
/**
* @return EntityPointerLink
*/
public function getLanguageLink(): ?EntityPointerLink
{
return $this->languageLink;
}
/**
* @param EntityPointerLink|null $languageLink
* @return static
*/
public function withLanguageLink(?EntityPointerLink $languageLink): self
{
if ($this->languageLink === $languageLink) {
return $this;
}
$target = clone $this;
$target->languageLink = $languageLink;
return $target;
}
/**
* @return EntityPointerLink
*/
public function getVersionLink(): ?EntityPointerLink
{
return $this->versionLink;
}
/**
* @param EntityPointerLink|null $versionLink
* @return static
*/
public function withVersionLink(?EntityPointerLink $versionLink): self
{
if ($this->versionLink === $versionLink) {
return $this;
}
$target = clone $this;
$target->versionLink = $versionLink;
return $target;
}
/**
* @return bool
*/
public function isNew(): bool
{
return !MathUtility::canBeInterpretedAsInteger(
$this->subject->getIdentifier()
);
}
/**
* Resolve identifier of node used as aggregate. For translated pages
* that would result in the `uid` of the outer-most language parent page.
*
* @return string
*/
public function resolveAggregateNodeIdentifier(): string
{
if ($this->subject->isNode()
&& $this->context->getLanguageId() > 0
&& $this->languageLink !== null
) {
return $this->languageLink->getHead()->getSubject()->getIdentifier();
}
return $this->node->getIdentifier();
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**