VYPR
High severityGHSA Advisory· Published May 26, 2026· Updated May 26, 2026

Kirby CMS has pre-authentication path traversal and PHP file inclusion during user lookup

CVE-2026-44177

Description

TL;DR

This vulnerability affects all Kirby sites on Kirby 5.3.0-5.4.0 and is independent from setup conditions and authentication.

This vulnerability is of high severity for all Kirby sites.

----

Introduction

Path traversal is a type of attack that allows to access arbitrary filesystem paths. By using special elements such as .. and / separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the ../ sequence, which in most modern operating systems is interpreted as the parent directory of the current location. Path traversal can give attackers information about the filesystem and directory structure on the server and can lead to additional attacks depending on the nature of the accessible files and directories.

PHP file inclusion is a type of attack that allows to load and execute PHP files on the server that are not intended for direct inclusion. Depending on the logic inside the PHP files, this can lead to disclosure of sensitive information or unintended, malicious actions.

Affected components

Kirby's Users collection received a performance improvement in Kirby 5.3.0. Starting in this release, Kirby loads user objects lazily when they are first needed. Users are queried by their user ID, which is then used to look up the user's account directory in the site/accounts directory.

This applies to the authentication API (accessible to unauthenticated requests), the users API (accessible to authenticated users only) as well as to other places that use $users->find() to look up an individual user with a request-provided email or user ID.

Impact

In affected releases, Kirby did not correctly validate the provided user ID, causing a path traversal vulnerability. This vulnerability results in the following impact:

  • Arbitrary PHP file inclusion of files with the filename index.php (e.g. the main PHP files of plugins), the impact of which depends on the contents and logic inside the includable files.
  • Probing of the existence of arbitrary directories on the server, which can allow attackers to fingerprint the server and site setup, including installed plugins and the content structure.

Patches

The problem has been patched in Kirby 5.4.1. Please update to this or a later version to fix the vulnerability.

In the mentioned release, Kirby has added additional checks to the user lookup that ensure that the provided user ID only contains valid characters and that the resulting path to the account directory is contained in the site/accounts directory.

Credits

Kirby thanks @offset for responsibly reporting the identified issue.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Path traversal in Kirby CMS user lookup allows unauthenticated PHP file inclusion on versions 5.3.0 through 5.4.0.

Vulnerability

Kirby CMS versions 5.3.0 to 5.4.0 inclusive contain a path traversal vulnerability in the Users collection's find() method. Starting with the lazy-loading performance improvement in 5.3.0, user objects are queried by a user ID that is then used to locate the corresponding account directory under site/accounts. The application fails to validate the provided user ID properly, allowing an attacker to inject directory traversal sequences such as ../. This flaw affects the authentication API (accessible to unauthenticated requests), the users API (for authenticated users), and any other call to $users->find() with a request-provided email or user ID [1][2][3].

Exploitation

An attacker can exploit this vulnerability without prior authentication. By sending a crafted request containing a user ID with path traversal payloads (e.g., ../../plugins/vulnerable-plugin), the attacker can cause Kirby to attempt loading a user from an arbitrary directory on the server. No special network position or additional privileges are required — the attack is independent of setup conditions and authentication status [1][2][3].

Impact

Successful exploitation results in arbitrary PHP file inclusion, specifically of files named index.php (for instance, the main entry point of a PHP plugin). The impact depends on the logic and contents of the included file: it may lead to disclosure of sensitive information, execution of unintended code, or further compromise of the server. Probing of filesystem structure is also possible. The vulnerability is rated high severity with a CVSS score of 8.8 [1][2][3].

Mitigation

The vulnerability is fixed in Kirby 5.4.1, released on the same day as the advisory. Users should upgrade immediately to version 5.4.1 or later. An updated release 5.4.2 is also available that includes a patched symfony/yaml dependency. No workaround exists for sites that remain on 5.3.0 through 5.4.0 [1][2][3].

AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
getkirby/cmsPackagist
>= 5.3.0, < 5.4.15.4.1

Affected products

2

Patches

3
7bf0194141e5

fix: Prevent path traversal during user lookup

https://github.com/getkirby/kirbyLukas BestleMay 1, 2026Fixed in 5.4.1via llm-release-walk
2 files changed · +38 1
  • src/Cms/Users.php+8 1 modified
    @@ -7,6 +7,7 @@
     use Kirby\Filesystem\Dir;
     use Kirby\Filesystem\F;
     use Kirby\Toolkit\Str;
    +use Kirby\Toolkit\V;
     use Kirby\Uuid\HasUuids;
     
     /**
    @@ -159,11 +160,17 @@ protected function hydrateElement(string $key): User|null
     			return null;
     		}
     
    +		// ensure the user ID only contains safe characters
    +		// to prevent path traversal into subfolders
    +		if (V::match($key, '/^([a-z0-9_-])+$/i') !== true) {
    +			return null;
    +		}
    +
     		// check if the user directory exists if not all keys have been
     		// populated in the collection, otherwise we can assume that
     		// this method will only be called on "unhydrated" user IDs
     		$root = $this->root . '/' . $key;
    -		if ($this->initialized === false && is_dir($root) === false) {
    +		if ($this->initialized === false && Dir::exists($root, $this->root) === false) {
     			return null;
     		}
     
    
  • tests/Cms/Users/UsersTest.php+30 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Cms;
     
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Filesystem\F;
     
     class UsersTest extends TestCase
     {
    @@ -225,6 +226,35 @@ public function testFindInFilesystem(): void
     		$this->assertNull($users->find('user://bar'));
     	}
     
    +	public function testFindPathTraversal(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'accounts' => static::TMP . '/accounts',
    +				'index'    => '/dev/null'
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		$app->users()->create(['id' => 'homer', 'email' => 'a@getkirby.com', 'password' => '12345678']);
    +
    +		$contents = '<?php throw new PHPUnit\Framework\AssertionFailedError("File was accessible via path traversal");';
    +		F::write(static::TMP . '/index.php', $contents);
    +		F::write(static::TMP . '/accounts/homer/subfolder/index.php', $contents);
    +
    +		// initialize a new fresh app instance to start with an empty collection
    +		$app   = $app->clone();
    +		$users = $app->users();
    +
    +		// block slashes in user IDs to prevent disclosure of path structures and access to subfolders
    +		$this->assertNull($users->find('../accounts/homer'));
    +		$this->assertNull($users->find('homer/subfolder'));
    +
    +		// block path traversal outside of the `accounts` directory
    +		$this->assertNull($users->find('..'));
    +	}
    +
     	public function testCustomMethods(): void
     	{
     		Users::$methods = [
    
d260d9f7c307

fix: Remove insecure search options in API routes

https://github.com/getkirby/kirbyBastian AllgeierApr 24, 2026Fixed in 5.4.1via llm-release-walk
7 files changed · +291 5
  • config/api/routes/files.php+12 2 modified
    @@ -70,7 +70,12 @@
     				return $files->search($this->requestQuery('q'));
     			}
     
    -			return $files->query($this->requestBody());
    +			return $files->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     	[
    @@ -140,7 +145,12 @@
     				return $files->search($this->requestQuery('q'));
     			}
     
    -			return $files->query($this->requestBody());
    +			return $files->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     ];
    
  • config/api/routes/site.php+6 1 modified
    @@ -81,7 +81,12 @@
     				return $pages->search($this->requestQuery('q'));
     			}
     
    -			return $pages->query($this->requestBody());
    +			return $pages->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     	[
    
  • config/api/routes/users.php+6 1 modified
    @@ -30,7 +30,12 @@
     				return Find::users()->search($this->requestQuery('q'));
     			}
     
    -			return Find::users()->query($this->requestBody());
    +			return Find::users()->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     	[
    
  • src/Cms/Api.php+6 1 modified
    @@ -184,7 +184,12 @@ public function searchPages(string|null $parent = null): Pages
     			return $pages->search($this->requestQuery('q'));
     		}
     
    -		return $pages->query($this->requestBody());
    +		return $pages->query(array_filter([
    +			'limit'    => $this->requestBody('limit'),
    +			'offset'   => $this->requestBody('offset'),
    +			'paginate' => $this->requestBody('paginate'),
    +			'search'   => $this->requestBody('search'),
    +		], fn ($value) => $value !== null));
     	}
     
     	/**
    
  • tests/Cms/Api/routes/PagesRoutesTest.php+134 0 modified
    @@ -279,4 +279,138 @@ public function testFile(): void
     
     		$this->assertSame('a.jpg', $response['data']['filename']);
     	}
    +
    +	public function testChildrenSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'     => 'parent',
    +						'children' => [
    +							[
    +								'slug'    => 'photography',
    +								'content' => ['title' => 'Photography']
    +							],
    +							[
    +								'slug'    => 'design',
    +								'content' => ['title' => 'Design']
    +							]
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// filterBy slug == photography would normally return only 1 page;
    +		// since filterBy is stripped from the body, both pages are returned
    +		$response = $app->api()->call('pages/parent/children/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'slug', 'operator' => '==', 'value' => 'photography']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testChildrenSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'     => 'parent',
    +						'children' => [
    +							[
    +								'slug'    => 'a',
    +								'content' => ['title' => 'A']
    +							],
    +							[
    +								'slug'    => 'b',
    +								'content' => ['title' => 'B']
    +							]
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// sortBy in the body is stripped; default order (a, b) is preserved
    +		$response = $app->api()->call('pages/parent/children/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'slug desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('parent/a', $response['data'][0]['id']);
    +		$this->assertSame('parent/b', $response['data'][1]['id']);
    +	}
    +
    +	public function testFilesSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'  => 'a',
    +						'files' => [
    +							['filename' => 'photo.jpg'],
    +							['filename' => 'document.pdf']
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// filterBy filename == photo.jpg would normally return only 1 file;
    +		// since filterBy is stripped from the body, both files are returned
    +		$response = $app->api()->call('pages/a/files/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'filename', 'operator' => '==', 'value' => 'photo.jpg']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testFilesSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'  => 'a',
    +						'files' => [
    +							['filename' => 'a.jpg'],
    +							['filename' => 'b.jpg']
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// sortBy in the body is stripped; default sorted order (a, b) is preserved
    +		$response = $app->api()->call('pages/a/files/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'filename desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('a.jpg', $response['data'][0]['filename']);
    +		$this->assertSame('b.jpg', $response['data'][1]['filename']);
    +	}
     }
    
  • tests/Cms/Api/routes/SiteRoutesTest.php+63 0 modified
    @@ -329,4 +329,67 @@ public function testSearchWithPostRequest(): void
     		$this->assertCount(1, $response['data']);
     		$this->assertSame('parent/child', $response['data'][0]['id']);
     	}
    +
    +	public function testSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'    => 'photography',
    +						'content' => ['title' => 'Photography']
    +					],
    +					[
    +						'slug'    => 'design',
    +						'content' => ['title' => 'Design']
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// filterBy slug = photography would normally return only 1 page;
    +		// since filterBy is stripped from the body, both pages are returned
    +		$response = $app->api()->call('site/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'slug', 'operator' => '==', 'value' => 'photography']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'    => 'a',
    +						'content' => ['title' => 'A']
    +					],
    +					[
    +						'slug'    => 'b',
    +						'content' => ['title' => 'B']
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// sortBy in the body is stripped; default order (a, b) is preserved
    +		$response = $app->api()->call('site/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'slug desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('a', $response['data'][0]['id']);
    +		$this->assertSame('b', $response['data'][1]['id']);
    +	}
     }
    
  • tests/Cms/Api/routes/UsersRoutesTest.php+64 0 modified
    @@ -529,6 +529,35 @@ public function testSearchWithPostRequest(): void
     		$this->assertSame('editor@getkirby.com', $response['data'][0]['email']);
     	}
     
    +	public function testSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		// filterBy role == editor would normally return only 1 user;
    +		// since filterBy is stripped from the body, both users are returned
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'role', 'operator' => '==', 'value' => 'editor']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		// sortBy in the body is stripped; default order (alphabetical asc) is preserved
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'email desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('admin@getkirby.com', $response['data'][0]['email']);
    +		$this->assertSame('editor@getkirby.com', $response['data'][1]['email']);
    +	}
    +
     	public function testSearchWithPostRequestWithoutAccess(): void
     	{
     		$app = $this->setUpAppWithoutUserAccess();
    @@ -542,6 +571,41 @@ public function testSearchWithPostRequestWithoutAccess(): void
     		$this->assertCount(0, $response['data']);
     	}
     
    +	public function testSearchWithPostRequestRespectsLimit(): void
    +	{
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'limit' => 1,
    +			]
    +		]);
    +
    +		$this->assertCount(1, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresNullValues(): void
    +	{
    +		// search: null should not restrict results
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'search' => null,
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresNotKey(): void
    +	{
    +		// the 'not' key is stripped; both users are returned
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'not' => ['admin@getkirby.com'],
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
     	public function testSections(): void
     	{
     		$app = $this->app->clone([
    
b583392a7246

Merge pull request #8149 from getkirby/release/5.4.1

https://github.com/getkirby/kirbyBastian AllgeierMay 19, 2026Fixed in 5.4.1via release-tag
120 files changed · +2712 1166
  • cacert.pem+3 575 modified
    @@ -1,7 +1,7 @@
     ##
     ## Bundle of CA Root Certificates
     ##
    -## Certificate data from Mozilla last updated on: Wed Feb 11 18:26:30 2026 GMT
    +## Certificate data from Mozilla as of: Thu May 14 03:12:02 2026 GMT
     ##
     ## Find updated versions here: https://curl.se/docs/caextract.html
     ##
    @@ -15,8 +15,8 @@
     ## an Apache+mod_ssl webserver for SSL client authentication.
     ## Just configure this file as the SSLCACertificateFile.
     ##
    -## Conversion done with mk-ca-bundle.pl version 1.32.
    -## SHA256: 3b98d4e3ff57a326d9587c33633039c8c3a9cf0b55f7ca581d7598ff329eb1f3
    +## Conversion done with mk-ca-bundle.pl version 1.33.
    +## SHA256: 77130ef91213772844561fbd3aa31d413b25c2ac7f576fea3bc3bbff7ef93489
     ##
     
     
    @@ -46,237 +46,6 @@ W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0
     tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8
     -----END CERTIFICATE-----
     
    -QuoVadis Root CA 2
    -==================
    ------BEGIN CERTIFICATE-----
    -MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT
    -EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMjAeFw0wNjExMjQx
    -ODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
    -aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4IC
    -DwAwggIKAoICAQCaGMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6
    -XJxgFyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55JWpzmM+Yk
    -lvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bBrrcCaoF6qUWD4gXmuVbB
    -lDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp+ARz8un+XJiM9XOva7R+zdRcAitMOeGy
    -lZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt
    -66/3FsvbzSUr5R/7mp/iUcw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1Jdxn
    -wQ5hYIizPtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og/zOh
    -D7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UHoycR7hYQe7xFSkyy
    -BNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuIyV77zGHcizN300QyNQliBJIWENie
    -J0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1Ud
    -DgQWBBQahGK8SEwzJQTU7tD2A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGU
    -a6FJpEcwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT
    -ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2fBluornFdLwUv
    -Z+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzng/iN/Ae42l9NLmeyhP3ZRPx3
    -UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2BlfF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodm
    -VjB3pjd4M1IQWK4/YY7yarHvGH5KWWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK
    -+JDSV6IZUaUtl0HaB0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrW
    -IozchLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPRTUIZ3Ph1
    -WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWDmbA4CD/pXvk1B+TJYm5X
    -f6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0ZohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II
    -4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8
    -VCLAAVBpQ570su9t+Oza8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u
    ------END CERTIFICATE-----
    -
    -QuoVadis Root CA 3
    -==================
    ------BEGIN CERTIFICATE-----
    -MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT
    -EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMzAeFw0wNjExMjQx
    -OTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
    -aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4IC
    -DwAwggIKAoICAQDMV0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNgg
    -DhoB4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUrH556VOij
    -KTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd8lyyBTNvijbO0BNO/79K
    -DDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9CabwvvWhDFlaJKjdhkf2mrk7AyxRllDdLkgbv
    -BNDInIjbC3uBr7E9KsRlOni27tyAsdLTmZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwp
    -p5ijJUMv7/FfJuGITfhebtfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8
    -nT8KKdjcT5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDtWAEX
    -MJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZc6tsgLjoC2SToJyM
    -Gf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A4iLItLRkT9a6fUg+qGkM17uGcclz
    -uD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYDVR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHT
    -BgkrBgEEAb5YAAMwgcUwgZMGCCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmlj
    -YXRlIGNvbnN0aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0
    -aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVudC4wLQYIKwYB
    -BQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2NwczALBgNVHQ8EBAMCAQYwHQYD
    -VR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4GA1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4
    -ywLQoUmkRzBFMQswCQYDVQQGEwJCTTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UE
    -AxMSUXVvVmFkaXMgUm9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZV
    -qyM07ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSemd1o417+s
    -hvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd+LJ2w/w4E6oM3kJpK27z
    -POuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2
    -Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadNt54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp
    -8kokUvd0/bpO5qgdAm6xDYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBC
    -bjPsMZ57k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6szHXu
    -g/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0jWy10QJLZYxkNc91p
    -vGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeTmJlglFwjz1onl14LBQaTNx47aTbr
    -qZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK4SVhM7JZG+Ju1zdXtg2pEto=
    ------END CERTIFICATE-----
    -
    -DigiCert Assured ID Root CA
    -===========================
    ------BEGIN CERTIFICATE-----
    -MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQG
    -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw
    -IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzEx
    -MTEwMDAwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL
    -ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0Ew
    -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7cJpSIqvTO
    -9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYPmDI2dsze3Tyoou9q+yHy
    -UmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW
    -/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpy
    -oeb6pNnVFzF1roV9Iq4/AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whf
    -GHdPAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRF
    -66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzANBgkq
    -hkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRCdWKuh+vy1dneVrOfzM4UKLkNl2Bc
    -EkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTffwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38Fn
    -SbNd67IJKusm7Xi+fT8r87cmNW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i
    -8b5QZ7dsvfPxH2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe
    -+o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g==
    ------END CERTIFICATE-----
    -
    -DigiCert Global Root CA
    -=======================
    ------BEGIN CERTIFICATE-----
    -MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQG
    -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw
    -HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAw
    -MDAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
    -dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkq
    -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsBCSDMAZOn
    -TjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97nh6Vfe63SKMI2tavegw5
    -BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt43C/dxC//AH2hdmoRBBYMql1GNXRor5H
    -4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7PT19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y
    -7vrTC0LUq7dBMtoM1O/4gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQAB
    -o2MwYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbRTLtm
    -8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEF
    -BQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/EsrhMAtudXH/vTBH1jLuG2cenTnmCmr
    -EbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIt
    -tep3Sp+dWOIrWcBAI+0tKIJFPnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886
    -UAb3LujEV0lsYSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
    -CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
    ------END CERTIFICATE-----
    -
    -DigiCert High Assurance EV Root CA
    -==================================
    ------BEGIN CERTIFICATE-----
    -MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQG
    -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSsw
    -KQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAw
    -MFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ
    -MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFu
    -Y2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0t
    -Mqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMS
    -OO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3
    -MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQ
    -NAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUe
    -h10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB
    -Af8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSY
    -JhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQ
    -V8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFp
    -myPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkK
    -mNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe
    -vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K
    ------END CERTIFICATE-----
    -
    -SwissSign Gold CA - G2
    -======================
    ------BEGIN CERTIFICATE-----
    -MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNIMRUw
    -EwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2lnbiBHb2xkIENBIC0gRzIwHhcN
    -MDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBFMQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dp
    -c3NTaWduIEFHMR8wHQYDVQQDExZTd2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0B
    -AQEFAAOCAg8AMIICCgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUq
    -t2/876LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+bbqBHH5C
    -jCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c6bM8K8vzARO/Ws/BtQpg
    -vd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqEemA8atufK+ze3gE/bk3lUIbLtK/tREDF
    -ylqM2tIrfKjuvqblCqoOpd8FUrdVxyJdMmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvR
    -AiTysybUa9oEVeXBCsdtMDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuend
    -jIj3o02yMszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69yFGkO
    -peUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPiaG59je883WX0XaxR
    -7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxMgI93e2CaHt+28kgeDrpOVG2Y4OGi
    -GqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw
    -AwEB/zAdBgNVHQ4EFgQUWyV7lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64
    -OfPAeGZe6Drn8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov
    -L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe645R88a7A3hfm
    -5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczOUYrHUDFu4Up+GC9pWbY9ZIEr
    -44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOf
    -Mke6UiI0HTJ6CVanfCU2qT1L2sCCbwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6m
    -Gu6uLftIdxf+u+yvGPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxp
    -mo/a77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCChdiDyyJk
    -vC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid392qgQmwLOM7XdVAyksLf
    -KzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEppLd6leNcG2mqeSz53OiATIgHQv2ieY2Br
    -NU0LbbqhPcCT4H8js1WtciVORvnSFu+wZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6Lqj
    -viOvrv1vA+ACOzB2+httQc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ
    ------END CERTIFICATE-----
    -
    -SecureTrust CA
    -==============
    ------BEGIN CERTIFICATE-----
    -MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBIMQswCQYDVQQG
    -EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xFzAVBgNVBAMTDlNlY3VyZVRy
    -dXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIzMTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAe
    -BgNVBAoTF1NlY3VyZVRydXN0IENvcnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCC
    -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQX
    -OZEzZum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO0gMdA+9t
    -DWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIaowW8xQmxSPmjL8xk037uH
    -GFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b
    -01k/unK8RCSc43Oz969XL0Imnal0ugBS8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmH
    -ursCAwEAAaOBnTCBmjATBgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/
    -BAUwAwEB/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCegJYYj
    -aHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ
    -KoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt36Z3q059c4EVlew3KW+JwULKUBRSu
    -SceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHf
    -mbx8IVQr5Fiiu1cprp6poxkmD5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZ
    -nMUFdAvnZyPSCPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR
    -3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE=
    ------END CERTIFICATE-----
    -
    -Secure Global CA
    -================
    ------BEGIN CERTIFICATE-----
    -MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQG
    -EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBH
    -bG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkxMjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEg
    -MB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwg
    -Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jx
    -YDiJiQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa/FHtaMbQ
    -bqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJjnIFHovdRIWCQtBJwB1g
    -8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnIHmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYV
    -HDGA76oYa8J719rO+TMg1fW9ajMtgQT7sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi
    -0XPnj3pDAgMBAAGjgZ0wgZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
    -EwEB/wQFMAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCswKaAn
    -oCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsGAQQBgjcVAQQDAgEA
    -MA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0LURYD7xh8yOOvaliTFGCRsoTciE6+
    -OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXOH0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cn
    -CDpOGR86p1hcF895P4vkp9MmI50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/5
    -3CYNv6ZHdAbYiNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc
    -f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW
    ------END CERTIFICATE-----
    -
    -COMODO Certification Authority
    -==============================
    ------BEGIN CERTIFICATE-----
    -MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCBgTELMAkGA1UE
    -BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG
    -A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNVBAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1
    -dGhvcml0eTAeFw0wNjEyMDEwMDAwMDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEb
    -MBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFD
    -T01PRE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0aG9yaXR5
    -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3UcEbVASY06m/weaKXTuH
    -+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI2GqGd0S7WWaXUF601CxwRM/aN5VCaTww
    -xHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV
    -4EajcNxo2f8ESIl33rXp+2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA
    -1KGzqSX+DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5OnKVI
    -rLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW/zAOBgNVHQ8BAf8E
    -BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6gPKA6hjhodHRwOi8vY3JsLmNvbW9k
    -b2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOC
    -AQEAPpiem/Yb6dc5t3iuHXIYSdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CP
    -OGEIqB6BCsAvIC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/
    -RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4zJVSk/BwJVmc
    -IGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5ddBA6+C4OmF4O5MBKgxTMVBbkN
    -+8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IBZQ==
    ------END CERTIFICATE-----
    -
     COMODO ECC Certification Authority
     ==================================
     -----BEGIN CERTIFICATE-----
    @@ -294,28 +63,6 @@ FAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeA
     U/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY=
     -----END CERTIFICATE-----
     
    -Certigna
    -========
    ------BEGIN CERTIFICATE-----
    -MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNVBAYTAkZSMRIw
    -EAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4XDTA3MDYyOTE1MTMwNVoXDTI3
    -MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwI
    -Q2VydGlnbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7q
    -XOEm7RFHYeGifBZ4QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyH
    -GxnygQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbwzBfsV1/p
    -ogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q130yGLMLLGq/jj8UEYkg
    -DncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKf
    -Irjxwo1p3Po6WAbfAgMBAAGjgbwwgbkwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQ
    -tCRZvgHyUtVF9lo53BEwZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJ
    -BgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzjAQ/J
    -SP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG9w0BAQUFAAOCAQEA
    -hQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8hbV6lUmPOEvjvKtpv6zf+EwLHyzs+
    -ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFncfca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1klu
    -PBS1xp81HlDQwY9qcEQCYsuuHWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY
    -1gkIl2PlwS6wt0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw
    -WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg==
    ------END CERTIFICATE-----
    -
     ePKI Root Certification Authority
     =================================
     -----BEGIN CERTIFICATE-----
    @@ -347,26 +94,6 @@ sP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTEW9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmOD
     BCEIZ43ygknQW/2xzQ+DhNQ+IIX3Sj0rnP0qCglN6oH4EZw=
     -----END CERTIFICATE-----
     
    -certSIGN ROOT CA
    -================
    ------BEGIN CERTIFICATE-----
    -MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYTAlJPMREwDwYD
    -VQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTAeFw0wNjA3MDQxNzIwMDRa
    -Fw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UE
    -CxMQY2VydFNJR04gUk9PVCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7I
    -JUqOtdu0KBuqV5Do0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHH
    -rfAQUySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5dRdY4zTW2
    -ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQOA7+j0xbm0bqQfWwCHTD
    -0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwvJoIQ4uNllAoEwF73XVv4EOLQunpL+943
    -AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B
    -Af8EBAMCAcYwHQYDVR0OBBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IB
    -AQA+0hyJLjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecYMnQ8
    -SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ44gx+FkagQnIl6Z0
    -x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6IJd1hJyMctTEHBDa0GpC9oHRxUIlt
    -vBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNwi/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7Nz
    -TogVZ96edhBiIL5VaZVDADlN9u6wWk5JRFRYX0KD
    ------END CERTIFICATE-----
    -
     NetLock Arany (Class Gold) Főtanúsítvány
     ========================================
     -----BEGIN CERTIFICATE-----
    @@ -536,90 +263,6 @@ iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jza
     YyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6
     -----END CERTIFICATE-----
     
    -AffirmTrust Commercial
    -======================
    ------BEGIN CERTIFICATE-----
    -MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UEBhMCVVMxFDAS
    -BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMB4XDTEw
    -MDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly
    -bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEF
    -AAOCAQ8AMIIBCgKCAQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6Eqdb
    -DuKPHx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yrba0F8PrV
    -C8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPALMeIrJmqbTFeurCA+ukV6
    -BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1yHp52UKqK39c/s4mT6NmgTWvRLpUHhww
    -MmWd5jyTXlBOeuM61G7MGvv50jeuJCqrVwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNV
    -HQ4EFgQUnZPGU4teyq8/nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
    -AQYwDQYJKoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYGXUPG
    -hi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNjvbz4YYCanrHOQnDi
    -qX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivtZ8SOyUOyXGsViQK8YvxO8rUzqrJv
    -0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9gN53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0kh
    -sUlHRUe072o0EclNmsxZt9YCnlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8=
    ------END CERTIFICATE-----
    -
    -AffirmTrust Networking
    -======================
    ------BEGIN CERTIFICATE-----
    -MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UEBhMCVVMxFDAS
    -BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMB4XDTEw
    -MDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly
    -bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEF
    -AAOCAQ8AMIIBCgKCAQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SE
    -Hi3yYJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbuakCNrmreI
    -dIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRLQESxG9fhwoXA3hA/Pe24
    -/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gb
    -h+0t+nvujArjqWaJGctB+d1ENmHP4ndGyH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNV
    -HQ4EFgQUBx/S55zawm6iQLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
    -AQYwDQYJKoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfOtDIu
    -UFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzuQY0x2+c06lkh1QF6
    -12S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZLgo/bNjR9eUJtGxUAArgFU2HdW23
    -WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4uolu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9
    -/ZFvgrG+CJPbFEfxojfHRZ48x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s=
    ------END CERTIFICATE-----
    -
    -AffirmTrust Premium
    -===================
    ------BEGIN CERTIFICATE-----
    -MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UEBhMCVVMxFDAS
    -BgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMB4XDTEwMDEy
    -OTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRy
    -dXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
    -MIICCgKCAgEAxBLfqV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtn
    -BKAQJG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ+jjeRFcV
    -5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrSs8PhaJyJ+HoAVt70VZVs
    -+7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmd
    -GPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d770O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5R
    -p9EixAqnOEhss/n/fauGV+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NI
    -S+LI+H+SqHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S5u04
    -6uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4IaC1nEWTJ3s7xgaVY5
    -/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TXOwF0lkLgAOIua+rF7nKsu7/+6qqo
    -+Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYEFJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB
    -/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByv
    -MiPIs0laUZx2KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg
    -Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B8OWycvpEgjNC
    -6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQMKSOyARiqcTtNd56l+0OOF6S
    -L5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK
    -+4w1IX2COPKpVJEZNZOUbWo6xbLQu4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmV
    -BtWVyuEklut89pMFu+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFg
    -IxpHYoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8GKa1qF60
    -g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaORtGdFNrHF+QFlozEJLUb
    -zxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6eKeC2uAloGRwYQw==
    ------END CERTIFICATE-----
    -
    -AffirmTrust Premium ECC
    -=======================
    ------BEGIN CERTIFICATE-----
    -MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMCVVMxFDASBgNV
    -BAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQcmVtaXVtIEVDQzAeFw0xMDAx
    -MjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1U
    -cnVzdDEgMB4GA1UEAwwXQWZmaXJtVHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQA
    -IgNiAAQNMF4bFZ0D0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQ
    -N8O9ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0GA1UdDgQW
    -BBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAK
    -BggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/VsaobgxCd05DhT1wV/GzTjxi+zygk8N53X
    -57hG8f2h4nECMEJZh0PUUd+60wkyWs6Iflc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKM
    -eQ==
    ------END CERTIFICATE-----
    -
     Certum Trusted Network CA
     =========================
     -----BEGIN CERTIFICATE-----
    @@ -946,35 +589,6 @@ EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWzaGHQRiapIVJpLesux+t3
     zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmyKwbQBM0=
     -----END CERTIFICATE-----
     
    -TeliaSonera Root CA v1
    -======================
    ------BEGIN CERTIFICATE-----
    -MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAwNzEUMBIGA1UE
    -CgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJvb3QgQ0EgdjEwHhcNMDcxMDE4
    -MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYDVQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwW
    -VGVsaWFTb25lcmEgUm9vdCBDQSB2MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+
    -6yfwIaPzaSZVfp3FVRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA
    -3GV17CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+XZ75Ljo1k
    -B1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+/jXh7VB7qTCNGdMJjmhn
    -Xb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxH
    -oLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkmdtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3
    -F0fUTPHSiXk+TT2YqGHeOh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJ
    -oWjiUIMusDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4pgd7
    -gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fsslESl1MpWtTwEhDc
    -TwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQarMCpgKIv7NHfirZ1fpoeDVNAgMB
    -AAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qW
    -DNXr+nuqF+gTEjANBgkqhkiG9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNm
    -zqjMDfz1mgbldxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx
    -0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1TjTQpgcmLNkQfW
    -pb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBedY2gea+zDTYa4EzAvXUYNR0PV
    -G6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpc
    -c41teyWRyu5FrgZLAMzTsVlQ2jqIOylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOT
    -JsjrDNYmiLbAJM+7vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2
    -qReWt88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcnHL/EVlP6
    -Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVxSK236thZiNSQvxaz2ems
    -WWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY=
    ------END CERTIFICATE-----
    -
     T-TeleSec GlobalRoot Class 2
     ============================
     -----BEGIN CERTIFICATE-----
    @@ -1371,50 +985,6 @@ ZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ
     3Wl9af0AVqW3rLatt8o+Ae+c
     -----END CERTIFICATE-----
     
    -Entrust Root Certification Authority - G2
    -=========================================
    ------BEGIN CERTIFICATE-----
    -MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMCVVMxFjAUBgNV
    -BAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVy
    -bXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ug
    -b25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIw
    -HhcNMDkwNzA3MTcyNTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoT
    -DUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMx
    -OTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25s
    -eTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwggEi
    -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP
    -/vaCeb9zYQYKpSfYs1/TRU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXz
    -HHfV1IWNcCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hWwcKU
    -s/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1U1+cPvQXLOZprE4y
    -TGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0jaWvYkxN4FisZDQSA/i2jZRjJKRx
    -AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ6
    -0B7vfec7aVHUbI2fkBJmqzANBgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5Z
    -iXMRrEPR9RP/jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ
    -Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v1fN2D807iDgi
    -nWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4RnAuknZoh8/CbCzB428Hch0P+
    -vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmHVHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xO
    -e4pIb4tF9g==
    ------END CERTIFICATE-----
    -
    -Entrust Root Certification Authority - EC1
    -==========================================
    ------BEGIN CERTIFICATE-----
    -MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkGA1UEBhMCVVMx
    -FjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVn
    -YWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXpl
    -ZCB1c2Ugb25seTEzMDEGA1UEAxMqRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5
    -IC0gRUMxMB4XDTEyMTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYw
    -FAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2Fs
    -LXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQg
    -dXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAt
    -IEVDMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHy
    -AsWfoPZb1YsGGYZPUxBtByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef
    -9eNi1KlHBz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
    -FLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVCR98crlOZF7ZvHH3h
    -vxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nXhTcGtXsI/esni0qU+eH6p44mCOh8
    -kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G
    ------END CERTIFICATE-----
    -
     CFCA EV ROOT
     ============
     -----BEGIN CERTIFICATE-----
    @@ -2197,71 +1767,6 @@ NMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE1LlSVHJ7liXMvGnjSG4N
     0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MXQRBdJ3NghVdJIgc=
     -----END CERTIFICATE-----
     
    -Trustwave Global Certification Authority
    -========================================
    ------BEGIN CERTIFICATE-----
    -MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJV
    -UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2
    -ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u
    -IEF1dGhvcml0eTAeFw0xNzA4MjMxOTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJV
    -UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2
    -ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u
    -IEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALldUShLPDeS0YLOvR29
    -zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0XznswuvCAAJWX/NKSqIk4cXGIDtiLK0thAf
    -LdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4Bq
    -stTnoApTAbqOl5F2brz81Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9o
    -WN0EACyW80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotPJqX+
    -OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1lRtzuzWniTY+HKE40
    -Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfwhI0Vcnyh78zyiGG69Gm7DIwLdVcE
    -uE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm
    -+9jaJXLE9gCxInm943xZYkqcBW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqj
    -ifLJS3tBEW1ntwiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud
    -EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1UdDwEB/wQEAwIB
    -BjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W0OhUKDtkLSGm+J1WE2pIPU/H
    -PinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfeuyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0H
    -ZJDmHvUqoai7PF35owgLEQzxPy0QlG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla
    -4gt5kNdXElE1GYhBaCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5R
    -vbbEsLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPTMaCm/zjd
    -zyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qequ5AvzSxnI9O4fKSTx+O
    -856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxhVicGaeVyQYHTtgGJoC86cnn+OjC/QezH
    -Yj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu
    -3R3y4G5OBVixwJAWKqQ9EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP
    -29FpHOTKyeC2nOnOcXHebD8WpHk=
    ------END CERTIFICATE-----
    -
    -Trustwave Global ECC P256 Certification Authority
    -=================================================
    ------BEGIN CERTIFICATE-----
    -MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYDVQQGEwJVUzER
    -MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI
    -b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZp
    -Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYD
    -VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy
    -dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1
    -NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABH77bOYj
    -43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoNFWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqm
    -P62jQzBBMA8GA1UdEwEB/wQFMAMBAf8wDwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt
    -0UrrdaVKEJmzsaGLSvcwCgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjz
    -RM4q3wghDDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7
    ------END CERTIFICATE-----
    -
    -Trustwave Global ECC P384 Certification Authority
    -=================================================
    ------BEGIN CERTIFICATE-----
    -MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzER
    -MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI
    -b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZp
    -Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYD
    -VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy
    -dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4
    -NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuBBAAiA2IABGvaDXU1CDFH
    -Ba5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJj9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr
    -/TklZvFe/oyujUF5nQlgziip04pt89ZF1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNV
    -HQ8BAf8EBQMDBwYAMB0GA1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNn
    -ADBkAjA3AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsCMGcl
    -CrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVuSw==
    ------END CERTIFICATE-----
    -
     NAVER Global Root Certification Authority
     =========================================
     -----BEGIN CERTIFICATE-----
    @@ -2354,36 +1859,6 @@ vLtoURMMA/cVi4RguYv/Uo7njLwcAjA8+RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+
     CAezNIm8BZ/3Hobui3A=
     -----END CERTIFICATE-----
     
    -GLOBALTRUST 2020
    -================
    ------BEGIN CERTIFICATE-----
    -MIIFgjCCA2qgAwIBAgILWku9WvtPilv6ZeUwDQYJKoZIhvcNAQELBQAwTTELMAkGA1UEBhMCQVQx
    -IzAhBgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVT
    -VCAyMDIwMB4XDTIwMDIxMDAwMDAwMFoXDTQwMDYxMDAwMDAwMFowTTELMAkGA1UEBhMCQVQxIzAh
    -BgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVTVCAy
    -MDIwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAri5WrRsc7/aVj6B3GyvTY4+ETUWi
    -D59bRatZe1E0+eyLinjF3WuvvcTfk0Uev5E4C64OFudBc/jbu9G4UeDLgztzOG53ig9ZYybNpyrO
    -VPu44sB8R85gfD+yc/LAGbaKkoc1DZAoouQVBGM+uq/ufF7MpotQsjj3QWPKzv9pj2gOlTblzLmM
    -CcpL3TGQlsjMH/1WljTbjhzqLL6FLmPdqqmV0/0plRPwyJiT2S0WR5ARg6I6IqIoV6Lr/sCMKKCm
    -fecqQjuCgGOlYx8ZzHyyZqjC0203b+J+BlHZRYQfEs4kUmSFC0iAToexIiIwquuuvuAC4EDosEKA
    -A1GqtH6qRNdDYfOiaxaJSaSjpCuKAsR49GiKweR6NrFvG5Ybd0mN1MkGco/PU+PcF4UgStyYJ9OR
    -JitHHmkHr96i5OTUawuzXnzUJIBHKWk7buis/UDr2O1xcSvy6Fgd60GXIsUf1DnQJ4+H4xj04KlG
    -DfV0OoIu0G4skaMxXDtG6nsEEFZegB31pWXogvziB4xiRfUg3kZwhqG8k9MedKZssCz3AwyIDMvU
    -clOGvGBG85hqwvG/Q/lwIHfKN0F5VVJjjVsSn8VoxIidrPIwq7ejMZdnrY8XD2zHc+0klGvIg5rQ
    -mjdJBKuxFshsSUktq6HQjJLyQUp5ISXbY9e2nKd+Qmn7OmMCAwEAAaNjMGEwDwYDVR0TAQH/BAUw
    -AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFNwuH9FhN3nkq9XVsxJxaD1qaJwiMB8GA1Ud
    -IwQYMBaAFNwuH9FhN3nkq9XVsxJxaD1qaJwiMA0GCSqGSIb3DQEBCwUAA4ICAQCR8EICaEDuw2jA
    -VC/f7GLDw56KoDEoqoOOpFaWEhCGVrqXctJUMHytGdUdaG/7FELYjQ7ztdGl4wJCXtzoRlgHNQIw
    -4Lx0SsFDKv/bGtCwr2zD/cuz9X9tAy5ZVp0tLTWMstZDFyySCstd6IwPS3BD0IL/qMy/pJTAvoe9
    -iuOTe8aPmxadJ2W8esVCgmxcB9CpwYhgROmYhRZf+I/KARDOJcP5YBugxZfD0yyIMaK9MOzQ0MAS
    -8cE54+X1+NZK3TTN+2/BT+MAi1bikvcoskJ3ciNnxz8RFbLEAwW+uxF7Cr+obuf/WEPPm2eggAe2
    -HcqtbepBEX4tdJP7wry+UUTF72glJ4DjyKDUEuzZpTcdN3y0kcra1LGWge9oXHYQSa9+pTeAsRxS
    -vTOBTI/53WXZFM2KJVj04sWDpQmQ1GwUY7VA3+vA/MRYfg0UFodUJ25W5HCEuGwyEn6CMUO+1918
    -oa2u1qsgEu8KwxCMSZY13At1XrFP1U80DhEgB3VDRemjEdqso5nCtnkn4rnvyOL2NSl6dPrFf4IF
    -YqYK6miyeUcGbvJXqBUzxvd4Sj1Ce2t+/vdG6tHrju+IaFvowdlxfv1k7/9nR4hYJS8+hge9+6jl
    -gqispdNpQ80xiEmEU5LAsTkbOYMBMMTyqfrQA71yN2BWHzZ8vTmR9W0Nv3vXkg==
    ------END CERTIFICATE-----
    -
     ANF Secure Server Root CA
     =========================
     -----BEGIN CERTIFICATE-----
    @@ -2708,36 +2183,6 @@ FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3
     gm3c
     -----END CERTIFICATE-----
     
    -GTS Root R2
    -===========
    ------BEGIN CERTIFICATE-----
    -MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJV
    -UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg
    -UjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE
    -ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0G
    -CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3Lv
    -CvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY6Dlo7JUl
    -e3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAuMC6C/Pq8tBcKSOWIm8Wb
    -a96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7kRXuJVfeKH2JShBKzwkCX44ofR5GmdFrS
    -+LFjKBC4swm4VndAoiaYecb+3yXuPuWgf9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7M
    -kogwTZq9TwtImoS1mKPV+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJG
    -r61K8YzodDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RWIr9q
    -S34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKaG73VululycslaVNV
    -J1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCqgc7dGtxRcw1PcOnlthYhGXmy5okL
    -dWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T
    -AQH/BAUwAwEB/zAdBgNVHQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQAD
    -ggIBAB/Kzt3HvqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8
    -0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyCB19m3H0Q/gxh
    -swWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2uNmSRXbBoGOqKYcl3qJfEycel
    -/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMgyALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVn
    -jWQye+mew4K6Ki3pHrTgSAai/GevHyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y5
    -9PYjJbigapordwj6xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M
    -7YNRTOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924SgJPFI/2R8
    -0L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV7LXTWtiBmelDGDfrs7vR
    -WGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjW
    -HYbL
    ------END CERTIFICATE-----
    -
     GTS Root R3
     ===========
     -----BEGIN CERTIFICATE-----
    @@ -3214,23 +2659,6 @@ HVlNjM7IDiPCtyaaEBRx/pOyiriA8A4QntOoUAw3gi/q4Iqd4Sw5/7W0cwDk90imc6y/st53BIe0
     o82bNSQ3+pCTE4FCxpgmdTdmQRCsu/WU48IxK63nI1bMNSWSs1A=
     -----END CERTIFICATE-----
     
    -FIRMAPROFESIONAL CA ROOT-A WEB
    -==============================
    ------BEGIN CERTIFICATE-----
    -MIICejCCAgCgAwIBAgIQMZch7a+JQn81QYehZ1ZMbTAKBggqhkjOPQQDAzBuMQswCQYDVQQGEwJF
    -UzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25hbCBTQTEYMBYGA1UEYQwPVkFURVMtQTYyNjM0MDY4
    -MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFMIENBIFJPT1QtQSBXRUIwHhcNMjIwNDA2MDkwMTM2
    -WhcNNDcwMzMxMDkwMTM2WjBuMQswCQYDVQQGEwJFUzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25h
    -bCBTQTEYMBYGA1UEYQwPVkFURVMtQTYyNjM0MDY4MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFM
    -IENBIFJPT1QtQSBXRUIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARHU+osEaR3xyrq89Zfe9MEkVz6
    -iMYiuYMQYneEMy3pA4jU4DP37XcsSmDq5G+tbbT4TIqk5B/K6k84Si6CcyvHZpsKjECcfIr28jlg
    -st7L7Ljkb+qbXbdTkBgyVcUgt5SjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUk+FD
    -Y1w8ndYn81LsF7Kpryz3dvgwHQYDVR0OBBYEFJPhQ2NcPJ3WJ/NS7Beyqa8s93b4MA4GA1UdDwEB
    -/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjAdfKR7w4l1M+E7qUW/Runpod3JIha3RxEL2Jq68cgL
    -cFBTApFwhVmpHqTm6iMxoAACMQD94vizrxa5HnPEluPBMBnYfubDl94cT7iJLzPrSA8Z94dGXSaQ
    -pYXFuXqUPoeovQA=
    ------END CERTIFICATE-----
    -
     TWCA CYBER Root CA
     ==================
     -----BEGIN CERTIFICATE-----
    
  • composer.json+5 5 modified
    @@ -3,7 +3,7 @@
     	"description": "The Kirby core",
     	"license": "proprietary",
     	"type": "kirby-cms",
    -	"version": "5.4.0",
    +	"version": "5.4.1",
     	"keywords": [
     		"kirby",
     		"cms",
    @@ -43,10 +43,10 @@
     		"getkirby/composer-installer": "^1.2.1",
     		"laminas/laminas-escaper": "2.18.0",
     		"michelf/php-smartypants": "1.8.1",
    -		"phpmailer/phpmailer": "7.0.2",
    -		"symfony/polyfill-intl-idn": "1.36.0",
    -		"symfony/polyfill-mbstring": "1.36.0",
    -		"symfony/yaml": "7.4.8"
    +		"phpmailer/phpmailer": "7.1.1",
    +		"symfony/polyfill-intl-idn": "1.37.0",
    +		"symfony/polyfill-mbstring": "1.37.0",
    +		"symfony/yaml": "7.4.11"
     	},
     	"replace": {
     		"symfony/polyfill-php72": "*"
    
  • composer.lock+32 28 modified
    @@ -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": "3aaeb1ef8387743ddc972f349a52e902",
    +    "content-hash": "c0e0b10d6a213c34f515e6189c58414b",
         "packages": [
             {
                 "name": "christian-riesen/base32",
    @@ -491,16 +491,16 @@
             },
             {
                 "name": "phpmailer/phpmailer",
    -            "version": "v7.0.2",
    +            "version": "v7.1.1",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/PHPMailer/PHPMailer.git",
    -                "reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088"
    +                "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
    -                "reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
    +                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
    +                "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
                     "shasum": ""
                 },
                 "require": {
    @@ -561,15 +561,15 @@
                 "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
                 "support": {
                     "issues": "https://github.com/PHPMailer/PHPMailer/issues",
    -                "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.0.2"
    +                "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
                 },
                 "funding": [
                     {
                         "url": "https://github.com/Synchro",
                         "type": "github"
                     }
                 ],
    -            "time": "2026-01-09T18:02:33+00:00"
    +            "time": "2026-05-18T08:06:14+00:00"
             },
             {
                 "name": "psr/log",
    @@ -623,16 +623,16 @@
             },
             {
                 "name": "symfony/deprecation-contracts",
    -            "version": "v3.6.0",
    +            "version": "v3.7.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/deprecation-contracts.git",
    -                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
    +                "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
    -                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
    +                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
    +                "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
                     "shasum": ""
                 },
                 "require": {
    @@ -645,7 +645,7 @@
                         "name": "symfony/contracts"
                     },
                     "branch-alias": {
    -                    "dev-main": "3.6-dev"
    +                    "dev-main": "3.7-dev"
                     }
                 },
                 "autoload": {
    @@ -670,7 +670,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.6.0"
    +                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
                 },
                 "funding": [
                     {
    @@ -681,16 +681,20 @@
                         "url": "https://github.com/fabpot",
                         "type": "github"
                     },
    +                {
    +                    "url": "https://github.com/nicolas-grekas",
    +                    "type": "github"
    +                },
                     {
                         "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                         "type": "tidelift"
                     }
                 ],
    -            "time": "2024-09-25T14:21:43+00:00"
    +            "time": "2026-04-13T15:52:40+00:00"
             },
             {
                 "name": "symfony/polyfill-ctype",
    -            "version": "v1.36.0",
    +            "version": "v1.37.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-ctype.git",
    @@ -749,7 +753,7 @@
                     "portable"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -773,7 +777,7 @@
             },
             {
                 "name": "symfony/polyfill-intl-idn",
    -            "version": "v1.36.0",
    +            "version": "v1.37.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-intl-idn.git",
    @@ -836,7 +840,7 @@
                     "shim"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -860,7 +864,7 @@
             },
             {
                 "name": "symfony/polyfill-intl-normalizer",
    -            "version": "v1.36.0",
    +            "version": "v1.37.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
    @@ -921,7 +925,7 @@
                     "shim"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -945,7 +949,7 @@
             },
             {
                 "name": "symfony/polyfill-mbstring",
    -            "version": "v1.36.0",
    +            "version": "v1.37.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-mbstring.git",
    @@ -1006,7 +1010,7 @@
                     "shim"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -1030,16 +1034,16 @@
             },
             {
                 "name": "symfony/yaml",
    -            "version": "v7.4.8",
    +            "version": "v7.4.11",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/yaml.git",
    -                "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883"
    +                "reference": "e2eb64a57763815ccae07ac1c7653d6cc1c326fd"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
    -                "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
    +                "url": "https://api.github.com/repos/symfony/yaml/zipball/e2eb64a57763815ccae07ac1c7653d6cc1c326fd",
    +                "reference": "e2eb64a57763815ccae07ac1c7653d6cc1c326fd",
                     "shasum": ""
                 },
                 "require": {
    @@ -1082,7 +1086,7 @@
                 "description": "Loads and dumps YAML files",
                 "homepage": "https://symfony.com",
                 "support": {
    -                "source": "https://github.com/symfony/yaml/tree/v7.4.8"
    +                "source": "https://github.com/symfony/yaml/tree/v7.4.11"
                 },
                 "funding": [
                     {
    @@ -1102,7 +1106,7 @@
                         "type": "tidelift"
                     }
                 ],
    -            "time": "2026-03-24T13:12:05+00:00"
    +            "time": "2026-05-13T12:04:42+00:00"
             }
         ],
         "packages-dev": [],
    
  • config/api/routes/files.php+12 2 modified
    @@ -70,7 +70,12 @@
     				return $files->search($this->requestQuery('q'));
     			}
     
    -			return $files->query($this->requestBody());
    +			return $files->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     	[
    @@ -140,7 +145,12 @@
     				return $files->search($this->requestQuery('q'));
     			}
     
    -			return $files->query($this->requestBody());
    +			return $files->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     ];
    
  • config/api/routes/site.php+6 1 modified
    @@ -81,7 +81,12 @@
     				return $pages->search($this->requestQuery('q'));
     			}
     
    -			return $pages->query($this->requestBody());
    +			return $pages->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     	[
    
  • config/api/routes/users.php+6 1 modified
    @@ -30,7 +30,12 @@
     				return Find::users()->search($this->requestQuery('q'));
     			}
     
    -			return Find::users()->query($this->requestBody());
    +			return Find::users()->query(array_filter([
    +				'limit'    => $this->requestBody('limit'),
    +				'offset'   => $this->requestBody('offset'),
    +				'paginate' => $this->requestBody('paginate'),
    +				'search'   => $this->requestBody('search'),
    +			], fn ($value) => $value !== null));
     		}
     	],
     	[
    
  • config/components.php+24 4 modified
    @@ -358,11 +358,31 @@
     			$kirby->option('thumbs.driver', 'gd'),
     			$kirby->option('thumbs', [])
     		);
    -		$options = $darkroom->preprocess($src, $options);
    -		$root    = (new Filename($src, $dst, $options))->toString();
    +		$options   = $darkroom->preprocess($src, $options);
    +		$root      = (new Filename($src, $dst, $options))->toString();
    +		$extension = F::extension($root);
     
    -		F::copy($src, $root, true);
    -		$darkroom->process($root, $options);
    +		// generate the thumb in a temp file so broken output never appears at the final path
    +		$tempRoot  = F::dirname($root) . '/' . F::name($root) . '.tmp-' . uniqid();
    +
    +		// keep the original extension so the darkroom can infer the output format.
    +		if ($extension !== '') {
    +			$tempRoot .= '.' . $extension;
    +		}
    +
    +		F::copy($src, $tempRoot, true);
    +
    +		try {
    +			$darkroom->process($tempRoot, $options);
    +
    +			// move the finished file into place so the final path
    +			// only appears once generation succeeded
    +			F::move($tempRoot, $root, true);
    +		} catch (Throwable $e) {
    +			// clean up the temp file so failed jobs don't leave broken output behind
    +			F::remove($tempRoot);
    +			throw $e;
    +		}
     
     		return $root;
     	},
    
  • config/fields/list.php+5 1 modified
    @@ -1,5 +1,7 @@
     <?php
     
    +use Kirby\Sane\Sane;
    +
     return [
     	'props' => [
     		/**
    @@ -17,7 +19,9 @@
     	],
     	'computed' => [
     		'value' => function () {
    -			return trim($this->value ?? '');
    +			$value = trim($this->value ?? '');
    +			$value = Sane::sanitizeProseMirrorFields($value);
    +			return $value;
     		}
     	]
     ];
    
  • config/fields/mixins/upload.php+5 4 modified
    @@ -59,12 +59,13 @@
     				);
     			}
     
    -			$parent = $this->uploadParent($params['parent'] ?? null);
    +			$parent   = $this->uploadParent($params['parent'] ?? null);
    +			$template = $params['template'] ?? null;
     
    -			return $api->upload(function ($source, $filename) use ($parent, $params, $map) {
    +			return $api->upload(function ($source, $filename, $template) use ($parent, $map) {
     				$props = [
     					'source'   => $source,
    -					'template' => $params['template'] ?? null,
    +					'template' => $template,
     					'filename' => $filename,
     				];
     
    @@ -78,7 +79,7 @@
     				}
     
     				return $map($file, $parent);
    -			});
    +			}, template: $template);
     		},
     		'uploadParent' => function (string|null $parentQuery = null) {
     			$parent = $this->model();
    
  • config/fields/time.php+1 1 modified
    @@ -107,7 +107,7 @@
     					key: 'validation.time.between',
     					data: [
     						'min' => $min->format($format),
    -						'max' => $min->format($format)
    +						'max' => $max->format($format)
     					]
     				);
     			}
    
  • config/fields/writer.php+1 7 modified
    @@ -63,13 +63,7 @@
     	'computed' => [
     		'value' => function () {
     			$value = trim($this->value ?? '');
    -			$value = Sane::sanitize($value, 'html');
    -
    -			// convert non-breaking spaces to HTML entity
    -			// as that's how ProseMirror handles it internally;
    -			// will allow comparing saved and current content
    -			$value = str_replace(' ', '&nbsp;', $value);
    -
    +			$value = Sane::sanitizeProseMirrorFields($value);
     			return $value;
     		}
     	],
    
  • config/helpers.php+1 1 modified
    @@ -202,7 +202,7 @@ function gist(string $url, string|null $file = null): string
     	 */
     	function go(string $url = '/', int $code = 302): never
     	{
    -		Response::go($url, $code);
    +		Response::go($url, $code); // @codeCoverageIgnore
     	}
     }
     
    
  • .github/workflows/backend.yml+19 19 modified
    @@ -73,7 +73,7 @@ jobs:
     
         steps:
           - name: Checkout
    -        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
    +        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
             with:
               fetch-depth: 2
     
    @@ -85,27 +85,27 @@ jobs:
     
           - name: Setup PHP cache environment
             id: ext-cache
    -        uses: shivammathur/cache-extensions@d814e887327271b6e290b018d51bba9f62590488 # pin@v1
    +        uses: shivammathur/cache-extensions@256729b5fef535345e27904657f78048c0990f81 # v1
             with:
               php-version: ${{ matrix.php }}
               extensions: ${{ env.extensions }}
               key: php-v1
     
           - name: Cache PHP extensions
    -        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # pin@v4
    +        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
             with:
               path: ${{ steps.ext-cache.outputs.dir }}
               key: ${{ steps.ext-cache.outputs.key }}
               restore-keys: ${{ steps.ext-cache.outputs.key }}
     
           - name: Setup PHP environment
    -        uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # pin@v2
    +        uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
             with:
               php-version: ${{ matrix.php }}
               extensions: ${{ env.extensions }}
               ini-values: ${{ env.ini }}
               coverage: pcov
    -          tools: phpunit:11.5.44, psalm:6.13.1
    +          tools: phpunit:11.5.55, psalm:6.16.1
     
           - name: Setup problem matchers
             run: |
    @@ -114,7 +114,7 @@ jobs:
     
           - name: Cache analysis data
             id: finishPrepare
    -        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # pin@v4
    +        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
             with:
               path: ~/.cache/psalm
               key: backend-analysis-${{ matrix.php }}
    @@ -136,7 +136,7 @@ jobs:
               token: ${{ secrets.CODECOV_TOKEN }}
               PHP: ${{ matrix.php }}
             if: env.token != ''
    -        uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # pin@v5
    +        uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
             with:
               token: ${{ secrets.CODECOV_TOKEN }} # for better reliability if the GitHub API is down
               fail_ci_if_error: true
    @@ -146,7 +146,7 @@ jobs:
     
           - name: Upload code scanning results to GitHub
             if: always() && steps.finishPrepare.outcome == 'success' && github.repository == 'getkirby/kirby' && matrix.php != '8.5'
    -        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # pin@v3
    +        uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
             with:
               sarif_file: sarif
     
    @@ -178,36 +178,36 @@ jobs:
     
         steps:
           - name: Checkout
    -        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
    +        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
     
           - name: Preparations
             run: mkdir sarif
     
           - name: Setup PHP cache environment
             id: ext-cache
    -        uses: shivammathur/cache-extensions@d814e887327271b6e290b018d51bba9f62590488 # pin@v1
    +        uses: shivammathur/cache-extensions@256729b5fef535345e27904657f78048c0990f81 # v1
             with:
               php-version: ${{ env.php }}
               extensions: ${{ env.extensions }}
               key: php-analysis-v1
     
           - name: Cache PHP extensions
    -        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # pin@v4
    +        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
             with:
               path: ${{ steps.ext-cache.outputs.dir }}
               key: ${{ steps.ext-cache.outputs.key }}
               restore-keys: ${{ steps.ext-cache.outputs.key }}
     
           - name: Setup PHP environment
             id: finishPrepare
    -        uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # pin@v2
    +        uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
             with:
               php-version: ${{ env.php }}
               extensions: ${{ env.extensions }}
               coverage: none
               tools: |
    -            composer:2.9.1, composer-normalize:2.48.2, composer-require-checker:4.18.0,
    -            composer-unused:0.9.5, phpmd:2.15.0
    +            composer:2.9.8, composer-normalize:2.51.0, composer-require-checker:4.24.0,
    +            composer-unused:0.9.6, phpmd:2.15.0
     
           - name: Validate composer.json/composer.lock
             if: always() && steps.finishPrepare.outcome == 'success'
    @@ -227,7 +227,7 @@ jobs:
     
           - name: Upload code scanning results to GitHub
             if: always() && steps.finishPrepare.outcome == 'success' && github.repository == 'getkirby/kirby'
    -        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # pin@v3
    +        uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
             with:
               sarif_file: sarif
     
    @@ -258,18 +258,18 @@ jobs:
     
         steps:
           - name: Checkout
    -        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
    +        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
     
           - name: Setup PHP environment
    -        uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # pin@v2
    +        uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
             with:
               php-version: ${{ env.php }}
               coverage: none
    -          tools: php-cs-fixer:3.90.0
    +          tools: php-cs-fixer:3.95.1
     
           - name: Cache analysis data
             id: finishPrepare
    -        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # pin@v4
    +        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
             with:
               path: ~/.php-cs-fixer
               key: coding-style
    
  • .github/workflows/frontend.yml+4 4 modified
    @@ -48,10 +48,10 @@ jobs:
     
         steps:
           - name: Checkout
    -        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
    +        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
     
           - name: Set up Node.js problem matchers and cache npm dependencies
    -        uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # pin@v4
    +        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
             with:
               cache: "npm"
               cache-dependency-path: panel/package-lock.json
    @@ -93,10 +93,10 @@ jobs:
     
         steps:
           - name: Checkout
    -        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
    +        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
     
           - name: Set up Node.js problem matchers and cache npm dependencies
    -        uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # pin@v4
    +        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
             with:
               cache: "npm"
               cache-dependency-path: panel/package-lock.json
    
  • panel/dist/css/style.min.css+1 1 modified
  • panel/dist/js/index.min.js+1 1 modified
  • panel/dist/js/vendor.min.js+1 1 modified
  • panel/dist/ui/FilesDialog.json+1 1 modified
    @@ -1 +1 @@
    -{"displayName":"FilesDialog","description":"The Dialog mixin is intended for all components\nthat extend <k-dialog> It forwards the methods to\nthe <k-dialog> ref. Extending <k-dialog> directly\ncan lead to breaking methods when the methods are not\nwired correctly to the right elements and refs.","tags":{},"props":[{"name":"cancelButton","description":"Options for the cancel button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"disabled","description":"Whether to disable the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"icon","description":"The icon type for the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"string"},"defaultValue":{"value":"\"check\""}},{"name":"submitButton","description":"Options for the submit button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"theme","description":"The theme of the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"values":["\"positive\"","\"negative\""],"type":{"name":"string"},"defaultValue":{"value":"\"positive\""}},{"name":"size","description":"Width of the dialog","tags":{},"values":["\"small\"","\"default\"","\"medium\"","\"large\"","\"huge\""],"type":{"name":"string"},"defaultValue":{"value":"\"medium\""}},{"name":"visible","type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"endpoint","type":{"name":"string"}},{"name":"empty","type":{"name":"object"},"defaultValue":{"value":"{\n    icon: \"image\",\n    text: window.panel.t(\"dialog.files.empty\")\n}"}},{"name":"fetchParams","type":{"name":"object"}},{"name":"item","type":{"name":"func"},"defaultValue":{"value":"(item) => item"}},{"name":"max","type":{"name":"number"}},{"name":"multiple","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"value","type":{"name":"array"},"defaultValue":{"value":"[]"}}],"events":[{"name":"cancel"},{"name":"submit","type":{"names":["undefined"]},"description":"The submit button is clicked or the form is submitted."},{"name":"close"},{"name":"input","type":{"names":["undefined"]}},{"name":"success","type":{"names":["undefined"]}}],"methods":[{"name":"cancel","description":"Triggers the `@cancel` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"close","description":"Triggers the `@close` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"focus","description":"Sets the focus on the first usable input\nor a given input by name","params":[{"name":"input","type":{"name":"String"}}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"String"},"name":"input"}]}},{"name":"input","description":"Updates the dialog values","params":[{"name":"value","type":{"name":"Object"},"description":"new values"}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"Object"},"name":"value","description":"new values"}]}},{"name":"open","description":"Opens the dialog and triggers the `@open` event.\nUse ready to fire events that should be run as\nsoon as the dialog is open","tags":{"access":[{"description":"public"}]}}],"slots":[{"name":"header"},{"name":"options","scoped":true,"bindings":[{"name":"item","title":"binding"}]}],"component":"k-files-dialog","sourceFile":"src/components/Dialogs/FilesDialog.vue"}
    \ No newline at end of file
    +{"displayName":"FilesDialog","description":"The Search mixin is intended for all components\nthat feature a query input that should trigger\nrunning a search via a required `search` method.","tags":{},"props":[{"name":"cancelButton","description":"Options for the cancel button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"disabled","description":"Whether to disable the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"icon","description":"The icon type for the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"string"},"defaultValue":{"value":"\"check\""}},{"name":"submitButton","description":"Options for the submit button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"theme","description":"The theme of the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"values":["\"positive\"","\"negative\""],"type":{"name":"string"},"defaultValue":{"value":"\"positive\""}},{"name":"size","description":"Width of the dialog","tags":{},"values":["\"small\"","\"default\"","\"medium\"","\"large\"","\"huge\""],"type":{"name":"string"},"defaultValue":{"value":"\"medium\""}},{"name":"visible","type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"delay","type":{"name":"number"},"defaultValue":{"value":"200"}},{"name":"hasSearch","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"endpoint","type":{"name":"string"}},{"name":"empty","type":{"name":"object"},"defaultValue":{"value":"{\n    icon: \"image\",\n    text: window.panel.t(\"dialog.files.empty\")\n}"}},{"name":"fetchParams","type":{"name":"object"}},{"name":"item","type":{"name":"func"},"defaultValue":{"value":"(item) => item"}},{"name":"max","type":{"name":"number"}},{"name":"multiple","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"value","type":{"name":"array"},"defaultValue":{"value":"[]"}}],"events":[{"name":"cancel"},{"name":"submit","type":{"names":["undefined"]},"description":"The submit button is clicked or the form is submitted."},{"name":"close"},{"name":"input","type":{"names":["undefined"]}},{"name":"success","type":{"names":["undefined"]}}],"methods":[{"name":"cancel","description":"Triggers the `@cancel` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"close","description":"Triggers the `@close` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"focus","description":"Sets the focus on the first usable input\nor a given input by name","params":[{"name":"input","type":{"name":"String"}}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"String"},"name":"input"}]}},{"name":"input","description":"Updates the dialog values","params":[{"name":"value","type":{"name":"Object"},"description":"new values"}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"Object"},"name":"value","description":"new values"}]}},{"name":"open","description":"Opens the dialog and triggers the `@open` event.\nUse ready to fire events that should be run as\nsoon as the dialog is open","tags":{"access":[{"description":"public"}]}}],"slots":[{"name":"header"},{"name":"options","scoped":true,"bindings":[{"name":"item","title":"binding"}]}],"component":"k-files-dialog","sourceFile":"src/components/Dialogs/FilesDialog.vue"}
    \ No newline at end of file
    
  • panel/dist/ui/PagesDialog.json+1 1 modified
    @@ -1 +1 @@
    -{"displayName":"PagesDialog","description":"The Dialog mixin is intended for all components\nthat extend <k-dialog> It forwards the methods to\nthe <k-dialog> ref. Extending <k-dialog> directly\ncan lead to breaking methods when the methods are not\nwired correctly to the right elements and refs.","tags":{},"props":[{"name":"cancelButton","description":"Options for the cancel button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"disabled","description":"Whether to disable the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"icon","description":"The icon type for the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"string"},"defaultValue":{"value":"\"check\""}},{"name":"submitButton","description":"Options for the submit button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"theme","description":"The theme of the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"values":["\"positive\"","\"negative\""],"type":{"name":"string"},"defaultValue":{"value":"\"positive\""}},{"name":"size","description":"Width of the dialog","tags":{},"values":["\"small\"","\"default\"","\"medium\"","\"large\"","\"huge\""],"type":{"name":"string"},"defaultValue":{"value":"\"medium\""}},{"name":"visible","type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"endpoint","type":{"name":"string"}},{"name":"empty","type":{"name":"object"},"defaultValue":{"value":"{\n    icon: \"page\",\n    text: window.panel.t(\"dialog.pages.empty\")\n}"}},{"name":"fetchParams","type":{"name":"object"}},{"name":"item","type":{"name":"func"},"defaultValue":{"value":"(item) => item"}},{"name":"max","type":{"name":"number"}},{"name":"multiple","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"value","type":{"name":"array"},"defaultValue":{"value":"[]"}}],"events":[{"name":"cancel"},{"name":"submit","type":{"names":["undefined"]},"description":"The submit button is clicked or the form is submitted."},{"name":"close"},{"name":"input","type":{"names":["undefined"]}},{"name":"success","type":{"names":["undefined"]}}],"methods":[{"name":"cancel","description":"Triggers the `@cancel` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"close","description":"Triggers the `@close` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"focus","description":"Sets the focus on the first usable input\nor a given input by name","params":[{"name":"input","type":{"name":"String"}}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"String"},"name":"input"}]}},{"name":"input","description":"Updates the dialog values","params":[{"name":"value","type":{"name":"Object"},"description":"new values"}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"Object"},"name":"value","description":"new values"}]}},{"name":"open","description":"Opens the dialog and triggers the `@open` event.\nUse ready to fire events that should be run as\nsoon as the dialog is open","tags":{"access":[{"description":"public"}]}}],"slots":[{"name":"header"},{"name":"options","scoped":true,"bindings":[{"name":"item","title":"binding"}]}],"component":"k-pages-dialog","sourceFile":"src/components/Dialogs/PagesDialog.vue"}
    \ No newline at end of file
    +{"displayName":"PagesDialog","description":"The Search mixin is intended for all components\nthat feature a query input that should trigger\nrunning a search via a required `search` method.","tags":{},"props":[{"name":"cancelButton","description":"Options for the cancel button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"disabled","description":"Whether to disable the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"icon","description":"The icon type for the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"string"},"defaultValue":{"value":"\"check\""}},{"name":"submitButton","description":"Options for the submit button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"theme","description":"The theme of the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"values":["\"positive\"","\"negative\""],"type":{"name":"string"},"defaultValue":{"value":"\"positive\""}},{"name":"size","description":"Width of the dialog","tags":{},"values":["\"small\"","\"default\"","\"medium\"","\"large\"","\"huge\""],"type":{"name":"string"},"defaultValue":{"value":"\"medium\""}},{"name":"visible","type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"delay","type":{"name":"number"},"defaultValue":{"value":"200"}},{"name":"hasSearch","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"endpoint","type":{"name":"string"}},{"name":"empty","type":{"name":"object"},"defaultValue":{"value":"{\n    icon: \"page\",\n    text: window.panel.t(\"dialog.pages.empty\")\n}"}},{"name":"fetchParams","type":{"name":"object"}},{"name":"item","type":{"name":"func"},"defaultValue":{"value":"(item) => item"}},{"name":"max","type":{"name":"number"}},{"name":"multiple","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"value","type":{"name":"array"},"defaultValue":{"value":"[]"}}],"events":[{"name":"cancel"},{"name":"submit","type":{"names":["undefined"]},"description":"The submit button is clicked or the form is submitted."},{"name":"close"},{"name":"input","type":{"names":["undefined"]}},{"name":"success","type":{"names":["undefined"]}}],"methods":[{"name":"cancel","description":"Triggers the `@cancel` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"close","description":"Triggers the `@close` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"focus","description":"Sets the focus on the first usable input\nor a given input by name","params":[{"name":"input","type":{"name":"String"}}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"String"},"name":"input"}]}},{"name":"input","description":"Updates the dialog values","params":[{"name":"value","type":{"name":"Object"},"description":"new values"}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"Object"},"name":"value","description":"new values"}]}},{"name":"open","description":"Opens the dialog and triggers the `@open` event.\nUse ready to fire events that should be run as\nsoon as the dialog is open","tags":{"access":[{"description":"public"}]}}],"slots":[{"name":"header"},{"name":"options","scoped":true,"bindings":[{"name":"item","title":"binding"}]}],"component":"k-pages-dialog","sourceFile":"src/components/Dialogs/PagesDialog.vue"}
    \ No newline at end of file
    
  • panel/dist/ui/UsersDialog.json+1 1 modified
    @@ -1 +1 @@
    -{"displayName":"UsersDialog","description":"The Dialog mixin is intended for all components\nthat extend <k-dialog> It forwards the methods to\nthe <k-dialog> ref. Extending <k-dialog> directly\ncan lead to breaking methods when the methods are not\nwired correctly to the right elements and refs.","tags":{},"props":[{"name":"cancelButton","description":"Options for the cancel button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"disabled","description":"Whether to disable the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"icon","description":"The icon type for the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"string"},"defaultValue":{"value":"\"check\""}},{"name":"submitButton","description":"Options for the submit button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"theme","description":"The theme of the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"values":["\"positive\"","\"negative\""],"type":{"name":"string"},"defaultValue":{"value":"\"positive\""}},{"name":"size","description":"Width of the dialog","tags":{},"values":["\"small\"","\"default\"","\"medium\"","\"large\"","\"huge\""],"type":{"name":"string"},"defaultValue":{"value":"\"medium\""}},{"name":"visible","type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"endpoint","type":{"name":"string"}},{"name":"empty","type":{"name":"object"},"defaultValue":{"value":"{\n    icon: \"users\",\n    text: window.panel.t(\"dialog.users.empty\")\n}"}},{"name":"fetchParams","type":{"name":"object"}},{"name":"item","type":{"name":"func"},"defaultValue":{"value":"(item) => ({\n    ...item,\n    key: item.email,\n    info: item.info !== item.text ? item.info : null\n})"}},{"name":"max","type":{"name":"number"}},{"name":"multiple","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"value","type":{"name":"array"},"defaultValue":{"value":"[]"}}],"events":[{"name":"cancel"},{"name":"submit","type":{"names":["undefined"]},"description":"The submit button is clicked or the form is submitted."},{"name":"close"},{"name":"input","type":{"names":["undefined"]}},{"name":"success","type":{"names":["undefined"]}}],"methods":[{"name":"cancel","description":"Triggers the `@cancel` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"close","description":"Triggers the `@close` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"focus","description":"Sets the focus on the first usable input\nor a given input by name","params":[{"name":"input","type":{"name":"String"}}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"String"},"name":"input"}]}},{"name":"input","description":"Updates the dialog values","params":[{"name":"value","type":{"name":"Object"},"description":"new values"}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"Object"},"name":"value","description":"new values"}]}},{"name":"open","description":"Opens the dialog and triggers the `@open` event.\nUse ready to fire events that should be run as\nsoon as the dialog is open","tags":{"access":[{"description":"public"}]}}],"slots":[{"name":"header"},{"name":"options","scoped":true,"bindings":[{"name":"item","title":"binding"}]}],"component":"k-users-dialog","sourceFile":"src/components/Dialogs/UsersDialog.vue"}
    \ No newline at end of file
    +{"displayName":"UsersDialog","description":"The Search mixin is intended for all components\nthat feature a query input that should trigger\nrunning a search via a required `search` method.","tags":{},"props":[{"name":"cancelButton","description":"Options for the cancel button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"disabled","description":"Whether to disable the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"icon","description":"The icon type for the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"type":{"name":"string"},"defaultValue":{"value":"\"check\""}},{"name":"submitButton","description":"Options for the submit button","type":{"name":"boolean|string|object"},"defaultValue":{"value":"true"}},{"name":"theme","description":"The theme of the submit button","tags":{"deprecated":[{"description":"4.0.0 use the `submit-button` prop instead","title":"deprecated"}]},"values":["\"positive\"","\"negative\""],"type":{"name":"string"},"defaultValue":{"value":"\"positive\""}},{"name":"size","description":"Width of the dialog","tags":{},"values":["\"small\"","\"default\"","\"medium\"","\"large\"","\"huge\""],"type":{"name":"string"},"defaultValue":{"value":"\"medium\""}},{"name":"visible","type":{"name":"boolean"},"defaultValue":{"value":"false"}},{"name":"delay","type":{"name":"number"},"defaultValue":{"value":"200"}},{"name":"hasSearch","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"endpoint","type":{"name":"string"}},{"name":"empty","type":{"name":"object"},"defaultValue":{"value":"{\n    icon: \"users\",\n    text: window.panel.t(\"dialog.users.empty\")\n}"}},{"name":"fetchParams","type":{"name":"object"}},{"name":"item","type":{"name":"func"},"defaultValue":{"value":"(item) => ({\n    ...item,\n    key: item.email,\n    info: item.info !== item.text ? item.info : null\n})"}},{"name":"max","type":{"name":"number"}},{"name":"multiple","type":{"name":"boolean"},"defaultValue":{"value":"true"}},{"name":"value","type":{"name":"array"},"defaultValue":{"value":"[]"}}],"events":[{"name":"cancel"},{"name":"submit","type":{"names":["undefined"]},"description":"The submit button is clicked or the form is submitted."},{"name":"close"},{"name":"input","type":{"names":["undefined"]}},{"name":"success","type":{"names":["undefined"]}}],"methods":[{"name":"cancel","description":"Triggers the `@cancel` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"close","description":"Triggers the `@close` event and closes the dialog.","tags":{"access":[{"description":"public"}]}},{"name":"focus","description":"Sets the focus on the first usable input\nor a given input by name","params":[{"name":"input","type":{"name":"String"}}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"String"},"name":"input"}]}},{"name":"input","description":"Updates the dialog values","params":[{"name":"value","type":{"name":"Object"},"description":"new values"}],"tags":{"access":[{"description":"public"}],"params":[{"title":"param","type":{"name":"Object"},"name":"value","description":"new values"}]}},{"name":"open","description":"Opens the dialog and triggers the `@open` event.\nUse ready to fire events that should be run as\nsoon as the dialog is open","tags":{"access":[{"description":"public"}]}}],"slots":[{"name":"header"},{"name":"options","scoped":true,"bindings":[{"name":"item","title":"binding"}]}],"component":"k-users-dialog","sourceFile":"src/components/Dialogs/UsersDialog.vue"}
    \ No newline at end of file
    
  • panel/package.json+6 6 modified
    @@ -20,25 +20,25 @@
     		"prosemirror-history": "^1.5.0",
     		"prosemirror-inputrules": "^1.5.1",
     		"prosemirror-keymap": "^1.2.3",
    -		"prosemirror-model": "^1.25.4",
    +		"prosemirror-model": "^1.25.6",
     		"prosemirror-schema-list": "^1.5.1",
     		"prosemirror-view": "^1.41.8",
     		"sortablejs": "^1.15.7",
     		"vue": "^2.7.16"
     	},
     	"devDependencies": {
    -		"@csstools/postcss-light-dark-function": "^2.0.11",
    +		"@csstools/postcss-light-dark-function": "^3.0.1",
     		"@vitejs/plugin-vue2": "^2.3.4",
     		"eslint": "^9.39.4",
     		"eslint-config-prettier": "^10.1.8",
     		"eslint-plugin-vue": "^9.33.0",
     		"glob": "^13.0.6",
    -		"jsdom": "^27.4.0",
    +		"jsdom": "^29.1.1",
     		"prettier": "^3.8.3",
    -		"terser": "^5.46.1",
    -		"vite": "^7.3.2",
    +		"terser": "^5.47.1",
    +		"vite": "^7.3.3",
     		"vite-plugin-static-copy": "^3.4.0",
    -		"vitest": "^4.1.5",
    +		"vitest": "^4.1.6",
     		"vue-docgen-api": "^4.79.2"
     	},
     	"browserslist": [
    
  • panel/package-lock.json+216 311 modified
    @@ -14,156 +14,70 @@
     				"prosemirror-history": "^1.5.0",
     				"prosemirror-inputrules": "^1.5.1",
     				"prosemirror-keymap": "^1.2.3",
    -				"prosemirror-model": "^1.25.4",
    +				"prosemirror-model": "^1.25.6",
     				"prosemirror-schema-list": "^1.5.1",
     				"prosemirror-view": "^1.41.8",
     				"sortablejs": "^1.15.7",
     				"vue": "^2.7.16"
     			},
     			"devDependencies": {
    -				"@csstools/postcss-light-dark-function": "^2.0.11",
    +				"@csstools/postcss-light-dark-function": "^3.0.1",
     				"@vitejs/plugin-vue2": "^2.3.4",
     				"eslint": "^9.39.4",
     				"eslint-config-prettier": "^10.1.8",
     				"eslint-plugin-vue": "^9.33.0",
     				"glob": "^13.0.6",
    -				"jsdom": "^27.4.0",
    +				"jsdom": "^29.1.1",
     				"prettier": "^3.8.3",
    -				"terser": "^5.46.1",
    -				"vite": "^7.3.2",
    +				"terser": "^5.47.1",
    +				"vite": "^7.3.3",
     				"vite-plugin-static-copy": "^3.4.0",
    -				"vitest": "^4.1.5",
    +				"vitest": "^4.1.6",
     				"vue-docgen-api": "^4.79.2"
     			}
     		},
    -		"node_modules/@acemir/cssom": {
    -			"version": "0.9.31",
    -			"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
    -			"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
    -			"dev": true,
    -			"license": "MIT"
    -		},
     		"node_modules/@asamuzakjp/css-color": {
    -			"version": "4.1.2",
    -			"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
    -			"integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
    +			"version": "5.1.11",
    +			"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
    +			"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    -				"@csstools/css-calc": "^3.0.0",
    -				"@csstools/css-color-parser": "^4.0.1",
    -				"@csstools/css-parser-algorithms": "^4.0.0",
    -				"@csstools/css-tokenizer": "^4.0.0",
    -				"lru-cache": "^11.2.5"
    -			}
    -		},
    -		"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": {
    -			"version": "3.2.0",
    -			"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
    -			"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
    -			"dev": true,
    -			"funding": [
    -				{
    -					"type": "github",
    -					"url": "https://github.com/sponsors/csstools"
    -				},
    -				{
    -					"type": "opencollective",
    -					"url": "https://opencollective.com/csstools"
    -				}
    -			],
    -			"license": "MIT",
    -			"engines": {
    -				"node": ">=20.19.0"
    -			},
    -			"peerDependencies": {
    +				"@asamuzakjp/generational-cache": "^1.0.1",
    +				"@csstools/css-calc": "^3.2.0",
    +				"@csstools/css-color-parser": "^4.1.0",
     				"@csstools/css-parser-algorithms": "^4.0.0",
     				"@csstools/css-tokenizer": "^4.0.0"
    -			}
    -		},
    -		"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": {
    -			"version": "4.1.0",
    -			"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
    -			"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
    -			"dev": true,
    -			"funding": [
    -				{
    -					"type": "github",
    -					"url": "https://github.com/sponsors/csstools"
    -				},
    -				{
    -					"type": "opencollective",
    -					"url": "https://opencollective.com/csstools"
    -				}
    -			],
    -			"license": "MIT",
    -			"dependencies": {
    -				"@csstools/color-helpers": "^6.0.2",
    -				"@csstools/css-calc": "^3.2.0"
     			},
     			"engines": {
    -				"node": ">=20.19.0"
    -			},
    -			"peerDependencies": {
    -				"@csstools/css-parser-algorithms": "^4.0.0",
    -				"@csstools/css-tokenizer": "^4.0.0"
    +				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
     			}
     		},
    -		"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": {
    -			"version": "4.0.0",
    -			"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
    -			"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
    +		"node_modules/@asamuzakjp/dom-selector": {
    +			"version": "7.1.1",
    +			"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
    +			"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
     			"dev": true,
    -			"funding": [
    -				{
    -					"type": "github",
    -					"url": "https://github.com/sponsors/csstools"
    -				},
    -				{
    -					"type": "opencollective",
    -					"url": "https://opencollective.com/csstools"
    -				}
    -			],
     			"license": "MIT",
    -			"engines": {
    -				"node": ">=20.19.0"
    +			"dependencies": {
    +				"@asamuzakjp/generational-cache": "^1.0.1",
    +				"@asamuzakjp/nwsapi": "^2.3.9",
    +				"bidi-js": "^1.0.3",
    +				"css-tree": "^3.2.1",
    +				"is-potential-custom-element-name": "^1.0.1"
     			},
    -			"peerDependencies": {
    -				"@csstools/css-tokenizer": "^4.0.0"
    -			}
    -		},
    -		"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": {
    -			"version": "4.0.0",
    -			"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
    -			"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
    -			"dev": true,
    -			"funding": [
    -				{
    -					"type": "github",
    -					"url": "https://github.com/sponsors/csstools"
    -				},
    -				{
    -					"type": "opencollective",
    -					"url": "https://opencollective.com/csstools"
    -				}
    -			],
    -			"license": "MIT",
     			"engines": {
    -				"node": ">=20.19.0"
    +				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
     			}
     		},
    -		"node_modules/@asamuzakjp/dom-selector": {
    -			"version": "6.8.1",
    -			"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
    -			"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
    +		"node_modules/@asamuzakjp/generational-cache": {
    +			"version": "1.0.1",
    +			"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
    +			"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
     			"dev": true,
     			"license": "MIT",
    -			"dependencies": {
    -				"@asamuzakjp/nwsapi": "^2.3.9",
    -				"bidi-js": "^1.0.3",
    -				"css-tree": "^3.1.0",
    -				"is-potential-custom-element-name": "^1.0.1",
    -				"lru-cache": "^11.2.6"
    +			"engines": {
    +				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
     			}
     		},
     		"node_modules/@asamuzakjp/nwsapi": {
    @@ -219,6 +133,19 @@
     				"node": ">=6.9.0"
     			}
     		},
    +		"node_modules/@bramus/specificity": {
    +			"version": "2.4.2",
    +			"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
    +			"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
    +			"dev": true,
    +			"license": "MIT",
    +			"dependencies": {
    +				"css-tree": "^3.0.0"
    +			},
    +			"bin": {
    +				"specificity": "bin/cli.js"
    +			}
    +		},
     		"node_modules/@csstools/color-helpers": {
     			"version": "6.0.2",
     			"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
    @@ -239,10 +166,62 @@
     				"node": ">=20.19.0"
     			}
     		},
    +		"node_modules/@csstools/css-calc": {
    +			"version": "3.2.0",
    +			"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
    +			"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
    +			"dev": true,
    +			"funding": [
    +				{
    +					"type": "github",
    +					"url": "https://github.com/sponsors/csstools"
    +				},
    +				{
    +					"type": "opencollective",
    +					"url": "https://opencollective.com/csstools"
    +				}
    +			],
    +			"license": "MIT",
    +			"engines": {
    +				"node": ">=20.19.0"
    +			},
    +			"peerDependencies": {
    +				"@csstools/css-parser-algorithms": "^4.0.0",
    +				"@csstools/css-tokenizer": "^4.0.0"
    +			}
    +		},
    +		"node_modules/@csstools/css-color-parser": {
    +			"version": "4.1.0",
    +			"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
    +			"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
    +			"dev": true,
    +			"funding": [
    +				{
    +					"type": "github",
    +					"url": "https://github.com/sponsors/csstools"
    +				},
    +				{
    +					"type": "opencollective",
    +					"url": "https://opencollective.com/csstools"
    +				}
    +			],
    +			"license": "MIT",
    +			"dependencies": {
    +				"@csstools/color-helpers": "^6.0.2",
    +				"@csstools/css-calc": "^3.2.0"
    +			},
    +			"engines": {
    +				"node": ">=20.19.0"
    +			},
    +			"peerDependencies": {
    +				"@csstools/css-parser-algorithms": "^4.0.0",
    +				"@csstools/css-tokenizer": "^4.0.0"
    +			}
    +		},
     		"node_modules/@csstools/css-parser-algorithms": {
    -			"version": "3.0.5",
    -			"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
    -			"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
    +			"version": "4.0.0",
    +			"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
    +			"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
     			"dev": true,
     			"funding": [
     				{
    @@ -256,10 +235,10 @@
     			],
     			"license": "MIT",
     			"engines": {
    -				"node": ">=18"
    +				"node": ">=20.19.0"
     			},
     			"peerDependencies": {
    -				"@csstools/css-tokenizer": "^3.0.4"
    +				"@csstools/css-tokenizer": "^4.0.0"
     			}
     		},
     		"node_modules/@csstools/css-syntax-patches-for-csstree": {
    @@ -288,9 +267,9 @@
     			}
     		},
     		"node_modules/@csstools/css-tokenizer": {
    -			"version": "3.0.4",
    -			"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
    -			"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
    +			"version": "4.0.0",
    +			"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
    +			"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
     			"dev": true,
     			"funding": [
     				{
    @@ -304,13 +283,13 @@
     			],
     			"license": "MIT",
     			"engines": {
    -				"node": ">=18"
    +				"node": ">=20.19.0"
     			}
     		},
     		"node_modules/@csstools/postcss-light-dark-function": {
    -			"version": "2.0.11",
    -			"resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.11.tgz",
    -			"integrity": "sha512-fNJcKXJdPM3Lyrbmgw2OBbaioU7yuKZtiXClf4sGdQttitijYlZMD5K7HrC/eF83VRWRrYq6OZ0Lx92leV2LFA==",
    +			"version": "3.0.1",
    +			"resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-3.0.1.tgz",
    +			"integrity": "sha512-tD2MMJmZ6XXCHgDythLHcXQDNi5z7KEEWPe7JeB3vPcw+YMuMabpW5ugRqndhIrui+vduhc0Md7f7yGPCmOErg==",
     			"dev": true,
     			"funding": [
     				{
    @@ -324,22 +303,22 @@
     			],
     			"license": "MIT-0",
     			"dependencies": {
    -				"@csstools/css-parser-algorithms": "^3.0.5",
    -				"@csstools/css-tokenizer": "^3.0.4",
    -				"@csstools/postcss-progressive-custom-properties": "^4.2.1",
    -				"@csstools/utilities": "^2.0.0"
    +				"@csstools/css-parser-algorithms": "^4.0.0",
    +				"@csstools/css-tokenizer": "^4.0.0",
    +				"@csstools/postcss-progressive-custom-properties": "^5.1.0",
    +				"@csstools/utilities": "^3.0.0"
     			},
     			"engines": {
    -				"node": ">=18"
    +				"node": ">=20.19.0"
     			},
     			"peerDependencies": {
     				"postcss": "^8.4"
     			}
     		},
     		"node_modules/@csstools/postcss-progressive-custom-properties": {
    -			"version": "4.2.1",
    -			"resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.2.1.tgz",
    -			"integrity": "sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw==",
    +			"version": "5.1.0",
    +			"resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-5.1.0.tgz",
    +			"integrity": "sha512-lt/4yHy2GdKcGVpK4OGhBdSIq+z2PXynSusSRggn/T4y7uFurYAhdHqo/aYM+xI37vNb8rJlEKchqKKvVCXROQ==",
     			"dev": true,
     			"funding": [
     				{
    @@ -356,16 +335,16 @@
     				"postcss-value-parser": "^4.2.0"
     			},
     			"engines": {
    -				"node": ">=18"
    +				"node": ">=20.19.0"
     			},
     			"peerDependencies": {
     				"postcss": "^8.4"
     			}
     		},
     		"node_modules/@csstools/utilities": {
    -			"version": "2.0.0",
    -			"resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-2.0.0.tgz",
    -			"integrity": "sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==",
    +			"version": "3.0.0",
    +			"resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-3.0.0.tgz",
    +			"integrity": "sha512-etDqA/4jYvOGBM6yfKCOsEXfH96BKztZdgGmGqKi2xHnDe0ILIBraRspwgYatJH9JsCZ5HCGoCst8w18EKOAdg==",
     			"dev": true,
     			"funding": [
     				{
    @@ -379,7 +358,7 @@
     			],
     			"license": "MIT-0",
     			"engines": {
    -				"node": ">=18"
    +				"node": ">=20.19.0"
     			},
     			"peerDependencies": {
     				"postcss": "^8.4"
    @@ -1548,16 +1527,16 @@
     			}
     		},
     		"node_modules/@vitest/expect": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
    -			"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
    +			"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
     				"@standard-schema/spec": "^1.1.0",
     				"@types/chai": "^5.2.2",
    -				"@vitest/spy": "4.1.5",
    -				"@vitest/utils": "4.1.5",
    +				"@vitest/spy": "4.1.6",
    +				"@vitest/utils": "4.1.6",
     				"chai": "^6.2.2",
     				"tinyrainbow": "^3.1.0"
     			},
    @@ -1566,13 +1545,13 @@
     			}
     		},
     		"node_modules/@vitest/mocker": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
    -			"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
    +			"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    -				"@vitest/spy": "4.1.5",
    +				"@vitest/spy": "4.1.6",
     				"estree-walker": "^3.0.3",
     				"magic-string": "^0.30.21"
     			},
    @@ -1593,9 +1572,9 @@
     			}
     		},
     		"node_modules/@vitest/pretty-format": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
    -			"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
    +			"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    @@ -1606,28 +1585,28 @@
     			}
     		},
     		"node_modules/@vitest/runner": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
    -			"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
    +			"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    -				"@vitest/utils": "4.1.5",
    +				"@vitest/utils": "4.1.6",
     				"pathe": "^2.0.3"
     			},
     			"funding": {
     				"url": "https://opencollective.com/vitest"
     			}
     		},
     		"node_modules/@vitest/snapshot": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
    -			"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
    +			"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    -				"@vitest/pretty-format": "4.1.5",
    -				"@vitest/utils": "4.1.5",
    +				"@vitest/pretty-format": "4.1.6",
    +				"@vitest/utils": "4.1.6",
     				"magic-string": "^0.30.21",
     				"pathe": "^2.0.3"
     			},
    @@ -1636,23 +1615,23 @@
     			}
     		},
     		"node_modules/@vitest/spy": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
    -			"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
    +			"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
     			"dev": true,
     			"license": "MIT",
     			"funding": {
     				"url": "https://opencollective.com/vitest"
     			}
     		},
     		"node_modules/@vitest/utils": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
    -			"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
    +			"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    -				"@vitest/pretty-format": "4.1.5",
    +				"@vitest/pretty-format": "4.1.6",
     				"convert-source-map": "^2.0.0",
     				"tinyrainbow": "^3.1.0"
     			},
    @@ -1775,16 +1754,6 @@
     				"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
     			}
     		},
    -		"node_modules/agent-base": {
    -			"version": "7.1.4",
    -			"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
    -			"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
    -			"dev": true,
    -			"license": "MIT",
    -			"engines": {
    -				"node": ">= 14"
    -			}
    -		},
     		"node_modules/ajv": {
     			"version": "6.14.0",
     			"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
    @@ -2186,50 +2155,24 @@
     				"node": ">=4"
     			}
     		},
    -		"node_modules/cssstyle": {
    -			"version": "5.3.7",
    -			"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
    -			"integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
    -			"dev": true,
    -			"license": "MIT",
    -			"dependencies": {
    -				"@asamuzakjp/css-color": "^4.1.1",
    -				"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
    -				"css-tree": "^3.1.0",
    -				"lru-cache": "^11.2.4"
    -			},
    -			"engines": {
    -				"node": ">=20"
    -			}
    -		},
     		"node_modules/csstype": {
     			"version": "3.2.3",
     			"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
     			"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
     			"license": "MIT"
     		},
     		"node_modules/data-urls": {
    -			"version": "6.0.1",
    -			"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
    -			"integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
    +			"version": "7.0.0",
    +			"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
    +			"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
     				"whatwg-mimetype": "^5.0.0",
    -				"whatwg-url": "^15.1.0"
    +				"whatwg-url": "^16.0.0"
     			},
     			"engines": {
    -				"node": ">=20"
    -			}
    -		},
    -		"node_modules/data-urls/node_modules/whatwg-mimetype": {
    -			"version": "5.0.0",
    -			"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
    -			"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
    -			"dev": true,
    -			"license": "MIT",
    -			"engines": {
    -				"node": ">=20"
    +				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
     			}
     		},
     		"node_modules/dayjs": {
    @@ -2985,34 +2928,6 @@
     				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
     			}
     		},
    -		"node_modules/http-proxy-agent": {
    -			"version": "7.0.2",
    -			"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
    -			"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
    -			"dev": true,
    -			"license": "MIT",
    -			"dependencies": {
    -				"agent-base": "^7.1.0",
    -				"debug": "^4.3.4"
    -			},
    -			"engines": {
    -				"node": ">= 14"
    -			}
    -		},
    -		"node_modules/https-proxy-agent": {
    -			"version": "7.0.6",
    -			"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
    -			"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
    -			"dev": true,
    -			"license": "MIT",
    -			"dependencies": {
    -				"agent-base": "^7.1.2",
    -				"debug": "4"
    -			},
    -			"engines": {
    -				"node": ">= 14"
    -			}
    -		},
     		"node_modules/ignore": {
     			"version": "5.3.2",
     			"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
    @@ -3197,35 +3112,36 @@
     			}
     		},
     		"node_modules/jsdom": {
    -			"version": "27.4.0",
    -			"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
    -			"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
    +			"version": "29.1.1",
    +			"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
    +			"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    -				"@acemir/cssom": "^0.9.28",
    -				"@asamuzakjp/dom-selector": "^6.7.6",
    -				"@exodus/bytes": "^1.6.0",
    -				"cssstyle": "^5.3.4",
    -				"data-urls": "^6.0.0",
    +				"@asamuzakjp/css-color": "^5.1.11",
    +				"@asamuzakjp/dom-selector": "^7.1.1",
    +				"@bramus/specificity": "^2.4.2",
    +				"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
    +				"@exodus/bytes": "^1.15.0",
    +				"css-tree": "^3.2.1",
    +				"data-urls": "^7.0.0",
     				"decimal.js": "^10.6.0",
     				"html-encoding-sniffer": "^6.0.0",
    -				"http-proxy-agent": "^7.0.2",
    -				"https-proxy-agent": "^7.0.6",
     				"is-potential-custom-element-name": "^1.0.1",
    -				"parse5": "^8.0.0",
    +				"lru-cache": "^11.3.5",
    +				"parse5": "^8.0.1",
     				"saxes": "^6.0.0",
     				"symbol-tree": "^3.2.4",
    -				"tough-cookie": "^6.0.0",
    +				"tough-cookie": "^6.0.1",
    +				"undici": "^7.25.0",
     				"w3c-xmlserializer": "^5.0.0",
    -				"webidl-conversions": "^8.0.0",
    -				"whatwg-mimetype": "^4.0.0",
    -				"whatwg-url": "^15.1.0",
    -				"ws": "^8.18.3",
    +				"webidl-conversions": "^8.0.1",
    +				"whatwg-mimetype": "^5.0.0",
    +				"whatwg-url": "^16.0.1",
     				"xml-name-validator": "^5.0.0"
     			},
     			"engines": {
    -				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
    +				"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
     			},
     			"peerDependencies": {
     				"canvas": "^3.0.0"
    @@ -3777,9 +3693,9 @@
     			}
     		},
     		"node_modules/prosemirror-model": {
    -			"version": "1.25.4",
    -			"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
    -			"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
    +			"version": "1.25.6",
    +			"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.6.tgz",
    +			"integrity": "sha512-RIm+e9BiqAaJ1mRECv3vR3C+VG8ELoTTI+47tVudGi82yLnFOx3G/p/iSPK1HmHQdKhkkrJ68NJqxh7S+FBVmQ==",
     			"license": "MIT",
     			"dependencies": {
     				"orderedmap": "^2.0.0"
    @@ -4261,9 +4177,9 @@
     			"license": "MIT"
     		},
     		"node_modules/terser": {
    -			"version": "5.46.1",
    -			"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
    -			"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
    +			"version": "5.47.1",
    +			"resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz",
    +			"integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
     			"dev": true,
     			"license": "BSD-2-Clause",
     			"dependencies": {
    @@ -4436,6 +4352,16 @@
     				"url": "https://github.com/sponsors/sindresorhus"
     			}
     		},
    +		"node_modules/undici": {
    +			"version": "7.25.0",
    +			"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
    +			"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
    +			"dev": true,
    +			"license": "MIT",
    +			"engines": {
    +				"node": ">=20.18.1"
    +			}
    +		},
     		"node_modules/uri-js": {
     			"version": "4.4.1",
     			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
    @@ -4454,9 +4380,9 @@
     			"license": "MIT"
     		},
     		"node_modules/vite": {
    -			"version": "7.3.2",
    -			"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
    -			"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
    +			"version": "7.3.3",
    +			"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
    +			"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    @@ -4552,19 +4478,19 @@
     			}
     		},
     		"node_modules/vitest": {
    -			"version": "4.1.5",
    -			"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
    -			"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
    +			"version": "4.1.6",
    +			"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
    +			"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    -				"@vitest/expect": "4.1.5",
    -				"@vitest/mocker": "4.1.5",
    -				"@vitest/pretty-format": "4.1.5",
    -				"@vitest/runner": "4.1.5",
    -				"@vitest/snapshot": "4.1.5",
    -				"@vitest/spy": "4.1.5",
    -				"@vitest/utils": "4.1.5",
    +				"@vitest/expect": "4.1.6",
    +				"@vitest/mocker": "4.1.6",
    +				"@vitest/pretty-format": "4.1.6",
    +				"@vitest/runner": "4.1.6",
    +				"@vitest/snapshot": "4.1.6",
    +				"@vitest/spy": "4.1.6",
    +				"@vitest/utils": "4.1.6",
     				"es-module-lexer": "^2.0.0",
     				"expect-type": "^1.3.0",
     				"magic-string": "^0.30.21",
    @@ -4592,12 +4518,12 @@
     				"@edge-runtime/vm": "*",
     				"@opentelemetry/api": "^1.9.0",
     				"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
    -				"@vitest/browser-playwright": "4.1.5",
    -				"@vitest/browser-preview": "4.1.5",
    -				"@vitest/browser-webdriverio": "4.1.5",
    -				"@vitest/coverage-istanbul": "4.1.5",
    -				"@vitest/coverage-v8": "4.1.5",
    -				"@vitest/ui": "4.1.5",
    +				"@vitest/browser-playwright": "4.1.6",
    +				"@vitest/browser-preview": "4.1.6",
    +				"@vitest/browser-webdriverio": "4.1.6",
    +				"@vitest/coverage-istanbul": "4.1.6",
    +				"@vitest/coverage-v8": "4.1.6",
    +				"@vitest/ui": "4.1.6",
     				"happy-dom": "*",
     				"jsdom": "*",
     				"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
    @@ -4844,27 +4770,28 @@
     			}
     		},
     		"node_modules/whatwg-mimetype": {
    -			"version": "4.0.0",
    -			"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
    -			"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
    +			"version": "5.0.0",
    +			"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
    +			"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
     			"dev": true,
     			"license": "MIT",
     			"engines": {
    -				"node": ">=18"
    +				"node": ">=20"
     			}
     		},
     		"node_modules/whatwg-url": {
    -			"version": "15.1.0",
    -			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
    -			"integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
    +			"version": "16.0.1",
    +			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
    +			"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
     			"dev": true,
     			"license": "MIT",
     			"dependencies": {
    +				"@exodus/bytes": "^1.11.0",
     				"tr46": "^6.0.0",
    -				"webidl-conversions": "^8.0.0"
    +				"webidl-conversions": "^8.0.1"
     			},
     			"engines": {
    -				"node": ">=20"
    +				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
     			}
     		},
     		"node_modules/which": {
    @@ -4926,28 +4853,6 @@
     				"node": ">=0.10.0"
     			}
     		},
    -		"node_modules/ws": {
    -			"version": "8.20.0",
    -			"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
    -			"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
    -			"dev": true,
    -			"license": "MIT",
    -			"engines": {
    -				"node": ">=10.0.0"
    -			},
    -			"peerDependencies": {
    -				"bufferutil": "^4.0.1",
    -				"utf-8-validate": ">=5.0.2"
    -			},
    -			"peerDependenciesMeta": {
    -				"bufferutil": {
    -					"optional": true
    -				},
    -				"utf-8-validate": {
    -					"optional": true
    -				}
    -			}
    -		},
     		"node_modules/xml-name-validator": {
     			"version": "4.0.0",
     			"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
    
  • panel/src/components/Dialogs/ModelsDialog.vue+2 1 modified
    @@ -46,6 +46,7 @@ import Dialog from "@/mixins/dialog.js";
     import Search from "@/mixins/search.js";
     
     export const props = {
    +	mixins: [Search],
     	props: {
     		endpoint: String,
     		empty: Object,
    @@ -71,7 +72,7 @@ export const props = {
     };
     
     export default {
    -	mixins: [Dialog, Search, props],
    +	mixins: [Dialog, props],
     	emits: ["cancel", "fetched", "submit"],
     	data() {
     		return {
    
  • panel/src/components/Forms/Field/BlocksField.vue+2 2 modified
    @@ -1,7 +1,7 @@
     <template>
     	<k-field
     		v-bind="$props"
    -		:input="id"
    +		:input="false"
     		:class="['k-blocks-field', $attrs.class]"
     		:style="$attrs.style"
     	>
    @@ -30,7 +30,7 @@
     		</template>
     
     		<k-input-validator
    -			v-bind="{ id, min, max, required }"
    +			v-bind="{ min, max, required }"
     			:value="JSON.stringify(value)"
     		>
     			<k-blocks
    
  • panel/src/components/Forms/Field/EntriesField.vue+2 2 modified
    @@ -1,7 +1,7 @@
     <template>
     	<k-field
     		v-bind="$props"
    -		:input="id"
    +		:input="false"
     		:class="['k-entries-field', $attrs.class]"
     		:style="$attrs.style"
     		@click.native.stop
    @@ -31,7 +31,7 @@
     		</template>
     
     		<k-input-validator
    -			v-bind="{ id, min, max, required }"
    +			v-bind="{ min, max, required }"
     			:value="JSON.stringify(entries)"
     		>
     			<!-- Empty State -->
    
  • panel/src/components/Forms/Field/LayoutField.vue+2 2 modified
    @@ -1,7 +1,7 @@
     <template>
     	<k-field
     		v-bind="$props"
    -		:input="id"
    +		:input="false"
     		:class="['k-layout-field', $attrs.class]"
     		:style="$attrs.style"
     	>
    @@ -27,7 +27,7 @@
     		</template>
     
     		<k-input-validator
    -			v-bind="{ id, min, max, required }"
    +			v-bind="{ min, max, required }"
     			:value="JSON.stringify(value)"
     		>
     			<k-layouts
    
  • panel/src/components/Forms/Field/StructureField.vue+2 2 modified
    @@ -2,7 +2,7 @@
     	<k-field
     		v-bind="$props"
     		:class="['k-structure-field', $attrs.class]"
    -		:input="id"
    +		:input="false"
     		:style="$attrs.style"
     		@click.native.stop
     	>
    @@ -57,7 +57,7 @@
     		</template>
     
     		<k-input-validator
    -			v-bind="{ id, min, max, required }"
    +			v-bind="{ min, max, required }"
     			:value="JSON.stringify(items)"
     		>
     			<template v-if="hasFields">
    
  • panel/src/components/Forms/Input/PicklistInput.vue+1 1 modified
    @@ -169,7 +169,7 @@ export default {
     		filteredOptions() {
     			// min length for the search to kick in
     			if (this.query.length < (this.search.min ?? 0)) {
    -				return;
    +				return this.options;
     			}
     			// include the info field in the search if the user has set the option to true...
     			if (this.search.info) {
    
  • panel/src/components/Forms/Input/Validator.js+23 38 modified
    @@ -4,36 +4,32 @@
      */
     export default class InputValidator extends HTMLElement {
     	static formAssociated = true;
    +	/** @type {ElementInternals} */
    +	internals = this.attachInternals();
    +	/** @type {Array<unknown>} */
    +	entries = [];
    +	/** @type {number | null} */
    +	max = null;
    +	/** @type {number | null} */
    +	min = null;
    +	/** @type {boolean} */
    +	required = false;
     
     	static get observedAttributes() {
     		return ["min", "max", "required", "value"];
     	}
     
     	attributeChangedCallback(attribute, oldValue, newValue) {
    -		this[attribute] = newValue;
    -	}
    -
    -	constructor() {
    -		super();
    -		this.internals = this.attachInternals();
    -		this.entries = [];
    -
    -		this.max = null;
    -		this.min = null;
    -		this.required = false;
    +		if (attribute === "required") {
    +			this.required = newValue !== null && newValue !== "false";
    +		} else if (attribute === "min" || attribute === "max") {
    +			this[attribute] = newValue === null ? null : parseInt(newValue);
    +		} else {
    +			this[attribute] = newValue;
    +		}
     	}
     
     	connectedCallback() {
    -		this.tabIndex = 0;
    -
    -		// pass-through the id attribute
    -		const id = this.getAttribute("id");
    -
    -		if (id) {
    -			this.input.setAttribute("id", id);
    -			this.removeAttribute("id");
    -		}
    -
     		this.validate();
     	}
     
    @@ -57,10 +53,6 @@ export default class InputValidator extends HTMLElement {
     		);
     	}
     
    -	get isEmpty() {
    -		return this.selected.length === 0;
    -	}
    -
     	get name() {
     		return this.getAttribute("name");
     	}
    @@ -74,29 +66,22 @@ export default class InputValidator extends HTMLElement {
     	}
     
     	validate() {
    -		const max = parseInt(this.getAttribute("max"));
    -		const min = parseInt(this.getAttribute("min"));
    -
    -		const required =
    -			this.hasAttribute("required") &&
    -			this.getAttribute("required") !== "false";
    -
    -		if (required && this.entries.length === 0) {
    +		if (this.required && this.entries.length === 0) {
     			this.internals.setValidity(
     				{ valueMissing: true },
    -				window.panel.$t("error.validation.required"),
    +				window.panel.t("error.validation.required"),
     				this.input
     			);
    -		} else if (this.hasAttribute("min") && this.entries.length < min) {
    +		} else if (this.min !== null && this.entries.length < this.min) {
     			this.internals.setValidity(
     				{ rangeUnderflow: true },
    -				window.panel.$t("error.validation.min", { min }),
    +				window.panel.t("error.validation.min", { min: this.min }),
     				this.input
     			);
    -		} else if (this.hasAttribute("max") && this.entries.length > max) {
    +		} else if (this.max !== null && this.entries.length > this.max) {
     			this.internals.setValidity(
     				{ rangeOverflow: true },
    -				window.panel.$t("error.validation.max", { max }),
    +				window.panel.t("error.validation.max", { max: this.max }),
     				this.input
     			);
     		} else {
    
  • panel/src/components/Forms/Input/Validator.test.js+301 0 added
    @@ -0,0 +1,301 @@
    +/**
    + * @vitest-environment jsdom
    + */
    +
    +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
    +import InputValidator from "./Validator.js";
    +
    +/**
    + * @typedef {InputValidator & HTMLElement} InputValidatorEl
    + */
    +
    +const TAG = "k-input-validator-test";
    +
    +beforeAll(() => {
    +	customElements.define(TAG, InputValidator);
    +
    +	// jsdom does not implement ElementInternals.setValidity. Stub it on the
    +	// prototype so validate() can run end-to-end and we can spy on calls.
    +	const proto = window.ElementInternals.prototype;
    +	proto.setValidity ??= () => {};
    +	proto.checkValidity ??= () => true;
    +	proto.reportValidity ??= () => true;
    +
    +	// Minimal panel.t shim used by validate()
    +	window.panel ??= {};
    +	window.panel.t = (key, params) =>
    +		params ? `${key}:${JSON.stringify(params)}` : key;
    +});
    +
    +afterEach(() => {
    +	document.body.innerHTML = "";
    +	vi.restoreAllMocks();
    +});
    +
    +/**
    + * Creates a validator element with the given attributes and children
    + * and appends it to the document body so connectedCallback fires.
    + *
    + * @param {Record<string, string>} [attrs]
    + * @param {HTMLElement | HTMLElement[]} [children]
    + * @returns {InputValidatorEl}
    + */
    +function mount(attrs = {}, children = []) {
    +	const validator = /** @type {InputValidatorEl} */ (
    +		document.createElement(TAG)
    +	);
    +
    +	for (const [key, value] of Object.entries(attrs)) {
    +		validator.setAttribute(key, value);
    +	}
    +
    +	for (const child of [].concat(children)) {
    +		validator.appendChild(child);
    +	}
    +
    +	document.body.appendChild(validator);
    +	return validator;
    +}
    +
    +/**
    + * @param {Record<string, string>} [attrs]
    + * @returns {HTMLInputElement}
    + */
    +function input(attrs = {}) {
    +	const el = document.createElement("input");
    +	for (const [key, value] of Object.entries(attrs)) {
    +		el.setAttribute(key, value);
    +	}
    +	return el;
    +}
    +
    +describe("InputValidator", () => {
    +	describe("attributeChangedCallback", () => {
    +		it("coerces min and max to numbers", () => {
    +			const validator = mount();
    +
    +			expect(validator.min).toBeNull();
    +			expect(validator.max).toBeNull();
    +
    +			validator.setAttribute("min", "1");
    +			validator.setAttribute("max", "5");
    +
    +			expect(validator.min).toBe(1);
    +			expect(validator.max).toBe(5);
    +		});
    +
    +		it("resets min and max to null when the attributes are removed", () => {
    +			const validator = mount({ min: "2", max: "8" });
    +
    +			expect(validator.min).toBe(2);
    +			expect(validator.max).toBe(8);
    +
    +			validator.removeAttribute("min");
    +			validator.removeAttribute("max");
    +
    +			expect(validator.min).toBeNull();
    +			expect(validator.max).toBeNull();
    +		});
    +
    +		it("treats required as a boolean: present → true", () => {
    +			const validator = mount();
    +			expect(validator.required).toBe(false);
    +			validator.setAttribute("required", "true");
    +			expect(validator.required).toBe(true);
    +		});
    +
    +		it("treats required='false' as not required", () => {
    +			const validator = mount({ required: "true" });
    +			expect(validator.required).toBe(true);
    +			validator.setAttribute("required", "false");
    +			expect(validator.required).toBe(false);
    +		});
    +
    +		it("resets required to false when the attribute is removed", () => {
    +			const validator = mount({ required: "true" });
    +			expect(validator.required).toBe(true);
    +			validator.removeAttribute("required");
    +			expect(validator.required).toBe(false);
    +		});
    +	});
    +
    +	describe("has", () => {
    +		it("returns true when the value is in entries", () => {
    +			const validator = mount();
    +			validator.value = JSON.stringify(["red", "blue"]);
    +			expect(validator.has("red")).toBe(true);
    +		});
    +
    +		it("returns false when the value is not in entries", () => {
    +			const validator = mount();
    +			validator.value = JSON.stringify(["red"]);
    +			expect(validator.has("blue")).toBe(false);
    +		});
    +	});
    +
    +	describe("input", () => {
    +		it("returns the element matching the anchor selector", () => {
    +			const target = input({ class: "preferred" });
    +			const other = input();
    +			const validator = mount({ anchor: ".preferred" }, [other, target]);
    +			expect(validator.input).toBe(target);
    +		});
    +
    +		it("falls back to the first focusable descendant", () => {
    +			const wrapper = document.createElement("div");
    +			const button = document.createElement("button");
    +			wrapper.appendChild(button);
    +			const validator = mount({}, wrapper);
    +			expect(validator.input).toBe(button);
    +		});
    +
    +		it("matches input, textarea, select and button", () => {
    +			for (const tag of ["input", "textarea", "select", "button"]) {
    +				const child = document.createElement(tag);
    +				const validator = mount({}, child);
    +				expect(validator.input).toBe(child);
    +				document.body.innerHTML = "";
    +			}
    +		});
    +
    +		it("falls back to the first direct child when nothing focusable exists", () => {
    +			const wrapper = document.createElement("div");
    +			const validator = mount({}, wrapper);
    +			expect(validator.input).toBe(wrapper);
    +		});
    +
    +		it("returns null when there are no children", () => {
    +			const validator = mount();
    +			expect(validator.input).toBeNull();
    +		});
    +	});
    +
    +	describe("name", () => {
    +		it("reflects the name attribute", () => {
    +			const validator = mount({ name: "tags" });
    +			expect(validator.name).toBe("tags");
    +		});
    +
    +		it("returns null when name is not set", () => {
    +			const validator = mount();
    +			expect(validator.name).toBeNull();
    +		});
    +	});
    +
    +	describe("type", () => {
    +		it("returns the local element name", () => {
    +			const validator = mount();
    +			expect(validator.type).toBe(TAG);
    +		});
    +	});
    +
    +	describe("validate", () => {
    +		it("flags valueMissing when required and entries are empty", () => {
    +			const validator = mount({ required: "true" }, input());
    +			const spy = vi.spyOn(validator.internals, "setValidity");
    +
    +			validator.validate();
    +
    +			expect(spy).toHaveBeenCalledWith(
    +				{ valueMissing: true },
    +				"error.validation.required",
    +				validator.input
    +			);
    +		});
    +
    +		it("treats required='false' as not required", () => {
    +			const validator = mount({ required: "false" }, input());
    +			const spy = vi.spyOn(validator.internals, "setValidity");
    +
    +			validator.validate();
    +
    +			expect(spy).toHaveBeenCalledWith({});
    +		});
    +
    +		it("flags rangeUnderflow when entries are below min", () => {
    +			const validator = mount({ min: "3" }, input());
    +			validator.value = JSON.stringify(["a", "b"]);
    +			const spy = vi.spyOn(validator.internals, "setValidity");
    +
    +			validator.validate();
    +
    +			expect(spy).toHaveBeenCalledWith(
    +				{ rangeUnderflow: true },
    +				expect.stringContaining("error.validation.min"),
    +				validator.input
    +			);
    +		});
    +
    +		it("flags rangeOverflow when entries are above max", () => {
    +			const validator = mount({ max: "2" }, input());
    +			validator.value = JSON.stringify(["a", "b", "c"]);
    +			const spy = vi.spyOn(validator.internals, "setValidity");
    +
    +			validator.validate();
    +
    +			expect(spy).toHaveBeenCalledWith(
    +				{ rangeOverflow: true },
    +				expect.stringContaining("error.validation.max"),
    +				validator.input
    +			);
    +		});
    +
    +		it("clears validity when constraints are satisfied", () => {
    +			const validator = mount({ min: "1", max: "3" }, input());
    +			validator.value = JSON.stringify(["a", "b"]);
    +			const spy = vi.spyOn(validator.internals, "setValidity");
    +
    +			validator.validate();
    +
    +			expect(spy).toHaveBeenCalledWith({});
    +		});
    +
    +		it("prefers valueMissing over min when both could apply", () => {
    +			const validator = mount({ required: "true", min: "2" }, input());
    +			const spy = vi.spyOn(validator.internals, "setValidity");
    +
    +			validator.validate();
    +
    +			expect(spy).toHaveBeenCalledWith(
    +				{ valueMissing: true },
    +				expect.any(String),
    +				validator.input
    +			);
    +		});
    +	});
    +
    +	describe("value", () => {
    +		it("parses a JSON-encoded array into entries", () => {
    +			const validator = mount();
    +			validator.value = JSON.stringify(["a", "b"]);
    +			expect(validator.entries).toEqual(["a", "b"]);
    +		});
    +
    +		it("falls back to an empty array for non-string input", () => {
    +			const validator = mount();
    +			validator.value = null;
    +			expect(validator.entries).toEqual([]);
    +		});
    +
    +		it("serializes entries back to a JSON string via the getter", () => {
    +			const validator = mount();
    +			validator.value = JSON.stringify([1, 2, 3]);
    +			expect(validator.value).toBe("[1,2,3]");
    +		});
    +
    +		it("returns an empty array as JSON when entries are missing", () => {
    +			const validator = mount();
    +			validator.entries = null;
    +			expect(validator.value).toBe("[]");
    +		});
    +
    +		it("re-runs validation when set", () => {
    +			const validator = mount({ required: "true" }, input());
    +			const spy = vi.spyOn(validator.internals, "setValidity");
    +
    +			validator.value = JSON.stringify(["x"]);
    +
    +			expect(spy).toHaveBeenCalledWith({});
    +		});
    +	});
    +});
    
  • panel/src/components/Navigation/FileBrowser.vue+4 0 modified
    @@ -154,6 +154,10 @@ export default {
     	justify-content: end;
     }
     
    +.k-file-browser-pagination .k-pagination {
    +	margin-bottom: 0;
    +}
    +
     @container (max-width: 30rem) {
     	.k-file-browser-layout {
     		grid-template-columns: minmax(0, 1fr);
    
  • panel/src/components/Views/ModelView.vue+7 0 modified
    @@ -68,6 +68,7 @@ export default {
     		this.$events.on("keydown.left", this.toPrev);
     		this.$events.on("keydown.right", this.toNext);
     		this.$events.on("model.reload", this.$reload);
    +		this.$events.on("model.update", this.onModelUpdate);
     		this.$events.on("view.save", this.onViewSave);
     	},
     	destroyed() {
    @@ -76,6 +77,7 @@ export default {
     		this.$events.off("keydown.left", this.toPrev);
     		this.$events.off("keydown.right", this.toNext);
     		this.$events.off("model.reload", this.$reload);
    +		this.$events.off("model.update", this.onModelUpdate);
     		this.$events.off("view.save", this.onViewSave);
     	},
     	methods: {
    @@ -111,6 +113,11 @@ export default {
     				language: this.$panel.language.code
     			});
     		},
    +		onModelUpdate({ path } = {}) {
    +			if (path === this.api) {
    +				this.$panel.view.refresh();
    +			}
    +		},
     		async onSubmit() {
     			try {
     				await this.$panel.content.publish(this.content, {
    
  • panel/src/panel/request.js+4 0 modified
    @@ -139,6 +139,10 @@ export const responder = async (request, response) => {
     		response.text = await response.text();
     		response.json = JSON.parse(response.text);
     	} catch (error) {
    +		if (error.name === "AbortError") {
    +			throw error;
    +		}
    +
     		throw new JsonRequestError("Invalid JSON response", {
     			cause: error,
     			request,
    
  • panel/src/panel/upload.js+3 1 modified
    @@ -40,7 +40,9 @@ export default (panel) => {
     		input: null,
     		announce() {
     			panel.notification.success({ context: "view" });
    -			panel.events.emit("model.update");
    +			panel.events.emit("model.update", {
    +				path: this.replacing?.link
    +			});
     		},
     		/**
     		 * Called when dialog's cancel button was clicked
    
  • src/Api/Api.php+10 2 modified
    @@ -615,8 +615,16 @@ protected function setRequestMethod(
     	public function upload(
     		Closure $callback,
     		bool $single = false,
    -		bool $debug = false
    +		bool $debug = false,
    +		string|null $template = null
     	): array {
    -		return (new Upload($this, $single, $debug))->process($callback);
    +		$upload = new Upload(
    +			api: $this,
    +			single: $single,
    +			debug: $debug,
    +			template: $template
    +		);
    +
    +		return $upload->process($callback);
     	}
     }
    
  • src/Api/Collection.php+7 2 modified
    @@ -107,8 +107,13 @@ public function toArray(): array
     	 */
     	public function toResponse(): array
     	{
    -		if ($query = $this->api->requestQuery('query')) {
    -			$this->data = $this->data->query($query);
    +		if (is_array($query = $this->api->requestQuery('query')) === true) {
    +			$this->data = $this->data->query(array_filter([
    +				'limit'    => $query['limit'] ?? null,
    +				'offset'   => $query['offset'] ?? null,
    +				'paginate' => $query['paginate'] ?? null,
    +				'search'   => $query['search'] ?? null,
    +			], fn ($value) => $value !== null));
     		}
     
     		if (!$this->data->pagination()) {
    
  • src/Api/Upload.php+4 3 modified
    @@ -34,7 +34,8 @@
     	public function __construct(
     		protected Api $api,
     		protected bool $single = true,
    -		protected bool $debug = false
    +		protected bool $debug = false,
    +		protected string|null $template = null
     	) {
     	}
     
    @@ -185,7 +186,7 @@ public function process(Closure $callback): array
     				// (incomplete chunk request will return empty $source)
     				$data = match ($source) {
     					null    => null,
    -					default => $callback($source, $filename)
    +					default => $callback($source, $filename, $this->template)
     				};
     
     				$uploads[$upload['name']] = match (true) {
    @@ -239,7 +240,7 @@ public function processChunk(
     			tmp:      $tmpRoot,
     			total:    $total,
     			offset:   $this->api->requestHeaders('Upload-Offset'),
    -			template: $this->api->requestBody('template'),
    +			template: $this->template ?? $this->api->requestBody('template'),
     		);
     
     		// stream chunk content and append it to partial file
    
  • src/Cms/Api.php+6 1 modified
    @@ -184,7 +184,12 @@ public function searchPages(string|null $parent = null): Pages
     			return $pages->search($this->requestQuery('q'));
     		}
     
    -		return $pages->query($this->requestBody());
    +		return $pages->query(array_filter([
    +			'limit'    => $this->requestBody('limit'),
    +			'offset'   => $this->requestBody('offset'),
    +			'paginate' => $this->requestBody('paginate'),
    +			'search'   => $this->requestBody('search'),
    +		], fn ($value) => $value !== null));
     	}
     
     	/**
    
  • src/Cms/App.php+1 1 modified
    @@ -1262,7 +1262,7 @@ public function resolve(
     		// search for a draft if the page cannot be found
     		if (!$page && $draft = $site->draft($path)) {
     			if (
    -				$this->user() ||
    +				($this->user() && $draft->isAccessible()) ||
     				$draft->renderVersionFromRequest() !== null
     			) {
     				$page = $draft;
    
  • src/Cms/Block.php+4 0 modified
    @@ -5,6 +5,7 @@
     use Kirby\Content\Content;
     use Kirby\Content\Field;
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Stringable;
     use Throwable;
    @@ -101,6 +102,7 @@ public function content(): Content
     	/**
     	 * Controller for the block snippet
     	 */
    +	#[BlockCollectionAccess]
     	public function controller(): array
     	{
     		return [
    @@ -195,6 +197,7 @@ public function toArray(): array
     	 * object. This can be used further
     	 * with all available field methods
     	 */
    +	#[BlockCollectionAccess]
     	public function toField(): Field
     	{
     		return new Field($this->parent(), $this->id(), $this->toHtml());
    @@ -203,6 +206,7 @@ public function toField(): Field
     	/**
     	 * Converts the block to HTML
     	 */
    +	#[BlockCollectionAccess]
     	public function toHtml(): string
     	{
     		try {
    
  • src/Cms/FileActions.php+12 0 modified
    @@ -9,6 +9,7 @@
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Exception\LogicException;
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Uuid\Uuid;
     use Kirby\Uuid\Uuids;
     
    @@ -36,6 +37,7 @@ protected function changeExtension(
     	 *
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public function changeName(
     		string $name,
     		bool $sanitize = true,
    @@ -99,6 +101,7 @@ public function changeName(
     	/**
     	 * Changes the file's sorting number in the meta file
     	 */
    +	#[BlockCollectionAccess]
     	public function changeSort(int $sort): static
     	{
     		// skip if the sort number stays the same
    @@ -129,6 +132,7 @@ function ($file, $sort) {
     	/**
     	 * @return $this|static
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTemplate(string|null $template): static
     	{
     		if ($template === $this->template()) {
    @@ -177,6 +181,7 @@ protected function commit(
     	/**
     	 * Copy the file to the given page
     	 */
    +	#[BlockCollectionAccess]
     	public function copy(Page $page): static
     	{
     		F::copy($this->root(), $page->root() . '/' . $this->filename());
    @@ -207,6 +212,7 @@ public function copy(Page $page): static
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props, bool $move = false): static
     	{
     		$props = static::normalizeProps($props);
    @@ -306,6 +312,7 @@ public static function create(array $props, bool $move = false): static
     	 * Deletes the file. The store is used to
     	 * manipulate the filesystem or whatever you prefer.
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(): bool
     	{
     		return $this->commit('delete', ['file' => $this], function ($file) {
    @@ -334,6 +341,7 @@ public function delete(): bool
     	/**
     	 * Resizes/crops the original file with Kirby's thumb handler
     	 */
    +	#[BlockCollectionAccess]
     	public function manipulate(array|null $options = []): static
     	{
     		// nothing to process
    @@ -390,6 +398,7 @@ protected static function normalizeProps(array $props): array
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(): static
     	{
     		Media::publish($this, $this->mediaRoot());
    @@ -406,6 +415,7 @@ public function publish(): static
     	 * @param bool $move If set to `true`, the source will be deleted
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public function replace(string $source, bool $move = false): static
     	{
     		$file = $this->clone();
    @@ -443,6 +453,7 @@ public function replace(string $source, bool $move = false): static
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function unpublish(bool $onlyMedia = false): static
     	{
     		// unpublish media files
    @@ -462,6 +473,7 @@ public function unpublish(bool $onlyMedia = false): static
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    
  • src/Cms/FileModifications.php+11 0 modified
    @@ -5,6 +5,7 @@
     use Kirby\Content\Field;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Filesystem\Asset;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * Trait for image resizing, blurring etc.
    @@ -20,6 +21,7 @@ trait FileModifications
     	/**
     	 * Blurs the image by the given amount of pixels
     	 */
    +	#[BlockCollectionAccess]
     	public function blur(int|bool $pixels = true): FileVersion|File|Asset
     	{
     		return $this->thumb(['blur' => $pixels]);
    @@ -28,6 +30,7 @@ public function blur(int|bool $pixels = true): FileVersion|File|Asset
     	/**
     	 * Converts the image to black and white
     	 */
    +	#[BlockCollectionAccess]
     	public function bw(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -36,6 +39,7 @@ public function bw(): FileVersion|File|Asset
     	/**
     	 * Crops the image by the given width and height
     	 */
    +	#[BlockCollectionAccess]
     	public function crop(
     		int $width,
     		int|null $height = null,
    @@ -66,6 +70,7 @@ public function crop(
     	/**
     	 * Alias for File::bw()
     	 */
    +	#[BlockCollectionAccess]
     	public function grayscale(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -74,6 +79,7 @@ public function grayscale(): FileVersion|File|Asset
     	/**
     	 * Alias for File::bw()
     	 */
    +	#[BlockCollectionAccess]
     	public function greyscale(): FileVersion|File|Asset
     	{
     		return $this->thumb(['grayscale' => true]);
    @@ -82,6 +88,7 @@ public function greyscale(): FileVersion|File|Asset
     	/**
     	 * Sets the JPEG compression quality
     	 */
    +	#[BlockCollectionAccess]
     	public function quality(int $quality): FileVersion|File|Asset
     	{
     		return $this->thumb(['quality' => $quality]);
    @@ -93,6 +100,7 @@ public function quality(int $quality): FileVersion|File|Asset
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 */
    +	#[BlockCollectionAccess]
     	public function resize(
     		int|null $width = null,
     		int|null $height = null,
    @@ -108,6 +116,7 @@ public function resize(
     	/**
     	 * Sharpens the image
     	 */
    +	#[BlockCollectionAccess]
     	public function sharpen(int $amount = 50): FileVersion|File|Asset
     	{
     		return $this->thumb(['sharpen' => $amount]);
    @@ -119,6 +128,7 @@ public function sharpen(int $amount = 50): FileVersion|File|Asset
     	 * also be set up in the config with the thumbs.srcsets option.
     	 * @since 3.1.0
     	 */
    +	#[BlockCollectionAccess]
     	public function srcset(array|string|null $sizes = null): string|null
     	{
     		if (empty($sizes) === true) {
    @@ -168,6 +178,7 @@ public function srcset(array|string|null $sizes = null): string|null
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException
     	 */
    +	#[BlockCollectionAccess]
     	public function thumb(
     		array|string|null $options = null
     	): FileVersion|File|Asset {
    
  • src/Cms/File.php+9 0 modified
    @@ -8,6 +8,7 @@
     use Kirby\Filesystem\F;
     use Kirby\Filesystem\IsFile;
     use Kirby\Panel\File as Panel;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -145,6 +146,7 @@ public function __debugInfo(): array
     	 * Returns the url to api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		return $this->parent()->apiUrl($relative) . '/files/' . $this->filename();
    @@ -375,6 +377,7 @@ public function isReadable(): bool
     	 * for the file and its versions
     	 * @since 5.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaDir(): string
     	{
     		return $this->parent()->mediaDir() . '/' . $this->mediaHash();
    @@ -383,6 +386,7 @@ public function mediaDir(): string
     	/**
     	 * Creates a unique media hash
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaHash(): string
     	{
     		return $this->mediaToken() . '-' . $this->modifiedFile();
    @@ -393,6 +397,7 @@ public function mediaHash(): string
     	 *
     	 * @param string|null $filename Optional override for the filename
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(string|null $filename = null): string
     	{
     		$filename ??= $this->filename();
    @@ -403,6 +408,7 @@ public function mediaRoot(string|null $filename = null): string
     	/**
     	 * Creates a non-guessable token string for this file
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaToken(): string
     	{
     		$token = $this->kirby()->contentToken($this, $this->id());
    @@ -528,6 +534,7 @@ public function permissions(): FilePermissions
     	/**
     	 * Returns the absolute root to the file
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string|null
     	{
     		return $this->root ??= $this->parent()->root() . '/' . $this->filename();
    @@ -598,6 +605,7 @@ public function templateSiblings(bool $self = true): Files
     	 * by injecting the information from
     	 * the asset.
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    @@ -623,6 +631,7 @@ public function url(): string
     	 * option is used to disable this behavior or enable it
     	 * on a per-file basis.
     	 */
    +	#[BlockCollectionAccess]
     	public function previewUrl(): string|null
     	{
     		// check if the clean file URL is accessible,
    
  • src/Cms/HasFiles.php+2 0 modified
    @@ -2,6 +2,7 @@
     
     namespace Kirby\Cms;
     
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Uuid\Uuid;
     
     /**
    @@ -41,6 +42,7 @@ public function code(): Files
     	 *
     	 * @param bool $move If set to `true`, the source will be deleted
     	 */
    +	#[BlockCollectionAccess]
     	public function createFile(array $props, bool $move = false): File
     	{
     		$props = [
    
  • src/Cms/HasMethods.php+5 1 modified
    @@ -4,6 +4,7 @@
     
     use Closure;
     use Kirby\Exception\BadMethodCallException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * HasMethods
    @@ -27,6 +28,7 @@ trait HasMethods
     	 *
     	 * @throws \Kirby\Exception\BadMethodCallException
     	 */
    +	#[BlockCollectionAccess]
     	protected function callMethod(string $method, array $args = []): mixed
     	{
     		$closure = $this->getMethod($method);
    @@ -43,6 +45,7 @@ protected function callMethod(string $method, array $args = []): mixed
     	/**
     	 * Checks if the object has a registered custom method
     	 */
    +	#[BlockCollectionAccess]
     	public function hasMethod(string $method): bool
     	{
     		return $this->getMethod($method) !== null;
    @@ -53,7 +56,8 @@ public function hasMethod(string $method): bool
     	 * the current class or from a parent class ordered by
     	 * inheritance order (top to bottom)
     	 */
    -	protected function getMethod(string $method): Closure|null
    +	#[BlockCollectionAccess]
    +	public function getMethod(string $method): Closure|null
     	{
     		if (isset(static::$methods[$method]) === true) {
     			return static::$methods[$method];
    
  • src/Cms/HasModels.php+3 0 modified
    @@ -2,6 +2,8 @@
     
     namespace Kirby\Cms;
     
    +use Kirby\Toolkit\BlockCollectionAccess;
    +
     /**
      * HasModels
      *
    @@ -23,6 +25,7 @@ trait HasModels
     	 * Adds new models to the registry
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public static function extendModels(array $models): array
     	{
     		return static::$models = [
    
  • src/Cms/Language.php+6 0 modified
    @@ -7,6 +7,7 @@
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Exception\NotFoundException;
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Locale;
     use Kirby\Toolkit\Str;
     use Stringable;
    @@ -153,6 +154,7 @@ public function code(): string
     	/**
     	 * Creates a new language object
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): static
     	{
     		$kirby         = App::instance();
    @@ -216,6 +218,7 @@ public static function create(array $props): static
     	 *
     	 * @throws \Kirby\Exception\Exception
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(): bool
     	{
     		$kirby = App::instance();
    @@ -456,6 +459,7 @@ public function permissions(): LanguagePermissions
     	/**
     	 * Returns the absolute path to the language file
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return App::instance()->root('languages') . '/' . $this->code() . '.php';
    @@ -489,6 +493,7 @@ public function rules(): array
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function save(): static
     	{
     		$existingData = Data::read($this->root(), fail: false);
    @@ -587,6 +592,7 @@ public function url(): string
     	/**
     	 * Update language properties and save them
     	 */
    +	#[BlockCollectionAccess]
     	public function update(array|null $props = null): static
     	{
     		$kirby = App::instance();
    
  • src/Cms/LazyCollection.php+2 2 modified
    @@ -207,7 +207,7 @@ public function filter(string|array|Closure $field, ...$args): static
     	/**
     	 * Returns the first element
     	 *
    -	 * @return TValue
    +	 * @return TValue|null
     	 */
     	public function first()
     	{
    @@ -337,7 +337,7 @@ public function keyOf(mixed $needle): int|string|false
     	/**
     	 * Returns the last element
     	 *
    -	 * @return TValue
    +	 * @return TValue|null
     	 */
     	public function last()
     	{
    
  • src/Cms/ModelWithContent.php+9 0 modified
    @@ -17,6 +17,7 @@
     use Kirby\Form\Fields;
     use Kirby\Form\Form;
     use Kirby\Panel\Model;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Kirby\Uuid\Identifiable;
     use Kirby\Uuid\Uuid;
    @@ -109,6 +110,7 @@ public function blueprints(string|null $inSection = null): array
     	 * @since 5.0.0
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function changeStorage(Storage|string $toStorage, bool $copy = false): static
     	{
     		if (is_string($toStorage) === true) {
    @@ -132,6 +134,7 @@ public function changeStorage(Storage|string $toStorage, bool $copy = false): st
     	 *
     	 * @todo eventually refactor without need of propertyData
     	 */
    +	#[BlockCollectionAccess]
     	public function clone(array $props = []): static
     	{
     		$props = array_replace_recursive($this->propertyData, $props);
    @@ -268,6 +271,7 @@ public function createDefaultContent(): array
     	/**
     	 * Decrement a given field value
     	 */
    +	#[BlockCollectionAccess]
     	public function decrement(
     		string $field,
     		int $by = 1,
    @@ -300,6 +304,7 @@ public function errors(): array
     	 * Creates a clone and fetches all
     	 * lazy-loaded getters to get a full copy
     	 */
    +	#[BlockCollectionAccess]
     	public function hardcopy(): static
     	{
     		$clone = $this->clone();
    @@ -324,6 +329,7 @@ public function id(): string|null
     	/**
     	 * Increment a given field value
     	 */
    +	#[BlockCollectionAccess]
     	public function increment(
     		string $field,
     		int $by = 1,
    @@ -442,6 +448,7 @@ abstract public function root(): string|null;
     	 * Low-level method to save the model with the given data.
     	 * Consider using `::update()` instead.
     	 */
    +	#[BlockCollectionAccess]
     	public function save(
     		array|null $data = null,
     		string|null $languageCode = null,
    @@ -655,6 +662,7 @@ public function translations(): Translations
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    @@ -723,6 +731,7 @@ public function versions(): Versions
     	 * @internal
     	 * @deprecated 5.0.0 Use `->version()->save()` instead
     	 */
    +	#[BlockCollectionAccess]
     	public function writeContent(array $data, string|null $languageCode = null): bool
     	{
     		Helpers::deprecated('$model->writeContent() is deprecated. Use $model->version()->save() instead.'); // @codeCoverageIgnore
    
  • src/Cms/PageActions.php+19 0 modified
    @@ -12,6 +12,7 @@
     use Kirby\Exception\LogicException;
     use Kirby\Filesystem\Dir;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     use Kirby\Toolkit\Str;
     use Kirby\Uuid\Uuid;
    @@ -39,6 +40,7 @@ trait PageActions
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If a draft is being sorted or the directory cannot be moved
     	 */
    +	#[BlockCollectionAccess]
     	public function changeNum(int|null $num = null): static
     	{
     		if ($this->isDraft() === true) {
    @@ -83,6 +85,7 @@ public function changeNum(int|null $num = null): static
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If the directory cannot be moved
     	 */
    +	#[BlockCollectionAccess]
     	public function changeSlug(
     		string $slug,
     		string|null $languageCode = null
    @@ -200,6 +203,7 @@ protected function changeSlugForLanguage(
     	 * @param int|null $position Optional sorting number
     	 * @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
     	 */
    +	#[BlockCollectionAccess]
     	public function changeStatus(
     		string $status,
     		int|null $position = null
    @@ -288,6 +292,7 @@ protected function changeStatusToUnlisted(): static
     	 *
     	 * @return $this|static
     	 */
    +	#[BlockCollectionAccess]
     	public function changeSort(int|null $position = null): static
     	{
     		return $this->changeStatus('listed', $position);
    @@ -299,6 +304,7 @@ public function changeSort(int|null $position = null): static
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTemplate(string $template): static
     	{
     		if ($template === $this->intendedTemplate()->name()) {
    @@ -314,6 +320,7 @@ public function changeTemplate(string $template): static
     	/**
     	 * Change the page title
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTitle(
     		string $title,
     		string|null $languageCode = null
    @@ -369,6 +376,7 @@ protected function commit(
     	 *
     	 * @throws \Kirby\Exception\DuplicateException If the page already exists
     	 */
    +	#[BlockCollectionAccess]
     	public function copy(array $options = []): static
     	{
     		$slug        = $options['slug']     ?? $this->slug();
    @@ -436,6 +444,7 @@ public function copy(array $options = []): static
     	/**
     	 * Creates and stores a new page
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): Page
     	{
     		$props = self::normalizeProps($props);
    @@ -505,6 +514,7 @@ function ($page) use ($storage) {
     	/**
     	 * Creates a child of the current page
     	 */
    +	#[BlockCollectionAccess]
     	public function createChild(array $props): Page
     	{
     		$props = [
    @@ -529,6 +539,7 @@ public function createChild(array $props): Page
     	 * Create the sorting number for the page
     	 * depending on the blueprint settings
     	 */
    +	#[BlockCollectionAccess]
     	public function createNum(int|null $num = null): int
     	{
     		$mode = $this->blueprint()->num();
    @@ -585,6 +596,7 @@ public function createNum(int|null $num = null): int
     	/**
     	 * Deletes the page
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(bool $force = false): bool
     	{
     		return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
    @@ -637,6 +649,7 @@ public function delete(bool $force = false): bool
     	 * Duplicates the page with the given
     	 * slug and optionally copies all files
     	 */
    +	#[BlockCollectionAccess]
     	public function duplicate(string|null $slug = null, array $options = []): static
     	{
     		// create the slug for the duplicate
    @@ -669,6 +682,7 @@ public function duplicate(string|null $slug = null, array $options = []): static
     	 * Moves the page to a new parent if the
     	 * new parent accepts the page type
     	 */
    +	#[BlockCollectionAccess]
     	public function move(Site|Page $parent): Page
     	{
     		// nothing to move
    @@ -739,6 +753,7 @@ protected static function normalizeProps(array $props): array
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If the folder cannot be moved
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(): static
     	{
     		if ($this->isDraft() === false) {
    @@ -786,6 +801,7 @@ public function publish(): static
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function purge(): static
     	{
     		parent::purge();
    @@ -855,6 +871,7 @@ protected function resortSiblingsAfterListing(int|null $position = null): bool
     	/**
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function resortSiblingsAfterUnlisting(): bool
     	{
     		$index    = 0;
    @@ -891,6 +908,7 @@ public function resortSiblingsAfterUnlisting(): bool
     	 * @return $this|static
     	 * @throws \Kirby\Exception\LogicException If the folder cannot be moved
     	 */
    +	#[BlockCollectionAccess]
     	public function unpublish(): static
     	{
     		if ($this->isDraft() === true) {
    @@ -935,6 +953,7 @@ public function unpublish(): static
     	/**
     	 * Updates the page data
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    
  • src/Cms/Page.php+14 0 modified
    @@ -13,6 +13,7 @@
     use Kirby\Panel\Page as Panel;
     use Kirby\Template\Template;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\LazyValue;
     use Kirby\Toolkit\Str;
     use Throwable;
    @@ -194,6 +195,7 @@ public function __debugInfo(): array
     	 * Returns the url to the api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		if ($relative === true) {
    @@ -298,6 +300,7 @@ public function contentFileData(
     	 *
     	 * @throws \Kirby\Exception\InvalidArgumentException If the controller returns invalid objects for `kirby`, `site`, `pages` or `page`
     	 */
    +	#[BlockCollectionAccess]
     	public function controller(
     		array $data = [],
     		string $contentType = 'html'
    @@ -423,6 +426,7 @@ public static function factory($props): static
     	 * @param array $options Options for `Kirby\Http\Uri` to create URL parts
     	 * @param int $code HTTP status code
     	 */
    +	#[BlockCollectionAccess]
     	public function go(array $options = [], int $code = 302): void
     	{
     		Response::go($this->url($options), $code);
    @@ -472,6 +476,7 @@ public function intendedTemplate(): Template
     	/**
     	 * Returns the inventory of files children and content files
     	 */
    +	#[BlockCollectionAccess]
     	public function inventory(): array
     	{
     		if ($this->inventory !== null) {
    @@ -768,6 +773,7 @@ public function isUnlisted(): bool
     	/**
     	 * Returns the absolute path to the media folder for the page
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaDir(): string
     	{
     		return $this->kirby()->root('media') . '/pages/' . $this->id();
    @@ -776,6 +782,7 @@ public function mediaDir(): string
     	/**
     	 * @see `::mediaDir`
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->mediaDir();
    @@ -887,6 +894,7 @@ public function permissions(): PagePermissions
     	 * Returns the preview URL with authentication for drafts and versions
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function previewUrl(VersionId|string $versionId = 'latest'): string|null
     	{
     		if ($this->permissions()->can('preview') !== true) {
    @@ -907,6 +915,7 @@ public function previewUrl(VersionId|string $versionId = 'latest'): string|null
     	 * @param \Kirby\Content\VersionId|string|null $versionId Optional override for the auto-detected version to render
     	 * @throws \Kirby\Exception\NotFoundException If the default template cannot be found
     	 */
    +	#[BlockCollectionAccess]
     	public function render(
     		array $data = [],
     		$contentType = 'html',
    @@ -1003,6 +1012,7 @@ public function render(
     	 * based on the token authentication in the current request
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function renderVersionFromRequest(): VersionId|null
     	{
     		$request = $this->kirby()->request();
    @@ -1037,6 +1047,7 @@ public function renderVersionFromRequest(): VersionId|null
     	/**
     	 * @throws \Kirby\Exception\NotFoundException If the content representation cannot be found
     	 */
    +	#[BlockCollectionAccess]
     	public function representation(mixed $type): Template
     	{
     		$kirby          = $this->kirby();
    @@ -1056,6 +1067,7 @@ public function representation(mixed $type): Template
     	 * Returns the absolute root to the page directory
     	 * No matter if it exists or not.
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root ??= $this->kirby()->root('content') . '/' . $this->diruri();
    @@ -1074,6 +1086,7 @@ protected function rules(): PageRules
     	/**
     	 * Search all pages within the current page
     	 */
    +	#[BlockCollectionAccess]
     	public function search(string|null $query = null, string|array $params = []): Pages
     	{
     		return $this->index()->search($query, $params);
    @@ -1190,6 +1203,7 @@ public function title(): Field
     	 * Converts the most important
     	 * properties to array
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    
  • src/Cms/Role.php+2 0 modified
    @@ -4,6 +4,7 @@
     
     use Kirby\Data\Data;
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     use Kirby\Toolkit\Str;
     use Stringable;
    @@ -148,6 +149,7 @@ public function isNobody(): bool
     		return $this->name() === 'nobody';
     	}
     
    +	#[BlockCollectionAccess]
     	public static function load(string $file, array $inject = []): static
     	{
     		$data = [
    
  • src/Cms/SiteActions.php+4 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Cms;
     
     use Closure;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * SiteActions
    @@ -40,6 +41,7 @@ protected function commit(
     	/**
     	 * Change the site title
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTitle(
     		string $title,
     		string|null $languageCode = null
    @@ -68,6 +70,7 @@ public function changeTitle(
     	/**
     	 * Creates a main page
     	 */
    +	#[BlockCollectionAccess]
     	public function createChild(array $props): Page
     	{
     		return Page::create([
    @@ -84,6 +87,7 @@ public function createChild(array $props): Page
     	 *
     	 * @return $this
     	 */
    +	#[BlockCollectionAccess]
     	public function purge(): static
     	{
     		parent::purge();
    
  • src/Cms/Site.php+9 0 modified
    @@ -8,6 +8,7 @@
     use Kirby\Filesystem\Dir;
     use Kirby\Panel\Site as Panel;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * The `$site` object is the root element
    @@ -150,6 +151,7 @@ public function __toString(): string
     	 * Returns the url to the api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		if ($relative === true) {
    @@ -245,6 +247,7 @@ public function homePageId(): string
     	 * Creates an inventory of all files
     	 * and children in the site directory
     	 */
    +	#[BlockCollectionAccess]
     	public function inventory(): array
     	{
     		if ($this->inventory !== null) {
    @@ -285,6 +288,7 @@ public function isAccessible(): bool
     	/**
     	 * Returns the absolute path to the media folder for the page
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaDir(): string
     	{
     		return $this->kirby()->root('media') . '/site';
    @@ -293,6 +297,7 @@ public function mediaDir(): string
     	/**
     	 * @see `::mediaDir`
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->mediaDir();
    @@ -374,6 +379,7 @@ public function permissions(): SitePermissions
     	 * Returns the preview URL with authentication for drafts and versions
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function previewUrl(VersionId|string $versionId = 'latest'): string|null
     	{
     		// the site previews the home page and thus needs to check permissions for it
    @@ -387,6 +393,7 @@ public function previewUrl(VersionId|string $versionId = 'latest'): string|null
     	/**
     	 * Returns the absolute path to the content directory
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root ??= $this->kirby()->root('content');
    @@ -405,6 +412,7 @@ protected function rules(): SiteRules
     	/**
     	 * Search all pages in the site
     	 */
    +	#[BlockCollectionAccess]
     	public function search(
     		string|null $query = null,
     		string|array $params = []
    @@ -476,6 +484,7 @@ public function urlForLanguage(
     	 * Sets the current page by id or page object and
     	 * returns the current page
     	 */
    +	#[BlockCollectionAccess]
     	public function visit(
     		string|Page $page,
     		string|null $languageCode = null
    
  • src/Cms/Translation.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Cms;
     
     use Kirby\Data\Data;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -105,6 +106,7 @@ public function id(): string
     	 * Loads the translation from the
     	 * json file in Kirby's translations folder
     	 */
    +	#[BlockCollectionAccess]
     	public static function load(
     		string $code,
     		string $root,
    
  • src/Cms/UserActions.php+14 0 modified
    @@ -12,6 +12,7 @@
     use Kirby\Filesystem\F;
     use Kirby\Http\Idn;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use SensitiveParameter;
     use Throwable;
    @@ -30,6 +31,7 @@ trait UserActions
     	/**
     	 * Changes the user email address
     	 */
    +	#[BlockCollectionAccess]
     	public function changeEmail(string $email): static
     	{
     		$email = trim($email);
    @@ -45,6 +47,7 @@ public function changeEmail(string $email): static
     	/**
     	 * Changes the user language
     	 */
    +	#[BlockCollectionAccess]
     	public function changeLanguage(string $language): static
     	{
     		return $this->commit('changeLanguage', ['user' => $this, 'language' => $language], function ($user, $language) {
    @@ -58,6 +61,7 @@ public function changeLanguage(string $language): static
     	/**
     	 * Changes the screen name of the user
     	 */
    +	#[BlockCollectionAccess]
     	public function changeName(string $name): static
     	{
     		$name = trim($name);
    @@ -76,6 +80,7 @@ public function changeName(string $name): static
     	 * If this method is used with user input, it is recommended to also
     	 * confirm the current password by the user via `::validatePassword()`
     	 */
    +	#[BlockCollectionAccess]
     	public function changePassword(
     		#[SensitiveParameter]
     		string $password
    @@ -101,6 +106,7 @@ public function changePassword(
     	/**
     	 * Changes the user role
     	 */
    +	#[BlockCollectionAccess]
     	public function changeRole(string $role): static
     	{
     		return $this->commit('changeRole', ['user' => $this, 'role' => $role], function ($user, $role) {
    @@ -115,6 +121,7 @@ public function changeRole(string $role): static
     	 * Changes the user's TOTP secret
     	 * @since 4.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function changeTotp(
     		#[SensitiveParameter]
     		string|null $secret
    @@ -166,6 +173,7 @@ protected function commit(
     	/**
     	 * Creates a new User from the given props and returns a new User object
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(array $props): User
     	{
     		$input = $props;
    @@ -217,6 +225,7 @@ public static function create(array $props): User
     	/**
     	 * Creates a new avatar for the user
     	 */
    +	#[BlockCollectionAccess]
     	public function createAvatar(string $source, string $extension, bool $move = false): static
     	{
     		return $this->commit('createAvatar', ['user' => $this, 'source' => $source, 'extension' => $extension], function ($user, $source, $extension) use ($move) {
    @@ -236,6 +245,7 @@ public function createAvatar(string $source, string $extension, bool $move = fal
     	/**
     	 * Returns a random user id
     	 */
    +	#[BlockCollectionAccess]
     	public function createId(): string
     	{
     		$length = 8;
    @@ -260,6 +270,7 @@ public function createId(): string
     	 *
     	 * @throws \Kirby\Exception\LogicException
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(): bool
     	{
     		return $this->commit('delete', ['user' => $this], function ($user) {
    @@ -292,6 +303,7 @@ public function delete(): bool
     	/**
     	 * Deletes the existing avatar if it exists
     	 */
    +	#[BlockCollectionAccess]
     	public function deleteAvatar(): bool
     	{
     		return $this->commit('deleteAvatar', ['user' => $this], function ($user) {
    @@ -379,6 +391,7 @@ protected function readSecrets(): array
     	/**
     	 * Replaces the existing avatar for the user
     	 */
    +	#[BlockCollectionAccess]
     	public function replaceAvatar(string $source, string $extension, bool $move = false): static
     	{
     		return $this->commit('replaceAvatar', ['user' => $this, 'source' => $source, 'extension' => $extension], function ($user, $source, $extension) use ($move) {
    @@ -422,6 +435,7 @@ public function replaceAvatar(string $source, string $extension, bool $move = fa
     	/**
     	 * Updates the user data
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array|null $input = null,
     		string|null $languageCode = null,
    
  • src/Cms/User.php+15 0 modified
    @@ -12,6 +12,7 @@
     use Kirby\Filesystem\F;
     use Kirby\Panel\User as Panel;
     use Kirby\Session\Session;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use SensitiveParameter;
     
    @@ -136,6 +137,7 @@ public function __debugInfo(): array
     	 * Returns the url to the api endpoint
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function apiUrl(bool $relative = false): string
     	{
     		if ($relative === true) {
    @@ -229,6 +231,7 @@ public static function factory(mixed $props): static
     	 * Hashes the provided password unless it is `null`,
     	 * which will leave it as `null`
     	 */
    +	#[BlockCollectionAccess]
     	public static function hashPassword(
     		#[SensitiveParameter]
     		string|null $password = null
    @@ -262,6 +265,7 @@ public function id(): string
     	 * Returns the inventory of files
     	 * children and content files
     	 */
    +	#[BlockCollectionAccess]
     	public function inventory(): array
     	{
     		if ($this->inventory !== null) {
    @@ -381,6 +385,7 @@ public function language(): string
     	 *
     	 * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in
     	 */
    +	#[BlockCollectionAccess]
     	public function login(
     		#[SensitiveParameter]
     		string $password,
    @@ -397,6 +402,7 @@ public function login(
     	 *
     	 * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in
     	 */
    +	#[BlockCollectionAccess]
     	public function loginPasswordless(
     		Session|array|null $session = null
     	): void {
    @@ -434,6 +440,7 @@ public function loginPasswordless(
     	 *
     	 * @param \Kirby\Session\Session|array|null $session Session options or session object to unset the user in
     	 */
    +	#[BlockCollectionAccess]
     	public function logout(Session|array|null $session = null): void
     	{
     		$kirby   = $this->kirby();
    @@ -464,6 +471,7 @@ public function logout(Session|array|null $session = null): void
     	/**
     	 * Returns the absolute path to the media folder for the user
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaDir(): string
     	{
     		return $this->kirby()->root('media') . '/users/' . $this->id();
    @@ -472,6 +480,7 @@ public function mediaDir(): string
     	/**
     	 * @see `::mediaDir`
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->mediaDir();
    @@ -543,6 +552,7 @@ public function panel(): Panel
     	/**
     	 * Returns the encrypted user password
     	 */
    +	#[BlockCollectionAccess]
     	public function password(): string|null
     	{
     		return $this->password ??= $this->readPassword();
    @@ -552,6 +562,7 @@ public function password(): string|null
     	 * Returns the timestamp when the password
     	 * was last changed
     	 */
    +	#[BlockCollectionAccess]
     	public function passwordTimestamp(): int|null
     	{
     		$file = $this->secretsFile();
    @@ -642,6 +653,7 @@ public function roles(): Roles
     	/**
     	 * The absolute path to the user directory
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->kirby()->root('accounts') . '/' . $this->id();
    @@ -660,6 +672,7 @@ protected function rules(): UserRules
     	 * Reads a specific secret from the user secrets file on disk
     	 * @since 4.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function secret(string $key): mixed
     	{
     		return $this->readSecrets()[$key] ?? null;
    @@ -711,6 +724,7 @@ protected function siblingsCollection(): Users
     	 * Converts the most important user properties
     	 * to an array
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    @@ -761,6 +775,7 @@ public function username(): string|null
     	 * @throws \Kirby\Exception\InvalidArgumentException If the entered password is not valid
     	 *                                                   or does not match the user password
     	 */
    +	#[BlockCollectionAccess]
     	public function validatePassword(
     		#[SensitiveParameter]
     		string|null $password = null
    
  • src/Cms/Users.php+8 1 modified
    @@ -7,6 +7,7 @@
     use Kirby\Filesystem\Dir;
     use Kirby\Filesystem\F;
     use Kirby\Toolkit\Str;
    +use Kirby\Toolkit\V;
     use Kirby\Uuid\HasUuids;
     
     /**
    @@ -159,11 +160,17 @@ protected function hydrateElement(string $key): User|null
     			return null;
     		}
     
    +		// ensure the user ID only contains safe characters
    +		// to prevent path traversal into subfolders
    +		if (V::match($key, '/^([a-z0-9_-])+$/i') !== true) {
    +			return null;
    +		}
    +
     		// check if the user directory exists if not all keys have been
     		// populated in the collection, otherwise we can assume that
     		// this method will only be called on "unhydrated" user IDs
     		$root = $this->root . '/' . $key;
    -		if ($this->initialized === false && is_dir($root) === false) {
    +		if ($this->initialized === false && Dir::exists($root, $this->root) === false) {
     			return null;
     		}
     
    
  • src/Content/Lock.php+4 2 modified
    @@ -208,13 +208,15 @@ public function modified(
     	 */
     	public function toArray(): array
     	{
    +		$user = $this->user?->isListable() === true ? $this->user : null;
    +
     		return [
     			'isLegacy' => $this->isLegacy(),
     			'isLocked' => $this->isLocked(),
     			'modified' => $this->modified('c', 'date'),
     			'user'     => [
    -				'id'    => $this->user?->id(),
    -				'email' => $this->user?->email()
    +				'id'    => $user?->id(),
    +				'email' => $user?->email()
     			]
     		];
     	}
    
  • src/Content/PlainTextStorage.php+44 0 modified
    @@ -261,6 +261,48 @@ public function modified(VersionId $versionId, Language $language): int|null
     		return null;
     	}
     
    +	/**
    +	 * Prevents stale page instances from recreating moved page directories.
    +	 *
    +	 * During a concurrent reorder, a page directory may be moved while another
    +	 * stale page instance still points to the old root path. If that stale
    +	 * instance writes content afterwards, the write path may recreate the missing
    +	 * directory and produce a ghost or duplicate page folder.
    +	 *
    +	 * To avoid that, this method re-resolves the page from the current app state
    +	 * when the expected root no longer exists. If the page was moved in the
    +	 * meantime, the write is aborted instead of recreating the old path.
    +	 */
    +	protected function ensureCurrentPageRoot(VersionId $versionId): void
    +	{
    +		if (
    +			$this->model instanceof Page === false ||
    +			$versionId->is('latest') === false
    +		) {
    +			return;
    +		}
    +
    +		$staleRoot = $this->contentDirectory($versionId);
    +
    +		if (is_dir($staleRoot) === true) {
    +			return;
    +		}
    +
    +		// `$this->model` may still point to a stale root after a concurrent move,
    +		// so we re-resolve the page from the current app state.
    +		$currentPage = $this->model->kirby()->page($this->model->id());
    +
    +		if (
    +			$currentPage !== null &&
    +			$currentPage->root() !== null &&
    +			$currentPage->root() !== $staleRoot
    +		) {
    +			throw new LogicException(
    +				message: 'The page was moved during a concurrent operation. Please retry the write with a fresh page instance.'
    +			);
    +		}
    +	}
    +
     	/**
     	 * Returns the stored content fields
     	 *
    @@ -319,6 +361,8 @@ protected function write(VersionId $versionId, Language $language, array $fields
     			return;
     		}
     
    +		$this->ensureCurrentPageRoot($versionId);
    +
     		$success = Data::write($this->contentFile($versionId, $language), $fields);
     
     		// @codeCoverageIgnoreStart
    
  • src/Content/Translation.php+3 0 modified
    @@ -6,6 +6,7 @@
     use Kirby\Cms\Language;
     use Kirby\Cms\ModelWithContent;
     use Kirby\Exception\Exception;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * Each page, file or site can have multiple
    @@ -62,6 +63,7 @@ public function content(): array
     	 *
     	 * @deprecated 5.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFile(): string
     	{
     		Helpers::deprecated('`$translation->contentFile()` has been deprecated. Please let us know if you have a use case for a replacement.', 'translation-methods');
    @@ -74,6 +76,7 @@ public function contentFile(): string
     	 * @todo Needs to be refactored as soon as Version::create becomes static
     	 * 		 (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
     	 */
    +	#[BlockCollectionAccess]
     	public static function create(
     		ModelWithContent $model,
     		Version $version,
    
  • src/Content/Version.php+14 0 modified
    @@ -11,6 +11,7 @@
     use Kirby\Exception\NotFoundException;
     use Kirby\Form\Fields;
     use Kirby\Http\Uri;
    +use Kirby\Toolkit\BlockCollectionAccess;
     
     /**
      * The Version class handles all actions for a single
    @@ -71,6 +72,7 @@ public function content(Language|string $language = 'default'): Content
     	 *
     	 * @internal
     	 */
    +	#[BlockCollectionAccess]
     	public function contentFile(Language|string $language = 'default'): string
     	{
     		return $this->model->storage()->contentFile(
    @@ -94,6 +96,7 @@ protected function convertFieldNamesToLowerCase(array $fields): array
     	 *
     	 * @param array<string, string> $fields Content fields
     	 */
    +	#[BlockCollectionAccess]
     	public function create(
     		array $fields,
     		Language|string $language = 'default'
    @@ -121,6 +124,7 @@ public function create(
     	/**
     	 * Deletes a version for a specific language
     	 */
    +	#[BlockCollectionAccess]
     	public function delete(Language|string $language = 'default'): void
     	{
     		if ($language === '*') {
    @@ -267,6 +271,7 @@ public function isValid(Language|string $language = 'default'): bool
     	/**
     	 * Returns the lock object for the version
     	 */
    +	#[BlockCollectionAccess]
     	public function lock(Language|string $language = 'default'): Lock
     	{
     		return Lock::for($this, $language);
    @@ -302,6 +307,7 @@ public function modified(
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function move(
     		Language|string $fromLanguage,
     		VersionId|null $toVersionId = null,
    @@ -396,6 +402,7 @@ protected function prepareFieldsAfterRead(array $fields, Language $language): ar
     	 * of draft and version previews
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function previewToken(): string
     	{
     		if ($this->model instanceof Site) {
    @@ -446,6 +453,7 @@ protected function previewTokenFromUrl(string $url): string
     	 * It will copy all fields over to the "latest" version and delete
     	 * this version afterwards.
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(Language|string $language = 'default'): void
     	{
     		$language = Language::ensure($language);
    @@ -482,6 +490,7 @@ public function publish(Language|string $language = 'default'): void
     	 *
     	 * @return array<string, string>|null
     	 */
    +	#[BlockCollectionAccess]
     	public function read(Language|string $language = 'default'): array|null
     	{
     		$language = Language::ensure($language);
    @@ -514,6 +523,7 @@ public function read(Language|string $language = 'default'): array|null
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function replace(
     		array $fields,
     		Language|string $language = 'default'
    @@ -537,6 +547,7 @@ public function replace(
     	/**
     	 * Convenience wrapper around ::create, ::replace and ::update.
     	 */
    +	#[BlockCollectionAccess]
     	public function save(
     		array $fields,
     		Language|string $language = 'default',
    @@ -571,6 +582,7 @@ public function sibling(VersionId|string $id): Version
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function touch(Language|string $language = 'default'): void
     	{
     		$language = Language::ensure($language);
    @@ -587,6 +599,7 @@ public function touch(Language|string $language = 'default'): void
     	 *
     	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
     	 */
    +	#[BlockCollectionAccess]
     	public function update(
     		array $fields,
     		Language|string $language = 'default'
    @@ -618,6 +631,7 @@ public function update(
     	 * Returns the preview URL with authentication for drafts and versions
     	 * @unstable
     	 */
    +	#[BlockCollectionAccess]
     	public function url(): string|null
     	{
     		if (
    
  • src/Form/Field/BlocksField.php+2 0 modified
    @@ -16,6 +16,7 @@
     use Kirby\Form\Mixin\EmptyState;
     use Kirby\Form\Mixin\Max;
     use Kirby\Form\Mixin\Min;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Throwable;
     
    @@ -108,6 +109,7 @@ public function fieldsetGroups(): array|null
     	 * @psalm-suppress MethodSignatureMismatch
     	 * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$value  = BlocksCollection::parse($value);
    
  • src/Form/FieldClass.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Form;
     
     use Kirby\Cms\HasSiblings;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\I18n;
     
     /**
    @@ -165,6 +166,7 @@ public function props(): array
     	 * @since 5.2.0
     	 * @todo Move to `Value` mixin once array-based fields are unsupported
     	 */
    +	#[BlockCollectionAccess]
     	public function reset(): static
     	{
     		$this->value = $this->emptyValue();
    
  • src/Form/Field/EntriesField.php+2 0 modified
    @@ -10,6 +10,7 @@
     use Kirby\Form\Mixin\Max;
     use Kirby\Form\Mixin\Min;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -58,6 +59,7 @@ public function fieldProps(): array
     	 * @psalm-suppress MethodSignatureMismatch
     	 * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$this->value = Data::decode($value ?? '', 'yaml');
    
  • src/Form/Field/LayoutField.php+2 0 modified
    @@ -11,6 +11,7 @@
     use Kirby\Data\Json;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Form\Form;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Throwable;
     
    @@ -34,6 +35,7 @@ public function __construct(array $params)
     	 * @psalm-suppress MethodSignatureMismatch
     	 * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$attrs   = $this->attrsForm();
    
  • src/Form/Field.php+3 0 modified
    @@ -5,6 +5,7 @@
     use Closure;
     use Kirby\Cms\HasSiblings;
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Component;
     use Kirby\Toolkit\I18n;
     
    @@ -284,6 +285,7 @@ public static function factory(
     	/**
     	 * Sets a new value for the field
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		// remember the current state to restore it afterwards
    @@ -316,6 +318,7 @@ public function fill(mixed $value): static
     	 *
     	 * @since 5.2.0
     	 */
    +	#[BlockCollectionAccess]
     	public function fillWithEmptyValue(): static
     	{
     		$this->value = $this->emptyValue();
    
  • src/Form/Mixin/Value.php+4 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Form\Mixin;
     
     use Kirby\Cms\Language;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use ReflectionProperty;
     
     /**
    @@ -63,6 +64,7 @@ public function emptyValue(): mixed
     	/**
     	 * Sets a new value for the field
     	 */
    +	#[BlockCollectionAccess]
     	public function fill(mixed $value): static
     	{
     		$this->value = $value;
    @@ -180,6 +182,7 @@ protected function setDefault(mixed $default = null): void
     	 *
     	 * @since 5.0.0
     	 */
    +	#[BlockCollectionAccess]
     	public function submit(mixed $value): static
     	{
     		return $this->fill($value);
    @@ -217,6 +220,7 @@ public function toStoredValue(): mixed
     	 * If you need the form value with the default as fallback, you should use
     	 * the fill method first `$field->fill($field->default())->toFormValue()`
     	 */
    +	#[BlockCollectionAccess]
     	public function value(bool $default = false): mixed
     	{
     		if ($default === true && $this->isEmpty() === true) {
    
  • src/Http/Url.php+27 3 modified
    @@ -111,18 +111,42 @@ public static function index(array $props = []): string
     		return Uri::index($props)->toString();
     	}
     
    +	/**
    +	 * Checks if a URL starts with a dangerous URI scheme
    +	 * (e.g. javascript:) that must never appear in rendered href
    +	 * or src attributes.
    +	 * @since 5.4.1
    +	 */
    +	public static function hasDangerousScheme(string|null $url = null): bool
    +	{
    +		if ($url === null) {
    +			return false;
    +		}
    +
    +		// strip any weird characters to prevent bypass attempts,
    +		// keeping only the characters we test for below
    +		// (especially removes any whitespace that the browser would ignore
    +		// when the resulting URL is evaluated)
    +		$url = preg_replace('/[^a-z:]/i', '', $url);
    +
    +		// try to find a match from the blocklist case-insensitively
    +		return preg_match('!^(?:javascript|vbscript|livescript|mocha|jar|data):!i', $url) === 1;
    +	}
    +
     	/**
     	 * Checks if an URL is absolute
     	 */
     	public static function isAbsolute(string|null $url = null): bool
     	{
    +		if ($url === null || static::hasDangerousScheme($url) === true) {
    +			return false;
    +		}
    +
     		// matches the following groups of URLs:
     		//  //example.com/uri
     		//  http://example.com/uri, https://example.com/uri, ftp://example.com/uri
     		//  mailto:example@example.com, geo:49.0158,8.3239?z=11
    -		return
    -			$url !== null &&
    -			preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1;
    +		return preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1;
     	}
     
     	/**
    
  • src/Image/Darkroom/Imagick.php+3 1 modified
    @@ -181,9 +181,11 @@ protected function resize(Image $image, array $options): Image
     			}
     		}
     
    -		$image->thumbnailImage(
    +		$image->resizeImage(
     			$options['width'],
     			$options['height'],
    +			Image::FILTER_LANCZOS,
    +			1,
     			true
     		);
     
    
  • src/Parsley/Inline.php+8 1 modified
    @@ -6,6 +6,7 @@
     use DOMNode;
     use DOMNodeList;
     use DOMText;
    +use Kirby\Http\Url;
     use Kirby\Toolkit\Html;
     
     /**
    @@ -62,10 +63,16 @@ public static function parseAttrs(
     		$defaults = $mark['defaults'] ?? [];
     
     		foreach ($mark['attrs'] ?? [] as $attr) {
    -			$attrs[$attr] = match ($node->hasAttribute($attr)) {
    +			$value = match ($node->hasAttribute($attr)) {
     				true    => $node->getAttribute($attr),
     				default => $defaults[$attr] ?? null
     			};
    +
    +			if ($attr === 'href' && Url::hasDangerousScheme($value) === true) {
    +				continue;
    +			}
    +
    +			$attrs[$attr] = $value;
     		}
     
     		return $attrs;
    
  • src/Parsley/Schema/Blocks.php+5 0 modified
    @@ -4,6 +4,7 @@
     
     use DOMElement;
     use DOMText;
    +use Kirby\Http\Url;
     use Kirby\Parsley\Element;
     use Kirby\Toolkit\Str;
     
    @@ -155,6 +156,10 @@ public function img(Element $node): array
     		$figcaption = $node->find('ancestor::figure[1]//figcaption');
     		$caption    = $figcaption?->innerHTML($this->marks());
     
    +		if (Url::hasDangerousScheme($link) === true) {
    +			$link = null;
    +		}
    +
     		// avoid parsing the caption twice
     		$figcaption?->remove();
     
    
  • src/Plugin/Asset.php+6 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Plugin;
     
     use Kirby\Filesystem\F;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Stringable;
     
     /**
    @@ -38,6 +39,7 @@ public function filename(): string
     	/**
     	 * Create a unique media hash
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaHash(): string
     	{
     		return crc32($this->filename()) . '-' . $this->modified();
    @@ -46,6 +48,7 @@ public function mediaHash(): string
     	/**
     	 * Absolute path to the asset file in the media folder
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->plugin()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->path();
    @@ -81,6 +84,7 @@ public function plugin(): Plugin
     	 * Publishes the asset file to the plugin's media folder
     	 * by creating a symlink
     	 */
    +	#[BlockCollectionAccess]
     	public function publish(): void
     	{
     		F::link($this->root(), $this->mediaRoot(), 'symlink');
    @@ -92,6 +96,7 @@ public function publish(): void
     	 * @deprecated 4.0.0
     	 * @codeCoverageIgnore
     	 */
    +	#[BlockCollectionAccess]
     	public function publishAt(string $path): void
     	{
     		F::link(
    @@ -101,6 +106,7 @@ public function publishAt(string $path): void
     		);
     	}
     
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root;
    
  • src/Plugin/Plugin.php+5 0 modified
    @@ -10,6 +10,7 @@
     use Kirby\Data\Data;
     use Kirby\Exception\InvalidArgumentException;
     use Kirby\Toolkit\A;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     use Kirby\Toolkit\V;
     use Throwable;
    @@ -189,6 +190,7 @@ public function license(): License
     	/**
     	 * Returns the path to the plugin's composer.json
     	 */
    +	#[BlockCollectionAccess]
     	public function manifest(): string
     	{
     		return $this->root() . '/composer.json';
    @@ -197,6 +199,7 @@ public function manifest(): string
     	/**
     	 * Returns the root where plugin assets are copied to
     	 */
    +	#[BlockCollectionAccess]
     	public function mediaRoot(): string
     	{
     		return $this->kirby()->root('media') . '/plugins/' . $this->name();
    @@ -237,6 +240,7 @@ public function prefix(): string
     	/**
     	 * Returns the root where the plugin files are stored
     	 */
    +	#[BlockCollectionAccess]
     	public function root(): string
     	{
     		return $this->root;
    @@ -245,6 +249,7 @@ public function root(): string
     	/**
     	 * Returns all available plugin metadata
     	 */
    +	#[BlockCollectionAccess]
     	public function toArray(): array
     	{
     		return [
    
  • src/Query/Argument.php+2 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Query;
     
     use Closure;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -100,6 +101,7 @@ public static function factory(string $argument): static
     	 * Return the argument value and
     	 * resolves nested objects to scaler types
     	 */
    +	#[BlockCollectionAccess]
     	public function resolve(array|object $data = []): mixed
     	{
     		// don't resolve the Closure immediately, instead
    
  • src/Query/Segment.php+3 0 modified
    @@ -5,6 +5,7 @@
     use Closure;
     use Kirby\Exception\BadMethodCallException;
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Str;
     
     /**
    @@ -38,6 +39,7 @@ public function __construct(
     	 *
     	 * @throws \Kirby\Exception\BadMethodCallException
     	 */
    +	#[BlockCollectionAccess]
     	public static function error(
     		mixed $data,
     		string $name,
    @@ -85,6 +87,7 @@ public static function factory(
     	 *
     	 * @param mixed $base Current value of the query chain
     	 */
    +	#[BlockCollectionAccess]
     	public function resolve(mixed $base = null, array|object $data = []): mixed
     	{
     		// resolve arguments to array
    
  • src/Sane/Sane.php+17 0 modified
    @@ -136,6 +136,23 @@ public static function sanitizeFile(
     		}
     	}
     
    +	/**
    +	 * Sanitizes the given string from ProseMirror-backed fields
    +	 * @since 5.4.1
    +	 */
    +	public static function sanitizeProseMirrorFields(
    +		string $string
    +	): string {
    +		$string = static::sanitize($string, 'html');
    +
    +		// convert non-breaking spaces to HTML entity
    +		// as that's how ProseMirror handles it internally;
    +		// will allow comparing saved and current content
    +		$string = str_replace(' ', '&nbsp;', $string);
    +
    +		return $string;
    +	}
    +
     	/**
     	 * Validates file contents with the specified handler
     	 *
    
  • src/Toolkit/BlockCollectionAccess.php+22 0 added
    @@ -0,0 +1,22 @@
    +<?php
    +
    +namespace Kirby\Toolkit;
    +
    +use Attribute;
    +
    +/**
    + * Marks a method as blocked from collection operations such as
    + * filterBy/sortBy/group/pluck/findBy to prevent sensitive data
    + * exposure (e.g. password hashes) or unintended write actions
    + * through queries driven by user input.
    + *
    + * @package   Kirby Toolkit
    + * @author    Bastian Allgeier <bastian@getkirby.com>
    + * @link      https://getkirby.com
    + * @copyright Bastian Allgeier
    + * @license   https://getkirby.com/license
    + */
    +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
    +class BlockCollectionAccess
    +{
    +}
    
  • src/Toolkit/Collection.php+57 7 modified
    @@ -4,6 +4,10 @@
     
     use Closure;
     use Exception;
    +use InvalidArgumentException;
    +use Kirby\Cms\App;
    +use ReflectionFunction;
    +use ReflectionMethod;
     use Stringable;
     
     /**
    @@ -413,7 +417,7 @@ public function findByKey(string $key)
     	/**
     	 * Returns the first element
     	 *
    -	 * @return TValue
    +	 * @return TValue|null
     	 */
     	public function first()
     	{
    @@ -452,7 +456,7 @@ public function get(string $key, mixed $default = null)
     	public function getAttribute(
     		array|object $item,
     		string $attribute,
    -		bool $split = false,
    +		bool|string $split = false,
     		$related = null
     	) {
     		$value = $this->{'getAttributeFrom' . gettype($item)}(
    @@ -478,10 +482,56 @@ protected function getAttributeFromArray(
     		return $array[$attribute] ?? null;
     	}
     
    +	/**
    +	 * Blocks access to methods that are marked with the
    +	 * #[BlockCollectionAccess] attribute to prevent sensitive data
    +	 * exposure (e.g. password hashes) or unintended write actions
    +	 * through collection operations driven by user input.
    +	 *
    +	 * This applies to both explicit PHP methods and closures registered
    +	 * via HasMethods::$methods. Attributes resolved via __call() that
    +	 * have no matching PHP method or registered closure (i.e. content
    +	 * fields) are always allowed through.
    +	 */
     	protected function getAttributeFromObject(
     		object $object,
     		string $attribute
     	): mixed {
    +		static $cache = [];
    +		$key = $object::class . '::' . strtolower($attribute);
    +
    +		if (isset($cache[$key]) === false) {
    +			if (method_exists($object, $attribute) === true) {
    +				// explicit PHP method: check for #[BlockCollectionAccess] via reflection
    +				$cache[$key] = (new ReflectionMethod($object, $attribute))
    +					->getAttributes(BlockCollectionAccess::class) === [];
    +			} elseif (method_exists($object, 'hasMethod') === true && $object->hasMethod($attribute) === true) {
    +				// closure registered via HasMethods::$methods: check the closure's attributes
    +				$closure = $object->getMethod($attribute);
    +				$cache[$key] = $closure === null ||
    +					(new ReflectionFunction($closure))
    +						->getAttributes(BlockCollectionAccess::class) === [];
    +			} else {
    +				// no PHP method and no registered closure (e.g. a CMS content field
    +				// resolved via __call()): always allow through
    +				$cache[$key] = true;
    +			}
    +		}
    +
    +		if ($cache[$key] === false) {
    +			// throw in debug mode so developers get a clear signal instead of a silent null
    +			if (
    +				class_exists('Kirby\Cms\App', false) === true &&
    +				App::instance(lazy: true)?->option('debug') === true
    +			) {
    +				throw new InvalidArgumentException(
    +					'The "' . $attribute . '" method is not accessible in collection operations.'
    +				);
    +			}
    +
    +			return null;
    +		}
    +
     		return $object->{$attribute}();
     	}
     
    @@ -640,7 +690,7 @@ public function join(
     	/**
     	 * Returns the last element
     	 *
    -	 * @return TValue
    +	 * @return TValue|null
     	 */
     	public function last()
     	{
    @@ -1177,7 +1227,7 @@ public function without(string ...$keys): static
     	Collection $collection,
     	string $field,
     	$test,
    -	bool $split = false
    +	bool|string $split = false
     ): Collection {
     	foreach ($collection->data as $key => $item) {
     		$value = $collection->getAttribute($item, $field, $split, $test);
    @@ -1201,7 +1251,7 @@ public function without(string ...$keys): static
     	Collection $collection,
     	string $field,
     	$test,
    -	bool $split = false
    +	bool|string $split = false
     ): Collection {
     	foreach ($collection->data as $key => $item) {
     		$value = $collection->getAttribute($item, $field, $split, $test);
    @@ -1237,15 +1287,15 @@ public function without(string ...$keys): static
      * Contains Filter
      */
     Collection::$filters['*='] = [
    -	'validator' => fn ($value, $test) => str_contains($value, $test) === true,
    +	'validator' => fn ($value, $test) => $value !== null && str_contains($value, $test) === true,
     	'strict'    => false
     ];
     
     /**
      * Not Contains Filter
      */
     Collection::$filters['!*='] = [
    -	'validator' => fn ($value, $test) => str_contains($value, $test) === false
    +	'validator' => fn ($value, $test) => $value === null || str_contains($value, $test) === false
     ];
     
     /**
    
  • src/Toolkit/Html.php+4 0 modified
    @@ -360,6 +360,10 @@ public static function link(
     		string|array|null $text = null,
     		array $attr = []
     	): string {
    +		if (Url::hasDangerousScheme($href) === true) {
    +			$href = '';
    +		}
    +
     		$attr = ['href' => $href, ...$attr];
     
     		if (empty($text) === true) {
    
  • tests/Api/CollectionTest.php+95 0 modified
    @@ -116,4 +116,99 @@ public function testToResponse(): void
     			'limit'  => 100
     		], $result['pagination']);
     	}
    +
    +	public function testToResponseIgnoresFilterByInQuery(): void
    +	{
    +		$api = new Api([
    +			'models' => [
    +				'test' => [
    +					'type'   => Page::class,
    +					'fields' => [
    +						'value' => fn ($model) => $model->slug()
    +					]
    +				]
    +			],
    +			'requestData' => [
    +				'query' => [
    +					'query' => [
    +						'filterBy' => [
    +							['field' => 'slug', 'operator' => '==', 'value' => 'a']
    +						]
    +					]
    +				]
    +			]
    +		]);
    +		$collection = new Collection($api, new Pages([
    +			new Page(['slug' => 'a']),
    +			new Page(['slug' => 'b']),
    +			new Page(['slug' => 'c']),
    +		]), [
    +			'model' => 'test'
    +		]);
    +
    +		// filterBy is stripped; all three pages are returned
    +		$this->assertCount(3, $collection->toResponse()['data']);
    +	}
    +
    +	public function testToResponseIgnoresSortByInQuery(): void
    +	{
    +		$api = new Api([
    +			'models' => [
    +				'test' => [
    +					'type'   => Page::class,
    +					'fields' => [
    +						'value' => fn ($model) => $model->slug()
    +					]
    +				]
    +			],
    +			'requestData' => [
    +				'query' => [
    +					'query' => [
    +						'sortBy' => 'slug desc'
    +					]
    +				]
    +			]
    +		]);
    +		$collection = new Collection($api, new Pages([
    +			new Page(['slug' => 'a']),
    +			new Page(['slug' => 'b']),
    +		]), [
    +			'model' => 'test'
    +		]);
    +
    +		// sortBy is stripped; original order (a, b) is preserved
    +		$result = $collection->toResponse()['data'];
    +		$this->assertSame('a', $result[0]['value']);
    +		$this->assertSame('b', $result[1]['value']);
    +	}
    +
    +	public function testToResponseRespectsLimitInQuery(): void
    +	{
    +		$api = new Api([
    +			'models' => [
    +				'test' => [
    +					'type'   => Page::class,
    +					'fields' => [
    +						'value' => fn ($model) => $model->slug()
    +					]
    +				]
    +			],
    +			'requestData' => [
    +				'query' => [
    +					'query' => [
    +						'limit' => 2
    +					]
    +				]
    +			]
    +		]);
    +		$collection = new Collection($api, new Pages([
    +			new Page(['slug' => 'a']),
    +			new Page(['slug' => 'b']),
    +			new Page(['slug' => 'c']),
    +		]), [
    +			'model' => 'test'
    +		]);
    +
    +		$this->assertCount(2, $collection->toResponse()['data']);
    +	}
     }
    
  • tests/Api/UploadTest.php+95 4 modified
    @@ -49,12 +49,14 @@ protected function api(array $props = []): Api
     	protected function upload(
     		array $api = [],
     		bool $single = true,
    -		bool $debug = false
    +		bool $debug = false,
    +		string|null $template = null
     	): Upload {
     		return new Upload(
    -			api:    $this->api($api),
    -			single: $single,
    -			debug:  $debug
    +			api:      $this->api($api),
    +			single:   $single,
    +			debug:    $debug,
    +			template: $template
     		);
     	}
     
    @@ -589,6 +591,95 @@ public function testValidateChunkTooLargeTotal(): void
     		$upload->processChunk($source, basename($source));
     	}
     
    +	public function testValidateChunkWithTemplate(): void
    +	{
    +		$source = static::TMP . '/a.md';
    +		$this->app->clone([
    +			'blueprints' => [
    +				'files/test' => [
    +					'name'   => 'test',
    +					'accept' => ['maxsize' => 100]
    +				]
    +			]
    +		]);
    +		$upload = $this->upload([
    +			'requestData' => [
    +				'headers' => [
    +					'Upload-Length' => 120,
    +					'Upload-Offset' => 0,
    +					'Upload-Id'     => 'abcd'
    +				]
    +			]
    +		], template: 'test');
    +
    +		$this->expectException(InvalidArgumentException::class);
    +		$this->expectExceptionCode('error.file.maxsize');
    +		$upload->processChunk($source, basename($source));
    +	}
    +
    +	public function testValidateChunkWithCustomTemplate(): void
    +	{
    +		$source = static::TMP . '/a.md';
    +		F::write($source, 'abcdef');
    +
    +		$this->app->clone([
    +			'blueprints' => [
    +				'files/default' => [
    +					'name'   => 'default',
    +					'accept' => ['maxsize' => 100]
    +				],
    +				'files/custom' => [
    +					'name'   => 'custom',
    +					'accept' => ['maxsize' => 200]
    +				]
    +			]
    +		]);
    +		$upload = $this->upload([
    +			'requestData' => [
    +				'headers' => [
    +					'Upload-Length' => 120,
    +					'Upload-Offset' => 0,
    +					'Upload-Id'     => 'abcd'
    +				]
    +			]
    +		], template: 'custom');
    +
    +		$this->assertNull($upload->processChunk($source, basename($source)));
    +	}
    +
    +	public function testValidateChunkTemplatePriority(): void
    +	{
    +		$source = static::TMP . '/a.md';
    +		$this->app->clone([
    +			'blueprints' => [
    +				'files/large' => [
    +					'name'   => 'large',
    +					'accept' => ['maxsize' => 200]
    +				],
    +				'files/small' => [
    +					'name'   => 'small',
    +					'accept' => ['maxsize' => 100]
    +				]
    +			]
    +		]);
    +		$upload = $this->upload([
    +			'requestData' => [
    +				'body' => [
    +					'template' => 'large'
    +				],
    +				'headers' => [
    +					'Upload-Length' => 120,
    +					'Upload-Offset' => 0,
    +					'Upload-Id'     => 'abcd'
    +				]
    +			]
    +		], template: 'small');
    +
    +		$this->expectException(InvalidArgumentException::class);
    +		$this->expectExceptionCode('error.file.maxsize');
    +		$upload->processChunk($source, basename($source));
    +	}
    +
     	public function testValidateChunkTooLargeCurrentChunk(): void
     	{
     		$dir    = static::TMP . '/site/cache/.uploads';
    
  • tests/Cms/Api/routes/PagesRoutesTest.php+134 0 modified
    @@ -279,4 +279,138 @@ public function testFile(): void
     
     		$this->assertSame('a.jpg', $response['data']['filename']);
     	}
    +
    +	public function testChildrenSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'     => 'parent',
    +						'children' => [
    +							[
    +								'slug'    => 'photography',
    +								'content' => ['title' => 'Photography']
    +							],
    +							[
    +								'slug'    => 'design',
    +								'content' => ['title' => 'Design']
    +							]
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// filterBy slug == photography would normally return only 1 page;
    +		// since filterBy is stripped from the body, both pages are returned
    +		$response = $app->api()->call('pages/parent/children/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'slug', 'operator' => '==', 'value' => 'photography']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testChildrenSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'     => 'parent',
    +						'children' => [
    +							[
    +								'slug'    => 'a',
    +								'content' => ['title' => 'A']
    +							],
    +							[
    +								'slug'    => 'b',
    +								'content' => ['title' => 'B']
    +							]
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// sortBy in the body is stripped; default order (a, b) is preserved
    +		$response = $app->api()->call('pages/parent/children/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'slug desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('parent/a', $response['data'][0]['id']);
    +		$this->assertSame('parent/b', $response['data'][1]['id']);
    +	}
    +
    +	public function testFilesSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'  => 'a',
    +						'files' => [
    +							['filename' => 'photo.jpg'],
    +							['filename' => 'document.pdf']
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// filterBy filename == photo.jpg would normally return only 1 file;
    +		// since filterBy is stripped from the body, both files are returned
    +		$response = $app->api()->call('pages/a/files/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'filename', 'operator' => '==', 'value' => 'photo.jpg']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testFilesSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'  => 'a',
    +						'files' => [
    +							['filename' => 'a.jpg'],
    +							['filename' => 'b.jpg']
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// sortBy in the body is stripped; default sorted order (a, b) is preserved
    +		$response = $app->api()->call('pages/a/files/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'filename desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('a.jpg', $response['data'][0]['filename']);
    +		$this->assertSame('b.jpg', $response['data'][1]['filename']);
    +	}
     }
    
  • tests/Cms/Api/routes/SiteRoutesTest.php+63 0 modified
    @@ -329,4 +329,67 @@ public function testSearchWithPostRequest(): void
     		$this->assertCount(1, $response['data']);
     		$this->assertSame('parent/child', $response['data'][0]['id']);
     	}
    +
    +	public function testSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'    => 'photography',
    +						'content' => ['title' => 'Photography']
    +					],
    +					[
    +						'slug'    => 'design',
    +						'content' => ['title' => 'Design']
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// filterBy slug = photography would normally return only 1 page;
    +		// since filterBy is stripped from the body, both pages are returned
    +		$response = $app->api()->call('site/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'slug', 'operator' => '==', 'value' => 'photography']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		$app = $this->app->clone([
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'    => 'a',
    +						'content' => ['title' => 'A']
    +					],
    +					[
    +						'slug'    => 'b',
    +						'content' => ['title' => 'B']
    +					]
    +				]
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		// sortBy in the body is stripped; default order (a, b) is preserved
    +		$response = $app->api()->call('site/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'slug desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('a', $response['data'][0]['id']);
    +		$this->assertSame('b', $response['data'][1]['id']);
    +	}
     }
    
  • tests/Cms/Api/routes/UsersRoutesTest.php+64 0 modified
    @@ -529,6 +529,35 @@ public function testSearchWithPostRequest(): void
     		$this->assertSame('editor@getkirby.com', $response['data'][0]['email']);
     	}
     
    +	public function testSearchWithPostRequestIgnoresFilterBy(): void
    +	{
    +		// filterBy role == editor would normally return only 1 user;
    +		// since filterBy is stripped from the body, both users are returned
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'filterBy' => [
    +					['field' => 'role', 'operator' => '==', 'value' => 'editor']
    +				]
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresSortBy(): void
    +	{
    +		// sortBy in the body is stripped; default order (alphabetical asc) is preserved
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'sortBy' => 'email desc'
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +		$this->assertSame('admin@getkirby.com', $response['data'][0]['email']);
    +		$this->assertSame('editor@getkirby.com', $response['data'][1]['email']);
    +	}
    +
     	public function testSearchWithPostRequestWithoutAccess(): void
     	{
     		$app = $this->setUpAppWithoutUserAccess();
    @@ -542,6 +571,41 @@ public function testSearchWithPostRequestWithoutAccess(): void
     		$this->assertCount(0, $response['data']);
     	}
     
    +	public function testSearchWithPostRequestRespectsLimit(): void
    +	{
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'limit' => 1,
    +			]
    +		]);
    +
    +		$this->assertCount(1, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresNullValues(): void
    +	{
    +		// search: null should not restrict results
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'search' => null,
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
    +	public function testSearchWithPostRequestIgnoresNotKey(): void
    +	{
    +		// the 'not' key is stripped; both users are returned
    +		$response = $this->app->api()->call('users/search', 'POST', [
    +			'body' => [
    +				'not' => ['admin@getkirby.com'],
    +			]
    +		]);
    +
    +		$this->assertCount(2, $response['data']);
    +	}
    +
     	public function testSections(): void
     	{
     		$app = $this->app->clone([
    
  • tests/Cms/App/AppResolveTest.php+132 3 modified
    @@ -99,15 +99,144 @@ public function testResolveDraft(): void
     
     		$result = $app->resolve('test/a-draft');
     		$this->assertNull($result);
    +	}
    +
    +	public function testResolveDraftWithUser(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'users' => [
    +				['email' => 'admin@getkirby.com', 'role' => 'admin']
    +			]
    +		]);
    +
    +		$app->impersonate('admin@getkirby.com');
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertIsPage($result);
    +		$this->assertSame('test/a-draft', $result->id());
    +	}
    +
    +	public function testResolveDraftWithUserDeniedByPermission(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'roles' => [
    +				[
    +					'name'        => 'editor',
    +					'permissions' => [
    +						'pages' => ['access' => false]
    +					]
    +				]
    +			],
    +			'users' => [
    +				['email' => 'editor@getkirby.com', 'role' => 'editor']
    +			]
    +		]);
    +
    +		$app->impersonate('editor@getkirby.com');
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertNull($result);
    +	}
     
    -		$app = $app->clone([
    +	public function testResolveDraftWithToken(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug' => 'test',
    +						'drafts' => [
    +							[
    +								'slug'  => 'a-draft',
    +							]
    +						]
    +					]
    +				]
    +			]
    +		]);
    +
    +		$token = $app->page('test/a-draft')->version()->previewToken();
    +		$app   = $app->clone([
     			'request' => [
    -				'query' => [
    -					'_token' => $app->page('test/a-draft')->version()->previewToken()
    +				'query' => ['_token' => $token]
    +			]
    +		]);
    +
    +		$result = $app->resolve('test/a-draft');
    +
    +		$this->assertIsPage($result);
    +		$this->assertSame('test/a-draft', $result->id());
    +	}
    +
    +	public function testResolveDraftWithTokenBypassesPermission(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'index' => '/dev/null'
    +			],
    +			'site' => [
    +				'children' => [
    +					[
    +						'slug'   => 'test',
    +						'drafts' => [
    +							['slug' => 'a-draft']
    +						]
    +					]
    +				]
    +			],
    +			'roles' => [
    +				[
    +					'name'        => 'editor',
    +					'permissions' => [
    +						'pages' => ['access' => false]
    +					]
     				]
    +			],
    +			'users' => [
    +				['email' => 'editor@getkirby.com', 'role' => 'editor']
    +			]
    +		]);
    +
    +		$token = $app->page('test/a-draft')->version()->previewToken();
    +		$app   = $app->clone([
    +			'request' => [
    +				'query' => ['_token' => $token]
     			]
     		]);
     
    +		$app->impersonate('editor@getkirby.com');
    +
     		$result = $app->resolve('test/a-draft');
     
     		$this->assertIsPage($result);
    
  • tests/Cms/Blocks/BlocksTest.php+8 0 modified
    @@ -146,6 +146,14 @@ public function testParseString(): void
     		$this->assertSame($expected, $result);
     	}
     
    +	public function testParseSkipsDangerousHref(): void
    +	{
    +		$value  = Blocks::parse('<a href="javascript://x%0Aalert(1)">Test</a>');
    +		$blocks = Blocks::factory($value);
    +
    +		$this->assertSame('<p><a rel="noreferrer">Test</a></p>', $blocks->toHtml());
    +	}
    +
     	public function testParsePageObject(): void
     	{
     		$expected = [
    
  • tests/Cms/Collections/CollectionTest.php+122 0 modified
    @@ -4,6 +4,7 @@
     
     use Exception;
     use Kirby\Content\Field;
    +use Kirby\Toolkit\BlockCollectionAccess;
     use Kirby\Toolkit\Obj;
     use PHPUnit\Framework\Attributes\CoversClass;
     use stdClass;
    @@ -45,6 +46,12 @@ class CollectionTest extends TestCase
     {
     	public const TMP = KIRBY_TMP_DIR . '/Cms.Collection';
     
    +	public function tearDown(): void
    +	{
    +		parent::tearDown();
    +		unset(User::$methods['allowedCustomMethod'], User::$methods['blockedCustomMethod']);
    +	}
    +
     	public function testCollectionMethods(): void
     	{
     		$kirby = $this->kirby([
    @@ -113,6 +120,121 @@ public function testGetAttributeWithField(): void
     		$this->assertSame($field, $value);
     	}
     
    +	public function testGetAttributeFromObjectBlocksRegisteredClosure(): void
    +	{
    +		User::$methods['blockedCustomMethod'] = #[BlockCollectionAccess] function () {
    +			return 'sensitive data';
    +		};
    +
    +		User::$methods['allowedCustomMethod'] = function () {
    +			return 'safe data';
    +		};
    +
    +		$users = Users::factory([['email' => 'test@getkirby.com']]);
    +		$user  = $users->first();
    +
    +		// closure with #[BlockCollectionAccess] must be blocked
    +		$this->assertNull($users->getAttribute($user, 'blockedCustomMethod'));
    +
    +		// closure without #[BlockCollectionAccess] must be allowed
    +		$this->assertSame('safe data', $users->getAttribute($user, 'allowedCustomMethod'));
    +	}
    +
    +	public function testGetAttributeFromObjectBlocksSensitiveMethod(): void
    +	{
    +		$users = Users::factory([
    +			[
    +				'email'    => 'test@getkirby.com',
    +				'password' => 'supersecret'
    +			]
    +		]);
    +
    +		$user = $users->first();
    +
    +		// password() has #[BlockCollectionAccess] - must be blocked
    +		$this->assertNull($users->getAttribute($user, 'password'));
    +
    +		// passwordTimestamp() has #[BlockCollectionAccess] - must be blocked
    +		$this->assertNull($users->getAttribute($user, 'passwordTimestamp'));
    +
    +		// secret() has #[BlockCollectionAccess] - must be blocked
    +		$this->assertNull($users->getAttribute($user, 'secret'));
    +	}
    +
    +	public function testGetAttributeFromObjectAllowsContentField(): void
    +	{
    +		$users = Users::factory([
    +			[
    +				'email'   => 'test@getkirby.com',
    +				'content' => ['bio' => 'hello world']
    +			]
    +		]);
    +
    +		$user = $users->first();
    +
    +		// content fields accessed via __call() must always be allowed
    +		$this->assertSame('hello world', (string)$users->getAttribute($user, 'bio'));
    +	}
    +
    +	public function testGetAttributeFromObjectAllowsAccessibleField(): void
    +	{
    +		$users = Users::factory([
    +			['email' => 'test@getkirby.com']
    +		]);
    +
    +		$user = $users->first();
    +
    +		// email() has no #[BlockCollectionAccess] - must be allowed
    +		$this->assertSame('test@getkirby.com', $users->getAttribute($user, 'email'));
    +	}
    +
    +	public function testGetAttributeFromObjectBlockedInDebugMode(): void
    +	{
    +		new App([
    +			'roots'   => ['index' => '/dev/null'],
    +			'options' => ['debug' => true]
    +		]);
    +
    +		$users = Users::factory([
    +			['email' => 'test@getkirby.com', 'password' => 'supersecret']
    +		]);
    +
    +		$user = $users->first();
    +
    +		$this->expectException(\InvalidArgumentException::class);
    +		$users->getAttribute($user, 'password');
    +	}
    +
    +	public function testGetAttributeFromObjectWithoutAccessibleFields(): void
    +	{
    +		// MockObject methods don't have #[BlockCollectionAccess] - access must be allowed
    +		$collection = new Collection([
    +			$obj = new MockObject(['id' => 'a', 'group' => 'x'])
    +		]);
    +
    +		$this->assertSame('a', $collection->getAttribute($obj, 'id'));
    +		$this->assertSame('x', $collection->getAttribute($obj, 'group'));
    +	}
    +
    +	public function testFilterByPasswordIsBlocked(): void
    +	{
    +		$users = Users::factory([
    +			[
    +				'email'    => 'a@getkirby.com',
    +				'password' => '$2y$10$abcdefghijklmnopqrstuvwxyABCDEFGHIJKLMNOPQRSTUVWXYZ01234'
    +			],
    +			[
    +				'email'    => 'b@getkirby.com',
    +				'password' => '$2y$10$zzzzzzzzzzzzzzzzzzzzzzzzABCDEFGHIJKLMNOPQRSTUVWXYZ01234'
    +			]
    +		]);
    +
    +		// filtering by password must return no match (null != any value)
    +		// rather than exposing hash contents via binary oracle
    +		$result = $users->filter('password', '*=', '$2y$');
    +		$this->assertCount(0, $result);
    +	}
    +
     	public function testAppend(): void
     	{
     		$a = new MockObject(['id' => 'a', 'name' => 'A']);
    
  • tests/Cms/Helpers/HelperFunctionsTest.php+31 0 modified
    @@ -246,6 +246,20 @@ public function testGist(): void
     		$this->assertSame($gist, $expected);
     	}
     
    +	public function testGet(): void
    +	{
    +		$this->app->clone([
    +			'request' => [
    +				'query' => [
    +					'foo' => 'bar'
    +				]
    +			]
    +		]);
    +
    +		$this->assertSame('bar', get('foo'));
    +		$this->assertSame('fallback', get('does-not-exist', 'fallback'));
    +	}
    +
     	public function testH(): void
     	{
     		$html = h('Guns & Roses');
    @@ -624,6 +638,9 @@ public function testPages(): void
     
     		$pages = pages('a', 'b');
     		$this->assertCount(2, $pages);
    +
    +		$pages = pages(['a', 'b']);
    +		$this->assertCount(2, $pages);
     	}
     
     	public function testParam(): void
    @@ -952,6 +969,20 @@ public function testTcWithPlaceholders(): void
     		$this->assertSame('1234567 Autos', tc('car', 1234567, 'de', false));
     	}
     
    +	public function testTt(): void
    +	{
    +		$this->app->clone([
    +			'translations' => [
    +				'en' => [
    +					'welcome' => 'Welcome {name}'
    +				]
    +			]
    +		]);
    +
    +		$this->assertSame('Welcome Kirby', tt('welcome', ['name' => 'Kirby']));
    +		$this->assertSame('Hello Kirby', tt('does-not-exist', 'Hello {name}', ['name' => 'Kirby']));
    +	}
    +
     	public function testUrl(): void
     	{
     		$this->app->clone([
    
  • tests/Cms/Media/MediaTest.php+55 0 modified
    @@ -8,6 +8,18 @@
     use Kirby\Filesystem\Dir;
     use Kirby\Filesystem\F;
     use Kirby\Http\Response;
    +use Kirby\Image\Darkroom;
    +
    +class MediaTestTrackingDarkroom extends Darkroom
    +{
    +	public static string|null $processedFile = null;
    +
    +	public function process(string $file, array $options = []): array
    +	{
    +		static::$processedFile = $file;
    +		return $options;
    +	}
    +}
     
     class MediaTest extends TestCase
     {
    @@ -307,6 +319,49 @@ public function testThumbWhenGenerationFails(): void
     		Media::thumb($site, $file->mediaHash(), $file->filename());
     	}
     
    +	public function testThumbComponentUsesTempFile(): void
    +	{
    +		$originalDarkroomTypes = Darkroom::$types;
    +		Darkroom::$types['tracking-test'] = MediaTestTrackingDarkroom::class;
    +		MediaTestTrackingDarkroom::$processedFile = null;
    +
    +		App::destroy();
    +		$this->app = new App([
    +			'options' => [
    +				'thumbs.driver' => 'tracking-test'
    +			],
    +			'roots'   => [
    +				'index' => static::TMP
    +			]
    +		]);
    +
    +		Dir::make(static::TMP . '/content');
    +		Dir::make(static::TMP . '/media');
    +		F::copy(static::FIXTURES . '/files/test.jpg', $source = static::TMP . '/content/test.jpg');
    +
    +		try {
    +			$this->assertSame(
    +				$destination = static::TMP . '/media/test.jpg',
    +				$this->app->thumb($source, $destination, [
    +					'width'  => 64,
    +					'height' => 64,
    +					'crop'   => 'center'
    +				])
    +			);
    +		} finally {
    +			Darkroom::$types = $originalDarkroomTypes;
    +			$processedFile = MediaTestTrackingDarkroom::$processedFile;
    +			MediaTestTrackingDarkroom::$processedFile = null;
    +		}
    +
    +		$this->assertNotNull($processedFile);
    +		$this->assertNotSame($destination, $processedFile);
    +		$this->assertStringStartsWith(static::TMP . '/media/test.tmp-', $processedFile);
    +		$this->assertStringEndsWith('.jpg', $processedFile);
    +		$this->assertFileExists($destination);
    +		$this->assertFileDoesNotExist($processedFile);
    +	}
    +
     	public function testThumbStringModel(): void
     	{
     		Dir::make(static::TMP . '/content');
    
  • tests/Cms/Page/PageChangeSortTest.php+28 0 modified
    @@ -140,4 +140,32 @@ public function testMassSorting(): void
     		$this->assertDirectoryExists(static::TMP . '/content/2_c');
     		$this->assertDirectoryExists(static::TMP . '/content/1_d');
     	}
    +
    +	public function testFreshPageRemainsWritableAfterChangeSort(): void
    +	{
    +		Page::create([
    +			'slug' => 'a',
    +			'num'  => 1,
    +		]);
    +
    +		Page::create([
    +			'slug' => 'b',
    +			'num'  => 2,
    +		]);
    +
    +		Page::create([
    +			'slug' => 'c',
    +			'num'  => 3,
    +		]);
    +
    +		$page = $this->site()->find('b')->changeSort(3);
    +		$page = $page->update([
    +			'headline' => 'Sorted'
    +		]);
    +
    +		$this->assertSame('Sorted', $page->headline()->value());
    +		$this->assertSame(3, $page->num());
    +		$this->assertDirectoryExists(static::TMP . '/content/3_b');
    +		$this->assertFileExists(static::TMP . '/content/3_b/default.txt');
    +	}
     }
    
  • tests/Cms/Page/PageUpdateTest.php+41 0 modified
    @@ -2,6 +2,7 @@
     
     namespace Kirby\Cms;
     
    +use Kirby\Exception\LogicException;
     use PHPUnit\Framework\Attributes\CoversClass;
     use PHPUnit\Framework\Attributes\DataProvider;
     
    @@ -231,4 +232,44 @@ public function testUpdateWithNullValue(): void
     		$this->assertNull($updated->test()->value());
     	}
     
    +	public function testUpdateFailsForStalePageAfterConcurrentMove(): void
    +	{
    +		Page::create([
    +			'slug' => 'a',
    +			'num'  => 1,
    +		]);
    +
    +		Page::create([
    +			'slug' => 'b',
    +			'num'  => 2,
    +		]);
    +
    +		Page::create([
    +			'slug' => 'c',
    +			'num'  => 3,
    +		]);
    +
    +		$stalePage = $this->app->site()->find('b')->clone();
    +		$freshPage = $this->app->site()->find('b');
    +
    +		$this->assertSame(static::TMP . '/content/2_b', $stalePage->root());
    +
    +		$freshPage = $freshPage->changeSort(3);
    +
    +		$this->assertSame(static::TMP . '/content/3_b', $freshPage->root());
    +		$this->assertDirectoryExists(static::TMP . '/content/3_b');
    +		$this->assertDirectoryDoesNotExist(static::TMP . '/content/2_b');
    +
    +		$this->expectException(LogicException::class);
    +		$this->expectExceptionMessage('The page was moved during a concurrent operation.');
    +
    +		try {
    +			$stalePage->update(['headline' => 'Test']);
    +		} finally {
    +			$this->assertDirectoryDoesNotExist(static::TMP . '/content/2_b');
    +			$this->assertDirectoryExists(static::TMP . '/content/3_b');
    +			$this->assertFileDoesNotExist(static::TMP . '/content/2_b/default.txt');
    +		}
    +	}
    +
     }
    
  • tests/Cms/Users/UsersTest.php+30 0 modified
    @@ -3,6 +3,7 @@
     namespace Kirby\Cms;
     
     use Kirby\Exception\InvalidArgumentException;
    +use Kirby\Filesystem\F;
     
     class UsersTest extends TestCase
     {
    @@ -225,6 +226,35 @@ public function testFindInFilesystem(): void
     		$this->assertNull($users->find('user://bar'));
     	}
     
    +	public function testFindPathTraversal(): void
    +	{
    +		$app = new App([
    +			'roots' => [
    +				'accounts' => static::TMP . '/accounts',
    +				'index'    => '/dev/null'
    +			]
    +		]);
    +
    +		$app->impersonate('kirby');
    +
    +		$app->users()->create(['id' => 'homer', 'email' => 'a@getkirby.com', 'password' => '12345678']);
    +
    +		$contents = '<?php throw new PHPUnit\Framework\AssertionFailedError("File was accessible via path traversal");';
    +		F::write(static::TMP . '/index.php', $contents);
    +		F::write(static::TMP . '/accounts/homer/subfolder/index.php', $contents);
    +
    +		// initialize a new fresh app instance to start with an empty collection
    +		$app   = $app->clone();
    +		$users = $app->users();
    +
    +		// block slashes in user IDs to prevent disclosure of path structures and access to subfolders
    +		$this->assertNull($users->find('../accounts/homer'));
    +		$this->assertNull($users->find('homer/subfolder'));
    +
    +		// block path traversal outside of the `accounts` directory
    +		$this->assertNull($users->find('..'));
    +	}
    +
     	public function testCustomMethods(): void
     	{
     		Users::$methods = [
    
  • tests/Cms/User/UserCommitTest.php+2 1 modified
    @@ -85,7 +85,8 @@ public function testCommitForTheKirbyUser(): void
     		$class->getMethod('commit')->invokeArgs($user, [
     			'changeName',
     			['user' => $user, 'name' => 'target'],
    -			function () {}
    +			function () {
    +			}
     		]);
     	}
     
    
  • tests/Content/LockTest.php+47 6 modified
    @@ -370,11 +370,52 @@ public function testModified(): void
     
     	public function testToArray(): void
     	{
    +		$this->app->impersonate('kirby');
    +
    +		$lock = new Lock(
    +			user: $this->app->user('editor'),
    +			modified: $modified = time()
    +		);
    +
    +		$this->assertSame([
    +			'isLegacy' => false,
    +			'isLocked' => true,
    +			'modified' => date('c', $modified),
    +			'user'     => [
    +				'id'    => 'editor',
    +				'email' => 'editor@getkirby.com'
    +			]
    +		], $lock->toArray());
    +	}
    +
    +	public function testToArrayWithUnlistableUser(): void
    +	{
    +		$this->app = $this->app->clone([
    +			'roles' => [
    +				[
    +					'name'        => 'restricted',
    +					'permissions' => [
    +						'users' => ['list' => false]
    +					]
    +				]
    +			],
    +			'users' => [
    +				[
    +					'email' => 'admin@getkirby.com',
    +					'id'    => 'admin',
    +				],
    +				[
    +					'email' => 'editor@getkirby.com',
    +					'id'    => 'editor',
    +					'role'  => 'restricted',
    +				],
    +			]
    +		]);
    +
    +		$this->app->impersonate('editor');
    +
     		$lock = new Lock(
    -			user: $user = new User([
    -				'email' => 'test@getkirby.com',
    -				'id'    => 'test'
    -			]),
    +			user: $this->app->user('admin'),
     			modified: $modified = time()
     		);
     
    @@ -383,8 +424,8 @@ public function testToArray(): void
     			'isLocked' => true,
     			'modified' => date('c', $modified),
     			'user'     => [
    -				'id'    => 'test',
    -				'email' => 'test@getkirby.com'
    +				'id'    => null,
    +				'email' => null
     			]
     		], $lock->toArray());
     	}
    
  • tests/Content/PlainTextStorageTest.php+59 0 modified
    @@ -7,6 +7,7 @@
     use Kirby\Cms\Page;
     use Kirby\Cms\User;
     use Kirby\Data\Data;
    +use Kirby\Exception\LogicException;
     use Kirby\Filesystem\Dir;
     use PHPUnit\Framework\Attributes\CoversClass;
     use PHPUnit\Framework\Attributes\DataProvider;
    @@ -581,6 +582,64 @@ public function testUpdateLatestSingleLang(): void
     		$this->assertSame($fields, Data::read($this->model->root() . '/article.txt'));
     	}
     
    +	public function testUpdateLatestSingleLangFailsForStalePageAfterMove(): void
    +	{
    +		$this->setUpSingleLanguage([
    +			'children' => [
    +				[
    +					'slug'     => 'a-page',
    +					'template' => 'article',
    +				],
    +				[
    +					'slug'     => 'a',
    +					'template' => 'article',
    +					'num'      => 1,
    +				],
    +				[
    +					'slug'     => 'b',
    +					'template' => 'article',
    +					'num'      => 2,
    +				],
    +				[
    +					'slug'     => 'c',
    +					'template' => 'article',
    +					'num'      => 3,
    +				]
    +			]
    +		]);
    +
    +		$this->app->impersonate('kirby');
    +
    +		Data::write($this->app->page('a')->root() . '/article.txt', []);
    +		Data::write($this->app->page('b')->root() . '/article.txt', []);
    +		Data::write($this->app->page('c')->root() . '/article.txt', []);
    +
    +		$stalePage = $this->app->page('b')->clone();
    +		$freshPage = $this->app->page('b');
    +		$storage   = new PlainTextStorage($stalePage);
    +
    +		$this->assertSame(static::TMP . '/content/2_b', $stalePage->root());
    +
    +		$freshPage = $freshPage->changeSort(3);
    +
    +		$this->assertSame(static::TMP . '/content/3_b', $freshPage->root());
    +		$this->assertDirectoryExists(static::TMP . '/content/3_b');
    +		$this->assertDirectoryDoesNotExist(static::TMP . '/content/2_b');
    +
    +		$this->expectException(LogicException::class);
    +		$this->expectExceptionMessage('The page was moved during a concurrent operation.');
    +
    +		try {
    +			$storage->update(VersionId::latest(), Language::single(), [
    +				'title' => 'Updated'
    +			]);
    +		} finally {
    +			$this->assertDirectoryDoesNotExist(static::TMP . '/content/2_b');
    +			$this->assertDirectoryExists(static::TMP . '/content/3_b');
    +			$this->assertFileDoesNotExist(static::TMP . '/content/2_b/article.txt');
    +		}
    +	}
    +
     	public function testUpdateForFileWithMetaData(): void
     	{
     		$this->setUpSingleLanguage();
    
  • tests/Form/Field/FilesFieldTest.php+71 0 modified
    @@ -2,11 +2,49 @@
     
     namespace Kirby\Form\Field;
     
    +use Closure;
    +use Kirby\Cms\Api;
     use Kirby\Cms\App;
    +use Kirby\Cms\File;
     use Kirby\Cms\Page;
     use Kirby\Cms\Site;
     use Kirby\Cms\User;
     
    +class FilesFieldTestApi extends Api
    +{
    +	public string|null $template = null;
    +
    +	public function upload(
    +		Closure $callback,
    +		bool $single = false,
    +		bool $debug = false,
    +		string|null $template = null
    +	): array {
    +		$this->template = $template;
    +
    +		return [
    +			'status' => 'ok',
    +			'data'   => $callback('source.txt', 'test.txt', $template)
    +		];
    +	}
    +}
    +
    +class FilesFieldTestPage extends Page
    +{
    +	public array $createdFile = [];
    +
    +	public function createFile(array $props, bool $move = false): File
    +	{
    +		$this->createdFile = $props;
    +
    +		return new File([
    +			'filename' => $props['filename'],
    +			'parent'   => $this,
    +			'template' => $props['template']
    +		]);
    +	}
    +}
    +
     class FilesFieldTest extends TestCase
     {
     	public const TMP = KIRBY_TMP_DIR . '/Form.Fields.Languages';
    @@ -278,6 +316,39 @@ public function testApi(): void
     		$this->assertSame('c.jpg', $api['data'][2]['id']);
     	}
     
    +	public function testUploadPassesTemplateToApi(): void
    +	{
    +		$this->app = $this->app->clone([
    +			'blueprints' => [
    +				'files/custom' => [
    +					'name'   => 'custom',
    +					'accept' => ['extension' => 'txt']
    +				]
    +			]
    +		]);
    +		$this->app->impersonate('kirby');
    +
    +		$parent = new FilesFieldTestPage(['slug' => 'test']);
    +
    +		$field = $this->field('files', [
    +			'model'   => $parent,
    +			'uploads' => 'custom'
    +		]);
    +		$this->assertSame('.txt', $field->uploads()['accept']);
    +
    +		$api = new FilesFieldTestApi(['kirby' => $this->app]);
    +
    +		$result = $field->upload(
    +			$api,
    +			$field->uploads(),
    +			fn ($file) => $file->template()
    +		);
    +
    +		$this->assertSame('custom', $api->template);
    +		$this->assertSame('custom', $parent->createdFile['template']);
    +		$this->assertSame('custom', $result['data']);
    +	}
    +
     	public function testParentModel(): void
     	{
     		$field = $this->field('files', [
    
  • tests/Form/Field/ListFieldTest.php+9 0 modified
    @@ -15,4 +15,13 @@ public function testDefaultProps(): void
     		$this->assertNull($field->text());
     		$this->assertTrue($field->save());
     	}
    +
    +	public function testValueSanitized(): void
    +	{
    +		$field = $this->field('list', [
    +			'value' => '<ul><li>Item <strong>one</strong></li></ul><script>alert("Hacked")</script>'
    +		]);
    +
    +		$this->assertSame('<ul><li>Item <strong>one</strong></li></ul>', $field->value());
    +	}
     }
    
  • tests/Http/RouteTest.php+6 2 modified
    @@ -11,8 +11,12 @@ class RouteTest extends TestCase
     {
     	public function _route()
     	{
    -		$route = new Route('a', 'GET', function () {});
    -		return $route;
    +		return new Route(
    +			'a',
    +			'GET',
    +			function () {
    +			}
    +		);
     	}
     
     	public function testConstruct(): void
    
  • tests/Http/UrlTest.php+33 0 modified
    @@ -90,6 +90,36 @@ public function testBuild(): void
     		$this->assertSame($result, Url::build($parts, 'http://getkirby.com'));
     	}
     
    +	public function testHasDangerousScheme(): void
    +	{
    +		$this->assertTrue(Url::hasDangerousScheme('javascript:alert(1)'));
    +		$this->assertTrue(Url::hasDangerousScheme('javascript://comment%0Aalert(1)'));
    +		$this->assertTrue(Url::hasDangerousScheme('vbscript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('data://text/html;base64,PHN2Zz4='));
    +		$this->assertTrue(Url::hasDangerousScheme('livescript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('mocha://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('jar://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('JAVASCRIPT://x'));
    +
    +		// whitespace prefix bypass attempts
    +		$this->assertTrue(Url::hasDangerousScheme(' javascript://x'));
    +		$this->assertTrue(Url::hasDangerousScheme("\tjavascript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("\njavascript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("\rjavascript://x"));
    +
    +		// whitespace bypass in the middle of the string
    +		$this->assertTrue(Url::hasDangerousScheme('java script://x'));
    +		$this->assertTrue(Url::hasDangerousScheme('java script://x'));
    +		$this->assertTrue(Url::hasDangerousScheme("java\tscript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("javas\ncript://x"));
    +		$this->assertTrue(Url::hasDangerousScheme("javasc\rript://x"));
    +
    +		$this->assertFalse(Url::hasDangerousScheme('https://getkirby.com'));
    +		$this->assertFalse(Url::hasDangerousScheme('//getkirby.com'));
    +		$this->assertFalse(Url::hasDangerousScheme('/relative/path'));
    +		$this->assertFalse(Url::hasDangerousScheme(null));
    +	}
    +
     	public function testIsAbsolute(): void
     	{
     		$this->assertTrue(Url::isAbsolute('http://getkirby.com/docs'));
    @@ -100,6 +130,9 @@ public function testIsAbsolute(): void
     		$this->assertTrue(Url::isAbsolute('geo:49.0158,8.3239?z=11'));
     		$this->assertFalse(Url::isAbsolute('../getkirby.com/docs'));
     		$this->assertFalse(Url::isAbsolute('javascript:alert("XSS")'));
    +		$this->assertFalse(Url::isAbsolute('javascript://comment%0Aalert(1)'));
    +		$this->assertFalse(Url::isAbsolute('vbscript://x'));
    +		$this->assertFalse(Url::isAbsolute('data://text/html;base64,PHN2Zz4='));
     	}
     
     	public function testMakeAbsolute(): void
    
  • tests/Image/Darkroom/ImagickTest.php+24 0 modified
    @@ -407,5 +407,29 @@ public function testStripWhenProcessing(
     		$meta = shell_exec('identify -verbose ' . escapeshellarg($file));
     		$this->assertStringNotContainsString('photoshop:CaptionWriter', $meta);
     		$this->assertStringNotContainsString('GPS', $meta);
    +		$this->assertStringNotContainsString('Profile-iptc', $meta);
    +	}
    +
    +	public function testStripPreservesProfileFromOption(): void
    +	{
    +		$fixture = static::FIXTURES . '/image/onigiri-adobe-rgb-gps.jpg';
    +
    +		// confirm the fixture has IPTC data before processing
    +		$command = 'identify -verbose ' . escapeshellarg($fixture) . ' 2>/dev/null';
    +		$this->assertStringContainsString('Profile-iptc', shell_exec($command));
    +
    +		// IPTC is preserved when listed in profiles option
    +		copy($fixture, $file = static::TMP . '/onigiri-iptc-preserved.jpg');
    +		$imagick = new Imagick(['profiles' => ['icc', 'iptc'], 'width' => 250]);
    +		$imagick->process($file);
    +		$meta = shell_exec('identify -verbose ' . escapeshellarg($file) . ' 2>/dev/null');
    +		$this->assertStringContainsString('Profile-iptc', $meta);
    +
    +		// IPTC is stripped when not listed in profiles option
    +		copy($fixture, $file = static::TMP . '/onigiri-iptc-stripped.jpg');
    +		$imagick = new Imagick(['width' => 250]);
    +		$imagick->process($file);
    +		$meta = shell_exec('identify -verbose ' . escapeshellarg($file) . ' 2>/dev/null');
    +		$this->assertStringNotContainsString('Profile-iptc', $meta);
     	}
     }
    
  • tests/Panel/Ui/Buttons/ViewButtonTest.php+1 1 modified
    @@ -66,7 +66,7 @@ public function testFactoryFromStringName(): void
     				'test' => fn () => [
     					'buttons' => [
     						'test' => ['component' => 'result'],
    -						'foo'  => function () {}
    +						'foo'  => fn () => null
     					]
     				]
     			]
    
  • tests/Parsley/InlineTest.php+29 0 modified
    @@ -145,6 +145,35 @@ public function testParseNodeWithKnownMarksWithAttrDefaults(): void
     		$this->assertSame('<a href="https://getkirby.com" rel="test">Test</a>', $html);
     	}
     
    +	public function testSkipsDangerousHref(): void
    +	{
    +		$dom  = new Dom('<p><a href="javascript://x%0Aalert(1)">Test</a></p>');
    +		$p    = $dom->query('//p')[0];
    +		$html = Inline::parseNode($p, [
    +			'a' => [
    +				'attrs' => ['href', 'rel'],
    +				'defaults' => [
    +					'rel' => 'noreferrer'
    +				]
    +			],
    +		]);
    +
    +		$this->assertSame('<a rel="noreferrer">Test</a>', $html);
    +	}
    +
    +	public function testSkipsObfuscatedHref(): void
    +	{
    +		$dom  = new Dom("<p><a href=\"java\tscript://x%0Aalert(1)\">Test</a></p>");
    +		$p    = $dom->query('//p')[0];
    +		$html = Inline::parseNode($p, [
    +			'a' => [
    +				'attrs' => ['href'],
    +			],
    +		]);
    +
    +		$this->assertSame('<a>Test</a>', $html);
    +	}
    +
     	public function testParseNodeWithUnkownMarks(): void
     	{
     		$dom     = new Dom('<p><b>Test</b> <i>Test</i></p>');
    
  • tests/Parsley/Schema/BlocksTest.php+23 0 modified
    @@ -419,6 +419,29 @@ public function testImgWithLink(): void
     		$this->assertSame($expected, $this->schema->img($element));
     	}
     
    +	public function testImgDangerousLink(): void
    +	{
    +		$html = <<<HTML
    +			<a href="javascript://x%0Aalert(1)">
    +				<img src="https://getkirby.com/image.jpg" alt="Test">
    +			</a>
    +			HTML;
    +
    +		$element  = $this->element($html, '//img');
    +		$expected = [
    +			'content' => [
    +				'alt'      => 'Test',
    +				'caption'  => null,
    +				'link'     => null,
    +				'location' => 'web',
    +				'src'      => 'https://getkirby.com/image.jpg'
    +			],
    +			'type' => 'image',
    +		];
    +
    +		$this->assertSame($expected, $this->schema->img($element));
    +	}
    +
     	public function testImgWithCaption(): void
     	{
     		$html = <<<HTML
    
  • tests/Sane/SaneTest.php+11 0 modified
    @@ -141,6 +141,17 @@ public function testSanitizeFileMultipleHandlersExplicit(): void
     		$this->assertFileEquals($expected, $tmp);
     	}
     
    +	public function testSanitizeProseMirrorFields(): void
    +	{
    +		$this->assertSame(
    +			'This is a <strong>test</strong> with <em>formatting</em>',
    +			Sane::sanitizeProseMirrorFields('This is a <strong>test</strong><script>alert("Hacked")</script> with <em>formatting</em>')
    +		);
    +
    +		// non-breaking spaces are converted to HTML entities
    +		$this->assertSame('foo&nbsp;bar', Sane::sanitizeProseMirrorFields("foo\u{00A0}bar"));
    +	}
    +
     	public function testValidate(): void
     	{
     		$this->assertNull(Sane::validate('<svg></svg>', 'svg'));
    
  • tests/Toolkit/CollectionFilterTest.php+22 4 modified
    @@ -106,13 +106,22 @@ public static function filterDataProvider(): array
     				'split'      => false
     			],
     
    -			// split strings
    +			// split strings with comma/default separator
     			[
     				'attributes' => ['a' => 'a, b', 'b' => 'b, c', 'c' => 'c, d'],
     				'operator'   =>  '==',
     				'test'       => 'b',
     				'expected'   => ['a', 'b'],
    -				'split'      => ','
    +				'split'      => true
    +			],
    +
    +			// split strings with non-comma separator
    +			[
    +				'attributes' => ['a' => 'a; b', 'b' => 'b; c', 'c' => 'c; d'],
    +				'operator'   =>  '==',
    +				'test'       => 'b',
    +				'expected'   => ['a', 'b'],
    +				'split'      => ';'
     			],
     
     			// booleans
    @@ -180,13 +189,22 @@ public static function filterDataProvider(): array
     				'split'      => false
     			],
     
    -			// split strings
    +			// split strings with comma/default separator
     			[
     				'attributes' => ['a' => 'a, b', 'b' => 'b, c', 'c' => 'c, d'],
     				'operator'   =>  '!=',
     				'test'       => 'b',
     				'expected'   => ['c'],
    -				'split'      => ','
    +				'split'      => true
    +			],
    +
    +			// split strings with non-comma separator
    +			[
    +				'attributes' => ['a' => 'a; b', 'b' => 'b; c', 'c' => 'c; d'],
    +				'operator'   =>  '!=',
    +				'test'       => 'b',
    +				'expected'   => ['c'],
    +				'split'      => ';'
     			],
     
     			// booleans
    
  • tests/Toolkit/CollectionTest.php+91 1 modified
    @@ -5,6 +5,51 @@
     use Exception;
     use PHPUnit\Framework\Attributes\CoversClass;
     
    +class AccessibleObject
    +{
    +	public function value(): string
    +	{
    +		return 'accessible';
    +	}
    +}
    +
    +class BlockedObject
    +{
    +	#[BlockCollectionAccess]
    +	public function secret(): string
    +	{
    +		return 'sensitive';
    +	}
    +}
    +
    +class HasMethodsObject
    +{
    +	public static array $methods = [];
    +
    +	public function __call(string $name, array $args): mixed
    +	{
    +		return $this->getMethod($name)?->call($this, ...$args);
    +	}
    +
    +	public function getMethod(string $method): \Closure|null
    +	{
    +		return static::$methods[$method] ?? null;
    +	}
    +
    +	public function hasMethod(string $method): bool
    +	{
    +		return isset(static::$methods[$method]);
    +	}
    +}
    +
    +class MagicCallObject
    +{
    +	public function __call(string $name, array $args): string
    +	{
    +		return 'magic';
    +	}
    +}
    +
     class StringObject
     {
     	public function __construct(
    @@ -166,6 +211,7 @@ public function testFilter(): void
     
     	public function testFirst(): void
     	{
    +		$this->assertNull((new Collection())->first());
     		$this->assertSame('My first element', $this->collection->first());
     	}
     
    @@ -192,9 +238,22 @@ public function testGetAttributeFromArray(): void
     		$this->assertSame('Homer', $collection->getAttribute($collection->first(), 'username'));
     		$this->assertSame('Marge', $collection->getAttribute($collection->last(), 'username'));
     
    -		// split
    +		// split with default comma separator (bool true)
     		$this->assertSame(['simpson', 'male'], $collection->getAttribute($collection->first(), 'tags', true));
     		$this->assertSame(['simpson', 'female'], $collection->getAttribute($collection->last(), 'tags', true));
    +
    +		// split with explicit comma separator (string ',')
    +		$this->assertSame(['simpson', 'male'], $collection->getAttribute($collection->first(), 'tags', ','));
    +		$this->assertSame(['simpson', 'female'], $collection->getAttribute($collection->last(), 'tags', ','));
    +
    +		// split with non-comma separator
    +		$collection2 = new Collection([
    +			'a' => ['username' => 'Homer', 'tags' => 'simpson; male'],
    +			'b' => ['username' => 'Marge', 'tags' => 'simpson; female'],
    +		]);
    +
    +		$this->assertSame(['simpson', 'male'], $collection2->getAttribute($collection2->first(), 'tags', ';'));
    +		$this->assertSame(['simpson', 'female'], $collection2->getAttribute($collection2->last(), 'tags', ';'));
     	}
     
     	public function testGetAttributeFromObject(): void
    @@ -212,6 +271,36 @@ public function testGetAttributeFromObject(): void
     		$this->assertSame('Marge', $collection->getAttribute($collection->last(), 'username'));
     	}
     
    +	public function testGetAttributeFromObjectAccessibleMethod(): void
    +	{
    +		$obj        = new AccessibleObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertSame('accessible', $collection->getAttribute($obj, 'value'));
    +	}
    +
    +	public function testGetAttributeFromObjectBlockedMethod(): void
    +	{
    +		$obj        = new BlockedObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertNull($collection->getAttribute($obj, 'secret'));
    +	}
    +
    +	public function testGetAttributeFromObjectViaHasMethods(): void
    +	{
    +		HasMethodsObject::$methods['custom'] = fn () => 'custom value';
    +		$obj        = new HasMethodsObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertSame('custom value', $collection->getAttribute($obj, 'custom'));
    +		HasMethodsObject::$methods = [];
    +	}
    +
    +	public function testGetAttributeFromObjectViaMagicCall(): void
    +	{
    +		$obj        = new MagicCallObject();
    +		$collection = new Collection([$obj]);
    +		$this->assertSame('magic', $collection->getAttribute($obj, 'anything'));
    +	}
    +
     	public function testGetters(): void
     	{
     		$this->assertSame('My first element', $this->collection->first);
    @@ -553,6 +642,7 @@ public function testKeys(): void
     
     	public function testLast(): void
     	{
    +		$this->assertNull((new Collection())->last());
     		$this->assertSame('My third element', $this->collection->last());
     	}
     
    
  • tests/Toolkit/HtmlTest.php+79 0 modified
    @@ -82,6 +82,85 @@ public function testAWithTargetAndRel(): void
     		$this->assertSame($expected, $html);
     	}
     
    +	public function testAWithDangerousSchemes(): void
    +	{
    +		// dangerous schemes are blocked via Html::link delegation
    +		$html = Html::a('javascript:alert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +		$this->assertStringContainsString('href=""', $html);
    +	}
    +
    +	public function testLink(): void
    +	{
    +		$html = Html::link('https://getkirby.com');
    +		$expected = '<a href="https://getkirby.com">getkirby.com</a>';
    +		$this->assertSame($expected, $html);
    +
    +		$html = Html::link('https://getkirby.com', 'Kirby');
    +		$expected = '<a href="https://getkirby.com">Kirby</a>';
    +		$this->assertSame($expected, $html);
    +	}
    +
    +	public function testLinkWithDangerousSchemes(): void
    +	{
    +		// javascript://
    +		$html = Html::link('javascript://comment%0Aalert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +		$this->assertStringContainsString('href=""', $html);
    +
    +		// bare javascript: (no //)
    +		$html = Html::link('javascript:alert(1)', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		// other dangerous schemes
    +		$html = Html::link('vbscript://x', 'click');
    +		$this->assertStringNotContainsString('vbscript:', $html);
    +
    +		$html = Html::link('data://text/html;base64,PHN2Zz4=', 'click');
    +		$this->assertStringNotContainsString('data:', $html);
    +
    +		$html = Html::link('livescript://x', 'click');
    +		$this->assertStringNotContainsString('livescript:', $html);
    +
    +		$html = Html::link('mocha://x', 'click');
    +		$this->assertStringNotContainsString('mocha:', $html);
    +
    +		$html = Html::link('jar://x', 'click');
    +		$this->assertStringNotContainsString('jar:', $html);
    +
    +		// whitespace/tab/newline bypass attempts
    +		$html = Html::link(' javascript://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::link("\tjavascript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::link("\njavascript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::link('java script://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::link('java script://x', 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::link("java\tscript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		$html = Html::link("javasc\nript://x", 'click');
    +		$this->assertStringNotContainsString('javascript:', $html);
    +
    +		// safe schemes must still work
    +		$html = Html::link('https://getkirby.com', 'Kirby');
    +		$this->assertStringContainsString('href="https://getkirby.com"', $html);
    +
    +		$html = Html::link('custom://getkirby.com', 'Kirby');
    +		$this->assertStringContainsString('href="custom://getkirby.com"', $html);
    +
    +		$html = Html::link('/relative/path', 'link');
    +		$this->assertStringContainsString('href="/relative/path"', $html);
    +	}
    +
     	#[DataProvider('attrProvider')]
     	public function testAttr(
     		array $input,
    
  • vendor/composer/autoload_classmap.php+1 0 modified
    @@ -397,6 +397,7 @@
         'Kirby\\Text\\Markdown' => $baseDir . '/src/Text/Markdown.php',
         'Kirby\\Text\\SmartyPants' => $baseDir . '/src/Text/SmartyPants.php',
         'Kirby\\Toolkit\\A' => $baseDir . '/src/Toolkit/A.php',
    +    'Kirby\\Toolkit\\BlockCollectionAccess' => $baseDir . '/src/Toolkit/BlockCollectionAccess.php',
         'Kirby\\Toolkit\\Collection' => $baseDir . '/src/Toolkit/Collection.php',
         'Kirby\\Toolkit\\Component' => $baseDir . '/src/Toolkit/Component.php',
         'Kirby\\Toolkit\\Config' => $baseDir . '/src/Toolkit/Config.php',
    
  • vendor/composer/autoload_static.php+1 0 modified
    @@ -518,6 +518,7 @@ class ComposerStaticInit0bf5c8a9cfa251a218fc581ac888fe35
             'Kirby\\Text\\Markdown' => __DIR__ . '/../..' . '/src/Text/Markdown.php',
             'Kirby\\Text\\SmartyPants' => __DIR__ . '/../..' . '/src/Text/SmartyPants.php',
             'Kirby\\Toolkit\\A' => __DIR__ . '/../..' . '/src/Toolkit/A.php',
    +        'Kirby\\Toolkit\\BlockCollectionAccess' => __DIR__ . '/../..' . '/src/Toolkit/BlockCollectionAccess.php',
             'Kirby\\Toolkit\\Collection' => __DIR__ . '/../..' . '/src/Toolkit/Collection.php',
             'Kirby\\Toolkit\\Component' => __DIR__ . '/../..' . '/src/Toolkit/Component.php',
             'Kirby\\Toolkit\\Config' => __DIR__ . '/../..' . '/src/Toolkit/Config.php',
    
  • vendor/composer/installed.json+38 34 modified
    @@ -509,17 +509,17 @@
             },
             {
                 "name": "phpmailer/phpmailer",
    -            "version": "v7.0.2",
    -            "version_normalized": "7.0.2.0",
    +            "version": "v7.1.1",
    +            "version_normalized": "7.1.1.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/PHPMailer/PHPMailer.git",
    -                "reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088"
    +                "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
    -                "reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
    +                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
    +                "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
                     "shasum": ""
                 },
                 "require": {
    @@ -550,7 +550,7 @@
                     "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
                     "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
                 },
    -            "time": "2026-01-09T18:02:33+00:00",
    +            "time": "2026-05-18T08:06:14+00:00",
                 "type": "library",
                 "installation-source": "dist",
                 "autoload": {
    @@ -582,7 +582,7 @@
                 "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
                 "support": {
                     "issues": "https://github.com/PHPMailer/PHPMailer/issues",
    -                "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.0.2"
    +                "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
                 },
                 "funding": [
                     {
    @@ -647,31 +647,31 @@
             },
             {
                 "name": "symfony/deprecation-contracts",
    -            "version": "v3.6.0",
    -            "version_normalized": "3.6.0.0",
    +            "version": "v3.7.0",
    +            "version_normalized": "3.7.0.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/deprecation-contracts.git",
    -                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
    +                "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
    -                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
    +                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
    +                "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
                     "shasum": ""
                 },
                 "require": {
                     "php": ">=8.1"
                 },
    -            "time": "2024-09-25T14:21:43+00:00",
    +            "time": "2026-04-13T15:52:40+00:00",
                 "type": "library",
                 "extra": {
                     "thanks": {
                         "url": "https://github.com/symfony/contracts",
                         "name": "symfony/contracts"
                     },
                     "branch-alias": {
    -                    "dev-main": "3.6-dev"
    +                    "dev-main": "3.7-dev"
                     }
                 },
                 "installation-source": "dist",
    @@ -697,7 +697,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.6.0"
    +                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
                 },
                 "funding": [
                     {
    @@ -708,6 +708,10 @@
                         "url": "https://github.com/fabpot",
                         "type": "github"
                     },
    +                {
    +                    "url": "https://github.com/nicolas-grekas",
    +                    "type": "github"
    +                },
                     {
                         "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                         "type": "tidelift"
    @@ -717,8 +721,8 @@
             },
             {
                 "name": "symfony/polyfill-ctype",
    -            "version": "v1.36.0",
    -            "version_normalized": "1.36.0.0",
    +            "version": "v1.37.0",
    +            "version_normalized": "1.37.0.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-ctype.git",
    @@ -779,7 +783,7 @@
                     "portable"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -803,8 +807,8 @@
             },
             {
                 "name": "symfony/polyfill-intl-idn",
    -            "version": "v1.36.0",
    -            "version_normalized": "1.36.0.0",
    +            "version": "v1.37.0",
    +            "version_normalized": "1.37.0.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-intl-idn.git",
    @@ -869,7 +873,7 @@
                     "shim"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -893,8 +897,8 @@
             },
             {
                 "name": "symfony/polyfill-intl-normalizer",
    -            "version": "v1.36.0",
    -            "version_normalized": "1.36.0.0",
    +            "version": "v1.37.0",
    +            "version_normalized": "1.37.0.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
    @@ -957,7 +961,7 @@
                     "shim"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -981,8 +985,8 @@
             },
             {
                 "name": "symfony/polyfill-mbstring",
    -            "version": "v1.36.0",
    -            "version_normalized": "1.36.0.0",
    +            "version": "v1.37.0",
    +            "version_normalized": "1.37.0.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/polyfill-mbstring.git",
    @@ -1045,7 +1049,7 @@
                     "shim"
                 ],
                 "support": {
    -                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0"
    +                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
                 },
                 "funding": [
                     {
    @@ -1069,17 +1073,17 @@
             },
             {
                 "name": "symfony/yaml",
    -            "version": "v7.4.8",
    -            "version_normalized": "7.4.8.0",
    +            "version": "v7.4.11",
    +            "version_normalized": "7.4.11.0",
                 "source": {
                     "type": "git",
                     "url": "https://github.com/symfony/yaml.git",
    -                "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883"
    +                "reference": "e2eb64a57763815ccae07ac1c7653d6cc1c326fd"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
    -                "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
    +                "url": "https://api.github.com/repos/symfony/yaml/zipball/e2eb64a57763815ccae07ac1c7653d6cc1c326fd",
    +                "reference": "e2eb64a57763815ccae07ac1c7653d6cc1c326fd",
                     "shasum": ""
                 },
                 "require": {
    @@ -1093,7 +1097,7 @@
                 "require-dev": {
                     "symfony/console": "^6.4|^7.0|^8.0"
                 },
    -            "time": "2026-03-24T13:12:05+00:00",
    +            "time": "2026-05-13T12:04:42+00:00",
                 "bin": [
                     "Resources/bin/yaml-lint"
                 ],
    @@ -1124,7 +1128,7 @@
                 "description": "Loads and dumps YAML files",
                 "homepage": "https://symfony.com",
                 "support": {
    -                "source": "https://github.com/symfony/yaml/tree/v7.4.8"
    +                "source": "https://github.com/symfony/yaml/tree/v7.4.11"
                 },
                 "funding": [
                     {
    
  • vendor/composer/installed.php+21 21 modified
    @@ -1,8 +1,8 @@
     <?php return array(
         'root' => array(
             'name' => 'getkirby/cms',
    -        'pretty_version' => '5.4.0',
    -        'version' => '5.4.0.0',
    +        'pretty_version' => '5.4.1',
    +        'version' => '5.4.1.0',
             'reference' => null,
             'type' => 'kirby-cms',
             'install_path' => __DIR__ . '/../../',
    @@ -47,8 +47,8 @@
                 'dev_requirement' => false,
             ),
             'getkirby/cms' => array(
    -            'pretty_version' => '5.4.0',
    -            'version' => '5.4.0.0',
    +            'pretty_version' => '5.4.1',
    +            'version' => '5.4.1.0',
                 'reference' => null,
                 'type' => 'kirby-cms',
                 'install_path' => __DIR__ . '/../../',
    @@ -98,9 +98,9 @@
                 'dev_requirement' => false,
             ),
             'phpmailer/phpmailer' => array(
    -            'pretty_version' => 'v7.0.2',
    -            'version' => '7.0.2.0',
    -            'reference' => 'ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088',
    +            'pretty_version' => 'v7.1.1',
    +            'version' => '7.1.1.0',
    +            'reference' => '1bc1716a507a65e039d4ac9d9adebbbd0d346e15',
                 'type' => 'library',
                 'install_path' => __DIR__ . '/../phpmailer/phpmailer',
                 'aliases' => array(),
    @@ -116,44 +116,44 @@
                 'dev_requirement' => false,
             ),
             'symfony/deprecation-contracts' => array(
    -            'pretty_version' => 'v3.6.0',
    -            'version' => '3.6.0.0',
    -            'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62',
    +            'pretty_version' => 'v3.7.0',
    +            'version' => '3.7.0.0',
    +            'reference' => '50f59d1f3ca46d41ac911f97a78626b6756af35b',
                 'type' => 'library',
                 'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
                 'aliases' => array(),
                 'dev_requirement' => false,
             ),
             'symfony/polyfill-ctype' => array(
    -            'pretty_version' => 'v1.36.0',
    -            'version' => '1.36.0.0',
    +            'pretty_version' => 'v1.37.0',
    +            'version' => '1.37.0.0',
                 'reference' => '141046a8f9477948ff284fa65be2095baafb94f2',
                 'type' => 'library',
                 'install_path' => __DIR__ . '/../symfony/polyfill-ctype',
                 'aliases' => array(),
                 'dev_requirement' => false,
             ),
             'symfony/polyfill-intl-idn' => array(
    -            'pretty_version' => 'v1.36.0',
    -            'version' => '1.36.0.0',
    +            'pretty_version' => 'v1.37.0',
    +            'version' => '1.37.0.0',
                 'reference' => '9614ac4d8061dc257ecc64cba1b140873dce8ad3',
                 'type' => 'library',
                 'install_path' => __DIR__ . '/../symfony/polyfill-intl-idn',
                 'aliases' => array(),
                 'dev_requirement' => false,
             ),
             'symfony/polyfill-intl-normalizer' => array(
    -            'pretty_version' => 'v1.36.0',
    -            'version' => '1.36.0.0',
    +            'pretty_version' => 'v1.37.0',
    +            'version' => '1.37.0.0',
                 'reference' => '3833d7255cc303546435cb650316bff708a1c75c',
                 'type' => 'library',
                 'install_path' => __DIR__ . '/../symfony/polyfill-intl-normalizer',
                 'aliases' => array(),
                 'dev_requirement' => false,
             ),
             'symfony/polyfill-mbstring' => array(
    -            'pretty_version' => 'v1.36.0',
    -            'version' => '1.36.0.0',
    +            'pretty_version' => 'v1.37.0',
    +            'version' => '1.37.0.0',
                 'reference' => '6a21eb99c6973357967f6ce3708cd55a6bec6315',
                 'type' => 'library',
                 'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
    @@ -167,9 +167,9 @@
                 ),
             ),
             'symfony/yaml' => array(
    -            'pretty_version' => 'v7.4.8',
    -            'version' => '7.4.8.0',
    -            'reference' => 'c58fdf7b3d6c2995368264c49e4e8b05bcff2883',
    +            'pretty_version' => 'v7.4.11',
    +            'version' => '7.4.11.0',
    +            'reference' => 'e2eb64a57763815ccae07ac1c7653d6cc1c326fd',
                 'type' => 'library',
                 'install_path' => __DIR__ . '/../symfony/yaml',
                 'aliases' => array(),
    
  • vendor/phpmailer/phpmailer/language/phpmailer.lang-nb.php+1 1 modified
    @@ -24,7 +24,7 @@
      $PHPMAILER_LANG['invalid_host']         = 'Ugyldig vert: ';
      $PHPMAILER_LANG['mailer_not_supported'] = ' sender er ikke støttet.';
      $PHPMAILER_LANG['provide_address']      = 'Du må oppgi minst én mottaker-e-postadresse.';
    - $PHPMAILER_LANG['recipients_failed']    = 'SMTP Feil: Følgende mottakeradresse feilet: ';
    + $PHPMAILER_LANG['recipients_failed']    = 'SMTP-feil: Følgende mottakeradresser feilet: ';
      $PHPMAILER_LANG['signing']              = 'Signeringsfeil: ';
      $PHPMAILER_LANG['smtp_code']            = 'SMTP-kode: ';
      $PHPMAILER_LANG['smtp_code_ex']         = 'Ytterligere SMTP-info: ';
    
  • vendor/phpmailer/phpmailer/language/phpmailer.lang-tr.php+5 0 modified
    @@ -8,6 +8,7 @@
      * @author Mehmet Benlioğlu
      * @author @yasinaydin
      * @author Ogün Karakuş
    + * @author Mustafa Deniz Buksur
      */
     
     $PHPMAILER_LANG['authenticate']         = 'SMTP Hatası: Oturum açılamadı.';
    @@ -36,3 +37,7 @@
     $PHPMAILER_LANG['smtp_detail']          = 'SMTP SMTP Detayı: ';
     $PHPMAILER_LANG['smtp_error']           = 'SMTP sunucu hatası: ';
     $PHPMAILER_LANG['variable_set']         = 'Değişken ayarlanamadı ya da sıfırlanamadı: ';
    +$PHPMAILER_LANG['no_smtputf8']          = 'Unicode adreslere gönderim için gereken SMTPUTF8 desteği sunucu tarafından desteklenmiyor.';
    +$PHPMAILER_LANG['imap_recommended']     = 'Basitleştirilmiş adres ayrıştırıcısını kullanmanız önerilmez. ' .
    +    'Tam RFC822 ayrıştırma için PHP IMAP eklentisini yükleyin.';
    +$PHPMAILER_LANG['deprecated_argument']  = 'Kullanımdan kaldırılmış argüman: ';
    
  • vendor/phpmailer/phpmailer/src/PHPMailer.php+80 19 modified
    @@ -59,6 +59,7 @@ class PHPMailer
         const ICAL_METHOD_REFRESH = 'REFRESH';
         const ICAL_METHOD_COUNTER = 'COUNTER';
         const ICAL_METHOD_DECLINECOUNTER = 'DECLINECOUNTER';
    +    const RFC822_DATE_FORMAT = 'D, j M Y H:i:s O';
     
         /**
          * Email priority.
    @@ -77,7 +78,7 @@ class PHPMailer
         public $CharSet = self::CHARSET_ISO88591;
     
         /**
    -     * The MIME Content-type of the message.
    +     * The MIME Content-Type of the message.
          *
          * @var string
          */
    @@ -159,7 +160,7 @@ class PHPMailer
         public $Ical = '';
     
         /**
    -     * Value-array of "method" in Contenttype header "text/calendar"
    +     * Value-array of "method" in Content-Type header "text/calendar"
          *
          * @var string[]
          */
    @@ -768,7 +769,7 @@ class PHPMailer
          *
          * @var string
          */
    -    const VERSION = '7.0.2';
    +    const VERSION = '7.1.1';
     
         /**
          * Error severity: message only, continue processing.
    @@ -1283,26 +1284,27 @@ protected function addAnAddress($kind, $address, $name = '')
         /**
          * Parse and validate a string containing one or more RFC822-style comma-separated email addresses
          * of the form "display name <address>" into an array of name/address pairs.
    -     * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available.
    +     * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available and
    +     * the deprecated $useimap argument is truthy.
          * Note that quotes in the name part are removed.
          *
          * @see https://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation
          *
          * @param string $addrstr The address list string
    -     * @param null   $useimap Unused. Argument has been deprecated in PHPMailer 6.11.0.
    -     *                        Previously this argument determined whether to use
    -     *                        the IMAP extension to parse the list and accepted a boolean value.
    +     * @param bool|null $useimap Deprecated in PHPMailer 6.11.0.
    +     *                           Truthy values request the deprecated IMAP parser
    +     *                           and trigger a deprecation warning.
          * @param string $charset The charset to use when decoding the address list string.
          *
          * @return array
          */
         public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591)
         {
    -        if ($useimap !== null) {
    +        if ($useimap == true) {
                 trigger_error(self::lang('deprecated_argument') . '$useimap', E_USER_DEPRECATED);
             }
             $addresses = [];
    -        if (function_exists('imap_rfc822_parse_adrlist')) {
    +        if ($useimap == true && function_exists('imap_rfc822_parse_adrlist')) {
                 //Use this built-in parser if it's available
                 // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.imap_rfc822_parse_adrlistRemoved -- wrapped in function_exists()
                 $list = imap_rfc822_parse_adrlist($addrstr, '');
    @@ -1779,6 +1781,8 @@ public function preSend()
     
                 //Trim subject consistently
                 $this->Subject = trim($this->Subject);
    +
    +
                 //Create body before headers in case body makes changes to headers (e.g. altering transfer encoding)
                 $this->MIMEHeader = '';
                 $this->MIMEBody = $this->createBody();
    @@ -1853,7 +1857,7 @@ public function postSend()
                         return $this->mailSend($this->MIMEHeader, $this->MIMEBody);
                     default:
                         $sendMethod = $this->Mailer . 'Send';
    -                    if (method_exists($this, $sendMethod)) {
    +                    if (!empty($this->Mailer) && method_exists($this, $sendMethod)) {
                             return $this->{$sendMethod}($this->MIMEHeader, $this->MIMEBody);
                         }
     
    @@ -1911,7 +1915,7 @@ protected function sendmailSend($header, $body)
     
             // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped.
             // Also don't add the -f automatically unless it has been set either via Sender
    -        // or sendmail_path. Otherwise it can introduce new problems.
    +        // or sendmail_path. Otherwise, it can introduce new problems.
             // @see http://github.com/PHPMailer/PHPMailer/issues/2298
             if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) {
                 $sendmailArgs[] = '-f' . $this->Sender;
    @@ -2510,7 +2514,7 @@ public static function setLanguage($langcode = 'en', $lang_path = '')
                 'authenticate' => 'SMTP Error: Could not authenticate.',
                 'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' .
                     ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' .
    -                ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.',
    +                ' your php.ini, switch to macOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.',
                 'connect_host' => 'SMTP Error: Could not connect to SMTP host.',
                 'data_not_accepted' => 'SMTP Error: data not accepted.',
                 'empty_message' => 'Message body empty',
    @@ -2847,7 +2851,10 @@ public function createHeader()
         {
             $result = '';
     
    -        $result .= $this->headerLine('Date', '' === $this->MessageDate ? self::rfcDate() : $this->MessageDate);
    +        $result .= $this->headerLine(
    +            'Date',
    +            self::sanitiseDate($this->MessageDate)
    +        );
     
             //The To header is created automatically by mail(), so needs to be omitted here
             if ('mail' !== $this->Mailer) {
    @@ -2916,7 +2923,7 @@ public function createHeader()
                 );
             } elseif (is_string($this->XMailer) && trim($this->XMailer) !== '') {
                 //Some string
    -            $result .= $this->headerLine('X-Mailer', trim($this->XMailer));
    +            $result .= $this->headerLine('X-Mailer', $this->secureHeader(trim($this->XMailer)));
             } //Other values result in no X-Mailer header
     
             if ('' !== $this->ConfirmReadingTo) {
    @@ -2966,13 +2973,20 @@ public function getMailMIME()
                     break;
                 default:
                     //Catches case 'plain': and case '':
    -                $result .= $this->textLine('Content-Type: ' . $this->ContentType . '; charset=' . $this->CharSet);
    +                $result .= $this->textLine(
    +                    'Content-Type: ' .
    +                    $this->secureHeader($this->ContentType) .
    +                    '; charset=' . $this->secureHeader($this->CharSet)
    +                );
                     $ismultipart = false;
                     break;
             }
    +        if (!$this->validateEncoding($this->Encoding)) {
    +            throw new Exception(self::lang('encoding') . $this->Encoding);
    +        }
             //RFC1341 part 5 says 7bit is assumed if not specified
             if (static::ENCODING_7BIT !== $this->Encoding) {
    -            //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE
    +            //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit, or binary CTE
                 if ($ismultipart) {
                     if (static::ENCODING_8BIT === $this->Encoding) {
                         $result .= $this->headerLine('Content-Transfer-Encoding', static::ENCODING_8BIT);
    @@ -3047,6 +3061,9 @@ public function createBody()
     
             $this->setWordWrap();
     
    +        if (!$this->validateEncoding($this->Encoding)) {
    +            throw new Exception(self::lang('encoding') . $this->Encoding);
    +        }
             $bodyEncoding = $this->Encoding;
             $bodyCharSet = $this->CharSet;
             //Can we do a 7-bit downgrade?
    @@ -4166,7 +4183,7 @@ public function addStringEmbeddedImage(
         protected function validateEncoding($encoding)
         {
             return in_array(
    -            $encoding,
    +            strtolower($encoding),
                 [
                     self::ENCODING_7BIT,
                     self::ENCODING_QUOTED_PRINTABLE,
    @@ -4426,7 +4443,7 @@ protected function setError($msg)
         }
     
         /**
    -     * Return an RFC 822 formatted date.
    +     * Return the current date and time as an RFC 822 formatted date.
          *
          * @return string
          */
    @@ -4436,7 +4453,51 @@ public static function rfcDate()
             //Will default to UTC if it's not set properly in php.ini
             date_default_timezone_set(@date_default_timezone_get());
     
    -        return date('D, j M Y H:i:s O');
    +        return date(self::RFC822_DATE_FORMAT);
    +    }
    +
    +    /**
    +     * Normalise a user-supplied date into a correctly-formatted RFC 5322 date value
    +     * string suitable for use in the Date header.
    +     *
    +     * Accepts:
    +     *  - A {@see \DateTime} (or \DateTimeImmutable) object
    +     *  - Any date/time string understood by PHP's DateTime constructor (RFC 5322, ISO 8601,
    +     *    Unix timestamp with leading "@", natural-language strings, etc.)
    +     *
    +     * Dates in the future are not permitted for email headers; if the parsed date is later
    +     * than "now" the method falls back to the current time via {@see self::rfcDate()}.
    +     * An empty value, a non-string/non-DateTime argument, or any value that cannot be
    +     * parsed will likewise fall back to {@see self::rfcDate()}.
    +     *
    +     * @param \DateTime|\DateTimeImmutable|string $date The date to normalise
    +     *
    +     * @return string An RFC 5322-formatted date string
    +     */
    +    private static function sanitiseDate($date)
    +    {
    +        try {
    +            //Ensure the default timezone is set properly
    +            date_default_timezone_set(@date_default_timezone_get());
    +
    +            if ($date instanceof \DateTimeInterface) {
    +                $dt = $date;
    +            } elseif (is_string($date) && $date !== '') {
    +                $dt = new \DateTime($date);
    +            } else {
    +                //Empty string, null, or any unsupported type
    +                return self::rfcDate();
    +            }
    +
    +            //Reject future dates — they are invalid for outgoing message headers
    +            if ($dt->getTimestamp() > time()) {
    +                return self::rfcDate();
    +            }
    +
    +            return $dt->format(self::RFC822_DATE_FORMAT);
    +        } catch (\Exception $e) {
    +            return self::rfcDate();
    +        }
         }
     
         /**
    
  • vendor/phpmailer/phpmailer/src/POP3.php+19 6 modified
    @@ -47,7 +47,7 @@ class POP3
          * @var string
          * @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead.
          */
    -    const VERSION = '7.0.2';
    +    const VERSION = '7.1.1';
     
         /**
          * Default POP3 port number.
    @@ -212,9 +212,9 @@ public function authorise($host, $port = false, $timeout = false, $username = ''
             } else {
                 $this->tval = (int) $timeout;
             }
    -        $this->do_debug = $debug_level;
    -        $this->username = $username;
    -        $this->password = $password;
    +        $this->do_debug = (int) $debug_level;
    +        $this->username = self::stripControls($username);
    +        $this->password = self::stripControls($password);
             //Reset the error log
             $this->errors = [];
             //Connect
    @@ -319,7 +319,8 @@ public function login($username = '', $password = '')
             if (empty($password)) {
                 $password = $this->password;
             }
    -
    +        $username = self::stripControls($username);
    +        $password = self::stripControls($password);
             //Send the Username
             $this->sendString("USER $username" . static::LE);
             $pop3_response = $this->getResponse();
    @@ -407,7 +408,7 @@ protected function sendString($string)
     
         /**
          * Checks the POP3 server response.
    -     * Looks for for +OK or -ERR.
    +     * Looks for +OK or -ERR.
          *
          * @param string $string
          *
    @@ -467,4 +468,16 @@ protected function catchWarning($errno, $errstr, $errfile, $errline)
                 "errno: $errno errstr: $errstr; errfile: $errfile; errline: $errline"
             );
         }
    +
    +    /**
    +     * Strip all control chars from a string.
    +     *
    +     * @param $string
    +     *
    +     * @return string
    +     */
    +    protected static function stripControls($string)
    +    {
    +        return preg_replace('/[\x00-\x1F\x7F]/u', '', $string);
    +    }
     }
    
  • vendor/phpmailer/phpmailer/src/SMTP.php+2 2 modified
    @@ -36,7 +36,7 @@ class SMTP
          * @var string
          * @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead.
          */
    -    const VERSION = '7.0.2';
    +    const VERSION = '7.1.1';
     
         /**
          * SMTP line break constant.
    @@ -1289,7 +1289,7 @@ public function getServerExtList()
          *   3. EHLO has been sent -
          *     $name == 'HELO'|'EHLO': returns the server name
          *     $name == any other string: if extension $name exists, returns True
    -     *       or its options (e.g. AUTH mechanisms supported). Otherwise returns False.
    +     *       or its options (e.g. AUTH mechanisms supported). Otherwise, returns False.
          *
          * @param string $name Name of SMTP extension or 'HELO'|'EHLO'
          *
    
  • vendor/symfony/deprecation-contracts/composer.json+1 1 modified
    @@ -25,7 +25,7 @@
         "minimum-stability": "dev",
         "extra": {
             "branch-alias": {
    -            "dev-main": "3.6-dev"
    +            "dev-main": "3.7-dev"
             },
             "thanks": {
                 "name": "symfony/contracts",
    
  • vendor/symfony/yaml/Command/LintCommand.php+2 2 modified
    @@ -199,7 +199,7 @@ private function displayJson(SymfonyStyle $io, array $filesInfo): int
         {
             $errors = 0;
     
    -        array_walk($filesInfo, function (&$v) use (&$errors) {
    +        array_walk($filesInfo, static function (&$v) use (&$errors) {
                 $v['file'] = (string) $v['file'];
                 if (!$v['valid']) {
                     ++$errors;
    @@ -239,7 +239,7 @@ private function getParser(): Parser
     
         private function getDirectoryIterator(string $directory): iterable
         {
    -        $default = fn ($directory) => new \RecursiveIteratorIterator(
    +        $default = static fn ($directory) => new \RecursiveIteratorIterator(
                 new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS),
                 \RecursiveIteratorIterator::LEAVES_ONLY
             );
    
  • vendor/symfony/yaml/Inline.php+14 6 modified
    @@ -387,6 +387,7 @@ private static function parseSequence(string $sequence, int $flags, int &$i = 0,
                         $value = self::parseMapping($sequence, $flags, $i, $references);
                         break;
                     default:
    +                    $hasAnchorAtStart = null === $tag && isset($sequence[$i]) && '&' === $sequence[$i];
                         $value = self::parseScalar($sequence, $flags, [',', ']'], $i, null === $tag, $references, $isQuoted);
     
                         // the value can be an array if a reference has been resolved to an array var
    @@ -422,9 +423,9 @@ private static function parseSequence(string $sequence, int $flags, int &$i = 0,
                             }
                         }
     
    -                    if (!$isQuoted && \is_string($value) && '' !== $value && '&' === $value[0] && Parser::preg_match(Parser::REFERENCE_PATTERN, $value, $matches)) {
    -                        $references[$matches['ref']] = $matches['value'];
    -                        $value = $matches['value'];
    +                    if ($hasAnchorAtStart && !$isQuoted && \is_string($value) && '' !== $value && '&' === $value[0] && Parser::preg_match(Parser::REFERENCE_PATTERN, $value, $matches)) {
    +                        $value = '' === $matches['value'] ? null : $matches['value'];
    +                        $references[$matches['ref']] = $value;
                         }
     
                         --$i;
    @@ -555,6 +556,7 @@ private static function parseMapping(string $mapping, int $flags, int &$i = 0, a
                             }
                             break;
                         default:
    +                        $hasAnchorAtStart = null === $tag && isset($mapping[$i]) && '&' === $mapping[$i];
                             $value = self::parseScalar($mapping, $flags, [',', '}', "\n"], $i, null === $tag, $references, $isValueQuoted);
                             // Spec: Keys MUST be unique; first one wins.
                             // Parser cannot abort this mapping earlier, since lines
    @@ -563,9 +565,9 @@ private static function parseMapping(string $mapping, int $flags, int &$i = 0, a
                             if ('<<' === $key) {
                                 $output += $value;
                             } elseif ($allowOverwrite || !isset($output[$key])) {
    -                            if (!$isValueQuoted && \is_string($value) && '' !== $value && '&' === $value[0] && !self::isBinaryString($value) && Parser::preg_match(Parser::REFERENCE_PATTERN, $value, $matches)) {
    -                                $references[$matches['ref']] = $matches['value'];
    -                                $value = $matches['value'];
    +                            if ($hasAnchorAtStart && !$isValueQuoted && \is_string($value) && '' !== $value && '&' === $value[0] && !self::isBinaryString($value) && Parser::preg_match(Parser::REFERENCE_PATTERN, $value, $matches)) {
    +                                $value = '' === $matches['value'] ? null : $matches['value'];
    +                                $references[$matches['ref']] = $value;
                                 }
     
                                 if (null !== $tag) {
    @@ -831,6 +833,12 @@ public static function evaluateBinaryScalar(string $scalar): string
         {
             $parsedBinaryData = self::parseScalar(preg_replace('/\s/', '', $scalar));
     
    +        if (!\is_scalar($parsedBinaryData ?? '') && !$parsedBinaryData instanceof \Stringable) {
    +            throw new ParseException(\sprintf('The "!!binary" tag only supports a base64 encoded string, got "%s".', get_debug_type($parsedBinaryData)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename);
    +        }
    +
    +        $parsedBinaryData = (string) $parsedBinaryData;
    +
             if (0 !== (\strlen($parsedBinaryData) % 4)) {
                 throw new ParseException(\sprintf('The normalized base64 encoded data (data without whitespace characters) length must be a multiple of four (%d bytes given).', \strlen($parsedBinaryData)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename);
             }
    

Vulnerability mechanics

Root cause

"Missing validation of user-provided ID allows path traversal in user lookup."

Attack vector

An attacker supplies a crafted user ID containing path traversal sequences (e.g., `../`) to Kirby's user lookup mechanism. This ID is used to construct a filesystem path to the user's account directory under `site/accounts`. Because Kirby did not validate the ID before using it in file operations, the attacker can escape the intended directory. The attack is reachable via the authentication API (unauthenticated) and the users API (authenticated), as well as any other code path that calls `$users->find()` with a request-provided email or user ID. The result is arbitrary PHP file inclusion of files named `index.php` (such as plugin entry points) and probing of arbitrary directory existence on the server.

Affected code

The vulnerability is in Kirby's `Users` collection, specifically in the user lookup logic introduced in Kirby 5.3.0 that loads user objects lazily. The user ID provided to `$users->find()` is used to construct a filesystem path to the account directory under `site/accounts` without sufficient validation.

What the fix does

The patch [patch_id=2595567] adds additional checks to the user lookup that ensure the provided user ID contains only valid characters and that the resulting path to the account directory is contained within the `site/accounts` directory. This closes the path traversal by rejecting any ID that would resolve to a location outside the intended accounts directory. The fix was released in Kirby 5.4.1.

Preconditions

  • networkThe attacker must be able to send HTTP requests to the Kirby site.
  • inputThe attacker must supply a crafted user ID or email containing path traversal sequences (e.g., ../) to a Kirby endpoint that calls $users->find().

Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.