Skip to content
Merged
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
8 changes: 8 additions & 0 deletions RELEASE_INFO-6.7.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ See below for the explicit replacements:
+ $fileName = \ReflectionClass(MyClass::class)->getFileName();
```

### New constraint to check for existing routes

The new constraint `\Shopware\Core\Framework\Routing\Validation\Constraint\RouteNotBlocked` checks if a route is available or already taken by another part of the application.

### Multiple payment finalize calls allowed

With the feature flag `REPEATED_PAYMENT_FINALIZE`, the `/payment-finalize` endpoint can now be called multiple times using the same payment token.
Expand All @@ -106,6 +110,10 @@ Example usage:

## Administration

### URL restrictions for product and category SEO URLs

When creating a SEO URL for a product or category, the URL is now checked for availability. Before it was possible to override existing URLs like `account` or `maintenance` with SEO URLs. Existing URLs are now blocked to be used as SEO URLs.

## Storefront

### Language selector twig blocks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"INVALID_URL": "Bitte gib eine gültige URL ein.",
"INVALID_MAIL": "Bitte gib eine gültige E-Mail-Adresse ein.",
"FRAMEWORK__RATE_LIMIT_EXCEEDED": "Zu viele Anfragen. Bitte warten Sie {seconds} Sekunden, bevor Sie es erneut versuchen.",
"FRAMEWORK__ROUTE_BLOCKED_MESSAGE": "Die URL {path} steht in Konflikt mit der bestehenden Route {blockedSegment} und kann nicht verwendet werden.",
"DUPLICATED_URL": "Diese URL wird bereits genutzt. Bitte wähle eine andere Domain.",
"DUPLICATED_NAME": "Dieser Name wird bereits genutzt.",
"CONTENT__MEDIA_EMPTY_FILE": "Gib einen gültigen Dateinamen an.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"INVALID_URL": "Please enter a valid url.",
"INVALID_MAIL": "Please enter a valid email address.",
"FRAMEWORK__RATE_LIMIT_EXCEEDED": "Too many requests. Please wait {seconds} seconds before trying again.",
"FRAMEWORK__ROUTE_BLOCKED_MESSAGE": "The URL {path} conflicts with the existing route {blockedSegment} and cannot be used.",
"DUPLICATED_URL": "This URL is already in use. Please choose another URL.",
"DUPLICATED_NAME": "This name is already in use.",
"CONTENT__MEDIA_EMPTY_FILE": "A valid filename must be provided.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,31 +635,35 @@ export default {
}

this.isLoading = true;
await this.updateSeoUrls();

const response = await this.systemConfigApiService.getValues('core.cms');
try {
await this.updateSeoUrls();

this.defaultCategoryId = response['core.cms.default_category_cms_page'];
const response = await this.systemConfigApiService.getValues('core.cms');

if (this.category.cmsPageId === this.defaultCategoryId) {
this.category.cmsPageId = null;
}
this.defaultCategoryId = response['core.cms.default_category_cms_page'];

return this.categoryRepository
.save(this.category, { ...Shopware.Context.api })
.then(() => {
this.isSaveSuccessful = true;
this.entryPointOverwriteConfirmed = false;
return this.setCategory();
})
.catch(() => {
this.isLoading = false;
this.entryPointOverwriteConfirmed = false;
if (this.category.cmsPageId === this.defaultCategoryId) {
this.category.cmsPageId = null;
}

await this.categoryRepository.save(this.category, { ...Shopware.Context.api });

this.isSaveSuccessful = true;
this.entryPointOverwriteConfirmed = false;
return this.setCategory();
} catch (error) {
this.isLoading = false;
this.entryPointOverwriteConfirmed = false;

if (!error.response?.data?.errors) {
this.createNotificationError({
message: this.$tc('global.notification.notificationSaveErrorMessageRequiredFieldsInvalid'),
message: this.$t('global.notification.notificationSaveErrorMessageRequiredFieldsInvalid'),
});
});
}

return Promise.reject(error);
}
},

checkForEntryPointOverwrite() {
Expand Down Expand Up @@ -869,7 +873,32 @@ export default {
seoUrls.map((seoUrl) => {
if (seoUrl.seoPathInfo) {
seoUrl.isModified = true;
return this.seoUrlService.updateCanonicalUrl(seoUrl, seoUrl.languageId);
return this.seoUrlService.updateCanonicalUrl(seoUrl, seoUrl.languageId).catch((error) => {
if (error.response?.data?.errors) {
error.response.data.errors.forEach((apiError) => {
const messageKey = `global.error-codes.${apiError.detail}`;
const params = apiError.meta?.parameters || {};
const translatedMessage = this.$t(messageKey, params);

const errorMessage =
translatedMessage !== messageKey
? translatedMessage
: apiError.detail ||
apiError.title ||
this.$t('global.notification.unspecifiedSaveErrorMessage');

this.createNotificationError({
message: errorMessage,
});
});
} else {
this.createNotificationError({
message: error.message || this.$t('global.notification.unspecifiedSaveErrorMessage'),
});
}

return Promise.reject(error);
});
}

return Promise.resolve();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1145,7 +1145,32 @@ export default {
seoUrl.isModified = true;
}

this.updateSeoPromises.push(this.seoUrlService.updateCanonicalUrl(seoUrl, seoUrl.languageId));
this.updateSeoPromises.push(
this.seoUrlService.updateCanonicalUrl(seoUrl, seoUrl.languageId).catch((error) => {
if (error.response?.data?.errors) {
error.response.data.errors.forEach((apiError) => {
const messageKey = `global.error-codes.${apiError.detail}`;
const params = apiError.meta?.parameters || {};
const translated = this.$t(messageKey, params);

const message =
translated !== messageKey
? translated
: apiError.detail ||
apiError.title ||
this.$t('global.notification.unspecifiedSaveErrorMessage');

this.createNotificationError({ message });
});
} else {
const message =
error.message || this.$t('global.notification.unspecifiedSaveErrorMessage');
this.createNotificationError({ message });
}

return Promise.reject(error);
}),
);
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/Core/Content/Seo/Validation/SeoUrlValidationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Validation\EntityExists;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Routing\Validation\Constraint\RouteNotBlocked;
use Shopware\Core\Framework\Validation\DataValidationDefinition;
use Shopware\Core\System\SalesChannel\SalesChannelDefinition;
use Symfony\Component\Validator\Constraints\NotBlank;
Expand Down Expand Up @@ -41,7 +42,7 @@ private function addConstraints(
->add('foreignKey', ...$fkConstraints)
->add('routeName', new NotBlank(), new Type('string'))
->add('pathInfo', new NotBlank(), new Type('string'))
->add('seoPathInfo', new NotBlank(), new Type('string'))
->add('seoPathInfo', new NotBlank(), new Type('string'), new RouteNotBlocked())
->add('salesChannelId', new NotBlank(), new EntityExists(
entity: SalesChannelDefinition::ENTITY_NAME,
context: $context,
Expand Down
9 changes: 9 additions & 0 deletions src/Core/Framework/DependencyInjection/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,15 @@ frame-ancestors 'none';
<argument type="service" id="event_dispatcher"/>
</service>

<service id="Shopware\Core\Framework\Routing\Validation\RouteBlocklistService">
<argument type="service" id="router"/>
</service>

<service id="Shopware\Core\Framework\Routing\Validation\Constraint\RouteNotBlockedValidator">
<argument type="service" id="Shopware\Core\Framework\Routing\Validation\RouteBlocklistService"/>
<tag name="validator.constraint_validator"/>
</service>

<!-- Custom Entity -->
<service id="Shopware\Core\System\CustomEntity\Xml\Config\CustomEntityEnrichmentService">
<argument type="service" id="Shopware\Core\System\CustomEntity\Xml\Config\AdminUi\AdminUiXmlSchemaValidator"/>
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Framework/Routing/RoutingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Routing\Exception\CustomerNotLoggedInRoutingException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

#[Package('framework')]
class RoutingException extends HttpException
Expand Down Expand Up @@ -111,4 +112,9 @@ public static function missingPrivileges(array $privileges): self
$errorMessage ?: ''
);
}

public static function unexpectedType(mixed $actualType, string $expectedType): UnexpectedTypeException
{
return new UnexpectedTypeException($actualType, $expectedType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Routing\Validation\Constraint;

use Shopware\Core\Framework\Log\Package;
use Symfony\Component\Validator\Constraint;

/**
* @internal
*
* @codeCoverageIgnore The class only has a simple getter, there's no real logic to test
*/
#[Package('framework')]
class RouteNotBlocked extends Constraint
{
final public const INVALID_TYPE_MESSAGE = 'This value should be of type string.';
final public const ROUTE_BLOCKED = 'FRAMEWORK__ROUTE_BLOCKED';

protected const ERROR_NAMES = [
self::ROUTE_BLOCKED => 'FRAMEWORK__ROUTE_BLOCKED',
];

protected string $message = 'FRAMEWORK__ROUTE_BLOCKED_MESSAGE';

/**
* @param array<string, mixed>|null $options
*/
public function __construct(
?array $options = null,
?array $groups = null,
mixed $payload = null
) {
parent::__construct($options, $groups, $payload);
}

public function getMessage(): string
{
return $this->message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Routing\Validation\Constraint;

use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Routing\RoutingException;
use Shopware\Core\Framework\Routing\Validation\RouteBlocklistService;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
* @internal
*/
#[Package('framework')]
class RouteNotBlockedValidator extends ConstraintValidator
{
public function __construct(
private readonly RouteBlocklistService $blocklistService
) {
}

public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof RouteNotBlocked) {
throw RoutingException::unexpectedType($constraint, RouteNotBlockedValidator::class);
}

if ($value === null || $value === '') {
return;
}

if (!\is_string($value)) {
$this->context->buildViolation(RouteNotBlocked::INVALID_TYPE_MESSAGE)
->addViolation();

return;
}

if (!$this->blocklistService->isPathBlocked($value)) {
return;
}

$normalizedPath = '/' . trim($value, '/');

$this->context->buildViolation($constraint->getMessage())
->setParameter('path', $this->formatValue($value))
->setParameter('blockedSegment', $this->formatValue($normalizedPath))
->setCode(RouteNotBlocked::ROUTE_BLOCKED)
->addViolation();
}
}
46 changes: 46 additions & 0 deletions src/Core/Framework/Routing/Validation/RouteBlocklistService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Routing\Validation;

use Shopware\Core\Framework\Log\Package;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\RouterInterface;

/**
* @internal
*/
#[Package('framework')]
readonly class RouteBlocklistService
{
public function __construct(
private RouterInterface $router
) {
}

public function isPathBlocked(string $path): bool
{
$normalizedPath = '/' . trim($path, '/');

if ($normalizedPath === '/') {
return true;
}

$originalMethod = $this->router->getContext()->getMethod();
try {
$this->router->getContext()->setMethod(Request::METHOD_GET);
$this->router->match($normalizedPath);
} catch (ResourceNotFoundException) {
// Resource not found means the route is completely unused
return false;
} catch (MethodNotAllowedException) {
// Method not allowed means the route exists for other methods, e.g., as POST in the API
return true;
} finally {
$this->router->getContext()->setMethod($originalMethod);
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,14 @@
{% if headlineLevel is not empty %}
{{ "<h#{headlineLevel} class=\"#{headlineClasses}\">"|raw }}
<a href="{{ seoUrl('frontend.detail.page', routeArguments) }}"
title="{{ name }}"
class="product-name stretched-link">
{{ name }}
</a>
{{ "</h#{headlineLevel}>"|raw }}
{% else %}
<a href="{{ seoUrl('frontend.detail.page', routeArguments) }}"
title="{{ name }}"
class="product-name stretched-link">
{{ name }}
</a>
Expand Down
Loading
Loading