Merge branch '5.0' into 5

This commit is contained in:
Steve Boyd 2023-05-22 12:46:02 +12:00
commit f54bbc50f0
14 changed files with 272 additions and 119 deletions

View File

@ -412,7 +412,10 @@ class CanonicalURLMiddleware implements HTTPMiddleware
$paths = (array) $this->getEnforceTrailingSlashConfigIgnorePaths();
if (!empty($paths)) {
foreach ($paths as $path) {
if (str_starts_with(trim($path, '/'), trim($requestPath, '/'))) {
if (str_starts_with(
$this->trailingSlashForComparison($requestPath),
$this->trailingSlashForComparison($path)
)) {
return false;
}
}
@ -439,6 +442,15 @@ class CanonicalURLMiddleware implements HTTPMiddleware
return true;
}
/**
* Ensure a string has a trailing slash to that we can use str_starts_with and compare
* paths like admin/ with administration/ and get a correct result.
*/
private function trailingSlashForComparison(string $path): string
{
return trim($path, '/') . '/';
}
/**
* @return int
*/

View File

@ -3,12 +3,11 @@
namespace SilverStripe\Core\Cache;
use SilverStripe\Core\Injector\Injector;
use Symfony\Component\Cache\Simple\ApcuCache;
use Memcached;
use Symfony\Component\Cache\Adapter\ApcuAdapter;
use Symfony\Component\Cache\Psr16Cache;
class ApcuCacheFactory implements CacheFactory
{
/**
* @var string
*/
@ -31,10 +30,11 @@ class ApcuCacheFactory implements CacheFactory
? $params['namespace'] . '_' . md5(BASE_PATH)
: md5(BASE_PATH);
$defaultLifetime = isset($params['defaultLifetime']) ? $params['defaultLifetime'] : 0;
return Injector::inst()->createWithArgs(ApcuCache::class, [
$psr6Cache = Injector::inst()->createWithArgs(ApcuAdapter::class, [
$namespace,
$defaultLifetime,
$this->version
]);
return Injector::inst()->createWithArgs(Psr16Cache::class, [$psr6Cache]);
}
}

View File

@ -3,7 +3,8 @@
namespace SilverStripe\Core\Cache;
use SilverStripe\Core\Injector\Injector;
use Symfony\Component\Cache\Simple\MemcachedCache;
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
use Symfony\Component\Cache\Psr16Cache;
use Memcached;
class MemcachedCacheFactory implements CacheFactory
@ -31,10 +32,11 @@ class MemcachedCacheFactory implements CacheFactory
? $params['namespace'] . '_' . md5(BASE_PATH)
: md5(BASE_PATH);
$defaultLifetime = isset($params['defaultLifetime']) ? $params['defaultLifetime'] : 0;
return Injector::inst()->createWithArgs(MemcachedCache::class, [
$psr6Cache = Injector::inst()->createWithArgs(MemcachedAdapter::class, [
$this->memcachedClient,
$namespace,
$defaultLifetime
]);
return Injector::inst()->createWithArgs(Psr16Cache::class, [$psr6Cache]);
}
}

View File

@ -6,6 +6,7 @@ use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Dev\Install\DatabaseAdapterRegistry;
use SilverStripe\ORM\DB;
use Exception;
use LogicException;
/**
* Simple Kernel container
@ -112,6 +113,29 @@ class CoreKernel extends BaseKernel
"password" => Environment::getEnv('SS_DATABASE_PASSWORD') ?: null,
];
// Only add SSL keys in the array if there is an actual value associated with them
$sslConf = [
'ssl_key' => 'SS_DATABASE_SSL_KEY',
'ssl_cert' => 'SS_DATABASE_SSL_CERT',
'ssl_ca' => 'SS_DATABASE_SSL_CA',
'ssl_cipher' => 'SS_DATABASE_SSL_CIPHER',
];
foreach ($sslConf as $key => $envVar) {
$envValue = Environment::getEnv($envVar);
if ($envValue) {
$databaseConfig[$key] = $envValue;
}
}
// Having only the key or cert without the other is bad configuration.
if ((isset($databaseConfig['ssl_key']) && !isset($databaseConfig['ssl_cert']))
|| (!isset($databaseConfig['ssl_key']) && isset($databaseConfig['ssl_cert']))
) {
user_error('Database SSL cert and key must both be defined to use SSL in the database.', E_USER_WARNING);
unset($databaseConfig['ssl_key']);
unset($databaseConfig['ssl_cert']);
}
// Set the port if called for
$dbPort = Environment::getEnv('SS_DATABASE_PORT');
if ($dbPort) {

View File

@ -32,15 +32,15 @@ class MySQLDatabaseConfigurationHelper implements DatabaseConfigurationHelper
case 'MySQLDatabase':
$conn = mysqli_init();
// Set SSL parameters if they exist. All parameters are required.
if (array_key_exists('ssl_key', $databaseConfig) &&
array_key_exists('ssl_cert', $databaseConfig) &&
array_key_exists('ssl_ca', $databaseConfig)
// Set SSL parameters if they exist.
// Must have both the SSL cert and key, or the common authority, or preferably all three.
if ((array_key_exists('ssl_key', $databaseConfig) && array_key_exists('ssl_cert', $databaseConfig))
|| array_key_exists('ssl_ca', $databaseConfig)
) {
$conn->ssl_set(
$databaseConfig['ssl_key'],
$databaseConfig['ssl_cert'],
$databaseConfig['ssl_ca'],
$databaseConfig['ssl_key'] ?? null,
$databaseConfig['ssl_cert'] ?? null,
$databaseConfig['ssl_ca'] ?? null,
dirname($databaseConfig['ssl_ca']),
array_key_exists('ssl_cipher', $databaseConfig)
? $databaseConfig['ssl_cipher']

View File

@ -1617,7 +1617,9 @@ class Form extends ViewableData implements HasRequestHandler
public function defaultAction()
{
if ($this->hasDefaultAction && $this->actions) {
return $this->actions->first();
return $this->actions->flattenFields()->filterByCallback(function ($field) {
return $field instanceof FormAction;
})->first();
}
return null;
}

View File

@ -6,6 +6,7 @@ use LogicException;
use SilverStripe\Admin\LeftAndMain;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\Schema\FormSchema;
@ -203,20 +204,36 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
return false;
}
$modelClass = $gridField->getModelClass();
// note: searchableFields() will return summary_fields if there are no searchable_fields on the model
$searchableFields = array_keys($modelClass::singleton()->searchableFields());
$summaryFields = array_keys($modelClass::singleton()->summaryFields());
sort($searchableFields);
sort($summaryFields);
// searchable_fields has been explictily defined i.e. searchableFields() is not falling back to summary_fields
if ($searchableFields !== $summaryFields) {
return true;
}
// we have fallen back to summary_fields, check they are filterable
foreach ($searchableFields as $searchableField) {
if ($list->canFilterBy($searchableField)) {
$singleton = singleton($modelClass);
if (ClassInfo::hasMethod($singleton, 'summaryFields')
&& ClassInfo::hasMethod($singleton, 'searchableFields')
) {
// note: searchableFields() will return summary_fields if there are no searchable_fields on the model
$searchableFields = array_keys($singleton->searchableFields());
$summaryFields = array_keys($singleton->summaryFields());
sort($searchableFields);
sort($summaryFields);
// searchable_fields has been explictily defined i.e. searchableFields() is not falling back to summary_fields
if ($searchableFields !== $summaryFields) {
return true;
}
// we have fallen back to summary_fields, check they are filterable
foreach ($searchableFields as $searchableField) {
if ($list->canFilterBy($searchableField)) {
return true;
}
}
} else {
// Allows non-DataObject classes to be used with this component
$columns = $gridField->getColumns();
foreach ($columns as $columnField) {
$metadata = $gridField->getColumnMetadata($columnField);
$title = $metadata['title'];
if ($title && $list->canFilterBy($columnField)) {
return true;
}
}
}
return false;
}

View File

@ -96,14 +96,15 @@ class MySQLiConnector extends DBConnector
);
}
// Set SSL parameters if they exist. All parameters are required.
if (array_key_exists('ssl_key', $parameters ?? []) &&
array_key_exists('ssl_cert', $parameters ?? []) &&
array_key_exists('ssl_ca', $parameters ?? [])) {
// Set SSL parameters if they exist.
// Must have both the SSL cert and key, or the common authority, or preferably all three.
if ((array_key_exists('ssl_key', $parameters ?? []) && array_key_exists('ssl_cert', $parameters ?? []))
|| array_key_exists('ssl_ca', $parameters ?? [])
) {
$this->dbConn->ssl_set(
$parameters['ssl_key'],
$parameters['ssl_cert'],
$parameters['ssl_ca'],
$parameters['ssl_key'] ?? null,
$parameters['ssl_cert'] ?? null,
$parameters['ssl_ca'] ?? null,
dirname($parameters['ssl_ca'] ?? ''),
array_key_exists('ssl_cipher', $parameters ?? [])
? $parameters['ssl_cipher']

View File

@ -26,8 +26,6 @@ class SSViewer_BasicIteratorSupport implements TemplateIteratorProvider
return [
'IsFirst',
'IsLast',
'First',
'Last',
'FirstLast',
'Middle',
'MiddleString',

View File

@ -85,86 +85,114 @@ class CanonicalURLMiddlewareTest extends SapphireTest
$this->assertFalse($middleware->getForceBasicAuthToSSL(), 'Explicitly set is returned');
}
public function testRedirectTrailingSlash()
public function provideRedirectTrailingSlash()
{
$testScenarios = [
$testScenarios = [];
foreach ([true, false] as $forceRedirect) {
foreach ([true, false] as $addTrailingSlash) {
foreach ([true, false] as $requestHasSlash) {
$testScenarios[] = [
$forceRedirect,
$addTrailingSlash,
$requestHasSlash,
];
}
}
}
return $testScenarios;
}
/**
* @dataProvider provideRedirectTrailingSlash
*/
public function testRedirectTrailingSlash(bool $forceRedirect, bool $addTrailingSlash, bool $requestHasSlash)
{
Controller::config()->set('add_trailing_slash', $addTrailingSlash);
$noRedirect = !$forceRedirect || ($addTrailingSlash && $requestHasSlash) || (!$addTrailingSlash && !$requestHasSlash);
$middleware = $this->getMockedMiddleware(false);
$middleware->setEnforceTrailingSlashConfig($forceRedirect);
$requestSlash = $requestHasSlash ? '/' : '';
$requestURL = "/about-us{$requestSlash}";
$this->performRedirectTest($requestURL, $middleware, !$noRedirect, $addTrailingSlash);
}
private function performRedirectTest(string $requestURL, CanonicalURLMiddleware $middleware, bool $shouldRedirect, bool $addTrailingSlash)
{
Environment::setEnv('REQUEST_URI', $requestURL);
$request = new HTTPRequest('GET', $requestURL);
$request->setScheme('https');
$request->addHeader('host', 'www.example.com');
$mockResponse = (new HTTPResponse)
->setStatusCode(200);
$result = $middleware->process($request, function () use ($mockResponse) {
return $mockResponse;
});
if (!$shouldRedirect) {
$this->assertNull($result->getHeader('Location'), 'No location header should be added');
$this->assertEquals(200, $result->getStatusCode(), 'No redirection should be made');
} else {
$this->assertEquals(301, $result->getStatusCode(), 'Responses should be redirected to include/omit trailing slash');
if ($addTrailingSlash) {
$this->assertStringEndsWith('/', $result->getHeader('Location'), 'Trailing slash should be added');
} else {
$this->assertStringEndsNotWith('/', $result->getHeader('Location'), 'Trailing slash should be removed');
}
}
}
public function provideRedirectTrailingSlashIgnorePaths()
{
return [
[
'forceRedirect' => true,
'addTrailingSlash' => true,
'requestHasSlash' => true,
],
[
'forceRedirect' => true,
'addTrailingSlash' => true,
'requestHasSlash' => false,
],
[
'forceRedirect' => true,
'addTrailingSlash' => false,
'requestHasSlash' => true,
],
[
'forceRedirect' => true,
'addTrailingSlash' => false,
'requestHasSlash' => false,
],
[
'forceRedirect' => false,
'addTrailingSlash' => true,
'requestHasSlash' => true,
],
[
'forceRedirect' => false,
'addTrailingSlash' => true,
'requestHasSlash' => false,
],
[
'forceRedirect' => false,
'addTrailingSlash' => false,
'requestHasSlash' => true,
],
[
'forceRedirect' => false,
'addTrailingSlash' => false,
'addTrailingSlash' => true,
'requestHasSlash' => true,
],
[
'addTrailingSlash' => true,
'requestHasSlash' => false,
],
];
foreach ($testScenarios as $scenario) {
$forceRedirect = $scenario['forceRedirect'];
$addTrailingSlash = $scenario['addTrailingSlash'];
$requestHasSlash = $scenario['requestHasSlash'];
}
$middleware = $this->getMockedMiddleware(false);
/**
* @dataProvider provideRedirectTrailingSlashIgnorePaths
*/
public function testRedirectTrailingSlashIgnorePaths(bool $addTrailingSlash, bool $requestHasSlash)
{
Controller::config()->set('add_trailing_slash', $addTrailingSlash);
$middleware->setEnforceTrailingSlashConfig($forceRedirect);
Controller::config()->set('add_trailing_slash', $addTrailingSlash);
$middleware = $this->getMockedMiddleware(false);
$middleware->setEnforceTrailingSlashConfig(true);
$requestSlash = $requestHasSlash ? '/' : '';
$requestURL = "/about-us{$requestSlash}";
$requestSlash = $requestHasSlash ? '/' : '';
$noRedirectPaths = [
"/admin{$requestSlash}",
"/admin/graphql{$requestSlash}",
"/dev/tasks/my-task{$requestSlash}",
];
$allowRedirectPaths = [
"/administration{$requestSlash}",
"/administration/more-path{$requestSlash}",
];
Environment::setEnv('REQUEST_URI', $requestURL);
$request = new HTTPRequest('GET', $requestURL);
$request->setScheme('https');
$request->addHeader('host', 'www.example.com');
$mockResponse = (new HTTPResponse)
->setStatusCode(200);
$result = $middleware->process($request, function () use ($mockResponse) {
return $mockResponse;
});
$noRedirect = !$forceRedirect || ($addTrailingSlash && $requestHasSlash) || (!$addTrailingSlash && !$requestHasSlash);
if ($noRedirect) {
$this->assertNull($result->getHeader('Location'), 'No location header should be added');
$this->assertEquals(200, $result->getStatusCode(), 'No redirection should be made');
} else {
$this->assertEquals(301, $result->getStatusCode(), 'Responses should be redirected to include/omit trailing slash');
if ($addTrailingSlash) {
$this->assertStringEndsWith('/', $result->getHeader('Location'), 'Trailing slash should be added');
} else {
$this->assertStringEndsNotWith('/', $result->getHeader('Location'), 'Trailing slash should be removed');
}
}
foreach ($noRedirectPaths as $path) {
$this->performRedirectTest($path, $middleware, false, $addTrailingSlash);
}
foreach ($allowRedirectPaths as $path) {
$this->performRedirectTest($path, $middleware, $addTrailingSlash !== $requestHasSlash, $addTrailingSlash);
}
}

View File

@ -2,14 +2,17 @@
namespace SilverStripe\Core\Tests\Cache;
use Behat\Gherkin\Cache\MemoryCache;
use Psr\SimpleCache\CacheInterface;
use SilverStripe\Core\Cache\ApcuCacheFactory;
use SilverStripe\Core\Cache\MemcachedCacheFactory;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Tests\Cache\CacheTest\MockCache;
use SilverStripe\Dev\SapphireTest;
use Symfony\Component\Cache\Simple\ApcuCache;
use Symfony\Component\Cache\Simple\MemcachedCache;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Cache\Adapter\ApcuAdapter;
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
use Memcached;
class CacheTest extends SapphireTest
{
@ -22,7 +25,10 @@ class CacheTest extends SapphireTest
ApcuCacheFactory::class => [
'constructor' => [ 'version' => 'ss40test' ]
],
MemcachedCacheFactory::class => MemcachedCacheFactory::class,
'MemcachedClient' => Memcached::class,
MemcachedCacheFactory::class => [
'constructor' => [ 'memcachedClient' => '%$MemcachedClient' ]
],
CacheInterface::class . '.TestApcuCache' => [
'factory' => ApcuCacheFactory::class,
'constructor' => [
@ -37,42 +43,42 @@ class CacheTest extends SapphireTest
'defaultLifetime' => 5600,
],
],
ApcuCache::class => MockCache::class,
MemcachedCache::class => MockCache::class,
Psr16Cache::class => MockCache::class,
ApcuAdapter::class => MockCache::class,
MemcachedAdapter::class => MockCache::class,
]);
}
public function testApcuCacheFactory()
{
$cache = Injector::inst()->get(CacheInterface::class . '.TestApcuCache');
$this->assertInstanceOf(
MockCache::class,
$cache
);
$psr16Cache = Injector::inst()->get(CacheInterface::class . '.TestApcuCache');
$this->assertInstanceOf(MockCache::class, $psr16Cache);
$this->assertEquals(MockCache::class, get_class($psr16Cache->getArgs()[0]));
$this->assertEquals(
[
'TestApcuCache_' . md5(BASE_PATH),
2600,
'ss40test'
],
$cache->getArgs()
$psr16Cache->getArgs()[0]->getArgs()
);
}
public function testMemCacheFactory()
{
$cache = Injector::inst()->get(CacheInterface::class . '.TestMemcache');
$this->assertInstanceOf(
MockCache::class,
$cache
);
if (!class_exists(Memcached::class)) {
$this->markTestSkipped('Memcached is not installed');
}
$psr16Cache = Injector::inst()->get(CacheInterface::class . '.TestMemcache');
$this->assertInstanceOf(MockCache::class, $psr16Cache);
$this->assertEquals(MockCache::class, get_class($psr16Cache->getArgs()[0]));
$this->assertEquals(
[
null,
new MemCached(),
'TestMemCache_' . md5(BASE_PATH),
5600
],
$cache->getArgs()
$psr16Cache->getArgs()[0]->getArgs()
);
}
}

View File

@ -7,6 +7,7 @@ use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
use SilverStripe\Dev\CSSContentParser;
use SilverStripe\Dev\FunctionalTest;
use SilverStripe\Forms\CompositeField;
use SilverStripe\Forms\DateField;
use SilverStripe\Forms\DatetimeField;
use SilverStripe\Forms\FieldList;
@ -23,7 +24,6 @@ use SilverStripe\Forms\Tests\FormTest\ControllerWithStrictPostCheck;
use SilverStripe\Forms\Tests\FormTest\Player;
use SilverStripe\Forms\Tests\FormTest\Team;
use SilverStripe\Forms\Tests\FormTest\TestController;
use SilverStripe\Forms\Tests\ValidatorTest\TestValidator;
use SilverStripe\Forms\TextareaField;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\TimeField;
@ -345,6 +345,23 @@ class FormTest extends FunctionalTest
);
}
public function testDefaultAction()
{
$form = Form::create(Controller::curr(), 'Form', new FieldList(), new FieldList(
new FormAction('doForm', 'Form Action')
));
$this->assertNotNull($form->defaultAction());
$this->assertEquals('action_doForm', $form->defaultAction()->getName());
$form = Form::create(Controller::curr(), 'AnotherForm', new FieldList(), new FieldList(
new CompositeField(
new FormAction('doAnotherForm', 'Another Form Action')
)
));
$this->assertNotNull($form->defaultAction());
$this->assertEquals('action_doAnotherForm', $form->defaultAction()->getName());
}
public function testLoadDataFromIgnoreFalseish()
{
$form = new Form(

View File

@ -8,13 +8,16 @@ use SilverStripe\Dev\SapphireTest;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig;
use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor;
use SilverStripe\Forms\GridField\GridFieldFilterHeader;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Cheerleader;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\CheerleaderHat;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Mom;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\NonDataObject;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Team;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\TeamGroup;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
@ -173,7 +176,7 @@ class GridFieldFilterHeaderTest extends SapphireTest
{
$gridField = $this->gridField;
$filterHeader = $gridField->getConfig()->getComponentByType(GridFieldFilterHeader::class);
// test that you can filter by something if searchable_fields is not defined
// silverstripe will scaffold db columns that are in the gridfield to be
// searchable by default
@ -194,4 +197,33 @@ class GridFieldFilterHeaderTest extends SapphireTest
Config::modify()->set(Team::class, 'summary_fields', ['MySummaryField']);
$this->assertFalse($filterHeader->canFilterAnyColumns($gridField));
}
public function testCanFilterAnyColumnsNonDataObject()
{
$list = new ArrayList([
new NonDataObject([]),
]);
$config = GridFieldConfig::create()->addComponent(new GridFieldFilterHeader());
$gridField = new GridField('testfield', 'testfield', $list, $config);
$form = new Form(null, 'Form', new FieldList([$gridField]), new FieldList());
/** @var GridFieldFilterHeader $component */
$component = $gridField->getConfig()->getComponentByType(GridFieldFilterHeader::class);
$this->assertFalse($component->canFilterAnyColumns($gridField));
}
public function testRenderHeadersNonDataObject()
{
$list = new ArrayList([
new NonDataObject([]),
]);
$config = GridFieldConfig::create()->addComponent(new GridFieldFilterHeader());
$gridField = new GridField('testfield', 'testfield', $list, $config);
$form = new Form(null, 'Form', new FieldList([$gridField]), new FieldList());
/** @var GridFieldFilterHeader $component */
$component = $gridField->getConfig()->getComponentByType(GridFieldFilterHeader::class);
$htmlFragment = $component->getHTMLFragments($gridField);
$this->assertNull($htmlFragment);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\View\ArrayData;
class NonDataObject extends ArrayData implements TestOnly
{
public function summaryFields()
{
return ['Title' => 'Title'];
}
}