diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md new file mode 100644 index 00000000000..27230bd1f98 --- /dev/null +++ b/CHANGELOG-5.11.md @@ -0,0 +1,13 @@ +# Release Notes for Craft CMS 5.11 (WIP) + +### Development +- The `params` argument of the `url()` Twig function now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\web\DbSession`, which should be used instead of `yii\web\DbSession` to prevent “headers already sent” warnings from getting logged. ([#19139](https://github.com/craftcms/cms/issues/19139)) + +### Extensibility +- Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- The `$params` argument of `craft\helpers\UrlHelper::url()` now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) + +### System +- Updated Twig to 3.28. diff --git a/composer.json b/composer.json index 1bef51ad8ac..8113c66be57 100644 --- a/composer.json +++ b/composer.json @@ -68,7 +68,7 @@ "symfony/var-dumper": "^5.0|^6.0|^7.0", "symfony/yaml": "^5.2.3|^6.0|^7.0", "theiconic/name-parser": "^1.2", - "twig/twig": "~3.27.0", + "twig/twig": "~3.28.0", "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~5.3.5", "yiisoft/yii2": "~2.0.55.0", diff --git a/composer.lock b/composer.lock index d16390af1fb..52697b00029 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d36b47860802c2f7f84fbfa81f5b2804", + "content-hash": "5e1315c0b13d8578cc2b675b2e0bb09c", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4223,16 +4223,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -4270,7 +4270,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -4290,7 +4290,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/dom-crawler", @@ -5462,16 +5462,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "8339098cae28673c15cce00d80734af0453054e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", "shasum": "" }, "require": { @@ -5518,7 +5518,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" }, "funding": [ { @@ -5538,7 +5538,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php84", @@ -6776,16 +6776,16 @@ }, { "name": "twig/twig", - "version": "v3.27.1", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "shasum": "" }, "require": { @@ -6840,7 +6840,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" }, "funding": [ { @@ -6852,7 +6852,7 @@ "type": "tidelift" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "voku/portable-ascii", diff --git a/src/helpers/UrlHelper.php b/src/helpers/UrlHelper.php index fd4aefa9d08..e27013eb31e 100644 --- a/src/helpers/UrlHelper.php +++ b/src/helpers/UrlHelper.php @@ -155,12 +155,61 @@ public static function urlWithParams(string $url, array|string $params): string * @since 3.2.2 */ public static function removeParam(string $url, string $param): string + { + return static::removeParams($url, [$param]); + } + + /** + * Removes query string params from a URL. + * + * @param string $url + * @param string[] $params + * @return string + * @since 5.11.0 + */ + public static function removeParams(string $url, array $params): string + { + // Extract any params/fragment from the base URL + [$url, $urlParams, $fragment] = self::_extractParams($url); + + // Remove the params + foreach ($params as $param) { + unset($urlParams[$param]); + } + + // Rebuild + if (($query = static::buildQuery($urlParams)) !== '') { + $url .= '?' . $query; + } + if ($fragment !== null) { + $url .= '#' . $fragment; + } + return $url; + } + + /** + * Removes all query string params from a URL. + * + * @param string $url + * @param string[] $except Any params that should be left alone + * @return string + * @since 5.11.0 + */ + public static function removeAllParams(string $url, array $except = []): string { // Extract any params/fragment from the base URL [$url, $params, $fragment] = self::_extractParams($url); - // Remove the param - unset($params[$param]); + // Remove the params + if (!empty($except)) { + foreach (array_keys($params) as $param) { + if (!in_array($param, $except)) { + unset($params[$param]); + } + } + } else { + $params = []; + } // Rebuild if (($query = static::buildQuery($params)) !== '') { @@ -275,18 +324,20 @@ public static function rootRelativeUrl(string $url): string * Returns either a control panel or a site URL, depending on the request type. * * @param string $path - * @param array|string|null $params + * @param array|string|false|null $params The query params to add to the URL. If `false`, any existing params will be removed. * @param string|null $scheme * @param bool|null $showScriptName Whether the script name (index.php) should be included in the URL. * By default (null) it will defer to the `omitScriptNameInUrls` config setting. * @return string */ - public static function url(string $path = '', array|string|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): string + public static function url(string $path = '', array|string|false|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): string { // Return $path if it appears to be an absolute URL. if (static::isFullUrl($path)) { if ($params) { $path = static::urlWithParams($path, $params); + } elseif ($params === false) { + $path = static::removeAllParams($path); } if ($scheme !== null) { @@ -312,7 +363,7 @@ public static function url(string $path = '', array|string|null $params = null, $scheme = 'https'; } - return self::_createUrl($path, $params, $scheme, $cpUrl, showScriptName: $showScriptName); + return self::_createUrl($path, $params ?: null, $scheme, $cpUrl, showScriptName: $showScriptName); } /** @@ -596,12 +647,12 @@ public static function cpReferralUrl(): ?string } // Make sure the CP referred it - if (!str_starts_with($referrer, self::baseCpUrl())) { + if (!str_starts_with($referrer, static::baseCpUrl())) { return null; } // to ensure we're comparing uris strip base cp url and query string from the referrer first - $referrerFullUri = ltrim(StringHelper::removeLeft($referrer, self::baseCpUrl()), '/'); + $referrerFullUri = ltrim(StringHelper::removeLeft($referrer, static::baseCpUrl()), '/'); $referrerFullUri = substr($referrerFullUri, 0, strpos($referrerFullUri, '?') ?: null); // Make sure it didn't refer itself diff --git a/src/web/DbSession.php b/src/web/DbSession.php new file mode 100644 index 00000000000..6963edb30f1 --- /dev/null +++ b/src/web/DbSession.php @@ -0,0 +1,33 @@ + + * @since 5.11.0 + * @mixin SessionBehavior + */ +class DbSession extends \yii\web\DbSession +{ + /** + * @inheritdoc + */ + public function has($key): bool + { + // don't open the session if the headers were already sent + if (!$this->getIsActive() && headers_sent()) { + return isset($_SESSION[$key]); + } + + return parent::has($key); + } +} diff --git a/tests/unit/helpers/UrlHelperTest.php b/tests/unit/helpers/UrlHelperTest.php index 6eb9c23ce71..c30e86627a9 100644 --- a/tests/unit/helpers/UrlHelperTest.php +++ b/tests/unit/helpers/UrlHelperTest.php @@ -158,6 +158,39 @@ public function testStripQueryString(string $expected, string $url): void self::assertSame($expected, UrlHelper::stripQueryString($url)); } + /** + * @dataProvider removeParamDataProvider + * @param string $expected + * @param string $url + * @param string $param + */ + public function testRemoveParam(string $expected, string $url, string $param): void + { + self::assertSame($expected, UrlHelper::removeParam($url, $param)); + } + + /** + * @dataProvider removeParamsDataProvider + * @param string $expected + * @param string $url + * @param string[] $params + */ + public function testRemoveParams(string $expected, string $url, array $params): void + { + self::assertSame($expected, UrlHelper::removeParams($url, $params)); + } + + /** + * @dataProvider removeAllParamsDataProvider + * @param string $expected + * @param string $url + * @param string[] $except + */ + public function testRemoveAllParams(string $expected, string $url, array $except = []): void + { + self::assertSame($expected, UrlHelper::removeAllParams($url, $except)); + } + /** * @dataProvider encodeParamsDataProvider */ @@ -192,11 +225,11 @@ public function testRootRelativeUrl(string $expected, string $url): void * @dataProvider urlFunctionDataProvider * @param string $expected * @param string $path - * @param array|null $params + * @param array|string|false|null $params * @param string|null $scheme * @param bool|null $showScriptName */ - public function testUrlFunction(string $expected, string $path = '', ?array $params = null, ?string $scheme = null, ?bool $showScriptName = null): void + public function testUrlFunction(string $expected, string $path = '', array|string|false|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): void { $scheme ??= 'https'; $expected = $this->_prepExpectedUrl($expected, $scheme); @@ -435,6 +468,129 @@ public static function stripQueryStringDataProvider(): array ]; } + /** + * Tests for UrlHelper::removeParam() method + * + * @return array + */ + public static function removeParamDataProvider(): array + { + return [ + 'basic' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + 'param1', + ], + 'last-param' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + 'param1', + ], + 'missing-param' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + 'param2', + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + 'param1', + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + 'param1', + ], + ]; + } + + /** + * Tests for UrlHelper::removeParams() method + * + * @return array + */ + public static function removeParamsDataProvider(): array + { + return [ + 'multiple' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param1', 'param3'], + ], + 'all-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + ['param1', 'param2'], + ], + 'missing-params' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + ['param2', 'param3'], + ], + 'empty-params' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + [], + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + ['param1'], + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3#anchor', + ['param1', 'param3'], + ], + ]; + } + + /** + * Tests for UrlHelper::removeAllParams() method + * + * @return array + */ + public static function removeAllParamsDataProvider(): array + { + return [ + 'basic' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + [], + ], + 'except' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param2'], + ], + 'except-multiple' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m3=value3', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param1', 'param3'], + ], + 'except-missing' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + ['param3'], + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + [], + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + [], + ], + 'except-keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + ['param2'], + ], + ]; + } + /** * Tests for UrlHelper::urlWithParams() method * @@ -716,6 +872,11 @@ public static function urlFunctionDataProvider(): array ['returnUrl' => 'https://example.test/admin/entries?site={handle}'], 'https', ], + 'remove-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?x-craft-preview=foo&test=bar', + false, + ], ]; }