├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── rector.php └── src ├── CorsAllowAll.php ├── Event └── SetLocaleEvent.php ├── Exception ├── BadUriPrefixException.php └── InvalidLocalesFormatException.php ├── ForceSecureConnection.php ├── HttpCache.php ├── IpFilter.php ├── Locale.php ├── Redirect.php ├── Subfolder.php └── TagRequest.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Yii Middleware Change Log 2 | 3 | ## 1.0.5 under development 4 | 5 | - Chg #129: Bump PHP minimal version to 8.1 and refactor code to use new features (@dagpro) 6 | - Chg #129: Change PHP constraint in `composer.json` to `8.1 - 8.4` (@dagpro) 7 | - Chg #129: Bump `yiisoft/router` version to `^4.0` (@dagpro) 8 | - Chg #129: Bump `yiisoft/session` version to `^3.0` (@dagpro) 9 | - Bug #129: Explicitly mark nullable parameters (@dagpro) 10 | - Chg #130: Bump minimal version of `yiisoft/cookie` to `^1.2.3` (@vjik) 11 | - Enh #130: Allow to use PSR-20 clock interface to get current time into `Locale` middleware (@vjik) 12 | 13 | ## 1.0.4 September 03, 2024 14 | 15 | - Enh #121: Add `network-utilities` dependency and use it instead of `validator` for `IpFilter` (@arogachev) 16 | - Enh #121: Add support for `validator` of version 2.0, mark it as deprecated (@arogachev) 17 | 18 | ## 1.0.3 June 06, 2024 19 | 20 | - Enh #117: Add support for `psr/http-message` version `^2.0` (@bautrukevich) 21 | 22 | ## 1.0.2 October 06, 2023 23 | 24 | - Enh #103: Add `Access-Control-Expose-Headers: *` to `CorsAllowAll` (@xepozz) 25 | - Bug #105: Fire `SetLocaleEvent` and prepare URL generator in `Locale` before handle request (@vjik) 26 | - Bug #112: Check ignored requests earlier and do not set default locale (@g-rodigy) 27 | 28 | ## 1.0.1 June 04, 2023 29 | 30 | - Chg #95: Remove unused network utilities dependency (@arogachev) 31 | - Bug #96: Fix unexpected redirects from `Locale` middleware on GET requests (@vjik) 32 | - Bug #97: Don't search locale in cookies when `$cookieDuration` is null (@vjik) 33 | 34 | ## 1.0.0 May 22, 2023 35 | 36 | - Initial release. 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008 by Yii Software () 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 |

Yii Middleware

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/yii-middleware/v)](https://packagist.org/packages/yiisoft/yii-middleware) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/yii-middleware/downloads)](https://packagist.org/packages/yiisoft/yii-middleware) 11 | [![Build status](https://github.com/yiisoft/yii-middleware/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/yii-middleware/actions/workflows/build.yml) 12 | [![Code Coverage](https://codecov.io/gh/yiisoft/yii-middleware/graph/badge.svg?token=fZ4S2L5kIJ)](https://codecov.io/gh/yiisoft/yii-middleware) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fyii-middleware%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-middleware/master) 14 | [![Static analysis](https://github.com/yiisoft/yii-middleware/actions/workflows/static.yml/badge.svg?branch=master)](https://github.com/yiisoft/yii-middleware/actions/workflows/static.yml?query=branch%3Amaster) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/yii-middleware/coverage.svg)](https://shepherd.dev/github/yiisoft/yii-middleware) 16 | [![psalm-level](https://shepherd.dev/github/yiisoft/yii-middleware/level.svg)](https://shepherd.dev/github/yiisoft/yii-middleware) 17 | 18 | The package provides middleware classes that implement [PSR-15](https://www.php-fig.org/psr/psr-15/#12-middleware): 19 | 20 | - [`ForceSecureConnection`](#forcesecureconnection). 21 | - [`HttpCache`](#httpcache). 22 | - [`IpFilter`](#ipfilter). 23 | - [`Redirect`](#redirect). 24 | - [`Subfolder`](#subfolder). 25 | - [`TagRequest`](#tagrequest). 26 | - [`Locale`](#locale). 27 | - [`CorsAllowAll`](#corsallowall). 28 | 29 | For proxy related middleware, there is a separate package - 30 | [Yii Proxy Middleware](https://github.com/yiisoft/proxy-middleware). 31 | 32 | For more information on how to use middleware in the [Yii Framework](https://www.yiiframework.com/), see the 33 | [Yii middleware guide](https://github.com/yiisoft/docs/blob/master/guide/en/structure/middleware.md). 34 | 35 | ## Requirements 36 | 37 | - PHP 8.1 or higher. 38 | 39 | ## Installation 40 | 41 | The package could be installed with [Composer](https://getcomposer.org): 42 | 43 | ```shell 44 | composer require yiisoft/yii-middleware 45 | ``` 46 | 47 | ## General usage 48 | 49 | All classes are separate implementations of [PSR 15](https://github.com/php-fig/http-server-middleware) 50 | middleware and don't interact with each other in any way. 51 | 52 | ### `ForceSecureConnection` 53 | 54 | Redirects insecure requests from HTTP to HTTPS, and adds headers necessary to enhance the security policy. 55 | 56 | ```php 57 | use Yiisoft\Yii\Middleware\ForceSecureConnection; 58 | 59 | /** 60 | * @var Psr\Http\Message\ResponseFactoryInterface $responseFactory 61 | * @var Psr\Http\Message\ServerRequestInterface $request 62 | * @var Psr\Http\Server\RequestHandlerInterface $handler 63 | */ 64 | 65 | $middleware = new ForceSecureConnection($responseFactory); 66 | 67 | // Enables redirection from HTTP to HTTPS: 68 | $middleware = $middleware->withRedirection(301); 69 | // Disables redirection from HTTP to HTTPS: 70 | $middleware = $middleware->withoutRedirection(); 71 | 72 | $response = $middleware->process($request, $handler); 73 | ``` 74 | 75 | The `Content-Security-Policy` (CSP) header can force the browser to load page resources only through 76 | a secure connection, even if links in the page layout are specified with an unprotected protocol. 77 | 78 | ```php 79 | $middleware = $middleware->withCSP('upgrade-insecure-requests; default-src https:'); 80 | // Or without the `Content-Security-Policy` header in response: 81 | $middleware = $middleware->withoutCSP(); 82 | ``` 83 | 84 | Middleware adds HTTP Strict-Transport-Security (HSTS) header to each response. 85 | The header tells the browser that your site works with HTTPS only. 86 | 87 | ```php 88 | $maxAge = 3600; // Default is 31_536_000 (12 months). 89 | $subDomains = false; // Whether to add the `includeSubDomains` option to the header value. 90 | 91 | $middleware = $middleware->withHSTS($maxAge, $subDomains); 92 | // Or without the `Strict-Transport-Security` header in response: 93 | $middleware = $middleware->withoutHSTS(); 94 | ``` 95 | 96 | ### `HttpCache` 97 | 98 | Implements client-side caching by utilizing the `Last-Modified` and `ETag` HTTP headers. 99 | 100 | ```php 101 | use Yiisoft\Yii\Middleware\HttpCache; 102 | 103 | /** 104 | * @var Psr\Http\Message\ServerRequestInterface $request 105 | * @var Psr\Http\Server\RequestHandlerInterface $handler 106 | */ 107 | 108 | $middleware = new HttpCache(); 109 | 110 | // Specify callable that generates the last modified: 111 | $middleware = $middleware->withLastModified(function (ServerRequestInterface $request, mixed $params): int { 112 | $defaultLastModified = 3600; 113 | // Some actions. 114 | return $defaultLastModified; 115 | }); 116 | // Specify callable that generates the ETag seed string: 117 | $middleware = $middleware->withEtagSeed(function (ServerRequestInterface $request, mixed $params): string { 118 | $defaultEtagSeed = '33a64df551425fcc55e4d42a148795d9f25f89d4'; 119 | // Some actions. 120 | return $defaultEtagSeed; 121 | }); 122 | 123 | $response = $middleware->process($request, $handler); 124 | ``` 125 | 126 | Additionally, you can specify the following options: 127 | 128 | ```php 129 | // Extra parameters for ETag seed string generation: 130 | $middleware = $middleware->withParams(['parameter' => 'value']); 131 | 132 | // The value of the `Cache-Control` HTTP header: 133 | $middleware = $middleware->withCacheControlHeader('public, max-age=31536000'); 134 | // Default is `public, max-age=3600`. If null, the header won't be sent. 135 | 136 | // Enable weak ETags generation (disabled by default): 137 | $middleware = $middleware->withWeakTag(); 138 | // You should use weak ETags if the content is semantically equal, but not byte-equal. 139 | ``` 140 | 141 | ### `IpFilter` 142 | 143 | `IpFilter` allows access from specified IP ranges only and responds with 403 for all other IPs. 144 | 145 | ```php 146 | use Yiisoft\Yii\Middleware\IpFilter; 147 | 148 | /** 149 | * @var Psr\Http\Message\ResponseFactoryInterface $responseFactory 150 | * @var Psr\Http\Message\ServerRequestInterface $request 151 | * @var Psr\Http\Server\RequestHandlerInterface $handler 152 | * @var Yiisoft\Validator\Rule\ValidatorInterface $validator 153 | */ 154 | 155 | // Name of the request attribute holding client IP: 156 | $clientIpAttribute = 'client-ip'; 157 | // If there is no such attribute, or it has no value, then the middleware will respond with 403 forbidden. 158 | // If the name of the request attribute is `null`, then `REMOTE_ADDR` server parameter is used to determine client IP. 159 | 160 | $middleware = new IpFilter($validator, $responseFactory, $clientIpAttribute); 161 | 162 | // Change client IP validator: 163 | $middleware = $middleware->withValidator($validator); 164 | 165 | $response = $middleware->process($request, $handler); 166 | ``` 167 | 168 | ### `Redirect` 169 | 170 | Generates and adds a `Location` header to the response. 171 | 172 | ```php 173 | use Yiisoft\Yii\Middleware\Redirect; 174 | 175 | /** 176 | * @var Psr\Http\Message\ResponseFactoryInterface $responseFactory 177 | * @var Psr\Http\Message\ServerRequestInterface $request 178 | * @var Psr\Http\Server\RequestHandlerInterface $handler 179 | * @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator 180 | */ 181 | 182 | $middleware = new Redirect($ipValidator, $urlGenerator); 183 | 184 | // Specify URL for redirection: 185 | $middleware = $middleware->toUrl('/login'); 186 | // Or specify route data for redirection: 187 | $middleware = $middleware->toRoute('auth/login', ['parameter' => 'value']); 188 | // If you have set a redirect URL with "toUrl()" method, the middleware ignores the route data, since the URL is a 189 | // priority. 190 | 191 | $response = $middleware->process($request, $handler); 192 | ``` 193 | 194 | You can also set the status of the response code for redirection. 195 | 196 | ```php 197 | // For permanent redirection (301): 198 | $middleware = $middleware->permanent(); 199 | 200 | // For temporary redirection (302): 201 | $middleware = $middleware->permanent(); 202 | 203 | // Or specify the status code yourself: 204 | $middleware = $middleware->withStatus(303); 205 | ``` 206 | 207 | ### `Subfolder` 208 | 209 | Supports routing when the entry point of the application isn't directly at the webroot. 210 | By default, it determines webroot based on server parameters. 211 | 212 | > Info: You should place this middleware before `Route` middleware in the middleware list. 213 | 214 | If you want the application to run on the specified path, use the prefix instead: 215 | 216 | ```php 217 | use Yiisoft\Yii\Middleware\Subfolder; 218 | 219 | /** 220 | * @var Psr\Http\Message\ServerRequestInterface $request 221 | * @var Psr\Http\Server\RequestHandlerInterface $handler 222 | * @var Yiisoft\Aliases\Aliases $aliases 223 | * @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator 224 | */ 225 | 226 | // URI prefix the specified immediately after the domain part (default is `null`): 227 | $prefix = '/blog'; 228 | // The prefix value usually begins with a slash and must not end with a slash. 229 | 230 | $middleware = new Subfolder($urlGenerator, $aliases, $prefix); 231 | 232 | $response = $middleware->process($request, $handler); 233 | ``` 234 | 235 | ### `TagRequest` 236 | 237 | Tags request with a random value that could be later used for identifying it. 238 | 239 | ```php 240 | use Yiisoft\Yii\Middleware\TagRequest; 241 | 242 | /** 243 | * @var Psr\Http\Message\ServerRequestInterface $request 244 | * @var Psr\Http\Server\RequestHandlerInterface $handler 245 | */ 246 | 247 | $middleware = new TagRequest(); 248 | // In the process, a request attribute with the name `requestTag` 249 | // and the generated value by the function `uniqid()` will be added. 250 | $response = $middleware->process($request, $handler); 251 | ``` 252 | 253 | ### `Locale` 254 | 255 | Supports locale-based routing and configures URL generator. 256 | 257 | > Info: You should place this middleware before `Route` middleware in the middleware list. 258 | 259 | ```php 260 | use Yiisoft\Yii\Middleware\Locale; 261 | 262 | // Available locales. 263 | $locales = ['en' => 'en-US', 'ru' => 'ru-RU', 'uz' => 'uz-UZ'] 264 | /** 265 | * Specify supported locales. 266 | * 267 | * @var Locale $middleware 268 | */ 269 | $middleware = $middleware->withSupportedLocales($locales); 270 | 271 | // Ignore requests which URLs that match "/api**" wildcard pattern. 272 | $middleware = $middleware->withIgnoredRequestUrlPatterns(['/api**']); 273 | 274 | $response = $middleware->process($request); 275 | ``` 276 | 277 | The priority of lookup is the following: 278 | 279 | 1. URI query path, that's `/de/blog`. 280 | 2. URI query parameter name, that's `/blog?_language=de`. 281 | You can customize parameter name via `withQueryParameterName()`. 282 | 3. Cookie named `_language`. You can customize name via `withCookieName()`. 283 | 4. `Accept-Language` header. Not enabled by default. Use `withDetectLocale(true)` to enable it. 284 | 285 | Found locale is not saved by default. It can be saved to cookies: 286 | 287 | ```php 288 | use Yiisoft\Yii\Middleware\Locale; 289 | 290 | /** @var Locale $middleware */ 291 | $middleware = $middleware 292 | ->withCookieDuration(new DateInterval('P30D')) // Key parameter for activating saving to cookies. 293 | // Extra customization. 294 | ->withCookieName('_custom_name') 295 | ->withSecureCookie(true) 296 | ``` 297 | 298 | To configure more services, such as translator or session, use `SetLocaleEvent` 299 | ([Yii Event Dispatcher](https://github.com/yiisoft/event-dispatcher) is required). 300 | 301 | ```php 302 | use Yiisoft\Translator\TranslatorInterface; 303 | use Yiisoft\Yii\Middleware\Event\SetLocaleEvent; 304 | 305 | final class SetLocaleEventHandler 306 | { 307 | public function __construct( 308 | private TranslatorInterface $translator 309 | ) { 310 | } 311 | 312 | public function handle(SetLocaleEvent $event): void 313 | { 314 | $this->translator->setLocale($event->getLocale()); 315 | } 316 | } 317 | ``` 318 | 319 | > Note: Using tranlator requires [Yii Message Translator](https://github.com/yiisoft/translator). 320 | 321 | ### `CorsAllowAll` 322 | 323 | Adds CORS headers to the response. 324 | 325 | ## Documentation 326 | 327 | - [Internals](docs/internals.md) 328 | 329 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 330 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 331 | 332 | ## License 333 | 334 | The Yii Middleware is free software. It is released under the terms of the BSD License. 335 | Please see [`LICENSE`](./LICENSE.md) for more information. 336 | 337 | Maintained by [Yii Software](https://www.yiiframework.com/). 338 | 339 | ## Support the project 340 | 341 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 342 | 343 | ## Follow updates 344 | 345 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 346 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 347 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 348 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 349 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 350 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/yii-middleware", 3 | "type": "library", 4 | "description": "Yii Middleware", 5 | "keywords": [ 6 | "yii", 7 | "framework", 8 | "middleware" 9 | ], 10 | "homepage": "https://www.yiiframework.com/", 11 | "license": "BSD-3-Clause", 12 | "support": { 13 | "issues": "https://github.com/yiisoft/yii-middleware/issues?state=open", 14 | "source": "https://github.com/yiisoft/yii-middleware", 15 | "forum": "https://www.yiiframework.com/forum/", 16 | "wiki": "https://www.yiiframework.com/wiki/", 17 | "irc": "ircs://irc.libera.chat:6697/yii", 18 | "chat": "https://t.me/yii3en" 19 | }, 20 | "funding": [ 21 | { 22 | "type": "opencollective", 23 | "url": "https://opencollective.com/yiisoft" 24 | }, 25 | { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/yiisoft" 28 | } 29 | ], 30 | "prefer-stable": true, 31 | "require": { 32 | "php": "8.1 - 8.4", 33 | "psr/event-dispatcher": "^1.0", 34 | "psr/http-factory": "^1.0", 35 | "psr/http-message": "^1.0|^2.0", 36 | "psr/http-server-handler": "^1.0", 37 | "psr/http-server-middleware": "^1.0", 38 | "psr/log": "^3.0", 39 | "yiisoft/aliases": "^3.0", 40 | "yiisoft/cookies": "^1.2.3", 41 | "yiisoft/friendly-exception": "^1.0", 42 | "yiisoft/http": "^1.2", 43 | "yiisoft/network-utilities": "^1.2", 44 | "yiisoft/router": "^4.0", 45 | "yiisoft/session": "^3.0", 46 | "yiisoft/strings": "^2.1", 47 | "yiisoft/validator": "^1.0|^2.0" 48 | }, 49 | "require-dev": { 50 | "httpsoft/http-message": "^1.0", 51 | "maglnet/composer-require-checker": "^4.2", 52 | "phpunit/phpunit": "^10.5.45", 53 | "rector/rector": "^2.0.10", 54 | "roave/infection-static-analysis-plugin": "^1.16", 55 | "psr/clock": "^1.0", 56 | "spatie/phpunit-watcher": "^1.23", 57 | "vimeo/psalm": "^5.26.1 || ^6.8.9", 58 | "yiisoft/router-fastroute": "^4.0", 59 | "yiisoft/test-support": "^3.0" 60 | }, 61 | "suggest": { 62 | "yiisoft/event-dispatcher": "For using events", 63 | "yiisoft/translator": "For updating translator's locale" 64 | }, 65 | "autoload": { 66 | "psr-4": { 67 | "Yiisoft\\Yii\\Middleware\\": "src" 68 | } 69 | }, 70 | "autoload-dev": { 71 | "psr-4": { 72 | "Yiisoft\\Yii\\Middleware\\Tests\\": "tests" 73 | } 74 | }, 75 | "config": { 76 | "sort-packages": true, 77 | "allow-plugins": { 78 | "infection/extension-installer": true, 79 | "composer/package-versions-deprecated": true 80 | } 81 | }, 82 | "scripts": { 83 | "test": "phpunit --testdox", 84 | "test-watch": "phpunit-watcher watch" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 11 | __DIR__ . '/src', 12 | __DIR__ . '/tests', 13 | ]) 14 | ->withPhpSets(php81: true) 15 | ->withRules([ 16 | InlineConstructorDefaultToPropertyRector::class, 17 | ]) 18 | ->withSkip([ 19 | NullToStrictStringFuncCallArgRector::class, 20 | ]); 21 | -------------------------------------------------------------------------------- /src/CorsAllowAll.php: -------------------------------------------------------------------------------- 1 | handle($request); 21 | 22 | return $response 23 | ->withHeader(Header::ALLOW, '*') 24 | ->withHeader(Header::VARY, 'Origin') 25 | ->withHeader(Header::ACCESS_CONTROL_ALLOW_ORIGIN, '*') 26 | ->withHeader(Header::ACCESS_CONTROL_ALLOW_METHODS, 'GET,OPTIONS,HEAD,POST,PUT,PATCH,DELETE') 27 | ->withHeader(Header::ACCESS_CONTROL_ALLOW_HEADERS, '*') 28 | ->withHeader(Header::ACCESS_CONTROL_EXPOSE_HEADERS, '*') 29 | ->withHeader(Header::ACCESS_CONTROL_ALLOW_CREDENTIALS, 'true') 30 | ->withHeader(Header::ACCESS_CONTROL_MAX_AGE, '86400'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Event/SetLocaleEvent.php: -------------------------------------------------------------------------------- 1 | locale; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exception/BadUriPrefixException.php: -------------------------------------------------------------------------------- 1 | value` array. For example: 21 | ``` 22 | ['en' => 'en-US', 'uz' => 'uz-UZ']; 23 | // or 24 | ['en' => 'en_US', 'uz' => 'uz_UZ']; 25 | ``` 26 | SOLUTION; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ForceSecureConnection.php: -------------------------------------------------------------------------------- 1 | redirect = true; 59 | $new->port = $port; 60 | $new->statusCode = $statusCode; 61 | return $new; 62 | } 63 | 64 | /** 65 | * Returns a new instance and disables redirection from HTTP to HTTPS. 66 | * 67 | * @see withRedirection() 68 | */ 69 | public function withoutRedirection(): self 70 | { 71 | $new = clone $this; 72 | $new->redirect = false; 73 | return $new; 74 | } 75 | 76 | /** 77 | * Returns a new instance with added the `Content-Security-Policy` header to response. 78 | * 79 | * @param string $directives The directives {@see DEFAULT_CSP_DIRECTIVES}. 80 | * 81 | * @see Header::CONTENT_SECURITY_POLICY 82 | */ 83 | public function withCSP(string $directives = self::DEFAULT_CSP_DIRECTIVES): self 84 | { 85 | $new = clone $this; 86 | $new->addCSP = true; 87 | $new->cspDirectives = $directives; 88 | return $new; 89 | } 90 | 91 | /** 92 | * Returns a new instance without the `Content-Security-Policy` header in response. 93 | * 94 | * @see withCSP() 95 | */ 96 | public function withoutCSP(): self 97 | { 98 | $new = clone $this; 99 | $new->addCSP = false; 100 | return $new; 101 | } 102 | 103 | /** 104 | * Returns a new instance with added the `Strict-Transport-Security` header to response. 105 | * 106 | * @param int $maxAge The max age {@see DEFAULT_HSTS_MAX_AGE}. 107 | * @param bool $subDomains Whether to add the `includeSubDomains` option to the header value. 108 | */ 109 | public function withHSTS(int $maxAge = self::DEFAULT_HSTS_MAX_AGE, bool $subDomains = false): self 110 | { 111 | $new = clone $this; 112 | $new->addHSTS = true; 113 | $new->hstsMaxAge = $maxAge; 114 | $new->hstsSubDomains = $subDomains; 115 | return $new; 116 | } 117 | 118 | /** 119 | * Returns a new instance without the `Strict-Transport-Security` header in response. 120 | * 121 | * @see withHSTS() 122 | */ 123 | public function withoutHSTS(): self 124 | { 125 | $new = clone $this; 126 | $new->addHSTS = false; 127 | return $new; 128 | } 129 | 130 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 131 | { 132 | if ($this->redirect && strcasecmp($request 133 | ->getUri() 134 | ->getScheme(), 'http') === 0) { 135 | $url = (string) $request 136 | ->getUri() 137 | ->withScheme('https') 138 | ->withPort($this->port); 139 | 140 | return $this->addHSTS( 141 | $this->responseFactory 142 | ->createResponse($this->statusCode) 143 | ->withHeader(Header::LOCATION, $url) 144 | ); 145 | } 146 | 147 | return $this->addHSTS($this->addCSP($handler->handle($request))); 148 | } 149 | 150 | private function addCSP(ResponseInterface $response): ResponseInterface 151 | { 152 | return $this->addCSP 153 | ? $response->withHeader(Header::CONTENT_SECURITY_POLICY, $this->cspDirectives) 154 | : $response; 155 | } 156 | 157 | private function addHSTS(ResponseInterface $response): ResponseInterface 158 | { 159 | $subDomains = $this->hstsSubDomains ? '; includeSubDomains' : ''; 160 | return $this->addHSTS 161 | ? $response->withHeader(Header::STRICT_TRANSPORT_SECURITY, "max-age={$this->hstsMaxAge}{$subDomains}") 162 | : $response; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/HttpCache.php: -------------------------------------------------------------------------------- 1 | lastModified = $lastModified; 62 | return $new; 63 | } 64 | 65 | /** 66 | * Returns a new instance with the specified callable that generates the ETag seed string. 67 | * 68 | * @param callable $etagSeed A PHP callback that generates the ETag seed string. 69 | * 70 | * The callback's signature should be: 71 | * 72 | * ```php 73 | * function (ServerRequestInterface $request, mixed $params): string; 74 | * ``` 75 | * 76 | * Where `$request` is the {@see ServerRequestInterface} object that this middleware is currently handling; 77 | * `$params` takes the value of {@see withParams()}. The callback should return a string serving 78 | * as the seed for generating an ETag. 79 | */ 80 | public function withEtagSeed(callable $etagSeed): self 81 | { 82 | $new = clone $this; 83 | $new->etagSeed = $etagSeed; 84 | return $new; 85 | } 86 | 87 | /** 88 | * Returns a new instance with weak ETags generation enabled. Disabled by default. 89 | * 90 | * You should use weak ETags if the content is semantically equal, but not byte-equal. 91 | * 92 | * @see https://tools.ietf.org/html/rfc7232#section-2.3 93 | */ 94 | public function withWeakEtag(): self 95 | { 96 | $new = clone $this; 97 | $new->weakEtag = true; 98 | return $new; 99 | } 100 | 101 | /** 102 | * Returns a new instance with the specified extra parameters for ETag seed string generation. 103 | * 104 | * @param mixed $params Extra parameters for {@see withEtagSeed()} callbacks. 105 | */ 106 | public function withParams(mixed $params): self 107 | { 108 | $new = clone $this; 109 | $new->params = $params; 110 | return $new; 111 | } 112 | 113 | /** 114 | * Returns a new instance with the specified value of the `Cache-Control` HTTP header. 115 | * 116 | * @param string|null $header The value of the `Cache-Control` HTTP header. If `null`, the header won't be sent. 117 | * 118 | * @see https://tools.ietf.org/html/rfc2616#section-14.9 119 | */ 120 | public function withCacheControlHeader(?string $header): self 121 | { 122 | $new = clone $this; 123 | $new->cacheControlHeader = $header; 124 | return $new; 125 | } 126 | 127 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 128 | { 129 | if ( 130 | ($this->lastModified === null && $this->etagSeed === null) || 131 | !in_array($request->getMethod(), [Method::GET, Method::HEAD], true) 132 | ) { 133 | return $handler->handle($request); 134 | } 135 | 136 | /** @var int|null $lastModified */ 137 | $lastModified = $this->lastModified === null ? null : ($this->lastModified)($request, $this->params); 138 | $etag = null; 139 | 140 | if ($this->etagSeed !== null) { 141 | /** @var string|null $seed */ 142 | $seed = ($this->etagSeed)($request, $this->params); 143 | 144 | if ($seed !== null) { 145 | $etag = $this->generateEtag($seed); 146 | } 147 | } 148 | 149 | $response = $handler->handle($request); 150 | 151 | if ($this->cacheControlHeader !== null) { 152 | $response = $response->withHeader(Header::CACHE_CONTROL, $this->cacheControlHeader); 153 | } 154 | if ($etag !== null) { 155 | $response = $response->withHeader(Header::ETAG, $etag); 156 | } 157 | 158 | $cacheIsValid = $this->validateCache($request, $lastModified, $etag); 159 | if ($cacheIsValid) { 160 | $response = $response->withStatus(Status::NOT_MODIFIED); 161 | } 162 | 163 | /** @see https://tools.ietf.org/html/rfc7232#section-4.1 */ 164 | if ($lastModified !== null && (!$cacheIsValid || $etag === null)) { 165 | $response = $response->withHeader( 166 | Header::LAST_MODIFIED, 167 | gmdate('D, d M Y H:i:s', $lastModified) . ' GMT', 168 | ); 169 | } 170 | 171 | return $response; 172 | } 173 | 174 | /** 175 | * Validates if the HTTP cache has valid content. 176 | * If both `Last-Modified` and `ETag` are `null`, it returns `false`. 177 | * 178 | * @param ServerRequestInterface $request The server request instance. 179 | * @param int|null $lastModified The calculated Last-Modified value in terms of a UNIX timestamp. 180 | * If `null`, the `Last-Modified` header won't be validated. 181 | * @param string|null $etag The calculated `ETag` value. If `null`, the `ETag` header won't be validated. 182 | * 183 | * @return bool Whether the HTTP cache is still valid. 184 | */ 185 | private function validateCache(ServerRequestInterface $request, ?int $lastModified, ?string $etag): bool 186 | { 187 | if ($request->hasHeader(Header::IF_NONE_MATCH)) { 188 | if ($etag === null) { 189 | return false; 190 | } 191 | 192 | $headerParts = preg_split( 193 | '/[\s,]+/', 194 | str_replace('-gzip', '', $request->getHeaderLine(Header::IF_NONE_MATCH)), 195 | flags: PREG_SPLIT_NO_EMPTY, 196 | ); 197 | 198 | // "HTTP_IF_NONE_MATCH" takes precedence over "HTTP_IF_MODIFIED_SINCE". 199 | // https://tools.ietf.org/html/rfc7232#section-3.3 200 | return $headerParts !== false && in_array($etag, $headerParts, true); 201 | } 202 | 203 | if ($request->hasHeader(Header::IF_MODIFIED_SINCE)) { 204 | $header = $request->getHeaderLine(Header::IF_MODIFIED_SINCE); 205 | return $lastModified !== null && @strtotime($header) >= $lastModified; 206 | } 207 | 208 | return false; 209 | } 210 | 211 | /** 212 | * Generates an ETag from the given seed string. 213 | * 214 | * @param string $seed Seed for the ETag. 215 | * 216 | * @return string The generated ETag. 217 | */ 218 | private function generateEtag(string $seed): string 219 | { 220 | $etag = '"' . rtrim(base64_encode(sha1($seed, true)), '=') . '"'; 221 | return $this->weakEtag ? 'W/' . $etag : $etag; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/IpFilter.php: -------------------------------------------------------------------------------- 1 | $ipRanges 34 | */ 35 | public function __construct( 36 | /** 37 | * @deprecated Will be removed in version 2.0. {@see IpRanges} from `network-utilities` package is used instead. 38 | */ 39 | ValidatorInterface $validator, 40 | private readonly ResponseFactoryInterface $responseFactory, 41 | private readonly ?string $clientIpAttribute = null, 42 | array $ipRanges = [], 43 | ) { 44 | $this->ipRanges = new IpRanges($ipRanges); 45 | } 46 | 47 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 48 | { 49 | if ($this->clientIpAttribute !== null) { 50 | $clientIp = $request->getAttribute($this->clientIpAttribute); 51 | } 52 | 53 | /** @psalm-var array{REMOTE_ADDR?: mixed} $serverParams */ 54 | $serverParams = $request->getServerParams(); 55 | $clientIp ??= $serverParams['REMOTE_ADDR'] ?? null; 56 | if ($clientIp === null) { 57 | return $this->createForbiddenResponse(); 58 | } 59 | 60 | if (!is_string($clientIp) || !IpHelper::isIp($clientIp)) { 61 | return $this->createForbiddenResponse(); 62 | } 63 | 64 | if (!$this->ipRanges->isAllowed($clientIp)) { 65 | return $this->createForbiddenResponse(); 66 | } 67 | 68 | return $handler->handle($request); 69 | } 70 | 71 | private function createForbiddenResponse(): ResponseInterface 72 | { 73 | $response = $this->responseFactory->createResponse(Status::FORBIDDEN); 74 | $response->getBody()->write(Status::TEXTS[Status::FORBIDDEN]); 75 | 76 | return $response; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Locale.php: -------------------------------------------------------------------------------- 1 | 47 | */ 48 | private array $supportedLocales; 49 | 50 | /** 51 | * @param EventDispatcherInterface $eventDispatcher Event dispatcher instance to dispatch events. 52 | * @param UrlGeneratorInterface $urlGenerator URL generator instance to set locale for. 53 | * @param LoggerInterface $logger Logger instance to write debug logs to. 54 | * @param ResponseFactoryInterface $responseFactory Response factory used to create redirect responses. 55 | * @param array $supportedLocales List of supported locales in key-value format such as `['ru' => 'ru_RU', 'uz' => 'uz_UZ']`. 56 | * @param string[] $ignoredRequestUrlPatterns {@see WildcardPattern Patterns} for ignoring requests with URLs matching. 57 | * @param ?DateInterval $cookieDuration Locale cookie lifetime. `null` disables saving locale to cookies completely. 58 | * @param bool $secureCookie Whether middleware should flag locale cookie as secure. Effective only when 59 | * {@see $cookieDuration} isn't `null`. 60 | */ 61 | public function __construct( 62 | private EventDispatcherInterface $eventDispatcher, 63 | private UrlGeneratorInterface $urlGenerator, 64 | private LoggerInterface $logger, 65 | private ResponseFactoryInterface $responseFactory, 66 | array $supportedLocales = [], 67 | private array $ignoredRequestUrlPatterns = [], 68 | private bool $secureCookie = false, 69 | private ?DateInterval $cookieDuration = null, 70 | private ?ClockInterface $clock = null, 71 | ) { 72 | $this->assertSupportedLocalesFormat($supportedLocales); 73 | $this->supportedLocales = $supportedLocales; 74 | } 75 | 76 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 77 | { 78 | if (empty($this->supportedLocales) || $this->isRequestIgnored($request)) { 79 | return $handler->handle($request); 80 | } 81 | 82 | $uri = $request->getUri(); 83 | $path = $uri->getPath(); 84 | $query = $uri->getQuery(); 85 | $locale = $this->getLocaleFromPath($path); 86 | 87 | if ($locale !== null) { 88 | if ($locale === $this->defaultLocale && $request->getMethod() === Method::GET) { 89 | return $this->saveLocale( 90 | $locale, 91 | $this->createRedirectResponse(substr($path, strlen($locale) + 1) ?: '/', $query) 92 | ); 93 | } 94 | } else { 95 | /** @psalm-var array $queryParameters */ 96 | $queryParameters = $request->getQueryParams(); 97 | $locale = $this->getLocaleFromQuery($queryParameters); 98 | 99 | if ($locale === null && $this->cookieDuration !== null) { 100 | /** @psalm-var array $cookieParameters */ 101 | $cookieParameters = $request->getCookieParams(); 102 | $locale = $this->getLocaleFromCookies($cookieParameters); 103 | } 104 | 105 | if ($locale === null && $this->detectLocale) { 106 | $locale = $this->detectLocale($request); 107 | } 108 | 109 | if ($locale === null || $locale === $this->defaultLocale) { 110 | $this->urlGenerator->setDefaultArgument($this->queryParameterName, null); 111 | $request = $request->withUri($uri->withPath('/' . $this->defaultLocale . $path)); 112 | 113 | return $handler->handle($request); 114 | } 115 | 116 | if ($request->getMethod() === Method::GET) { 117 | return $this->createRedirectResponse('/' . $locale . $path, $query); 118 | } 119 | } 120 | 121 | /** @var string $locale */ 122 | $this->eventDispatcher->dispatch(new SetLocaleEvent($this->supportedLocales[$locale])); 123 | $this->urlGenerator->setDefaultArgument($this->queryParameterName, $locale); 124 | 125 | $response = $handler->handle($request); 126 | 127 | return $this->saveLocale($locale, $response); 128 | } 129 | 130 | private function createRedirectResponse(string $path, string $query): ResponseInterface 131 | { 132 | return $this 133 | ->responseFactory 134 | ->createResponse(Status::FOUND) 135 | ->withHeader( 136 | Header::LOCATION, 137 | $this->getBaseUrl() . $path . ($query !== '' ? '?' . $query : '') 138 | ); 139 | } 140 | 141 | private function getLocaleFromPath(string $path): ?string 142 | { 143 | $parts = []; 144 | foreach ($this->supportedLocales as $code => $locale) { 145 | $parts[] = $code; 146 | $parts[] = $locale; 147 | } 148 | 149 | $pattern = implode('|', $parts); 150 | if (preg_match("#^/($pattern)\b(/?)#i", $path, $matches)) { 151 | $matchedLocale = $matches[1]; 152 | if (!isset($this->supportedLocales[$matchedLocale])) { 153 | $matchedLocale = $this->parseLocale($matchedLocale); 154 | } 155 | if (isset($this->supportedLocales[$matchedLocale])) { 156 | $this->logger->debug(sprintf("Locale '%s' found in URL.", $matchedLocale)); 157 | return $matchedLocale; 158 | } 159 | } 160 | return null; 161 | } 162 | 163 | /** 164 | * @psalm-param array $queryParameters 165 | */ 166 | private function getLocaleFromQuery($queryParameters): ?string 167 | { 168 | if (!isset($queryParameters[$this->queryParameterName])) { 169 | return null; 170 | } 171 | 172 | $this->logger->debug( 173 | sprintf("Locale '%s' found in query string.", $queryParameters[$this->queryParameterName]), 174 | ); 175 | 176 | return $this->parseLocale($queryParameters[$this->queryParameterName]); 177 | } 178 | 179 | /** 180 | * @psalm-param array $cookieParameters 181 | */ 182 | private function getLocaleFromCookies($cookieParameters): ?string 183 | { 184 | if (!isset($cookieParameters[$this->cookieName])) { 185 | return null; 186 | } 187 | 188 | $this->logger->debug(sprintf("Locale '%s' found in cookies.", $cookieParameters[$this->cookieName])); 189 | 190 | return $this->parseLocale($cookieParameters[$this->cookieName]); 191 | } 192 | 193 | private function detectLocale(ServerRequestInterface $request): ?string 194 | { 195 | foreach ($request->getHeader(Header::ACCEPT_LANGUAGE) as $language) { 196 | if (!isset($this->supportedLocales[$language])) { 197 | $language = $this->parseLocale($language); 198 | } 199 | if (isset($this->supportedLocales[$language])) { 200 | return $language; 201 | } 202 | } 203 | return null; 204 | } 205 | 206 | private function saveLocale(string $locale, ResponseInterface $response): ResponseInterface 207 | { 208 | if ($this->cookieDuration === null) { 209 | return $response; 210 | } 211 | 212 | $this->logger->debug('Saving found locale to cookies.'); 213 | $cookie = new Cookie( 214 | name: $this->cookieName, 215 | value: $locale, 216 | secure: $this->secureCookie, 217 | clock: $this->clock, 218 | ); 219 | $cookie = $cookie->withMaxAge($this->cookieDuration); 220 | 221 | return $cookie->addToResponse($response); 222 | } 223 | 224 | private function parseLocale(string $locale): string 225 | { 226 | foreach (self::LOCALE_SEPARATORS as $separator) { 227 | $separatorPosition = strpos($locale, $separator); 228 | if ($separatorPosition !== false) { 229 | return substr($locale, 0, $separatorPosition); 230 | } 231 | } 232 | 233 | return $locale; 234 | } 235 | 236 | private function isRequestIgnored(ServerRequestInterface $request): bool 237 | { 238 | foreach ($this->ignoredRequestUrlPatterns as $ignoredRequest) { 239 | if ((new WildcardPattern($ignoredRequest))->match($request->getUri()->getPath())) { 240 | return true; 241 | } 242 | } 243 | return false; 244 | } 245 | 246 | /** 247 | * @psalm-assert array $supportedLocales 248 | * 249 | * @throws InvalidLocalesFormatException 250 | */ 251 | private function assertSupportedLocalesFormat(array $supportedLocales): void 252 | { 253 | foreach ($supportedLocales as $code => $locale) { 254 | if (!is_string($code) || !is_string($locale)) { 255 | throw new InvalidLocalesFormatException(); 256 | } 257 | } 258 | } 259 | 260 | private function getBaseUrl(): string 261 | { 262 | return rtrim($this->urlGenerator->getUriPrefix(), '/'); 263 | } 264 | 265 | /** 266 | * Return new instance with supported locales specified. 267 | * 268 | * @param array $locales List of supported locales in key-value format such as `['ru' => 'ru_RU', 'uz' => 'uz_UZ']`. 269 | * 270 | * @throws InvalidLocalesFormatException 271 | */ 272 | public function withSupportedLocales(array $locales): self 273 | { 274 | $this->assertSupportedLocalesFormat($locales); 275 | $new = clone $this; 276 | $new->supportedLocales = $locales; 277 | return $new; 278 | } 279 | 280 | /** 281 | * Return new instance with default locale specified. 282 | * 283 | * @param string $defaultLocale Default locale. It must be present as a key in {@see $supportedLocales}. 284 | */ 285 | public function withDefaultLocale(string $defaultLocale): self 286 | { 287 | if (!array_key_exists($defaultLocale, $this->supportedLocales)) { 288 | throw new InvalidArgumentException('Default locale allows only keys from supported locales.'); 289 | } 290 | 291 | $new = clone $this; 292 | $new->defaultLocale = $defaultLocale; 293 | return $new; 294 | } 295 | 296 | /** 297 | * Return new instance with the name of the query string parameter to look for locale. 298 | * 299 | * @param string $queryParameterName Name of the query string parameter. 300 | */ 301 | public function withQueryParameterName(string $queryParameterName): self 302 | { 303 | $new = clone $this; 304 | $new->queryParameterName = $queryParameterName; 305 | return $new; 306 | } 307 | 308 | /** 309 | * Return new instance with the name of cookie parameter to store found locale. Effective only when 310 | * {@see $cookieDuration} isn't `null`. 311 | * 312 | * @param string $cookieName Name of cookie parameter. 313 | */ 314 | public function withCookieName(string $cookieName): self 315 | { 316 | $new = clone $this; 317 | $new->cookieName = $cookieName; 318 | return $new; 319 | } 320 | 321 | /** 322 | * Return new instance with enabled or disabled detection of locale based on `Accept-Language` header. 323 | * 324 | * @param bool $enabled Whether middleware should detect locale. 325 | */ 326 | public function withDetectLocale(bool $enabled): self 327 | { 328 | $new = clone $this; 329 | $new->detectLocale = $enabled; 330 | return $new; 331 | } 332 | 333 | /** 334 | * Return new instance with {@see WildcardPattern patterns} for ignoring requests with URLs matching. 335 | * 336 | * @param string[] $patterns Patterns. 337 | */ 338 | public function withIgnoredRequestUrlPatterns(array $patterns): self 339 | { 340 | $new = clone $this; 341 | $new->ignoredRequestUrlPatterns = $patterns; 342 | return $new; 343 | } 344 | 345 | /** 346 | * Return new instance with enabled or disabled secure cookies. 347 | * 348 | * @param bool $secure Whether middleware should flag locale cookie as secure. 349 | */ 350 | public function withSecureCookie(bool $secure): self 351 | { 352 | $new = clone $this; 353 | $new->secureCookie = $secure; 354 | return $new; 355 | } 356 | 357 | /** 358 | * Return new instance with changed cookie duration. 359 | * 360 | * @param ?DateInterval $cookieDuration Locale cookie lifetime. When set to `null`, saving locale to cookies is 361 | * disabled completely. 362 | */ 363 | public function withCookieDuration(?DateInterval $cookieDuration): self 364 | { 365 | $new = clone $this; 366 | $new->cookieDuration = $cookieDuration; 367 | return $new; 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/Redirect.php: -------------------------------------------------------------------------------- 1 | $parameters 25 | */ 26 | private array $parameters = []; 27 | private int $statusCode = Status::MOVED_PERMANENTLY; 28 | 29 | public function __construct( 30 | private ResponseFactoryInterface $responseFactory, 31 | private UrlGeneratorInterface $urlGenerator, 32 | ) { 33 | } 34 | 35 | /** 36 | * Returns a new instance with the specified URL for redirection. 37 | * 38 | * @param string $url URL for redirection. 39 | */ 40 | public function toUrl(string $url): self 41 | { 42 | $new = clone $this; 43 | $new->uri = $url; 44 | return $new; 45 | } 46 | 47 | /** 48 | * Returns a new instance with the specified route data for redirection. 49 | * 50 | * If you've set a redirect URL with {@see toUrl()}, the middleware ignores the route data, since the URL 51 | * is a priority. 52 | * 53 | * @param string $name The route name for redirection. 54 | * @param array $parameters $parameters The route parameters for redirection. 55 | */ 56 | public function toRoute(string $name, array $parameters = []): self 57 | { 58 | $new = clone $this; 59 | $new->route = $name; 60 | $new->parameters = $parameters; 61 | return $new; 62 | } 63 | 64 | /** 65 | * Returns a new instance with the specified status code of the response for redirection. 66 | * 67 | * @param int $statusCode The status code of the response for redirection. 68 | */ 69 | public function withStatus(int $statusCode): self 70 | { 71 | $new = clone $this; 72 | $new->statusCode = $statusCode; 73 | return $new; 74 | } 75 | 76 | /** 77 | * Returns a new instance with the response status code of permanent redirection. 78 | * 79 | * @see Status::MOVED_PERMANENTLY 80 | */ 81 | public function permanent(): self 82 | { 83 | $new = clone $this; 84 | $new->statusCode = Status::MOVED_PERMANENTLY; 85 | return $new; 86 | } 87 | 88 | /** 89 | * Returns a new instance with the response status code of temporary redirection. 90 | * 91 | * @see Status::FOUND 92 | */ 93 | public function temporary(): self 94 | { 95 | $new = clone $this; 96 | $new->statusCode = Status::FOUND; 97 | return $new; 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | * 103 | * @throws RuntimeException If the data for redirection wasn't set earlier. 104 | */ 105 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 106 | { 107 | if ($this->route === null && $this->uri === null) { 108 | throw new RuntimeException('Either `toUrl()` or `toRoute()` method should be used.'); 109 | } 110 | 111 | /** @psalm-suppress PossiblyNullArgument */ 112 | $uri = $this->uri ?? $this->urlGenerator->generate($this->route, $this->parameters); 113 | 114 | return $this->responseFactory 115 | ->createResponse($this->statusCode) 116 | ->withAddedHeader('Location', $uri); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Subfolder.php: -------------------------------------------------------------------------------- 1 | getUri(); 44 | $path = $uri->getPath(); 45 | $baseUrl = $this->prefix ?? $this->getBaseUrl($request); 46 | $length = strlen($baseUrl); 47 | 48 | if ($this->prefix !== null) { 49 | if (empty($this->prefix)) { 50 | throw new BadUriPrefixException('URI prefix can\'t be empty.'); 51 | } 52 | 53 | if ($baseUrl[-1] === '/') { 54 | throw new BadUriPrefixException('Wrong URI prefix value.'); 55 | } 56 | 57 | if (!str_starts_with($path, $baseUrl)) { 58 | throw new BadUriPrefixException('URI prefix doesn\'t match.'); 59 | } 60 | } 61 | 62 | if ($length > 0) { 63 | $newPath = substr($path, $length); 64 | 65 | if ($newPath === '') { 66 | $newPath = '/'; 67 | } 68 | 69 | if ($newPath[0] === '/') { 70 | $request = $request->withUri($uri->withPath($newPath)); 71 | $this->uriGenerator->setUriPrefix($baseUrl); 72 | if ($this->baseUrlAlias !== null && $this->prefix === null) { 73 | $this->aliases->set($this->baseUrlAlias, $baseUrl); 74 | } 75 | } elseif ($this->prefix !== null) { 76 | throw new BadUriPrefixException('URI prefix doesn\'t match completely.'); 77 | } 78 | } 79 | 80 | return $handler->handle($request); 81 | } 82 | 83 | private function getBaseUrl(ServerRequestInterface $request): string 84 | { 85 | /** 86 | * @var array{ 87 | * SCRIPT_FILENAME?:string, 88 | * PHP_SELF?:string, 89 | * ORIG_SCRIPT_NAME?:string, 90 | * DOCUMENT_ROOT?:string 91 | * } $serverParams 92 | */ 93 | $serverParams = $request->getServerParams(); 94 | $scriptUrl = $serverParams['SCRIPT_FILENAME'] ?? '/index.php'; 95 | $scriptName = basename($scriptUrl); 96 | 97 | if (isset($serverParams['PHP_SELF']) && basename($serverParams['PHP_SELF']) === $scriptName) { 98 | $scriptUrl = $serverParams['PHP_SELF']; 99 | } elseif ( 100 | isset($serverParams['ORIG_SCRIPT_NAME']) && 101 | basename($serverParams['ORIG_SCRIPT_NAME']) === $scriptName 102 | ) { 103 | $scriptUrl = $serverParams['ORIG_SCRIPT_NAME']; 104 | } elseif ( 105 | isset($serverParams['PHP_SELF']) && 106 | ($pos = strpos($serverParams['PHP_SELF'], $scriptName)) !== false 107 | ) { 108 | $scriptUrl = substr($serverParams['PHP_SELF'], 0, $pos + strlen($scriptName)); 109 | } elseif ( 110 | !empty($serverParams['DOCUMENT_ROOT']) && 111 | str_starts_with($scriptUrl, $serverParams['DOCUMENT_ROOT']) 112 | ) { 113 | $scriptUrl = str_replace([$serverParams['DOCUMENT_ROOT'], '\\'], ['', '/'], $scriptUrl); 114 | } 115 | 116 | return rtrim(dirname($scriptUrl), '\\/'); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/TagRequest.php: -------------------------------------------------------------------------------- 1 | handle($request->withAttribute('requestTag', $this->getRequestTag())); 22 | } 23 | 24 | private function getRequestTag(): string 25 | { 26 | return uniqid('', true); 27 | } 28 | } 29 | --------------------------------------------------------------------------------