diff --git a/eZ/Bundle/EzPublishCoreBundle/Resources/config/fieldtype_services.yml b/eZ/Bundle/EzPublishCoreBundle/Resources/config/fieldtype_services.yml index c100dbaa09..4a28e769c4 100644 --- a/eZ/Bundle/EzPublishCoreBundle/Resources/config/fieldtype_services.yml +++ b/eZ/Bundle/EzPublishCoreBundle/Resources/config/fieldtype_services.yml @@ -29,6 +29,7 @@ parameters: ezpublish.fieldType.ezrichtext.validator.xml.class: eZ\Publish\Core\FieldType\RichText\Validator ezpublish.fieldType.ezrichtext.validator.dispatcher.class: eZ\Publish\Core\FieldType\RichText\ValidatorDispatcher ezpublish.fieldType.ezrichtext.resources: %ezpublish.kernel.root_dir%/eZ/Publish/Core/FieldType/RichText/Resources + ezpublish.fieldType.ezrichtext.validator.internal_link.class: eZ\Publish\Core\FieldType\RichText\InternalLinkValidator ezpublish.fieldType.ezrichtext.converter.input.xhtml5.resources: %ezpublish.fieldType.ezrichtext.resources%/stylesheets/xhtml5/edit/docbook.xsl ezpublish.fieldType.ezrichtext.converter.edit.xhtml5.resources: %ezpublish.fieldType.ezrichtext.resources%/stylesheets/docbook/xhtml5/edit/xhtml5.xsl @@ -190,6 +191,12 @@ services: http://ez.no/namespaces/ezpublish5/xhtml5/edit: null http://ez.no/namespaces/ezpublish5/xhtml5: "@ezpublish.fieldType.ezrichtext.validator.output.ezxhtml5" + ezpublish.fieldType.ezrichtext.validator.internal_link: + class: '%ezpublish.fieldType.ezrichtext.validator.internal_link.class%' + arguments: + - '@ezpublish.spi.persistence.cache.contentHandler' + - '@ezpublish.spi.persistence.cache.locationHandler' + # Image ezpublish.fieldType.ezimage.io_service: class: "%ezpublish.fieldType.ezimage.io_legacy.class%" diff --git a/eZ/Publish/Core/FieldType/RichText/InternalLinkValidator.php b/eZ/Publish/Core/FieldType/RichText/InternalLinkValidator.php index 4dbd4d74c2..6b4d827bcf 100644 --- a/eZ/Publish/Core/FieldType/RichText/InternalLinkValidator.php +++ b/eZ/Publish/Core/FieldType/RichText/InternalLinkValidator.php @@ -8,8 +8,9 @@ */ namespace eZ\Publish\Core\FieldType\RichText; -use eZ\Publish\API\Repository\ContentService; -use eZ\Publish\API\Repository\LocationService; +use DOMDocument; +use eZ\Publish\SPI\Persistence\Content\Handler as ContentHandler; +use eZ\Publish\SPI\Persistence\Content\Location\Handler as LocationHandler; use eZ\Publish\API\Repository\Exceptions\NotFoundException; use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException; @@ -19,13 +20,58 @@ class InternalLinkValidator { /** - * @param \eZ\Publish\API\Repository\ContentService $contentService - * @param \eZ\Publish\API\Repository\LocationService $locationService + * @var \eZ\Publish\SPI\Persistence\Content\Handler */ - public function __construct(ContentService $contentService, LocationService $locationService) + private $contentHandler; + + /** + * @var \eZ\Publish\SPI\Persistence\Content\Location\Handler; + */ + private $locationHandler; + + /** + * InternalLinkValidator constructor. + * @param \eZ\Publish\SPI\Persistence\Content\Handler $contentHandler + * @param \eZ\Publish\SPI\Persistence\Content\Location\Handler $locationHandler + */ + public function __construct(ContentHandler $contentHandler, LocationHandler $locationHandler) { - $this->contentService = $contentService; - $this->locationService = $locationService; + $this->contentHandler = $contentHandler; + $this->locationHandler = $locationHandler; + } + + /** + * Extracts and validate internal links. + * + * @param \DOMDocument $xml + * @return array + */ + public function validateDocument(DOMDocument $xml) + { + $errors = []; + + $xpath = new \DOMXPath($xml); + $xpath->registerNamespace('docbook', 'http://docbook.org/ns/docbook'); + + foreach (['link', 'ezlink'] as $tagName) { + $xpathExpression = $this->getXPathForLinkTag($tagName); + /** @var \DOMElement $element */ + foreach ($xpath->query($xpathExpression) as $element) { + $url = $element->getAttribute('xlink:href'); + preg_match('~^(.+)://([^#]*)?(#.*|\\s*)?$~', $url, $matches); + list(, $scheme, $id) = $matches; + + if (empty($id)) { + continue; + } + + if (!$this->validate($scheme, $id)) { + $errors[] = $this->getInvalidLinkError($scheme, $url); + } + } + } + + return $errors; } /** @@ -43,13 +89,13 @@ public function validate($scheme, $id) try { switch ($scheme) { case 'ezcontent': - $this->contentService->loadContentInfo($id); + $this->contentHandler->loadContentInfo($id); break; case 'ezremote': - $this->contentService->loadContentByRemoteId($id); + $this->contentHandler->loadContentInfoByRemoteId($id); break; case 'ezlocation': - $this->locationService->loadLocation($id); + $this->locationHandler->load($id); break; default: throw new InvalidArgumentException($scheme, "Given scheme '{$scheme}' is not supported."); @@ -60,4 +106,37 @@ public function validate($scheme, $id) return true; } + + /** + * Builds error message for invalid url. + * + * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException If given $scheme is not supported. + * + * @param string $scheme + * @param string $url + * @return string + */ + private function getInvalidLinkError($scheme, $url) + { + switch ($scheme) { + case 'ezcontent': + case 'ezremote': + return sprintf('Invalid link "%s": target content cannot be found', $url); + case 'ezlocation': + return sprintf('Invalid link "%s": target location cannot be found', $url); + default: + throw new InvalidArgumentException($scheme, "Given scheme '{$scheme}' is not supported."); + } + } + + /** + * Generates XPath expression for given link tag. + * + * @param string $tagName + * @return string + */ + private function getXPathForLinkTag($tagName) + { + return "//docbook:{$tagName}[starts-with(@xlink:href, 'ezcontent://') or starts-with(@xlink:href, 'ezlocation://') or starts-with(@xlink:href, 'ezremote://')]"; + } } diff --git a/eZ/Publish/Core/FieldType/RichText/Type.php b/eZ/Publish/Core/FieldType/RichText/Type.php index 3f0a75c7fb..38f1fa2ad9 100644 --- a/eZ/Publish/Core/FieldType/RichText/Type.php +++ b/eZ/Publish/Core/FieldType/RichText/Type.php @@ -59,22 +59,30 @@ class Type extends FieldType */ protected $inputValidatorDispatcher; + /** + * @var null|\eZ\Publish\Core\FieldType\RichText\InternalLinkValidator + */ + protected $internalLinkValidator; + /** * @param \eZ\Publish\Core\FieldType\RichText\Validator $internalFormatValidator * @param \eZ\Publish\Core\FieldType\RichText\ConverterDispatcher $inputConverterDispatcher * @param null|\eZ\Publish\Core\FieldType\RichText\Normalizer $inputNormalizer * @param null|\eZ\Publish\Core\FieldType\RichText\ValidatorDispatcher $inputValidatorDispatcher + * @param null|\eZ\Publish\Core\FieldType\RichText\InternalLinkValidator $internalLinkValidator */ public function __construct( Validator $internalFormatValidator, ConverterDispatcher $inputConverterDispatcher, Normalizer $inputNormalizer = null, - ValidatorDispatcher $inputValidatorDispatcher = null + ValidatorDispatcher $inputValidatorDispatcher = null, + InternalLinkValidator $internalLinkValidator = null ) { $this->internalFormatValidator = $internalFormatValidator; $this->inputConverterDispatcher = $inputConverterDispatcher; $this->inputNormalizer = $inputNormalizer; $this->inputValidatorDispatcher = $inputValidatorDispatcher; + $this->internalLinkValidator = $internalLinkValidator; } /** @@ -266,6 +274,13 @@ public function validate(FieldDefinition $fieldDefinition, SPIValue $value) ); } + if ($this->internalLinkValidator !== null) { + $errors = $this->internalLinkValidator->validateDocument($value->xml); + foreach ($errors as $error) { + $validationErrors[] = new ValidationError($error); + } + } + return $validationErrors; } diff --git a/eZ/Publish/Core/FieldType/Tests/RichText/InternalLinkValidatorTest.php b/eZ/Publish/Core/FieldType/Tests/RichText/InternalLinkValidatorTest.php new file mode 100644 index 0000000000..3d8c47f7e3 --- /dev/null +++ b/eZ/Publish/Core/FieldType/Tests/RichText/InternalLinkValidatorTest.php @@ -0,0 +1,321 @@ +contentHandler = $this->createMock(ContentHandler::class); + $this->locationHandler = $this->createMock(LocationHandler::class); + } + + /** + * @expectedException \eZ\Publish\Core\Base\Exceptions\InvalidArgumentException + * @expectedExceptionMessage Argument 'eznull' is invalid: Given scheme 'eznull' is not supported. + */ + public function testValidateFailOnNotSupportedSchema() + { + $validator = $this->getInternalLinkValidator(); + $validator->validate('eznull', 1); + } + + public function testValidateEzContentWithExistingContentId() + { + $validator = $this->getInternalLinkValidator(); + + $contentId = 1; + $this->contentHandler + ->expects($this->once()) + ->method('loadContentInfo') + ->with($contentId); + + $this->assertTrue($validator->validate('ezcontent', $contentId)); + } + + public function testValidateEzContentNonExistingContentId() + { + $validator = $this->getInternalLinkValidator(); + + $contentId = 1; + $exception = $this->createMock(NotFoundException::class); + + $this->contentHandler + ->expects($this->once()) + ->method('loadContentInfo') + ->with($contentId) + ->willThrowException($exception); + + $this->assertFalse($validator->validate('ezcontent', $contentId)); + } + + public function testValidateEzLocationWithExistingLocationId() + { + $validator = $this->getInternalLinkValidator(); + + $locationId = 1; + + $this->locationHandler + ->expects($this->once()) + ->method('load') + ->with($locationId); + + $this->assertTrue($validator->validate('ezlocation', $locationId)); + } + + public function testValidateEzLocationWithNonExistingLocationId() + { + $validator = $this->getInternalLinkValidator(); + + $locationId = 1; + $exception = $this->createMock(NotFoundException::class); + + $this->locationHandler + ->expects($this->once()) + ->method('load') + ->with($locationId) + ->willThrowException($exception); + + $this->assertFalse($validator->validate('ezlocation', $locationId)); + } + + public function testValidateEzRemoteWithExistingRemoteId() + { + $validator = $this->getInternalLinkValidator(); + + $contentRemoteId = '0ba685755118cf95abb0fe25f3f6a1c8'; + + $this->contentHandler + ->expects($this->once()) + ->method('loadContentInfoByRemoteId') + ->with($contentRemoteId); + + $this->assertTrue($validator->validate('ezremote', $contentRemoteId)); + } + + public function testValidateEzRemoteWithNonExistingRemoteId() + { + $validator = $this->getInternalLinkValidator(); + + $contentRemoteId = '0ba685755118cf95abb0fe25f3f6a1c8'; + $exception = $this->createMock(NotFoundException::class); + + $this->contentHandler + ->expects($this->once()) + ->method('loadContentInfoByRemoteId') + ->with($contentRemoteId) + ->willThrowException($exception); + + $this->assertFalse($validator->validate('ezremote', $contentRemoteId)); + } + + public function testValidateDocumentSkipMissingTargetId() + { + $scheme = 'ezcontent'; + $contentId = null; + + $validator = $this->getInternalLinkValidator(['validate']); + $validator + ->expects($this->never()) + ->method('validate') + ->with($scheme, $contentId); + + $errors = $validator->validateDocument( + $this->createInputDocument($scheme, $contentId) + ); + + $this->assertEmpty($errors); + } + + public function testValidateDocumentEzContentExistingContentId() + { + $scheme = 'ezcontent'; + $contentId = 1; + + $validator = $this->getInternalLinkValidator(['validate']); + $validator + ->expects($this->once()) + ->method('validate') + ->with($scheme, $contentId) + ->willReturn(true); + + $errors = $validator->validateDocument( + $this->createInputDocument($scheme, $contentId) + ); + + $this->assertEmpty($errors); + } + + public function testValidateDocumentEzContentNonExistingContentId() + { + $scheme = 'ezcontent'; + $contentId = 1; + + $validator = $this->getInternalLinkValidator(['validate']); + $validator + ->expects($this->once()) + ->method('validate') + ->with($scheme, $contentId) + ->willReturn(false); + + $errors = $validator->validateDocument( + $this->createInputDocument($scheme, $contentId) + ); + + $this->assertCount(1, $errors); + $this->assertContainsEzContentInvalidLinkError($contentId, $errors); + } + + public function testValidateDocumentEzContentExistingLocationId() + { + $scheme = 'ezlocation'; + $locationId = 1; + + $validator = $this->getInternalLinkValidator(['validate']); + $validator + ->expects($this->once()) + ->method('validate') + ->with($scheme, $locationId) + ->willReturn(true); + + $errors = $validator->validateDocument( + $this->createInputDocument($scheme, $locationId) + ); + + $this->assertEmpty($errors); + } + + public function testValidateDocumentEzContentNonExistingLocationId() + { + $scheme = 'ezlocation'; + $locationId = 1; + + $validator = $this->getInternalLinkValidator(['validate']); + $validator + ->expects($this->once()) + ->method('validate') + ->with($scheme, $locationId) + ->willReturn(false); + + $errors = $validator->validateDocument( + $this->createInputDocument($scheme, $locationId) + ); + + $this->assertCount(1, $errors); + $this->assertContainsEzLocationInvalidLinkError($locationId, $errors); + } + + public function testValidateDocumentEzRemoteExistingId() + { + $scheme = 'ezremote'; + $contentRemoteId = '0ba685755118cf95abb0fe25f3f6a1c8'; + + $validator = $this->getInternalLinkValidator(['validate']); + $validator + ->expects($this->once()) + ->method('validate') + ->with($scheme, $contentRemoteId) + ->willReturn(true); + + $errors = $validator->validateDocument( + $this->createInputDocument($scheme, $contentRemoteId) + ); + + $this->assertEmpty($errors); + } + + public function testValidateDocumentEzRemoteNonExistingId() + { + $scheme = 'ezremote'; + $contentRemoteId = '0ba685755118cf95abb0fe25f3f6a1c8'; + + $validator = $this->getInternalLinkValidator(['validate']); + $validator + ->expects($this->once()) + ->method('validate') + ->with($scheme, $contentRemoteId) + ->willReturn(false); + + $errors = $validator->validateDocument( + $this->createInputDocument($scheme, $contentRemoteId) + ); + + $this->assertCount(1, $errors); + $this->assertContainsEzRemoteInvalidLinkError($contentRemoteId, $errors); + } + + private function assertContainsEzLocationInvalidLinkError($locationId, array $errors) + { + $format = 'Invalid link "ezlocation://%d": target location cannot be found'; + + $this->assertContains(sprintf($format, $locationId), $errors); + } + + private function assertContainsEzContentInvalidLinkError($contentId, array $errors) + { + $format = 'Invalid link "ezcontent://%d": target content cannot be found'; + + $this->assertContains(sprintf($format, $contentId), $errors); + } + + private function assertContainsEzRemoteInvalidLinkError($contentId, array $errors) + { + $format = 'Invalid link "ezremote://%s": target content cannot be found'; + + $this->assertContains(sprintf($format, $contentId), $errors); + } + + /** + * @return \eZ\Publish\Core\FieldType\RichText\InternalLinkValidator|\PHPUnit_Framework_MockObject_MockObject + */ + private function getInternalLinkValidator(array $methods = null) + { + return $this->getMockBuilder(InternalLinkValidator::class) + ->setMethods($methods) + ->setConstructorArgs([ + $this->contentHandler, + $this->locationHandler, + ]) + ->getMock(); + } + + private function createInputDocument($scheme, $id) + { + $url = $scheme . '://' . $id; + $xml = ' +
+ + Content link + +
'; + + $doc = new \DOMDocument(); + $doc->loadXML($xml); + + return $doc; + } +} diff --git a/eZ/Publish/Core/settings/fieldtype_services.yml b/eZ/Publish/Core/settings/fieldtype_services.yml index c859e12d90..4c63861245 100644 --- a/eZ/Publish/Core/settings/fieldtype_services.yml +++ b/eZ/Publish/Core/settings/fieldtype_services.yml @@ -7,6 +7,7 @@ parameters: ezpublish.fieldType.ezrichtext.validator.docbook.resources: - "%ezpublish.fieldType.ezrichtext.resources%/schemas/docbook/ezpublish.rng" - "%ezpublish.fieldType.ezrichtext.resources%/schemas/docbook/docbook.iso.sch.xsl" + ezpublish.fieldType.ezrichtext.validator.internal_link.class: eZ\Publish\Core\FieldType\RichText\InternalLinkValidator ezpublish.fieldType.ezpage.pageService.class: eZ\Publish\Core\FieldType\Page\PageService ezpublish.fieldType.ezpage.hashConverter.class: eZ\Publish\Core\FieldType\Page\HashConverter ezpublish.fieldType.ezimage.io_legacy.class: eZ\Publish\Core\FieldType\Image\IO\Legacy @@ -77,6 +78,12 @@ services: - http://docbook.org/ns/docbook: null + ezpublish.fieldType.ezrichtext.validator.internal_link: + class: '%ezpublish.fieldType.ezrichtext.validator.internal_link.class%' + arguments: + - '@ezpublish.spi.persistence.cache.contentHandler' + - '@ezpublish.spi.persistence.cache.locationHandler' + ezpublish.fieldType.ezrichtext.normalizer.input: class: "%ezpublish.fieldType.ezrichtext.normalizer.aggregate.class%" diff --git a/eZ/Publish/Core/settings/fieldtypes.yml b/eZ/Publish/Core/settings/fieldtypes.yml index 82fec9c117..793f77a857 100644 --- a/eZ/Publish/Core/settings/fieldtypes.yml +++ b/eZ/Publish/Core/settings/fieldtypes.yml @@ -412,6 +412,7 @@ services: - "@ezpublish.fieldType.ezrichtext.converter.input.dispatcher" - "@ezpublish.fieldType.ezrichtext.normalizer.input" - "@ezpublish.fieldType.ezrichtext.validator.input.dispatcher" + - '@ezpublish.fieldType.ezrichtext.validator.internal_link' tags: - {name: ezpublish.fieldType, alias: ezrichtext}