Merge branch 'origin/3.1'

Conflicts:
	templates/forms/CheckboxSetField.ss
	templates/forms/FormField_holder.ss
	templates/forms/OptionsetField.ss
This commit is contained in:
Hamish Friedlander 2013-07-19 16:25:38 +12:00
commit 0a79ac3592
71 changed files with 900 additions and 299 deletions

View File

@ -449,6 +449,9 @@ class LeftAndMain extends Controller implements PermissionProvider {
$title = $this->Title();
if(!$response->getHeader('X-Controller')) $response->addHeader('X-Controller', $this->class);
if(!$response->getHeader('X-Title')) $response->addHeader('X-Title', urlencode($title));
// Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
$this->response->addHeader('X-Frame-Options', 'SAMEORIGIN');
return $response;
}

View File

@ -1,15 +1,15 @@
<div class="breadcrumbs-wrapper" data-pjax-fragment="Breadcrumbs">
<h2 id="page-title-heading">
<% if ToplevelController %>
<% if $ToplevelController %>
<span class="section-icon icon icon-16 icon-{$ToplevelController.MenuCurrentItem.Code.LowerCase}"></span>
<% else_if Controller %>
<% else_if $Controller %>
<span class="section-icon icon icon-16 icon-{$Controller.MenuCurrentItem.Code.LowerCase}"></span>
<% else %>
<span class="section-icon icon icon-16 icon-{$MenuCurrentItem.Code.LowerCase}"></span>
<% end_if %>
<% loop Breadcrumbs %>
<% if Last %>
<% loop $Breadcrumbs %>
<% if $Last %>
<span class="cms-panel-link crumb last">$Title.XML</span>
<% else %>
<a class="cms-panel-link crumb" href="$Link">$Title.XML</a>

View File

@ -1,18 +1,18 @@
<div id="settings-controller-cms-content" class="cms-content center cms-tabset $BaseCSSClasses" data-layout-type="border" data-pjax-fragment="Content CurrentForm">
<div class="cms-content-header north">
<% with EditForm %>
<% with $EditForm %>
<div class="cms-content-header-info">
<% with Controller %>
<% with $Controller %>
<% include CMSBreadcrumbs %>
<% end_with %>
</div>
<% if Fields.hasTabset %>
<% with Fields.fieldByName('Root') %>
<% if $Fields.hasTabset %>
<% with $Fields.fieldByName('Root') %>
<div class="cms-content-header-tabs">
<ul class="cms-tabset-nav-primary">
<% loop Tabs %>
<li<% if extraClass %> class="$extraClass"<% end_if %>><a href="#$id">$Title</a></li>
<% loop $Tabs %>
<li<% if $extraClass %> class="$extraClass"<% end_if %>><a href="#$id">$Title</a></li>
<% end_loop %>
</ul>
</div>

View File

@ -5,12 +5,12 @@
<%-- e.g. through LeftAndMain_EditForm.ss. --%>
<div $AttributesHTML>
<% loop Tabs %>
<% if Tabs %>
<% loop $Tabs %>
<% if $Tabs %>
$FieldHolder
<% else %>
<div $AttributesHTML>
<% loop Fields %>
<% loop $Fields %>
$FieldHolder
<% end_loop %>
</div>

View File

@ -1,7 +1,7 @@
<% if Backlink %>
<% if $Backlink %>
<div class="cms_backlink">
<a class="backlink ss-ui-button cms-panel-link" data-icon="back" href="$Backlink">
<% _t('BackLink_Button.ss.Back', 'Back') %>
</a>
</div>
<% end_if %>
<% end_if %>

View File

@ -1,7 +1,7 @@
<% if ToplevelController %>
<% if $ToplevelController %>
<span class="section-icon icon icon-16 icon-{$ToplevelController.MenuCurrentItem.Code.LowerCase}"></span>
<% else_if Controller %>
<% else_if $Controller %>
<span class="section-icon icon icon-16 icon-{$Controller.MenuCurrentItem.Code.LowerCase}"></span>
<% else %>
<span class="section-icon icon icon-16 icon-{$MenuCurrentItem.Code.LowerCase}"></span>
<% end_if %>
<% end_if %>

View File

@ -1,20 +1,20 @@
<% with EditorToolbar %>
<% with $EditorToolbar %>
<div class="mceToolbarExternal" id="mce_editor_toolbar">
<table width="100%" border="0">
<tbody>
<tr>
<td>
<% loop Buttons %>
<% if Type = button %>
<% loop $Buttons %>
<% if $Type = button %>
<a href="#$Command">
<img width="20" height="20" class="mceButtonNormal" title="$Title" alt="$Title" src="$Icon" id="mce_editor_$IDSegment" />
</a>
<% else_if Type = dropdown %>
<% else_if $Type = dropdown %>
<select name="$Command" class="mceSelectList" id="mce_editor_$IDSegment">$Options</select>
<% else_if Type = separator %>
<% else_if $Type = separator %>
<img width="1" height="15" class="mceSeparatorLine" src="{$MceRoot}themes/advanced/images/separator.gif" alt="|" />
<% else_if Type = break %>
<% else_if $Type = break %>
<br />
<% end_if %>
<% end_loop %>

View File

@ -1,19 +1,19 @@
<% if IncludeFormTag %>
<% if $IncludeFormTag %>
<form $FormAttributes data-layout-type="border">
<% end_if %>
<div class="cms-content-header north">
<div class="cms-content-header-info">
<% include BackLink_Button %>
<% with Controller %>
<% with $Controller %>
<% include CMSBreadcrumbs %>
<% end_with %>
</div>
<% if Fields.hasTabset %>
<% with Fields.fieldByName('Root') %>
<% if $Fields.hasTabset %>
<% with $Fields.fieldByName('Root') %>
<div class="cms-content-header-tabs cms-tabset-nav-primary">
<ul>
<% loop Tabs %>
<li<% if extraClass %> class="$extraClass"<% end_if %>><a href="#$id">$Title</a></li>
<% loop $Tabs %>
<li<% if $extraClass %> class="$extraClass"<% end_if %>><a href="#$id">$Title</a></li>
<% end_loop %>
</ul>
</div>
@ -23,20 +23,20 @@
<!-- <div class="cms-content-search">...</div> -->
</div>
<% with Controller %>
<% with $Controller %>
$EditFormTools
<% end_with %>
<div class="cms-content-fields center <% if not $Fields.hasTabset %>cms-panel-padded<% end_if %>">
<% if Message %>
<% if $Message %>
<p id="{$FormName}_error" class="message $MessageType">$Message</p>
<% else %>
<p id="{$FormName}_error" class="message $MessageType" style="display: none"></p>
<% end_if %>
<fieldset>
<% if Legend %><legend>$Legend</legend><% end_if %>
<% loop Fields %>
<% if $Legend %><legend>$Legend</legend><% end_if %>
<% loop $Fields %>
$FieldHolder
<% end_loop %>
<div class="clear"><!-- --></div>
@ -44,12 +44,12 @@
</div>
<div class="cms-content-actions cms-content-controls south">
<% if Actions %>
<% if $Actions %>
<div class="Actions">
<% loop Actions %>
<% loop $Actions %>
$Field
<% end_loop %>
<% if Controller.LinkPreview %>
<% if $Controller.LinkPreview %>
<a href="$Controller.LinkPreview" class="cms-preview-toggle-link ss-ui-button" data-icon="preview">
<% _t('LeftAndMain.PreviewButton', 'Preview') %> &raquo;
</a>
@ -57,6 +57,6 @@
</div>
<% end_if %>
</div>
<% if IncludeFormTag %>
<% if $IncludeFormTag %>
</form>
<% end_if %>

View File

@ -2,18 +2,18 @@
<div class="cms-logo-header north">
<div class="cms-logo">
<a href="$ApplicationLink" target="_blank" title="$ApplicationName (Version - $CMSVersion)">
$ApplicationName <% if CMSVersion %><abbr class="version">$CMSVersion</abbr><% end_if %>
$ApplicationName <% if $CMSVersion %><abbr class="version">$CMSVersion</abbr><% end_if %>
</a>
<span><% if SiteConfig %>$SiteConfig.Title<% else %>$ApplicationName<% end_if %></span>
<span><% if $SiteConfig %>$SiteConfig.Title<% else %>$ApplicationName<% end_if %></span>
</div>
<div class="cms-login-status">
<a href="Security/logout" class="logout-link" title="<% _t('LeftAndMain_Menu.ss.LOGOUT','Log out') %>"><% _t('LeftAndMain_Menu.ss.LOGOUT','Log out') %></a>
<% with CurrentMember %>
<% with $CurrentMember %>
<span>
<% _t('LeftAndMain_Menu.ss.Hello','Hi') %>
<a href="{$AbsoluteBaseURL}admin/myprofile" class="profile-link">
<% if FirstName && Surname %>$FirstName $Surname<% else_if FirstName %>$FirstName<% else %>$Email<% end_if %>
<% if $FirstName && $Surname %>$FirstName $Surname<% else_if $FirstName %>$FirstName<% else %>$Email<% end_if %>
</a>
</span>
<% end_with %>
@ -22,9 +22,9 @@
<div class="cms-panel-content center">
<ul class="cms-menu-list">
<% loop MainMenu %>
<li class="$LinkingMode $FirstLast <% if LinkingMode == 'link' %><% else %>opened<% end_if %>" id="Menu-$Code" title="$Title.ATT">
<a href="$Link" <% if Code == 'Help' %>target="_blank"<% end_if%>>
<% loop $MainMenu %>
<li class="$LinkingMode $FirstLast <% if $LinkingMode == 'link' %><% else %>opened<% end_if %>" id="Menu-$Code" title="$Title.ATT">
<a href="$Link" <% if $Code == 'Help' %>target="_blank"<% end_if%>>
<span class="icon icon-16 icon-{$Code.LowerCase}">&nbsp;</span>
<span class="text">$Title</span>
</a>

View File

@ -19,13 +19,13 @@
</select>
</span>
<% if Items %>
<% if Items.Count < 5 %>
<% if $Items %>
<% if $Items.Count < 5 %>
<fieldset id="preview-states" class="cms-preview-states switch-states size_{$Items.Count}">
<div class="switch">
<% loop Items %>
<input id="$Title" data-name="$Name" class="state-name $FirstLast" data-link="$Link" name="view" type="radio" <% if isActive %>checked<% end_if %>>
<label for="$Title"<% if isActive %> class="active"<% end_if %>><span>$Title</span></label>
<% loop $Items %>
<input id="$Title" data-name="$Name" class="state-name $FirstLast" data-link="$Link" name="view" type="radio" <% if $isActive %>checked<% end_if %>>
<label for="$Title"<% if $isActive %> class="active"<% end_if %>><span>$Title</span></label>
<% end_loop %>
<span class="slide-button"></span>
</div>
@ -33,8 +33,8 @@
<% else %>
<span id="preview-state-dropdown" class="cms-preview-states field dropdown">
<select title="<% _t('SilverStripeNavigator.PreviewState', 'Preview State') %>" id="preview-states" class="preview-state dropdown nolabel" autocomplete="off" name="preview-state">
<% loop Items %>
<option name="$Name" data-name="$Name" data-link="$Link" class="state-name $FirstLast" value="$Link" <% if isActive %>selected<% end_if %>>
<% loop $Items %>
<option name="$Name" data-name="$Name" data-link="$Link" class="state-name $FirstLast" value="$Link" <% if $isActive %>selected<% end_if %>>
$Title
</option>
<% end_loop %>
@ -42,4 +42,4 @@
</span>
<% end_if %>
<% end_if %>
</div>
</div>

View File

@ -4,7 +4,7 @@
<div class="cms-content-header-info">
<h2>
<% include CMSSectionIcon %>
<% if SectionTitle %>
<% if $SectionTitle %>
$SectionTitle
<% else %>
<% _t('ModelAdmin.Title', 'Data Models') %>
@ -14,7 +14,7 @@
<div class="cms-content-header-tabs cms-tabset-nav-primary ss-ui-tabs-nav">
<ul>
<% loop ManagedModelTabs %>
<% loop $ManagedModelTabs %>
<li class="tab-$ClassName $LinkOrCurrent<% if $LinkOrCurrent == 'current' %> ui-tabs-active<% end_if %>">
<a href="$Link" class="cms-panel-link" title="Form_EditForm">$Title</a>
</li>

View File

@ -3,7 +3,7 @@
<div class="details" id="SpecDetailsFor{$ModelName}">
<h4><% sprintf(_t('ModelAdmin_ImportSpec.ss.IMPORTSPECTITLE', 'Specification for %s'),$ModelName) %></h4>
<h5><% _t('ModelAdmin_ImportSpec.ss.IMPORTSPECFIELDS', 'Database columns') %></h5>
<% loop Fields %>
<% loop $Fields %>
<dl>
<dt><em>$Name</em></dt>
<dd>$Description</dd>
@ -11,7 +11,7 @@
<% end_loop %>
<h5><% _t('ModelAdmin_ImportSpec.ss.IMPORTSPECRELATIONS', 'Relations') %></h5>
<% loop Relations %>
<% loop $Relations %>
<dl>
<dt><em>$Name</em></dt>
<dd>$Description</dd>

View File

@ -3,7 +3,7 @@
<h3 class="cms-panel-header"><% _t('ModelAdmin_Tools.ss.FILTER', 'Filter') %></h3>
$SearchForm
<% if ImportForm %>
<% if $ImportForm %>
<h3 class="cms-panel-header"><% _t('ModelAdmin_Tools.ss.IMPORT', 'Import') %></h3>
$ImportForm
<% end_if %>

View File

@ -1,7 +1,7 @@
<h3><% _t('ModelSidebar.ss.SEARCHLISTINGS','Search') %></h3>
$SearchForm
<% if ImportForm %>
<% if $ImportForm %>
<h3><% _t('ModelSidebar.ss.IMPORT_TAB_HEADER', 'Import') %></h3>
$ImportForm
<% end_if %>
<% end_if %>

View File

@ -352,7 +352,7 @@ class RestfulService extends ViewableData {
$match[1] = preg_replace_callback(
'/(?<=^|[\x09\x20\x2D])./',
create_function('$matches', 'return strtoupper($matches[0]);'),
strtolower(trim($match[1]))
trim($match[1])
);
if( isset($headers[$match[1]]) ) {
if (!is_array($headers[$match[1]])) {

View File

@ -0,0 +1,121 @@
<?php
/**
* Class ErrorControlChain
*
* Runs a set of steps, optionally suppressing (but recording) any errors (even fatal ones) that occur in each step.
* If an error does occur, subsequent steps are normally skipped, but can optionally be run anyway
*
* Normal errors are suppressed even past the end of the chain. Fatal errors are only suppressed until the end
* of the chain - the request will then die silently.
*
* The exception is if an error occurs and BASE_URL is not yet set - in that case the error is never suppressed.
*
* Usage:
*
* $chain = new ErrorControlChain();
* $chain->then($callback1)->then($callback2)->then(true, $callback3)->execute();
*
* WARNING: This class is experimental and designed specifically for use pre-startup in main.php
* It will likely be heavily refactored before the release of 3.2
*/
class ErrorControlChain {
protected $error = false;
protected $steps = array();
protected $suppression = true;
/** We can't unregister_shutdown_function, so this acts as a flag to enable handling */
protected $handleFatalErrors = false;
public function hasErrored() {
return $this->error;
}
public function setErrored($error) {
$this->error = (bool)$error;
}
public function setSuppression($suppression) {
$this->suppression = (bool)$suppression;
}
/**
* Add this callback to the chain of callbacks to call along with the state
* that $error must be in this point in the chain for the callback to be called
*
* @param $callback - The callback to call
* @param $onErrorState - false if only call if no errors yet, true if only call if already errors, null for either
* @return $this
*/
public function then($callback, $onErrorState = false) {
$this->steps[] = array(
'callback' => $callback,
'onErrorState' => $onErrorState
);
return $this;
}
public function thenWhileGood($callback) {
return $this->then($callback, false);
}
public function thenIfErrored($callback) {
return $this->then($callback, true);
}
public function thenAlways($callback) {
return $this->then($callback, null);
}
public function handleError() {
if ($this->suppression && defined('BASE_URL')) throw new Exception('Generic Error');
else return false;
}
protected function lastErrorWasFatal() {
$error = error_get_last();
return $error && $error['type'] == 1;
}
public function handleFatalError() {
if ($this->handleFatalErrors && $this->suppression && defined('BASE_URL')) {
if ($this->lastErrorWasFatal()) {
ob_clean();
$this->error = true;
$this->step();
}
}
}
public function execute() {
set_error_handler(array($this, 'handleError'), error_reporting());
register_shutdown_function(array($this, 'handleFatalError'));
$this->handleFatalErrors = true;
$this->step();
}
protected function step() {
if ($this->steps) {
$step = array_shift($this->steps);
if ($step['onErrorState'] === null || $step['onErrorState'] === $this->error) {
try {
call_user_func($step['callback'], $this);
}
catch (Exception $e) {
if ($this->suppression && defined('BASE_URL')) $this->error = true;
else throw $e;
}
}
$this->step();
}
else {
// Now clean up
$this->handleFatalErrors = false;
restore_error_handler();
}
}
}

View File

@ -0,0 +1,110 @@
<?php
/**
* Class ParameterConfirmationToken
*
* When you need to use a dangerous GET parameter that needs to be set before core/Core.php is
* established, this class takes care of allowing some other code of confirming the parameter,
* by generating a one-time-use token & redirecting with that token included in the redirected URL
*
* WARNING: This class is experimental and designed specifically for use pre-startup in main.php
* It will likely be heavily refactored before the release of 3.2
*/
class ParameterConfirmationToken {
protected $parameterName = null;
protected $parameter = null;
protected $token = null;
protected function pathForToken($token) {
if (defined('BASE_PATH')) {
$basepath = BASE_PATH;
}
else {
$basepath = rtrim(dirname(dirname(dirname(dirname(__FILE__)))), DIRECTORY_SEPARATOR);
}
require_once(dirname(dirname(__FILE__)).'/TempPath.php');
$tempfolder = getTempFolder($basepath ? $basepath : DIRECTORY_SEPARATOR);
return $tempfolder.'/token_'.preg_replace('/[^a-z0-9]+/', '', $token);
}
protected function genToken() {
// Generate a new random token (as random as possible)
require_once(dirname(dirname(dirname(__FILE__))).'/security/RandomGenerator.php');
$rg = new RandomGenerator();
$token = $rg->randomToken('md5');
// Store a file in the session save path (safer than /tmp, as open_basedir might limit that)
file_put_contents($this->pathForToken($token), $token);
return $token;
}
protected function checkToken($token) {
$file = $this->pathForToken($token);
$content = null;
if (file_exists($file)) {
$content = file_get_contents($file);
unlink($file);
}
return $content == $token;
}
public function __construct($parameterName) {
// Store the parameter name
$this->parameterName = $parameterName;
// Store the parameter value
$this->parameter = isset($_GET[$parameterName]) ? $_GET[$parameterName] : null;
// Store the token
$this->token = isset($_GET[$parameterName.'token']) ? $_GET[$parameterName.'token'] : null;
// If a token was provided, but isn't valid, ignore it
if ($this->token && (!$this->checkToken($this->token))) $this->token = null;
}
public function parameterProvided() {
return $this->parameter !== null;
}
public function tokenProvided() {
return $this->token !== null;
}
public function params() {
return array(
$this->parameterName => $this->parameter,
$this->parameterName.'token' => $this->genToken()
);
}
public function reloadWithToken() {
global $url;
// Are we http or https?
$proto = 'http';
if(isset($_SERVER['HTTP_X_FORWARDED_PROTOCOL'])) {
if(strtolower($_SERVER['HTTP_X_FORWARDED_PROTOCOL']) == 'https') $proto = 'https';
}
if((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off')) $proto = 'https';
if(isset($_SERVER['SSL'])) $proto = 'https';
// What's our host
$host = $_SERVER['HTTP_HOST'];
// What's our GET params (ensuring they include the original parameter + a new token)
$params = array_merge($_GET, $this->params());
unset($params['url']);
// Join them all together into the original URL
$location = "$proto://" . $host . BASE_URL . $url . ($params ? '?'.http_build_query($params) : '');
// And redirect
header('location: '.$location, true, 302);
die;
}
}

View File

@ -9,7 +9,7 @@ class BehatFixtureFactory extends \FixtureFactory {
// Copy identifier to some visible property unless its already defined.
// Exclude files, since they generate their own named based on the file path.
if(!is_a($name, 'File', true)) {
if(!$name != 'File' && !is_subclass_of($name, 'File')) {
foreach(array('Name', 'Title') as $fieldName) {
if(singleton($name)->hasField($fieldName) && !isset($data[$fieldName])) {
$data[$fieldName] = $identifier;

View File

@ -1,5 +1,32 @@
# 3.0.6 (Not yet released)
## Overview
* Security: Require ADMIN for `?flush=1` (stop denial of service attacks)
([#1692](https://github.com/silverstripe/silverstripe-framework/issues/1692))
## Details
### Security: Require ADMIN for ?flush=1
Flushing the various manifests (class, template, config) is performed through a GET
parameter (`flush=1`). Since this action requires more server resources than normal requests,
it can facilitate [denial-of-service attacks](https://en.wikipedia.org/wiki/Denial-of-service_attack).
To prevent this, main.php now checks and only allows the flush parameter in the following cases:
* The [environment](/topics/environment-management) is in "dev mode"
* A user is logged in with ADMIN permissions
* An error occurs during startup
This applies to both `flush=1` and `flush=all` (technically we only check for the existence of any parameter value)
but only through web requests made through main.php - CLI requests, or any other request that goes through
a custom start up script will still process all flush requests as normal.
## Upgrading
* If you have created your own composite database fields, then you shoulcd amend the setValue() to allow the passing of an object (usually DataObject) as well as an array.
* If you have created your own composite database fields, then you should amend the setValue() to allow the passing of
an object (usually DataObject) as well as an array.
* If you have provided your own startup scripts (ones that include core/Core.php) that can be accessed via a web
request, you should ensure that you limit use of the flush parameter

View File

@ -18,6 +18,8 @@
### Framework
* Security: Require ADMIN for `?flush=1` (stop denial of service attacks)
([#1692](https://github.com/silverstripe/silverstripe-framework/issues/1692))
* Static properties are immutable and private, you must use Config API
* Statics in custom Page classes need to be "private"
* `$default_cast` is now `Text` instead of `HTMLText`, to secure templates from XSS by default
@ -37,6 +39,24 @@
* Support for [Composer](http://getcomposer.org) dependency manager (also works with 3.0)
* Added support for filtering incoming HTML from TinyMCE (disabled by default, see [security](/topics/security))
## Details
### Security: Require ADMIN for ?flush=1
Flushing the various manifests (class, template, config) is performed through a GET
parameter (`flush=1`). Since this action requires more server resources than normal requests,
it can facilitate [denial-of-service attacks](https://en.wikipedia.org/wiki/Denial-of-service_attack).
To prevent this, main.php now checks and only allows the flush parameter in the following cases:
* The [environment](/topics/environment-management) is in "dev mode"
* A user is logged in with ADMIN permissions
* An error occurs during startup
This applies to both `flush=1` and `flush=all` (technically we only check for the existence of any parameter value)
but only through web requests made through main.php - CLI requests, or any other request that goes through
a custom start up script will still process all flush requests as normal.
## Upgrading
### Statics in custom Page classes need to be "private"

View File

@ -154,7 +154,7 @@ The fields displayed in the edit form are from `DataObject::getCMSFields()`
The `GridFieldDetailForm` component drives the record editing form which is usually configured
through the configs `GridFieldConfig_RecordEditor` and `GridFieldConfig_RelationEditor`
described above. It takes its fields from `DataObject->getCMSFields()`,
but can be customized to accept different fields via its `[api:GridFieldDetailForm->setFields()](api:setFields())` method.
but can be customized to accept different fields via its `[api:GridFieldDetailForm->setFields()]` method.
The component also has the ability to load and save data stored on join tables
when two records are related via a "many_many" relationship, as defined through

View File

@ -17,10 +17,8 @@ Append the option and corresponding value to your URL in your browser's address
| URL Variable | | Values | | Description |
| ------------ | | ------ | | ----------- |
| flush | | 1,all | | This will clear out all cached information about the page. This is used frequently during development - for example, when adding new PHP or SS files. See below for value descriptions. |
| flush=1 | | 1 | | Clears out all caches. Used mainly during development, e.g. when adding new classes or templates. Requires "dev" mode or ADMIN login |
| showtemplate | | 1 | | Show the compiled version of all the templates used, including line numbers. Good when you have a syntax error in a template. Cannot be used on a Live site without **isDev**. **flush** can be used with the following values: |
| ?flush=1 | | | | Flushes the current page and included templates |
| ?flush=all | | | | Flushes the entire template cache |
## General Testing

26
docs/en/topics/caching.md Normal file
View File

@ -0,0 +1,26 @@
# Caching
## Built-In Caches
The framework uses caches to store infrequently changing values.
By default, the storage mechanism is simply the filesystem, although
other cache backends can be configured. All caches use the `[api:SS_Cache]` API.
The most common caches are manifests of various resources:
* PHP class locations (`[api:SS_ClassManifest]`)
* Template file locations and compiled templates (`[api:SS_TemplateManifest]`)
* Configuration settings from YAML files (`[api:SS_ConfigManifest]`)
* Language files (`[api:i18n]`)
Flushing the various manifests is performed through a GET
parameter (`flush=1`). Since this action requires more server resources than normal requests,
executing the action is limited to the following cases when performed via a web request:
* The [environment](/topics/environment-management) is in "dev mode"
* A user is logged in with ADMIN permissions
* An error occurs during startup
## Custom Caches
See `[api:SS_Cache]`.

View File

@ -4,6 +4,7 @@ This section provides an overview on how things fit together, the "conceptual gl
It is where most documentation should live, and is the natural "second step" after finishing the tutorials.
* [Access Control and Page Security](access-control): Restricting access and setting up permissions on your website
* [Caching](caching): Explains built-in caches for classes, config and templates. How to use your own caches.
* [Command line Usage](commandline): Calling controllers via the command line interface using `sake`
* [Configuration](configuration): Influence behaviour through PHP and YAML configuration
* [Controller](controller): The intermediate layer between your templates and the data model

View File

@ -127,6 +127,38 @@ or [sanitize](http://htmlpurifier.org/) it correctly.
See [http://shiflett.org/articles/foiling-cross-site-attacks](http://shiflett.org/articles/foiling-cross-site-attacks)
for in-depth information about "Cross-Site-Scripting".
### What if I can't trust my editors?
The default configuration of SilverStripe assumes some level of trust is given to your editors who have access
to the CMS. Though the HTML WYSIWYG editor is configured to provide some control over the HTML an editor provides,
this is not enforced server side, and so can be bypassed by a malicious editor. A editor that does so can use an
XSS attack against an admin to perform any administrative action.
If you can't trust your editors, SilverStripe must be configured to filter the content so that any javascript is
stripped out
To enable filtering, set the HtmlEditorField::$sanitise_server_side [configuration](/topics/configuration) property to
true, e.g.
HtmlEditorField::config()->sanitise_server_side = true
The built in sanitiser enforces the TinyMCE whitelist rules on the server side, and is sufficient to eliminate the
most common XSS vectors.
However some subtle XSS attacks that exploit HTML parsing bugs need heavier filtering. For greater protection
you can install the [htmlpurifier](https://github.com/silverstripe-labs/silverstripe-htmlpurifier) module which
will replace the built in sanitiser with one that uses the [HTML Purifier](http://htmlpurifier.org/) library.
In both cases, you must ensure that you have not configured TinyMCE to explicitly allow script elements or other
javascript-specific attributes.
##### But I also need my editors to provide javascript
It is not currently possible to allow editors to provide javascript content and yet still protect other users
from any malicious code within that javascript.
We recommend configuring [shortcodes](/reference/shortcodes) that can be used by editors in place of using javascript directly.
### Escaping model properties
`[api:SSViewer]` (the SilverStripe template engine) automatically takes care of escaping HTML tags from specific
@ -377,11 +409,62 @@ you need to serve directly.
See [Apache](/installation/webserver) and [Nginx](/installation/nginx) installation documentation for details
specific to your web server
See [Apache](/installation/webserver) and [Nginx](/installation/nginx) installation documentation for details specific to your web server
## Passwords
SilverStripe stores passwords with a strong hashing algorithm (blowfish) by default
(see [api:PasswordEncryptor]). It adds randomness to these hashes via
salt values generated with the strongest entropy generators available on the platform
(see [api:RandomGenerator]). This prevents brute force attacks with
[Rainbow tables](http://en.wikipedia.org/wiki/Rainbow_table).
Strong passwords are a crucial part of any system security.
So in addition to storing the password in a secure fashion,
you can also enforce specific password policies by configuring
a [api:PasswordValidator]:
:::php
$validator = new PasswordValidator();
$validator->minLength(7);
$validator->checkHistoricalPasswords(6);
$validator->characterStrength('lowercase','uppercase','digits','punctuation');
Member::set_password_validator($validator);
In addition, you can tighten password security with the following configuration settings:
* `Member.password_expiry_days`: Set the number of days that a password should be valid for.
* `Member.lock_out_after_incorrect_logins`: Number of incorrect logins after which
the user is blocked from further attempts for the timespan defined in `$lock_out_delay_mins`
* `Member.lock_out_delay_mins`: Minutes of enforced lockout after incorrect password attempts.
Only applies if `lock_out_after_incorrect_logins` is greater than 0.
## Clickjacking: Prevent iframe Inclusion
"[Clickjacking](http://en.wikipedia.org/wiki/Clickjacking)" is a malicious technique
where a web user is tricked into clicking on hidden interface elements, which can
lead to the attacker gaining access to user data or taking control of the website behaviour.
You can signal to browsers that the current response isn't allowed to be
included in HTML "frame" or "iframe" elements, and thereby prevent the most common
attack vector. This is done through a HTTP header, which is usually added in your
controller's `init()` method:
:::php
class MyController extends Controller {
public function init() {
parent::init();
$this->response->addHeader('X-Frame-Options', 'SAMEORIGIN');
}
}
This is a recommended option to secure any controller which displays
or submits sensitive user input, and is enabled by default in all CMS controllers,
as well as the login form.
## Related
* [http://silverstripe.org/security-releases/](http://silverstripe.org/security-releases/)
## Links
* [Best-practices for securing MySQL (securityfocus.com)](http://www.securityfocus.com/infocus/1726)

View File

@ -1195,6 +1195,7 @@ class UploadField extends FileField {
*
* @param SS_HTTPRequest $request
* @return SS_HTTPResponse
* @return SS_HTTPResponse
*/
public function upload(SS_HTTPRequest $request) {
if($this->isDisabled() || $this->isReadonly() || !$this->canUpload()) {
@ -1222,6 +1223,7 @@ class UploadField extends FileField {
// Format response with json
$response = new SS_HTTPResponse(Convert::raw2json(array($return)));
$response->addHeader('Content-Type', 'text/plain');
if(!empty($return['error'])) $response->setStatusCode(403);
return $response;
}

View File

@ -367,6 +367,7 @@ en:
EMPTYNEWPASSWORD: 'The new password can''t be empty, please try again'
ENTEREMAIL: 'Please enter an email address to get a password reset link.'
ERRORLOCKEDOUT: 'Your account has been temporarily disabled because of too many failed attempts at logging in. Please try again in 20 minutes.'
ERRORLOCKEDOUT2: 'Your account has been temporarily disabled because of too many failed attempts at logging in. Please try again in {count} minutes.'
ERRORNEWPASSWORD: 'You have entered your new password differently, try again'
ERRORPASSWORDNOTMATCH: 'Your current password does not match, please try again'
ERRORWRONGCRED: 'That doesn''t seem to be the right e-mail address or password. Please try again.'

149
main.php
View File

@ -56,52 +56,113 @@ if (version_compare(phpversion(), '5.3.2', '<')) {
/**
* Include SilverStripe's core code
*/
require_once('core/Core.php');
require_once('core/startup/ErrorControlChain.php');
require_once('core/startup/ParameterConfirmationToken.php');
// IIS will sometimes generate this.
if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) {
$_SERVER['REQUEST_URI'] = $_SERVER['HTTP_X_ORIGINAL_URL'];
}
$chain = new ErrorControlChain();
$token = new ParameterConfirmationToken('flush');
// PHP 5.4's built-in webserver uses this
if (php_sapi_name() == 'cli-server') {
$url = $_SERVER['REQUEST_URI'];
// Querystring args need to be explicitly parsed
if(strpos($url,'?') !== false) {
list($url, $query) = explode('?',$url,2);
parse_str($query, $_GET);
if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET);
}
// Pass back to the webserver for files that exist
if(file_exists(BASE_PATH . $url)) return false;
$chain
// First, if $_GET['flush'] was set, but no valid token, suppress the flush
->then(function($chain) use ($token){
if (isset($_GET['flush']) && !$token->tokenProvided()) {
unset($_GET['flush']);
}
else {
$chain->setSuppression(false);
}
})
// Then load in core
->then(function(){
require_once('core/Core.php');
})
// Then build the URL (even if Core didn't load beyond setting BASE_URL)
->thenAlways(function(){
global $url;
// Apache rewrite rules use this
} else if (isset($_GET['url'])) {
$url = $_GET['url'];
// IIS includes get variables in url
$i = strpos($url, '?');
if($i !== false) {
$url = substr($url, 0, $i);
}
// Lighttpd uses this
} else {
if(strpos($_SERVER['REQUEST_URI'],'?') !== false) {
list($url, $query) = explode('?', $_SERVER['REQUEST_URI'], 2);
parse_str($query, $_GET);
if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET);
} else {
$url = $_SERVER["REQUEST_URI"];
}
}
// IIS will sometimes generate this.
if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) {
$_SERVER['REQUEST_URI'] = $_SERVER['HTTP_X_ORIGINAL_URL'];
}
// Remove base folders from the URL if webroot is hosted in a subfolder
if (substr(strtolower($url), 0, strlen(BASE_URL)) == strtolower(BASE_URL)) $url = substr($url, strlen(BASE_URL));
// PHP 5.4's built-in webserver uses this
if (php_sapi_name() == 'cli-server') {
$url = $_SERVER['REQUEST_URI'];
// Connect to database
require_once('model/DB.php');
// Querystring args need to be explicitly parsed
if(strpos($url,'?') !== false) {
list($url, $query) = explode('?',$url,2);
parse_str($query, $_GET);
if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET);
}
// Apache rewrite rules use this
} else if (isset($_GET['url'])) {
$url = $_GET['url'];
// IIS includes get variables in url
$i = strpos($url, '?');
if($i !== false) {
$url = substr($url, 0, $i);
}
// Lighttpd uses this
} else {
if(strpos($_SERVER['REQUEST_URI'],'?') !== false) {
list($url, $query) = explode('?', $_SERVER['REQUEST_URI'], 2);
parse_str($query, $_GET);
if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET);
} else {
$url = $_SERVER["REQUEST_URI"];
}
}
// Remove base folders from the URL if webroot is hosted in a subfolder
if (substr(strtolower($url), 0, strlen(BASE_URL)) == strtolower(BASE_URL)) $url = substr($url, strlen(BASE_URL));
})
// Then start up the database
->then(function(){
require_once('model/DB.php');
global $databaseConfig;
if ($databaseConfig) DB::connect($databaseConfig);
})
// Then if a flush was requested, redirect to it
->then(function($chain) use ($token){
if ($token->parameterProvided() && !$token->tokenProvided()) {
// First, check if we're in dev mode, or the database doesn't have any security data
$canFlush = Director::isDev() || !Security::database_is_ready();
// Otherwise, we start up the session if needed, then check for admin
if (!$canFlush) {
if(!isset($_SESSION) && (isset($_COOKIE[session_name()]) || isset($_REQUEST[session_name()]))) {
Session::start();
}
if (Permission::check('ADMIN')) {
$canFlush = true;
}
else {
$loginPage = Director::absoluteURL(Config::inst()->get('Security', 'login_url'));
$loginPage .= "?BackURL=" . urlencode($_SERVER['REQUEST_URI']);
header('location: '.$loginPage, true, 302);
die;
}
}
// And if we can flush, reload with an authority token
if ($canFlush) $token->reloadWithToken();
}
})
// Finally if a flush was requested but there was an error while figuring out if it's allowed, do it anyway
->thenIfErrored(function() use ($token){
if ($token->parameterProvided() && !$token->tokenProvided()) {
$token->reloadWithToken();
}
})
->execute();
// If we're in PHP's built in webserver, pass back to the webserver for files that exist
if (php_sapi_name() == 'cli-server' && file_exists(BASE_PATH . $url) && is_file(BASE_PATH . $url)) return false;
global $databaseConfig;
@ -112,17 +173,15 @@ if(!isset($databaseConfig) || !isset($databaseConfig['database']) || !$databaseC
}
$s = (isset($_SERVER['SSL']) || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off')) ? 's' : '';
$installURL = "http$s://" . $_SERVER['HTTP_HOST'] . BASE_URL . '/install.php';
// The above dirname() will equate to "\" on Windows when installing directly from http://localhost (not using
// a sub-directory), this really messes things up in some browsers. Let's get rid of the backslashes
$installURL = str_replace('\\', '', $installURL);
header("Location: $installURL");
die();
}
DB::connect($databaseConfig);
// Direct away - this is the "main" function, that hands control to the appropriate controller
DataModel::set_inst(new DataModel());
Director::direct($url, DataModel::inst());

View File

@ -29,6 +29,8 @@
* NOTE: The cache logic uses tags, and so a backend that supports tags is required. Currently only the File
* backend (and the two-level backend with the File backend as the slow store) meets this requirement
*
* @deprecated 3.1 Use DataList to aggregate data
*
* @author hfried
* @package framework
* @subpackage core
@ -60,10 +62,15 @@ class Aggregate extends ViewableData {
/**
* Constructor
*
* @deprecated 3.1 Use DataList to aggregate data
*
* @param string $type The DataObject type we are building an aggregate for
* @param string $filter (optional) An SQL filter to apply to the selected rows before calculating the aggregate
*/
public function __construct($type, $filter = '') {
Deprecation::notice('3.1', 'Call aggregate methods on a DataList directly instead. In templates'
. ' an example of the new syntax is &lt% cached List(Member).max(LastEdited) %&gt instead'
. ' (check partial-caching.md documentation for more details.)');
$this->type = $type;
$this->filter = $filter;
parent::__construct();

View File

@ -1468,39 +1468,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
}
/**
* Get the query object for a $has_many Component.
*
* @param string $componentName
* @param string $filter
* @param string|array $sort
* @param string $join Deprecated, use leftJoin($table, $joinClause) instead
* @param string|array $limit
* @return SQLQuery
* @deprecated 3.1 Use getComponents to get a filtered DataList for an object's relation
*/
public function getComponentsQuery($componentName, $filter = "", $sort = "", $join = "", $limit = "") {
if(!$componentClass = $this->has_many($componentName)) {
user_error("DataObject::getComponentsQuery(): Unknown 1-to-many component '$componentName'"
. " on class '$this->class'", E_USER_ERROR);
}
if($join) {
throw new \InvalidArgumentException(
'The $join argument has been removed. Use leftJoin($table, $joinClause) instead.'
);
}
$joinField = $this->getRemoteJoinField($componentName, 'has_many');
$id = $this->getField("ID");
// get filter
$combinedFilter = "\"$joinField\" = '$id'";
if(!empty($filter)) $combinedFilter .= " AND ({$filter})";
return DataList::create($componentClass)
->where($combinedFilter)
->canSortBy($sort)
->limit($limit);
Deprecation::notice('3.1', "Use getComponents to get a filtered DataList for an object's relation");
return $this->getComponents($componentName, $filter, $sort, $join, $limit);
}
/**

View File

@ -7,7 +7,7 @@
* @uses DropdownField
*
* @param string $name
* @param DataOject $object The object that the foreign key is stored on (should have a relation with $name)
* @param DataObject $object The object that the foreign key is stored on (should have a relation with $name)
*
* @package framework
* @subpackage model

View File

@ -113,9 +113,18 @@ class Member extends DataObject implements TemplateGlobalProvider {
/**
* @config
* @var Int
* @var Int Number of incorrect logins after which
* the user is blocked from further attempts for the timespan
* defined in {@link $lock_out_delay_mins}.
*/
private static $lock_out_after_incorrect_logins = null;
/**
* @config
* @var integer Minutes of enforced lockout after incorrect password attempts.
* Only applies if {@link $lock_out_after_incorrect_logins} greater than 0.
*/
private static $lock_out_delay_mins = 15;
/**
* @config
@ -210,6 +219,9 @@ class Member extends DataObject implements TemplateGlobalProvider {
public function checkPassword($password) {
$result = $this->canLogIn();
// Short-circuit the result upon failure, no further checks needed.
if (!$result->valid()) return $result;
if(empty($this->Password) && $this->exists()) {
$result->error(_t('Member.NoPassword','There is no password on this member.'));
return $result;
@ -238,11 +250,15 @@ class Member extends DataObject implements TemplateGlobalProvider {
$result = ValidationResult::create();
if($this->isLockedOut()) {
$result->error(_t (
'Member.ERRORLOCKEDOUT',
'Your account has been temporarily disabled because of too many failed attempts at ' .
'logging in. Please try again in 20 minutes.'
));
$result->error(
_t(
'Member.ERRORLOCKEDOUT2',
'Your account has been temporarily disabled because of too many failed attempts at ' .
'logging in. Please try again in {count} minutes.',
null,
array('count' => $this->config()->lock_out_delay_mins)
)
);
}
$this->extend('canLogIn', $result);
@ -429,7 +445,9 @@ class Member extends DataObject implements TemplateGlobalProvider {
self::session_regenerate_id();
Session::set("loggedInAs", $member->ID);
// This lets apache rules detect whether the user has logged in
if(Member::config()->login_marker_cookie) Cookie::set(Member::config()->login_marker_cookie, 1, 0, null, null, false, true);
if(Member::config()->login_marker_cookie) {
Cookie::set(Member::config()->login_marker_cookie, 1, 0, null, null, false, true);
}
$generator = new RandomGenerator();
$token = $generator->randomToken('sha1');
@ -717,7 +735,8 @@ class Member extends DataObject implements TemplateGlobalProvider {
$encryption_details = Security::encrypt_password(
$this->Password, // this is assumed to be cleartext
$this->Salt,
($this->PasswordEncryption) ? $this->PasswordEncryption : Security::config()->password_encryption_algorithm,
($this->PasswordEncryption) ?
$this->PasswordEncryption : Security::config()->password_encryption_algorithm,
$this
);
@ -1407,7 +1426,8 @@ class Member extends DataObject implements TemplateGlobalProvider {
$this->write();
if($this->FailedLoginCount >= self::config()->lock_out_after_incorrect_logins) {
$this->LockedOutUntil = date('Y-m-d H:i:s', time() + 15*60);
$lockoutMins = self::config()->lock_out_delay_mins;
$this->LockedOutUntil = date('Y-m-d H:i:s', time() + $lockoutMins*60);
$this->write();
}
}

View File

@ -270,6 +270,13 @@ class Security extends Controller {
return;
}
public function init() {
parent::init();
// Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
$this->response->addHeader('X-Frame-Options', 'SAMEORIGIN');
}
/**
* Get the login form to process according to the submitted data

View File

@ -8,17 +8,17 @@
<body>
<div class="mainblock" style="width: 290px;">
<% if UseSimpleForm %>
<% if $UseSimpleForm %>
$EditImageSimpleForm
<% else %>
$EditImageForm
<% end_if %>
</div>
<% if Image.ID %>
<% if $Image.ID %>
<div class="mainblock" >
$Image.CMSThumbnail
<% if DeleteImageForm %>
<% if $DeleteImageForm %>
$DeleteImageForm
<% end_if %>
</div>
@ -26,4 +26,4 @@
</body>
</html>
</html>

View File

@ -1,27 +1,27 @@
<% if IncludeFormTag %>
<% if $IncludeFormTag %>
<form $AttributesHTML>
<% end_if %>
<% if Message %>
<% if $Message %>
<p id="{$FormName}_error" class="message $MessageType">$Message</p>
<% else %>
<p id="{$FormName}_error" class="message $MessageType" style="display: none"></p>
<% end_if %>
<fieldset>
<% if Legend %><legend>$Legend</legend><% end_if %>
<% loop Fields %>
<% if $Legend %><legend>$Legend</legend><% end_if %>
<% loop $Fields %>
$FieldHolder
<% end_loop %>
<div class="clear"><!-- --></div>
</fieldset>
<% if Actions %>
<% if $Actions %>
<div class="Actions">
<% loop Actions %>
<% loop $Actions %>
$Field
<% end_loop %>
</div>
<% end_if %>
<% if IncludeFormTag %>
<% if $IncludeFormTag %>
</form>
<% end_if %>

View File

@ -1,5 +1,5 @@
<div class="add-existing-autocompleter">
<% loop Fields %>
<% loop $Fields %>
<span>$Field</span>
<% end_loop %>
</div>

View File

@ -1,5 +1,5 @@
<tr class="filter-header" style="display:none;">
<% loop Fields %>
<% loop $Fields %>
<th class="extra">$Field</th>
<% end_loop %>
</tr>

View File

@ -7,8 +7,8 @@
$NumRecords
</span>
<% if Message %>
<% if $Message %>
<div class="datagrid-footer-message">$Message</div>
<% end_if %>
</td>
</tr>
</tr>

View File

@ -1,5 +1,5 @@
<% if Backlink %>
<% if $Backlink %>
<a href="$Backlink"><% _t('Go back', 'Go back' ) %></a>
<% end_if %>
$ItemEditForm
$ItemEditForm

View File

@ -1,5 +1,5 @@
<tr class="sortable-header">
<% loop Fields %>
<% loop $Fields %>
<th class="main col-$getName">$Field</th>
<% end_loop %>
</tr>

View File

@ -1,7 +1,7 @@
<tr class="title">
<th colspan="$ColumnCount">
<% if Title %><h2>$Title</h2><% end_if %>
<% if $Title %><h2>$Title</h2><% end_if %>
<div class="right">\$DefineFragment(toolbar-header-right)</div>
<div class="left">\$DefineFragment(toolbar-header-left)</div>
</th>
</tr>
</tr>

View File

@ -1,3 +1,3 @@
<button $AttributesHTML>
<% if ButtonContent %>$ButtonContent<% else %>$Title<% end_if %>
</button>
<% if $ButtonContent %>$ButtonContent<% else %>$Title<% end_if %>
</button>

View File

@ -1,11 +1,11 @@
<tr class="ss-gridfield-item ss-gridfield-{$EvenOdd} $FirstLast" data-id="$ID">
<% if $GridField.ExtraColumnsCount %>
<% loop Fields %>
<% loop $Fields %>
<td>$Value</td>
<% end_loop %>
<td colspan="$GridField.ExtraColumnsCount" class="ss-gridfield-last"></td>
<% else %>
<% loop Fields %>
<% loop $Fields %>
<td <% if FirstLast %>class="ss-gridfield-{$FirstLast}"<% end_if %>>$Value</td>
<% end_loop %>
<% end_if %>

View File

@ -7,12 +7,12 @@
<% if $Title %><h3>$Title</h3><% end_if %>
<table>
<thead>
<tr><% loop Header %><th>$CellString</th><% end_loop %></tr>
<tr><% loop $Header %><th>$CellString</th><% end_loop %></tr>
</thead>
<tbody>
<% if ItemRows %>
<% loop ItemRows %>
<tr><% loop ItemRow %><td>$CellString</td><% end_loop %></tr>
<% if $ItemRows %>
<% loop $ItemRows %>
<tr><% loop $ItemRow %><td>$CellString</td><% end_loop %></tr>
<% end_loop %>
<% else %>
<tr>

View File

@ -38,10 +38,10 @@
</div>
</div>
</div>
<% if Info %><div class="info">$Info</div><% end_if %>
<% if $Info %><div class="info">$Info</div><% end_if %>
<div class="details ss-uploadfield-item-editform loading">
<fieldset>
<% loop Fields %>
<% loop $Fields %>
$FieldHolder
<% end_loop %>
</fieldset>

View File

@ -1,4 +1,4 @@
<% if canEdit %>
<% if $canEdit %>
<button class="ss-uploadfield-item-edit ss-ui-button ui-corner-all" title="<% _t('UploadField.EDITINFO', 'Edit this file') %>" data-icon="pencil">
<% _t('UploadField.EDIT', 'Edit') %>
<span class="toggle-details">
@ -8,10 +8,10 @@
<% end_if %>
<button class="ss-uploadfield-item-remove ss-ui-button ui-corner-all" title="<% _t('UploadField.REMOVEINFO', 'Remove this file from here, but do not delete it from the file store') %>" data-icon="plug-disconnect-prohibition">
<% _t('UploadField.REMOVE', 'Remove') %></button>
<% if canDelete %>
<% if $canDelete %>
<button data-href="$UploadFieldDeleteLink" class="ss-uploadfield-item-delete ss-ui-button ui-corner-all" title="<% _t('UploadField.DELETEINFO', 'Permanently delete this file from the file store') %>" data-icon="minus-circle"><% _t('UploadField.DELETE', 'Delete from files') %></button>
<% end_if %>
<% if UploadField.canAttachExisting %>
<% if $UploadField.canAttachExisting %>
<button class="ss-uploadfield-item-choose-another ss-uploadfield-fromfiles ss-ui-button ui-corner-all" title="<% _t('UploadField.CHOOSEANOTHERINFO', 'Replace this file with another one from the file store') %>" data-icon="network-cloud">
<% _t('UploadField.CHOOSEANOTHERFILE', 'Choose another file') %></button>
<% end_if %>

View File

@ -6,14 +6,14 @@
<atom:link href="$Link" rel="self" type="application/rss+xml" />
<description>$Description.XML</description>
<% loop Entries %>
<% loop $Entries %>
<item>
<title>$Title.XML</title>
<link>$AbsoluteLink</link>
<% if Description %><description>$Description.AbsoluteLinks.XML</description><% end_if %>
<% if Date %><pubDate>$Date.Rfc822</pubDate>
<% if $Description %><description>$Description.AbsoluteLinks.XML</description><% end_if %>
<% if $Date %><pubDate>$Date.Rfc822</pubDate>
<% else %><pubDate>$Created.Rfc822</pubDate><% end_if %>
<% if Author %><dc:creator>$Author.XML</dc:creator><% end_if %>
<% if $Author %><dc:creator>$Author.XML</dc:creator><% end_if %>
<guid>$AbsoluteLink</guid>
</item>
<% end_loop %>

View File

@ -1,5 +1,5 @@
<p id="$Name" class="field $Type">
$Field
<label class="right" for="$id">$Title</label>
<% if Message %><span class="message $MessageType">$Message</span><% end_if %>
</p>
<% if $Message %><span class="message $MessageType">$Message</span><% end_if %>
</p>

View File

@ -1,15 +1,15 @@
<form $FormAttributes>
<% if Message %>
<% if $Message %>
<p id="{$FormName}_error" class="message $MessageType">$Message</p>
<% else %>
<p id="{$FormName}_error" class="message $MessageType" style="display: none"></p>
<% end_if %>
<fieldset>
<% loop Fields %>
$FieldHolder
<% loop $Fields %>
$FieldHolder
<% end_loop %>
<% loop Actions %>
$Field
<% loop $Actions %>
$Field
<% end_loop %>
</fieldset>
</form>

View File

@ -13,7 +13,7 @@
<div class="clear"><!-- --></div>
</label>
<div class="ss-uploadfield-item-actions">
<% if Top.isActive %>
<% if $Top.isActive %>
$UploadFieldFileButtons
<% end_if %>
</div>
@ -25,7 +25,7 @@
<% end_loop %>
<% end_if %>
</ul>
<% if canUpload || canAttachExisting %>
<% if $canUpload || $canAttachExisting %>
<div class="ss-uploadfield-item ss-uploadfield-addfile<% if $CustomisedItems %> borderTop<% end_if %>">
<% if canUpload %>
<div class="ss-uploadfield-item-preview ss-uploadfield-dropzone ui-corner-all">
@ -43,21 +43,21 @@
<% else %>
<b><% _t('UploadField.ATTACHFILE', 'Attach a file') %></b>
<% end_if %>
<% if canPreviewFolder %>
<% if $canPreviewFolder %>
<small>(<%t UploadField.UPLOADSINTO 'saves into /{path}' path=$FolderName %>)</small>
<% end_if %>
</label>
<% if canUpload %>
<% if $canUpload %>
<label class="ss-uploadfield-fromcomputer ss-ui-button ui-corner-all" title="<% _t('UploadField.FROMCOMPUTERINFO', 'Upload from your computer') %>" data-icon="drive-upload">
<% _t('UploadField.FROMCOMPUTER', 'From your computer') %>
<input id="$id" name="{$Name}[Uploads][]" class="$extraClass ss-uploadfield-fromcomputer-fileinput" data-config="$configString" type="file"<% if $multiple %> multiple="multiple"<% end_if %> />
</label>
<% end_if %>
<% if canAttachExisting %>
<% if $canAttachExisting %>
<button class="ss-uploadfield-fromfiles ss-ui-button ui-corner-all" title="<% _t('UploadField.FROMCOMPUTERINFO', 'Select from files') %>" data-icon="network-cloud"><% _t('UploadField.FROMFILES', 'From files') %></button>
<% end_if %>
<% if canUpload %>
<% if $canUpload %>
<% if not $autoUpload %>
<button class="ss-uploadfield-startall ss-ui-button ui-corner-all" data-icon="navigation"><% _t('UploadField.STARTALL', 'Start all') %></button>
<% end_if %>

View File

@ -1,6 +1,6 @@
<div id="$HolderID" class="field<% if extraClass %> $extraClass<% end_if %>">
$Field
<label class="right" for="$ID">$Title</label>
<% if Message %><span class="message $MessageType">$Message</span><% end_if %>
<% if Description %><span class="description">$Description</span><% end_if %>
<% if $Message %><span class="message $MessageType">$Message</span><% end_if %>
<% if $Description %><span class="description">$Description</span><% end_if %>
</div>

View File

@ -1,5 +1,5 @@
$Field
<% if $Title %>
<label class="checkboxfield-small" <% if ID %>for="$ID"<% end_if %>>$Title</label>
<% end_if %>
<label class="checkboxfield-small" <% if $ID %>for="$ID"<% end_if %>>$Title</label>
<% end_if %>

View File

@ -1,8 +1,8 @@
<ul id="$HolderID" class="$extraClass">
<% if Options.Count %>
<% loop Options %>
<% if $Options.Count %>
<% loop $Options %>
<li class="$Class">
<input id="$ID" class="checkbox" name="$Name" type="checkbox" value="$Value"<% if isChecked %> checked="checked"<% end_if %><% if isDisabled %> disabled="disabled"<% end_if %> />
<input id="$ID" class="checkbox" name="$Name" type="checkbox" value="$Value"<% if $isChecked %> checked="checked"<% end_if %><% if $isDisabled %> disabled="disabled"<% end_if %> />
<label for="$ID">$Title</label>
</li>
<% end_loop %>

View File

@ -3,8 +3,8 @@
<legend>$Legend</legend>
<% end_if %>
<% loop FieldList %>
<% if ColumnCount %>
<% loop $FieldList %>
<% if $ColumnCount %>
<div class="column-{$ColumnCount} $FirstLast">
$Field
</div>
@ -12,4 +12,4 @@
$Field
<% end_if %>
<% end_loop %>
</$Tag>
</$Tag>

View File

@ -3,8 +3,8 @@
<legend>$Legend</legend>
<% end_if %>
<% loop FieldList %>
<% if ColumnCount %>
<% loop $FieldList %>
<% if $ColumnCount %>
<div class="column-{$ColumnCount} $FirstLast">
$FieldHolder
</div>
@ -13,5 +13,5 @@
<% end_if %>
<% end_loop %>
<% if Description %><span class="description">$Description</span><% end_if %>
</$Tag>
<% if $Description %><span class="description">$Description</span><% end_if %>
</$Tag>

View File

@ -1,5 +1,5 @@
<select $AttributesHTML>
<% loop Options %>
<option value="$Value.XML"<% if Selected %> selected="selected"<% end_if %><% if Disabled %> disabled="disabled"<% end_if %>>$Title.XML</option>
<% loop $Options %>
<option value="$Value.XML"<% if $Selected %> selected="selected"<% end_if %><% if $Disabled %> disabled="disabled"<% end_if %>>$Title.XML</option>
<% end_loop %>
</select>

View File

@ -1,7 +1,7 @@
<div class="<% if extraClass %>$extraClass<% else %>fieldgroup<% end_if %><% if Zebra %> fieldgroup-zebra<% end_if %>" <% if ID %>id="$ID"<% end_if %>>
<% loop FieldList %>
<div class="<% if $extraClass %>$extraClass<% else %>fieldgroup<% end_if %><% if $Zebra %> fieldgroup-zebra<% end_if %>" <% if $ID %>id="$ID"<% end_if %>>
<% loop $FieldList %>
<div class="fieldgroup-field $FirstLast $EvenOdd">
$SmallFieldHolder
</div>
<% end_loop %>
</div>
</div>

View File

@ -1,7 +1,7 @@
<div class="<% if extraClass %>$extraClass<% else %>fieldgroup<% end_if %><% if Zebra %> fieldgroup-zebra<% end_if %>" <% if ID %>id="$ID"<% end_if %>>
<% loop FieldList %>
<div class="<% if $extraClass %>$extraClass<% else %>fieldgroup<% end_if %><% if $Zebra %> fieldgroup-zebra<% end_if %>" <% if ID %>id="$ID"<% end_if %>>
<% loop $FieldList %>
<div class="fieldgroup-field $FirstLast $EvenOdd">
$FieldHolder
</div>
<% end_loop %>
</div>
</div>

View File

@ -1,13 +1,13 @@
<div <% if Name %>id="$Name"<% end_if %> class="field $Type $extraClass">
<% if Title %><label class="left">$Title</label><% end_if %>
<div <% if $Name %>id="$Name"<% end_if %> class="field $Type $extraClass">
<% if $Title %><label class="left">$Title</label><% end_if %>
<div class="middleColumn fieldgroup <% if Zebra %>fieldgroup-$Zebra<% end_if %>">
<% loop FieldList %>
<% loop $FieldList %>
<div class="fieldgroup-field $FirstLast $EvenOdd">
$SmallFieldHolder
</div>
<% end_loop %>
</div>
<% if RightTitle %><label class="right">$RightTitle</label><% end_if %>
<% if Message %><span class="message $MessageType">$Message</span><% end_if %>
</div>
<% if $RightTitle %><label class="right">$RightTitle</label><% end_if %>
<% if $Message %><span class="message $MessageType">$Message</span><% end_if %>
</div>

View File

@ -1,7 +1,7 @@
<% if UseButtonTag %>
<% if $UseButtonTag %>
<button $AttributesHTML>
<% if ButtonContent %>$ButtonContent<% else %>$Title<% end_if %>
<% if $ButtonContent %>$ButtonContent<% else %>$Title<% end_if %>
</button>
<% else %>
<input $AttributesHTML />
<% end_if %>
<% end_if %>

View File

@ -1,5 +1,5 @@
<% if isReadonly %>
<span id="$ID"<% if extraClass %> class="$extraClass"<% end_if %>>
<% if $isReadonly %>
<span id="$ID"<% if $extraClass %> class="$extraClass"<% end_if %>>
$Value
</span>
<% else %>

View File

@ -1,9 +1,9 @@
<div id="$HolderID" class="field<% if extraClass %> $extraClass<% end_if %>">
<% if Title %><label class="left" for="$ID">$Title</label><% end_if %>
<div id="$HolderID" class="field<% if $extraClass %> $extraClass<% end_if %>">
<% if $Title %><label class="left" for="$ID">$Title</label><% end_if %>
<div class="middleColumn">
$Field
</div>
<% if RightTitle %><label class="right" for="$ID">$RightTitle</label><% end_if %>
<% if Message %><span class="message $MessageType">$Message</span><% end_if %>
<% if Description %><span class="description">$Description</span><% end_if %>
</div>
<% if $RightTitle %><label class="right" for="$ID">$RightTitle</label><% end_if %>
<% if $Message %><span class="message $MessageType">$Message</span><% end_if %>
<% if $Description %><span class="description">$Description</span><% end_if %>
</div>

View File

@ -1,11 +1,11 @@
<div class="fieldholder-small">
<% if $RightTitle %>
<label class="right fieldholder-small-label" <% if ID %>for="$ID"<% end_if %>>$RightTitle</label>
<label class="right fieldholder-small-label" <% if $ID %>for="$ID"<% end_if %>>$RightTitle</label>
<% else_if $LeftTitle %>
<label class="left fieldholder-small-label" <% if ID %>for="$ID"<% end_if %>>$LeftTitle</label>
<label class="left fieldholder-small-label" <% if $ID %>for="$ID"<% end_if %>>$LeftTitle</label>
<% else_if $Title %>
<label class="fieldholder-small-label" <% if ID %>for="$ID"<% end_if %>>$Title</label>
<label class="fieldholder-small-label" <% if $ID %>for="$ID"<% end_if %>>$Title</label>
<% end_if %>
$Field
</div>
</div>

View File

@ -1,7 +1,7 @@
<ul id="$HolderID" class="$extraClass">
<% loop Options %>
<% loop $Options %>
<li class="$Class">
<input id="$ID" class="radio" name="$Name" type="radio" value="$Value"<% if isChecked %> checked<% end_if %><% if isDisabled %> disabled<% end_if %> />
<input id="$ID" class="radio" name="$Name" type="radio" value="$Value"<% if $isChecked %> checked<% end_if %><% if $isDisabled %> disabled<% end_if %> />
<label for="$ID">$Title</label>
</li>
<% end_loop %>

View File

@ -1,14 +1,20 @@
<% if IsReadonly %>
<% if $IsReadonly %>
<ul class="SelectionGroup<% if extraClass %> $extraClass<% end_if %>">
<% loop FieldSet %>
<% if Selected %>
<li$Selected>
$RadioLabel
$FieldHolder
</li>
<% loop $FieldSet %>
<% if $Selected %>
<li$Selected>
$RadioLabel
$FieldHolder
</li>
</ul>
<% end_if %>
<% end_loop %>
<% else %>
<ul class="SelectionGroup<% if extraClass %> $extraClass<% end_if %>"><% loop FieldSet %><li$Selected>{$RadioButton}{$RadioLabel}{$FieldHolder}</li><% end_loop %></ul>
<ul class="SelectionGroup<% if extraClass %> $extraClass<% end_if %>">
<% loop $FieldSet %>
<li$Selected>
{$RadioButton}{$RadioLabel}{$FieldHolder}
</li>
<% end_loop %>
</ul>
<% end_if %>

View File

@ -1,16 +1,16 @@
<div $getAttributesHTML("class") class="ss-tabset $extraClass">
<ul>
<% loop Tabs %>
<% loop $Tabs %>
<li class="$FirstLast $MiddleString $extraClass"><a href="#$id" id="tab-$id">$Title</a></li>
<% end_loop %>
</ul>
<% loop Tabs %>
<% if Tabs %>
<% loop $Tabs %>
<% if $Tabs %>
$FieldHolder
<% else %>
<div $AttributesHTML>
<% loop Fields %>
<% loop $Fields %>
$FieldHolder
<% end_loop %>
</div>

View File

@ -2,7 +2,7 @@
class="TreeDropdownField single<% if extraClass %> $extraClass<% end_if %><% if ShowSearch %> searchable<% end_if %>"
data-url-tree="$Link(tree)"
data-title="$TitleURLEncoded"
<% if Description %>title="$Description"<% end_if %>
<% if Metadata %>data-metadata="$Metadata"<% end_if %>>
<% if $Description %>title="$Description"<% end_if %>
<% if $Metadata %>data-metadata="$Metadata"<% end_if %>>
<input id="$ID" type="hidden" name="$Name" value="$Value" />
</div>
</div>

View File

@ -172,12 +172,14 @@ class RestfulServiceTest extends SapphireTest {
public function testHttpHeaderParseing() {
$headers = "content-type: text/html; charset=UTF-8\r\n".
"Server: Funky/1.0\r\n".
"X-BB-ExampleMANycaPS: test\r\n".
"Set-Cookie: foo=bar\r\n".
"Set-Cookie: baz=quux\r\n".
"Set-Cookie: bar=foo\r\n";
$expected = array(
'Content-Type' => 'text/html; charset=UTF-8',
'Server' => 'Funky/1.0',
'X-BB-ExampleMANycaPS' => 'test',
'Set-Cookie' => array(
'foo=bar',
'baz=quux',

View File

@ -0,0 +1,90 @@
<?php
class ErrorControlChainTest extends SapphireTest {
function testErrorSuppression() {
$chain = new ErrorControlChain();
$chain
->then(function(){
user_error('This error should be suppressed', E_USER_ERROR);
})
->execute();
$this->assertTrue($chain->hasErrored());
}
function testMultipleErrorSuppression() {
$chain = new ErrorControlChain();
$chain
->then(function(){
user_error('This error should be suppressed', E_USER_ERROR);
})
->thenAlways(function(){
user_error('This error should also be suppressed', E_USER_ERROR);
})
->execute();
$this->assertTrue($chain->hasErrored());
}
function testExceptionSuppression() {
$chain = new ErrorControlChain();
$chain
->then(function(){
throw new Exception('This exception should be suppressed');
})
->execute();
$this->assertTrue($chain->hasErrored());
}
function testMultipleExceptionSuppression() {
$chain = new ErrorControlChain();
$chain
->then(function(){
throw new Exception('This exception should be suppressed');
})
->thenAlways(function(){
throw new Exception('This exception should also be suppressed');
})
->execute();
$this->assertTrue($chain->hasErrored());
}
function testErrorControl() {
$preError = $postError = array('then' => false, 'thenIfErrored' => false, 'thenAlways' => false);
$chain = new ErrorControlChain();
$chain
->then(function() use (&$preError) { $preError['then'] = true; })
->thenIfErrored(function() use (&$preError) { $preError['thenIfErrored'] = true; })
->thenAlways(function() use (&$preError) { $preError['thenAlways'] = true; })
->then(function(){ user_error('An error', E_USER_ERROR); })
->then(function() use (&$postError) { $postError['then'] = true; })
->thenIfErrored(function() use (&$postError) { $postError['thenIfErrored'] = true; })
->thenAlways(function() use (&$postError) { $postError['thenAlways'] = true; })
->execute();
$this->assertEquals(
array('then' => true, 'thenIfErrored' => false, 'thenAlways' => true),
$preError,
'Then and thenAlways callbacks called before error, thenIfErrored callback not called'
);
$this->assertEquals(
array('then' => false, 'thenIfErrored' => true, 'thenAlways' => true),
$postError,
'thenIfErrored and thenAlways callbacks called after error, then callback not called'
);
}
}

View File

@ -153,6 +153,33 @@ class UploadFieldTest extends FunctionalTest {
$this->assertEquals($relationCount + 1, $record->ManyManyFiles()->Count());
}
/**
* Partially covered by {@link UploadTest->testUploadAcceptsAllowedExtension()},
* but this test additionally verifies that those constraints are actually enforced
* in this controller method.
*/
public function testAllowedExtensions() {
$this->loginWithPermission('ADMIN');
$invalidFile = 'invalid.php';
$_FILES = array('AllowedExtensionsField' => $this->getUploadFile($invalidFile));
$response = $this->post(
'UploadFieldTest_Controller/Form/field/AllowedExtensionsField/upload',
array('AllowedExtensionsField' => $this->getUploadFile($invalidFile))
);
$this->assertTrue($response->isError());
$this->assertContains('Extension is not allowed', $response->getBody());
$validFile = 'valid.txt';
$_FILES = array('AllowedExtensionsField' => $this->getUploadFile($validFile));
$response = $this->post(
'UploadFieldTest_Controller/Form/field/AllowedExtensionsField/upload',
array('AllowedExtensionsField' => $this->getUploadFile($validFile))
);
$this->assertFalse($response->isError());
$this->assertNotContains('Extension is not allowed', $response->getBody());
}
/**
* Test that has_one relations do not support multiple files
*/
@ -899,6 +926,9 @@ class UploadFieldTestForm extends Form implements TestOnly {
$fieldCanAttachExisting = UploadField::create('CanAttachExistingFalseField')
->setCanAttachExisting(false);
$fieldAllowedExtensions = new UploadField('AllowedExtensionsField');
$fieldAllowedExtensions->getValidator()->setAllowedExtensions(array('txt'));
$fields = new FieldList(
$fieldNoRelation,
$fieldHasOne,
@ -913,7 +943,8 @@ class UploadFieldTestForm extends Form implements TestOnly {
$fieldDisabled,
$fieldSubfolder,
$fieldCanUploadFalse,
$fieldCanAttachExisting
$fieldCanAttachExisting,
$fieldAllowedExtensions
);
$actions = new FieldList(
new FormAction('submit')
@ -943,4 +974,4 @@ class UploadFieldTest_Controller extends Controller implements TestOnly {
public function Form() {
return new UploadFieldTestForm($this, 'Form');
}
}
}

View File

@ -250,61 +250,76 @@ class SecurityTest extends FunctionalTest {
i18n::set_locale('en_US');
Member::config()->lock_out_after_incorrect_logins = 5;
Member::config()->lock_out_delay_mins = 15;
/* LOG IN WITH A BAD PASSWORD 7 TIMES */
for($i=1;$i<=7;$i++) {
// Login with a wrong password for more than the defined threshold
for($i = 1; $i <= Member::config()->lock_out_after_incorrect_logins+1; $i++) {
$this->doTestLoginForm('sam@silverstripe.com' , 'incorrectpassword');
$member = DataObject::get_by_id("Member", $this->idFromFixture('Member', 'test'));
/* THE FIRST 4 TIMES, THE MEMBER SHOULDN'T BE LOCKED OUT */
if($i < 5) {
$this->assertNull($member->LockedOutUntil);
if($i < Member::config()->lock_out_after_incorrect_logins) {
$this->assertNull(
$member->LockedOutUntil,
'User does not have a lockout time set if under threshold for failed attempts'
);
$this->assertContains($this->loginErrorMessage(), _t('Member.ERRORWRONGCRED'));
} else {
// Fuzzy matching for time to avoid side effects from slow running tests
$this->assertGreaterThan(
time() + 14*60,
strtotime($member->LockedOutUntil),
'User has a lockout time set after too many failed attempts'
);
}
/* AFTER THAT THE USER IS LOCKED OUT FOR 15 MINUTES */
//(we check for at least 14 minutes because we don't want a slow running test to report a failure.)
else {
$this->assertGreaterThan(time() + 14*60, strtotime($member->LockedOutUntil));
}
if($i > 5) {
$this->assertContains(_t('Member.ERRORLOCKEDOUT'), $this->loginErrorMessage());
// $this->assertTrue(false !== stripos($this->loginErrorMessage(), _t('Member.ERRORLOCKEDOUT')));
$msg = _t(
'Member.ERRORLOCKEDOUT2',
'Your account has been temporarily disabled because of too many failed attempts at ' .
'logging in. Please try again in {count} minutes.',
null,
array('count' => Member::config()->lock_out_delay_mins)
);
if($i > Member::config()->lock_out_after_incorrect_logins) {
$this->assertContains($msg, $this->loginErrorMessage());
}
}
/* THE USER CAN'T LOG IN NOW, EVEN IF THEY GET THE RIGHT PASSWORD */
$this->doTestLoginForm('sam@silverstripe.com' , '1nitialPassword');
$this->assertNull($this->session()->inst_get('loggedInAs'));
$this->assertNull(
$this->session()->inst_get('loggedInAs'),
'The user can\'t log in after being locked out, even with the right password'
);
/* BUT, IF TIME PASSES, THEY CAN LOG IN */
// (We fake this by re-setting LockedOutUntil)
$member = DataObject::get_by_id("Member", $this->idFromFixture('Member', 'test'));
$member->LockedOutUntil = date('Y-m-d H:i:s', time() - 30);
$member->write();
$this->doTestLoginForm('sam@silverstripe.com' , '1nitialPassword');
$this->assertEquals($this->session()->inst_get('loggedInAs'), $member->ID);
$this->assertEquals(
$this->session()->inst_get('loggedInAs'),
$member->ID,
'After lockout expires, the user can login again'
);
// Log the user out
$this->session()->inst_set('loggedInAs', null);
/* NOW THAT THE LOCK-OUT HAS EXPIRED, CHECK THAT WE ARE ALLOWED 4 FAILED ATTEMPTS BEFORE LOGGING IN */
$this->doTestLoginForm('sam@silverstripe.com' , 'incorrectpassword');
$this->doTestLoginForm('sam@silverstripe.com' , 'incorrectpassword');
$this->doTestLoginForm('sam@silverstripe.com' , 'incorrectpassword');
$this->doTestLoginForm('sam@silverstripe.com' , 'incorrectpassword');
// Login again with wrong password, but less attempts than threshold
for($i = 1; $i < Member::config()->lock_out_after_incorrect_logins; $i++) {
$this->doTestLoginForm('sam@silverstripe.com' , 'incorrectpassword');
}
$this->assertNull($this->session()->inst_get('loggedInAs'));
$this->assertTrue(false !== stripos($this->loginErrorMessage(), _t('Member.ERRORWRONGCRED')));
$this->assertTrue(
false !== stripos($this->loginErrorMessage(), _t('Member.ERRORWRONGCRED')),
'The user can retry with a wrong password after the lockout expires'
);
$this->doTestLoginForm('sam@silverstripe.com' , '1nitialPassword');
$this->assertEquals($this->session()->inst_get('loggedInAs'), $member->ID);
$this->assertEquals(
$this->session()->inst_get('loggedInAs'),
$member->ID,
'The user can login successfully after lockout expires, if staying below the threshold'
);
i18n::set_locale($local);
}