diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..74a5344 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: [ push ] + +jobs: + phpunit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: php-actions/composer@v6 + - name: PHPUnit Tests + uses: php-actions/phpunit@v4 + with: + php_extensions: xdebug + bootstrap: vendor/autoload.php + configuration: phpunit.xml + coverage_text: true + env: + XDEBUG_MODE: coverage + + phpstan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: php-actions/composer@v6 + - uses: php-actions/phpstan@v3 + with: + version: latest + path: Classes Tests + configuration: phpstan.neon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ecbb95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +composer.lock +vendor +Packages +.phpunit.result.cache +bin +Build diff --git a/Classes/RoutingMiddleware.php b/Classes/RoutingMiddleware.php new file mode 100644 index 0000000..b82baa3 --- /dev/null +++ b/Classes/RoutingMiddleware.php @@ -0,0 +1,111 @@ +configuration['enable']['trailingSlash'] ?? false; + $isToLowerCaseEnabled = $this->configuration['enable']['toLowerCase'] ?? false; + + $uri = $request->getUri(); + + if (! $isTrailingSlashEnabled && ! $isToLowerCaseEnabled) { + return $handler->handle($request); + } + + if ($this->matchesBlocklist($uri)) { + return $handler->handle($request); + } + + $oldPath = $uri->getPath(); + + if ($isTrailingSlashEnabled) { + $uri = $this->handleTrailingSlash($uri); + } + + if ($isToLowerCaseEnabled) { + $uri = $this->handleToLowerCase($uri); + } + + if ($uri->getPath() === $oldPath) { + return $handler->handle($request); + } + + $response = $this->responseFactory->createResponse($this->configuration['statusCode'] ?? 301); + + return $response->withAddedHeader('Location', (string)$uri); + } + + private function handleTrailingSlash(UriInterface $uri): UriInterface + { + if (strlen($uri->getPath()) === 0) { + return $uri; + } + + if (array_key_exists('extension', pathinfo($uri->getPath()))) { + return $uri; + } + + return $uri->withPath(rtrim($uri->getPath(), '/') . '/') + ->withQuery($uri->getQuery()) + ->withFragment($uri->getFragment()); + } + + private function handleToLowerCase(UriInterface $uri): UriInterface + { + $loweredPath = strtolower($uri->getPath()); + + if ($uri->getPath() === $loweredPath) { + return $uri; + } + + $newUri = str_replace($uri->getPath(), $loweredPath, (string)$uri); + + return $this->uriFactory->createUri($newUri); + } + + private function matchesBlocklist(UriInterface $uri): bool + { + $path = $uri->getPath(); + foreach ($this->blocklist as $rawPattern => $active) { + $pattern = '/' . str_replace('/', '\/', $rawPattern) . '/'; + + if (! $active) { + continue; + } + + if (preg_match($pattern, $path) === 1) { + return true; + } + } + + return false; + } +} diff --git a/Configuration/NodeTypes.Document.yaml b/Configuration/NodeTypes.Document.yaml new file mode 100644 index 0000000..b54b82e --- /dev/null +++ b/Configuration/NodeTypes.Document.yaml @@ -0,0 +1,6 @@ +'Neos.Neos:Document': + properties: + uriPathSegment: + validation: + 'Neos.Neos/Validation/RegularExpressionValidator': + regularExpression: '/^[a-z0-9\-]+$/' #override original regex (removes insensitive) diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml new file mode 100644 index 0000000..9ab0397 --- /dev/null +++ b/Configuration/Settings.yaml @@ -0,0 +1,16 @@ +Flowpack: + SeoRouting: + redirect: + enable: + trailingSlash: true + toLowerCase: false + statusCode: 301 + blocklist: + '/neos.*': true + +Neos: + Flow: + http: + middlewares: + 'before routing': + middleware: 'Flowpack\SeoRouting\RoutingMiddleware' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ff1d95 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-2025 yeebase media GmbH, Sandstorm Media GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4ff41a --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Flowpack.SeoRouting + + + +* [Flowpack.SeoRouting](#flowpackseorouting) + * [Introduction](#introduction) + * [Features](#features) + * [Installation](#installation) + * [Configuration](#configuration) + * [Standard Configuration](#standard-configuration) + * [Blocklist for redirects](#blocklist-for-redirects) + * [Thank you](#thank-you) + + + +## Introduction + +This package allows you to enforce a trailing slash and/or lower case urls in Flow/Neos. + +## Features + +This package has 2 main features: + +- **trailingSlash**: ensure that all links ends with a trailing slash (e.g. `example.com/test/` instead of + `example.com/test`) +- **toLowerCase**: ensure that camelCase links gets redirected to lowercase (e.g. `example.com/lowercase` instead of + `example.com/lowerCase`) + +You can de- and activate both of them. + +Another small feature is to restrict all _new_ neos pages to have a lowercased `uriPathSegment`. This is done by +extending the `NodeTypes.Document.yaml`. + +## Installation + +Just require it via composer: + +`composer require flowpack/seo-routing` + +## Configuration + +### Standard Configuration + +In the standard configuration we have activated the trailingSlash (to redirect all uris without a / at the end to an uri +with / at the end) and do all redirects with a 301 http status. + +*Note: The lowercase redirect is deactivated by default, because you have to make sure, that there is +no `uriPathSegment` +with camelCase or upperspace letters - this would lead to redirects in the neverland.* + +``` +Flowpack: + SeoRouting: + redirect: + enable: + trailingSlash: true + toLowerCase: false + statusCode: 301 + blocklist: + '/neos.*': true +``` + +### Blocklist for redirects + +By default, all `/neos` URLs are ignored for redirects. You can extend the blocklist array with regex as you like: + +```yaml +Flowpack: + SeoRouting: + blocklist: + '/neos.*': true +``` + +## Thank you + +This package originates from https://github.com/t3n/seo-routing. + +Thank you, T3N and associates for your work. diff --git a/Tests/Unit/RoutingMiddlewareTest.php b/Tests/Unit/RoutingMiddlewareTest.php new file mode 100644 index 0000000..88847cb --- /dev/null +++ b/Tests/Unit/RoutingMiddlewareTest.php @@ -0,0 +1,213 @@ + */ + private readonly ReflectionClass $routingMiddlewareReflection; + private readonly ResponseFactoryInterface&MockObject $responseFactoryMock; + private readonly ResponseInterface&MockObject $responseMock; + private readonly UriFactoryInterface&MockObject $uriFactoryMock; + private readonly ServerRequestInterface&MockObject $requestMock; + private readonly RequestHandlerInterface&MockObject $requestHandlerMock; + + /** + * @throws Exception + * @throws ReflectionException + */ + protected function setUp(): void + { + parent::setUp(); + + $this->routingMiddleware = new RoutingMiddleware(); + + $this->responseFactoryMock = $this->createMock(ResponseFactoryInterface::class); + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->uriFactoryMock = $this->createMock(UriFactoryInterface::class); + $this->requestMock = $this->createMock(ServerRequestInterface::class); + $this->requestHandlerMock = $this->createMock(RequestHandlerInterface::class); + + $this->routingMiddlewareReflection = new ReflectionClass($this->routingMiddleware); + + $property = $this->routingMiddlewareReflection->getProperty('responseFactory'); + $property->setValue($this->routingMiddleware, $this->responseFactoryMock); + + $property = $this->routingMiddlewareReflection->getProperty('uriFactory'); + $property->setValue($this->routingMiddleware, $this->uriFactoryMock); + + $this->injectBlocklist([]); + } + + /** + * @param array{enable: array{trailingSlash: bool, toLowerCase: bool}, statusCode?: int} $configuration + * @param array{string: bool} $blocklist + */ + #[DataProvider('urlsWithConfigAndBlocklist')] + public function testProcessShouldHandleUrlsCorrectly( + string $originalUrl, + string $expectedUrl, + array $configuration, + array $blocklist, + ): void { + $this->injectConfiguration($configuration); + $this->injectBlocklist($blocklist); + + /* + * We're not using a Mock here because it wouldn't make sense and we couldn't really test whether the code + * works, the test would just consist of a lot of expect assertions. + */ + $originalUri = new Uri($originalUrl); + $expectedUri = new Uri($expectedUrl); + + + $this->requestMock->expects($this->once())->method('getUri')->willReturn($originalUri); + $this->uriFactoryMock->method('createUri')->willReturn($expectedUri); + + if ($originalUrl === $expectedUrl) { + $this->requestHandlerMock->method('handle')->willReturn($this->responseMock); + } else { + $this->responseFactoryMock + ->expects($this->once()) + ->method('createResponse') + ->with($configuration['statusCode'] ?? 301) + ->willReturn($this->responseMock); + + $this->responseMock + ->expects($this->once()) + ->method('withAddedHeader') + ->with('Location', (string)$expectedUri) + ->willReturnSelf(); + } + + self::assertSame( + $this->responseMock, + $this->routingMiddleware->process($this->requestMock, $this->requestHandlerMock) + ); + } + + /** + * @param array{enable: array{trailingSlash: bool, toLowerCase: bool}, statusCode?: int} $configuration + */ + private function injectConfiguration(array $configuration): void + { + $property = $this->routingMiddlewareReflection->getProperty('configuration'); + $property->setValue($this->routingMiddleware, $configuration); + } + + /** + * @param array{string: bool}|array{} $blocklist + */ + private function injectBlocklist(array $blocklist): void + { + $property = $this->routingMiddlewareReflection->getProperty('blocklist'); + $property->setValue($this->routingMiddleware, $blocklist); + } + + /** + * @return mixed[] + */ + public static function urlsWithConfigAndBlocklist(): array + { + /* + * originalUrl, expectedUrl, configuration, blocklist + */ + return [ + [ + 'https://local.dev', + 'https://local.dev', + ['enable' => ['trailingSlash' => false, 'toLowerCase' => false]], + [], + ], + [ + 'https://local.dev', + 'https://local.dev', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => false], 'statusCode' => 302], + [], + ], + [ + 'https://local.dev/test/test2', + 'https://local.dev/test/test2/', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => false,],], + [], + ], + [ + 'https://local.dev/public/css/main.css', + 'https://local.dev/public/css/main.css', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => false,],], + [], + ], + [ + 'https://local.dev/test?foo=bar&bar=baz', + 'https://local.dev/test/?foo=bar&bar=baz', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => false,],], + [], + ], + [ + 'https://local.dev/test/', + 'https://local.dev/test/', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => false,],], + [], + ], + [ + 'https://local.dev/test/test2?foo=bar&bar=baz#hash', + 'https://local.dev/test/test2/?foo=bar&bar=baz#hash', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => false,],], + [], + ], + [ + 'https://local.dev/camelCase/?foo=bar&bar=baz#hash', + 'https://local.dev/camelcase/?foo=bar&bar=baz#hash', + ['enable' => ['trailingSlash' => false, 'toLowerCase' => true]], + [], + ], + [ + 'https://local.dev/nocaps', + 'https://local.dev/nocaps', + ['enable' => ['trailingSlash' => false, 'toLowerCase' => true]], + [], + ], + [ + 'https://local.dev/CAPSLOCK?foo=bar&bar=baz#hash', + 'https://local.dev/capslock/?foo=bar&bar=baz#hash', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => true]], + [], + ], + [ + 'https://local.dev/neos', + 'https://local.dev/neos', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => true]], + [ + '/neos.*' => true, + ], + ], + [ + 'https://local.dev/NEOS', + 'https://local.dev/neos/', + ['enable' => ['trailingSlash' => true, 'toLowerCase' => true]], + [ + '/neos.*' => false, + ], + ], + ]; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f07749e --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "description": "Flow Framework Router to ensure a trailing slash in Neos.", + "type": "neos-package", + "license": "MIT", + "name": "flowpack/seo-routing", + "config": { + "bin-dir": "bin", + "allow-plugins": { + "neos/composer-plugin": true, + "phpstan/extension-installer": true + } + }, + "require": { + "neos/flow": "^8.3", + "guzzlehttp/psr7": "^2.0", + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5" + }, + "autoload": { + "psr-4": { + "Flowpack\\SeoRouting\\": "Classes/" + } + }, + "autoload-dev": { + "psr-4": { + "Flowpack\\SeoRouting\\Tests\\": "Tests/" + } + }, + "extra": { + "neos": { + "package-key": "Flowpack.SeoRouting" + } + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..49d417a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: max + paths: + - Classes + - Tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..35190cc --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + Tests + + + + + + Classes + + +