Don't use session and FormSchema to manage server-side React validation responses

This commit is contained in:
Sam Minnee 2016-11-23 10:29:15 +13:00 committed by Damian Mooyman
parent f39c4d94f2
commit 6650561dac
19 changed files with 5985 additions and 5715 deletions

View File

@ -24,552 +24,552 @@ use SilverStripe\Core\Convert;
class DBFile extends DBComposite implements AssetContainer, Thumbnail class DBFile extends DBComposite implements AssetContainer, Thumbnail
{ {
use ImageManipulation; use ImageManipulation;
/** /**
* List of allowed file categories. * List of allowed file categories.
* *
* {@see File::$app_categories} * {@see File::$app_categories}
* *
* @var array * @var array
*/ */
protected $allowedCategories = array(); protected $allowedCategories = array();
/** /**
* List of image mime types supported by the image manipulations API * List of image mime types supported by the image manipulations API
* *
* {@see File::app_categories} for matching extensions. * {@see File::app_categories} for matching extensions.
* *
* @config * @config
* @var array * @var array
*/ */
private static $supported_images = array( private static $supported_images = array(
'image/jpeg', 'image/jpeg',
'image/gif', 'image/gif',
'image/png' 'image/png'
); );
/** /**
* Create a new image manipulation * Create a new image manipulation
* *
* @param string $name * @param string $name
* @param array|string $allowed List of allowed file categories (not extensions), as per File::$app_categories * @param array|string $allowed List of allowed file categories (not extensions), as per File::$app_categories
*/ */
public function __construct($name = null, $allowed = array()) public function __construct($name = null, $allowed = array())
{ {
parent::__construct($name); parent::__construct($name);
$this->setAllowedCategories($allowed); $this->setAllowedCategories($allowed);
} }
/** /**
* Determine if a valid non-empty image exists behind this asset, which is a format * Determine if a valid non-empty image exists behind this asset, which is a format
* compatible with image manipulations * compatible with image manipulations
* *
* @return boolean * @return boolean
*/ */
public function getIsImage() public function getIsImage()
{ {
// Check file type // Check file type
$mime = $this->getMimeType(); $mime = $this->getMimeType();
return $mime && in_array($mime, $this->config()->supported_images); return $mime && in_array($mime, $this->config()->supported_images);
} }
/** /**
* @return AssetStore * @return AssetStore
*/ */
protected function getStore() protected function getStore()
{ {
return Injector::inst()->get('AssetStore'); return Injector::inst()->get('AssetStore');
} }
private static $composite_db = array( private static $composite_db = array(
"Hash" => "Varchar(255)", // SHA of the base content "Hash" => "Varchar(255)", // SHA of the base content
"Filename" => "Varchar(255)", // Path identifier of the base content "Filename" => "Varchar(255)", // Path identifier of the base content
"Variant" => "Varchar(255)", // Identifier of the variant to the base, if given "Variant" => "Varchar(255)", // Identifier of the variant to the base, if given
); );
private static $casting = array( private static $casting = array(
'URL' => 'Varchar', 'URL' => 'Varchar',
'AbsoluteURL' => 'Varchar', 'AbsoluteURL' => 'Varchar',
'Basename' => 'Varchar', 'Basename' => 'Varchar',
'Title' => 'Varchar', 'Title' => 'Varchar',
'MimeType' => 'Varchar', 'MimeType' => 'Varchar',
'String' => 'Text', 'String' => 'Text',
'Tag' => 'HTMLFragment', 'Tag' => 'HTMLFragment',
'Size' => 'Varchar' 'Size' => 'Varchar'
); );
public function scaffoldFormField($title = null, $params = null) public function scaffoldFormField($title = null, $params = null)
{ {
return AssetField::create($this->getName(), $title); return AssetField::create($this->getName(), $title);
} }
/** /**
* Return a html5 tag of the appropriate for this file (normally img or a) * Return a html5 tag of the appropriate for this file (normally img or a)
* *
* @return string * @return string
*/ */
public function XML() public function XML()
{ {
return $this->getTag() ?: ''; return $this->getTag() ?: '';
} }
/** /**
* Return a html5 tag of the appropriate for this file (normally img or a) * Return a html5 tag of the appropriate for this file (normally img or a)
* *
* @return string * @return string
*/ */
public function getTag() public function getTag()
{ {
$template = $this->getFrontendTemplate(); $template = $this->getFrontendTemplate();
if (empty($template)) { if(empty($template)) {
return ''; return '';
} }
return (string)$this->renderWith($template); return (string)$this->renderWith($template);
} }
/** /**
* Determine the template to render as on the frontend * Determine the template to render as on the frontend
* *
* @return string Name of template * @return string Name of template
*/ */
public function getFrontendTemplate() public function getFrontendTemplate()
{ {
// Check that path is available // Check that path is available
$url = $this->getURL(); $url = $this->getURL();
if (empty($url)) { if(empty($url)) {
return null; return null;
} }
// Image template for supported images // Image template for supported images
if ($this->getIsImage()) { if($this->getIsImage()) {
return 'DBFile_image'; return 'DBFile_image';
} }
// Default download // Default download
return 'DBFile_download'; return 'DBFile_download';
} }
/** /**
* Get trailing part of filename * Get trailing part of filename
* *
* @return string * @return string
*/ */
public function getBasename() public function getBasename()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return basename($this->getSourceURL()); return basename($this->getSourceURL());
} }
/** /**
* Get file extension * Get file extension
* *
* @return string * @return string
*/ */
public function getExtension() public function getExtension()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return pathinfo($this->Filename, PATHINFO_EXTENSION); return pathinfo($this->Filename, PATHINFO_EXTENSION);
} }
/** /**
* Alt title for this * Alt title for this
* *
* @return string * @return string
*/ */
public function getTitle() public function getTitle()
{ {
// If customised, use the customised title // If customised, use the customised title
if ($this->failover && ($title = $this->failover->Title)) { if($this->failover && ($title = $this->failover->Title)) {
return $title; return $title;
} }
// fallback to using base name // fallback to using base name
return $this->getBasename(); return $this->getBasename();
} }
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array()) public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array())
{ {
$this->assertFilenameValid($filename ?: $path); $this->assertFilenameValid($filename ?: $path);
$result = $this $result = $this
->getStore() ->getStore()
->setFromLocalFile($path, $filename, $hash, $variant, $config); ->setFromLocalFile($path, $filename, $hash, $variant, $config);
// Update from result // Update from result
if ($result) { if($result) {
$this->setValue($result); $this->setValue($result);
} }
return $result; return $result;
} }
public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array()) public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array())
{ {
$this->assertFilenameValid($filename); $this->assertFilenameValid($filename);
$result = $this $result = $this
->getStore() ->getStore()
->setFromStream($stream, $filename, $hash, $variant, $config); ->setFromStream($stream, $filename, $hash, $variant, $config);
// Update from result // Update from result
if ($result) { if($result) {
$this->setValue($result); $this->setValue($result);
} }
return $result; return $result;
} }
public function setFromString($data, $filename, $hash = null, $variant = null, $config = array()) public function setFromString($data, $filename, $hash = null, $variant = null, $config = array())
{ {
$this->assertFilenameValid($filename); $this->assertFilenameValid($filename);
$result = $this $result = $this
->getStore() ->getStore()
->setFromString($data, $filename, $hash, $variant, $config); ->setFromString($data, $filename, $hash, $variant, $config);
// Update from result // Update from result
if ($result) { if($result) {
$this->setValue($result); $this->setValue($result);
} }
return $result; return $result;
} }
public function getStream() public function getStream()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return $this return $this
->getStore() ->getStore()
->getAsStream($this->Filename, $this->Hash, $this->Variant); ->getAsStream($this->Filename, $this->Hash, $this->Variant);
} }
public function getString() public function getString()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return $this return $this
->getStore() ->getStore()
->getAsString($this->Filename, $this->Hash, $this->Variant); ->getAsString($this->Filename, $this->Hash, $this->Variant);
} }
public function getURL($grant = true) public function getURL($grant = true)
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
$url = $this->getSourceURL($grant); $url = $this->getSourceURL($grant);
$this->updateURL($url); $this->updateURL($url);
$this->extend('updateURL', $url); $this->extend('updateURL', $url);
return $url; return $url;
} }
/** /**
* Get URL, but without resampling. * Get URL, but without resampling.
* Note that this will return the url even if the file does not exist. * Note that this will return the url even if the file does not exist.
* *
* @param bool $grant Ensures that the url for any protected assets is granted for the current user. * @param bool $grant Ensures that the url for any protected assets is granted for the current user.
* @return string * @return string
*/ */
public function getSourceURL($grant = true) public function getSourceURL($grant = true)
{ {
return $this return $this
->getStore() ->getStore()
->getAsURL($this->Filename, $this->Hash, $this->Variant, $grant); ->getAsURL($this->Filename, $this->Hash, $this->Variant, $grant);
} }
/** /**
* Get the absolute URL to this resource * Get the absolute URL to this resource
* *
* @return string * @return string
*/ */
public function getAbsoluteURL() public function getAbsoluteURL()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return Director::absoluteURL($this->getURL()); return Director::absoluteURL($this->getURL());
} }
public function getMetaData() public function getMetaData()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return $this return $this
->getStore() ->getStore()
->getMetadata($this->Filename, $this->Hash, $this->Variant); ->getMetadata($this->Filename, $this->Hash, $this->Variant);
} }
public function getMimeType() public function getMimeType()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return $this return $this
->getStore() ->getStore()
->getMimeType($this->Filename, $this->Hash, $this->Variant); ->getMimeType($this->Filename, $this->Hash, $this->Variant);
} }
public function getValue() public function getValue()
{ {
if (!$this->exists()) { if(!$this->exists()) {
return null; return null;
} }
return array( return array(
'Filename' => $this->Filename, 'Filename' => $this->Filename,
'Hash' => $this->Hash, 'Hash' => $this->Hash,
'Variant' => $this->Variant 'Variant' => $this->Variant
); );
} }
public function getVisibility() public function getVisibility()
{ {
if (empty($this->Filename)) { if(empty($this->Filename)) {
return null; return null;
} }
return $this return $this
->getStore() ->getStore()
->getVisibility($this->Filename, $this->Hash); ->getVisibility($this->Filename, $this->Hash);
} }
public function exists() public function exists()
{ {
if (empty($this->Filename)) { if(empty($this->Filename)) {
return false; return false;
} }
return $this return $this
->getStore() ->getStore()
->exists($this->Filename, $this->Hash, $this->Variant); ->exists($this->Filename, $this->Hash, $this->Variant);
} }
public function getFilename() public function getFilename()
{ {
return $this->getField('Filename'); return $this->getField('Filename');
} }
public function getHash() public function getHash()
{ {
return $this->getField('Hash'); return $this->getField('Hash');
} }
public function getVariant() public function getVariant()
{ {
return $this->getField('Variant'); return $this->getField('Variant');
} }
/** /**
* Return file size in bytes. * Return file size in bytes.
* *
* @return int * @return int
*/ */
public function getAbsoluteSize() public function getAbsoluteSize()
{ {
$metadata = $this->getMetaData(); $metadata = $this->getMetaData();
if (isset($metadata['size'])) { if(isset($metadata['size'])) {
return $metadata['size']; return $metadata['size'];
} }
return 0; return 0;
} }
/** /**
* Customise this object with an "original" record for getting other customised fields * Customise this object with an "original" record for getting other customised fields
* *
* @param AssetContainer $original * @param AssetContainer $original
* @return $this * @return $this
*/ */
public function setOriginal($original) public function setOriginal($original)
{ {
$this->failover = $original; $this->failover = $original;
return $this; return $this;
} }
/** /**
* Get list of allowed file categories * Get list of allowed file categories
* *
* @return array * @return array
*/ */
public function getAllowedCategories() public function getAllowedCategories()
{ {
return $this->allowedCategories; return $this->allowedCategories;
} }
/** /**
* Assign allowed categories * Assign allowed categories
* *
* @param array|string $categories * @param array|string $categories
* @return $this * @return $this
*/ */
public function setAllowedCategories($categories) public function setAllowedCategories($categories)
{ {
if (is_string($categories)) { if(is_string($categories)) {
$categories = preg_split('/\s*,\s*/', $categories); $categories = preg_split('/\s*,\s*/', $categories);
} }
$this->allowedCategories = (array)$categories; $this->allowedCategories = (array)$categories;
return $this; return $this;
} }
/** /**
* Gets the list of extensions (if limited) for this field. Empty list * Gets the list of extensions (if limited) for this field. Empty list
* means there is no restriction on allowed types. * means there is no restriction on allowed types.
* *
* @return array * @return array
*/ */
protected function getAllowedExtensions() protected function getAllowedExtensions()
{ {
$categories = $this->getAllowedCategories(); $categories = $this->getAllowedCategories();
return File::get_category_extensions($categories); return File::get_category_extensions($categories);
} }
/** /**
* Validate that this DBFile accepts this filename as valid * Validate that this DBFile accepts this filename as valid
* *
* @param string $filename * @param string $filename
* @throws ValidationException * @throws ValidationException
* @return bool * @return bool
*/ */
protected function isValidFilename($filename) protected function isValidFilename($filename)
{ {
$extension = strtolower(File::get_file_extension($filename)); $extension = strtolower(File::get_file_extension($filename));
// Validate true if within the list of allowed extensions // Validate true if within the list of allowed extensions
$allowed = $this->getAllowedExtensions(); $allowed = $this->getAllowedExtensions();
if ($allowed) { if($allowed) {
return in_array($extension, $allowed); return in_array($extension, $allowed);
} }
// If no extensions are configured, fallback to global list // If no extensions are configured, fallback to global list
$globalList = File::config()->allowed_extensions; $globalList = File::config()->allowed_extensions;
if (in_array($extension, $globalList)) { if(in_array($extension, $globalList)) {
return true; return true;
} }
// Only admins can bypass global rules // Only admins can bypass global rules
return !File::config()->apply_restrictions_to_admin && Permission::check('ADMIN'); return !File::config()->apply_restrictions_to_admin && Permission::check('ADMIN');
} }
/** /**
* Check filename, and raise a ValidationException if invalid * Check filename, and raise a ValidationException if invalid
* *
* @param string $filename * @param string $filename
* @throws ValidationException * @throws ValidationException
*/ */
protected function assertFilenameValid($filename) protected function assertFilenameValid($filename)
{ {
$result = new ValidationResult(); $result = new ValidationResult();
$this->validate($result, $filename); $this->validate($result, $filename);
if (!$result->valid()) { if(!$result->valid()) {
throw new ValidationException($result); throw new ValidationException($result);
} }
} }
/** /**
* Hook to validate this record against a validation result * Hook to validate this record against a validation result
* *
* @param ValidationResult $result * @param ValidationResult $result
* @param string $filename Optional filename to validate. If omitted, the current value is validated. * @param string $filename Optional filename to validate. If omitted, the current value is validated.
* @return bool Valid flag * @return bool Valid flag
*/ */
public function validate(ValidationResult $result, $filename = null) public function validate(ValidationResult $result, $filename = null)
{ {
if (empty($filename)) { if(empty($filename)) {
$filename = $this->getFilename(); $filename = $this->getFilename();
} }
if (empty($filename) || $this->isValidFilename($filename)) { if(empty($filename) || $this->isValidFilename($filename)) {
return true; return true;
} }
// Check allowed extensions // Check allowed extensions
$extensions = $this->getAllowedExtensions(); $extensions = $this->getAllowedExtensions();
if (empty($extensions)) { if(empty($extensions)) {
$extensions = File::config()->allowed_extensions; $extensions = File::config()->allowed_extensions;
} }
sort($extensions); sort($extensions);
$message = _t( $message = _t(
'File.INVALIDEXTENSION', 'File.INVALIDEXTENSION',
'Extension is not allowed (valid: {extensions})', 'Extension is not allowed (valid: {extensions})',
'Argument 1: Comma-separated list of valid extensions', 'Argument 1: Comma-separated list of valid extensions',
array('extensions' => wordwrap(implode(', ', $extensions))) array('extensions' => wordwrap(implode(', ',$extensions)))
); );
$result->error($message); $result->addError($message);
return false; return false;
} }
public function setField($field, $value, $markChanged = true) public function setField($field, $value, $markChanged = true)
{ {
// Catch filename validation on direct assignment // Catch filename validation on direct assignment
if ($field === 'Filename' && $value) { if($field === 'Filename' && $value) {
$this->assertFilenameValid($value); $this->assertFilenameValid($value);
} }
return parent::setField($field, $value, $markChanged); return parent::setField($field, $value, $markChanged);
} }
/** /**
* Returns the size of the file type in an appropriate format. * Returns the size of the file type in an appropriate format.
* *
* @return string|false String value, or false if doesn't exist * @return string|false String value, or false if doesn't exist
*/ */
public function getSize() public function getSize()
{ {
$size = $this->getAbsoluteSize(); $size = $this->getAbsoluteSize();
if ($size) { if($size) {
return File::format_size($size); return File::format_size($size);
} }
return false; return false;
} }
public function deleteFile() public function deleteFile()
{ {
if (!$this->Filename) { if(!$this->Filename) {
return false; return false;
} }
return $this return $this
->getStore() ->getStore()
->delete($this->Filename, $this->Hash); ->delete($this->Filename, $this->Hash);
} }
public function publishFile() public function publishFile()
{ {
if ($this->Filename) { if($this->Filename) {
$this $this
->getStore() ->getStore()
->publish($this->Filename, $this->Hash); ->publish($this->Filename, $this->Hash);
} }
} }
public function protectFile() public function protectFile()
{ {
if ($this->Filename) { if($this->Filename) {
$this $this
->getStore() ->getStore()
->protect($this->Filename, $this->Hash); ->protect($this->Filename, $this->Hash);
} }
} }
public function grantFile() public function grantFile()
{ {
if ($this->Filename) { if($this->Filename) {
$this $this
->getStore() ->getStore()
->grant($this->Filename, $this->Hash); ->grant($this->Filename, $this->Hash);
} }
} }
public function revokeFile() public function revokeFile()
{ {
if ($this->Filename) { if($this->Filename) {
$this $this
->getStore() ->getStore()
->revoke($this->Filename, $this->Hash); ->revoke($this->Filename, $this->Hash);
} }
} }
public function canViewFile() public function canViewFile()
{ {
return $this->Filename return $this->Filename
&& $this && $this
->getStore() ->getStore()
->canView($this->Filename, $this->Hash); ->canView($this->Filename, $this->Hash);
} }
} }

View File

@ -33,378 +33,374 @@ use SimpleXMLElement;
*/ */
class FunctionalTest extends SapphireTest class FunctionalTest extends SapphireTest
{ {
/** /**
* Set this to true on your sub-class to disable the use of themes in this test. * Set this to true on your sub-class to disable the use of themes in this test.
* This can be handy for functional testing of modules without having to worry about whether a user has changed * This can be handy for functional testing of modules without having to worry about whether a user has changed
* behaviour by replacing the theme. * behaviour by replacing the theme.
* *
* @var bool * @var bool
*/ */
protected static $disable_themes = false; protected static $disable_themes = false;
/** /**
* Set this to true on your sub-class to use the draft site by default for every test in this class. * Set this to true on your sub-class to use the draft site by default for every test in this class.
* *
* @var bool * @var bool
*/ */
protected static $use_draft_site = false; protected static $use_draft_site = false;
/** /**
* @var TestSession * @var TestSession
*/ */
protected $mainSession = null; protected $mainSession = null;
/** /**
* CSSContentParser for the most recently requested page. * CSSContentParser for the most recently requested page.
* *
* @var CSSContentParser * @var CSSContentParser
*/ */
protected $cssParser = null; protected $cssParser = null;
/** /**
* If this is true, then 30x Location headers will be automatically followed. * If this is true, then 30x Location headers will be automatically followed.
* If not, then you will have to manaully call $this->mainSession->followRedirection() to follow them. * If not, then you will have to manaully call $this->mainSession->followRedirection() to follow them.
* However, this will let you inspect the intermediary headers * However, this will let you inspect the intermediary headers
* *
* @var bool * @var bool
*/ */
protected $autoFollowRedirection = true; protected $autoFollowRedirection = true;
/** /**
* Returns the {@link Session} object for this test * Returns the {@link Session} object for this test
* *
* @return Session * @return Session
*/ */
public function session() public function session()
{ {
return $this->mainSession->session(); return $this->mainSession->session();
} }
public function setUp() public function setUp()
{ {
// Skip calling FunctionalTest directly. // Skip calling FunctionalTest directly.
if (get_class($this) == __CLASS__) { if(get_class($this) == __CLASS__) {
$this->markTestSkipped(sprintf('Skipping %s ', get_class($this))); $this->markTestSkipped(sprintf('Skipping %s ', get_class($this)));
} }
parent::setUp(); parent::setUp();
$this->mainSession = new TestSession(); $this->mainSession = new TestSession();
// Disable theme, if necessary // Disable theme, if necessary
if (static::get_disable_themes()) { if(static::get_disable_themes()) {
SSViewer::config()->update('theme_enabled', false); SSViewer::config()->update('theme_enabled', false);
} }
// Switch to draft site, if necessary // Switch to draft site, if necessary
if (static::get_use_draft_site()) { if(static::get_use_draft_site()) {
$this->useDraftSite(); $this->useDraftSite();
} }
// Unprotect the site, tests are running with the assumption it's off. They will enable it on a case-by-case // Unprotect the site, tests are running with the assumption it's off. They will enable it on a case-by-case
// basis. // basis.
BasicAuth::protect_entire_site(false); BasicAuth::protect_entire_site(false);
SecurityToken::disable(); SecurityToken::disable();
} }
public function tearDown() public function tearDown()
{ {
SecurityToken::enable(); SecurityToken::enable();
parent::tearDown(); parent::tearDown();
unset($this->mainSession); unset($this->mainSession);
} }
/** /**
* Run a test while mocking the base url with the provided value * Run a test while mocking the base url with the provided value
* @param string $url The base URL to use for this test * @param string $url The base URL to use for this test
* @param callable $callback The test to run * @param callable $callback The test to run
*/ */
protected function withBaseURL($url, $callback) protected function withBaseURL($url, $callback)
{ {
$oldBase = Config::inst()->get('SilverStripe\\Control\\Director', 'alternate_base_url'); $oldBase = Config::inst()->get('SilverStripe\\Control\\Director', 'alternate_base_url');
Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_url', $url); Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_url', $url);
$callback($this); $callback($this);
Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_url', $oldBase); Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_url', $oldBase);
} }
/** /**
* Run a test while mocking the base folder with the provided value * Run a test while mocking the base folder with the provided value
* @param string $folder The base folder to use for this test * @param string $folder The base folder to use for this test
* @param callable $callback The test to run * @param callable $callback The test to run
*/ */
protected function withBaseFolder($folder, $callback) protected function withBaseFolder($folder, $callback)
{ {
$oldFolder = Config::inst()->get('SilverStripe\\Control\\Director', 'alternate_base_folder'); $oldFolder = Config::inst()->get('SilverStripe\\Control\\Director', 'alternate_base_folder');
Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_folder', $folder); Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_folder', $folder);
$callback($this); $callback($this);
Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_folder', $oldFolder); Config::inst()->update('SilverStripe\\Control\\Director', 'alternate_base_folder', $oldFolder);
} }
/** /**
* Submit a get request * Submit a get request
* @uses Director::test() * @uses Director::test()
* *
* @param string $url * @param string $url
* @param Session $session * @param Session $session
* @param array $headers * @param array $headers
* @param array $cookies * @param array $cookies
* @return HTTPResponse * @return HTTPResponse
*/ */
public function get($url, $session = null, $headers = null, $cookies = null) public function get($url, $session = null, $headers = null, $cookies = null)
{ {
$this->cssParser = null; $this->cssParser = null;
$response = $this->mainSession->get($url, $session, $headers, $cookies); $response = $this->mainSession->get($url, $session, $headers, $cookies);
if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) { if($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
$response = $this->mainSession->followRedirection(); $response = $this->mainSession->followRedirection();
} }
return $response; return $response;
} }
/** /**
* Submit a post request * Submit a post request
* *
* @uses Director::test() * @uses Director::test()
* @param string $url * @param string $url
* @param array $data * @param array $data
* @param array $headers * @param array $headers
* @param Session $session * @param Session $session
* @param string $body * @param string $body
* @param array $cookies * @param array $cookies
* @return HTTPResponse * @return HTTPResponse
*/ */
public function post($url, $data, $headers = null, $session = null, $body = null, $cookies = null) public function post($url, $data, $headers = null, $session = null, $body = null, $cookies = null)
{ {
$this->cssParser = null; $this->cssParser = null;
$response = $this->mainSession->post($url, $data, $headers, $session, $body, $cookies); $response = $this->mainSession->post($url, $data, $headers, $session, $body, $cookies);
if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) { if($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
$response = $this->mainSession->followRedirection(); $response = $this->mainSession->followRedirection();
} }
return $response; return $response;
} }
/** /**
* Submit the form with the given HTML ID, filling it out with the given data. * Submit the form with the given HTML ID, filling it out with the given data.
* Acts on the most recent response. * Acts on the most recent response.
* *
* Any data parameters have to be present in the form, with exact form field name * Any data parameters have to be present in the form, with exact form field name
* and values, otherwise they are removed from the submission. * and values, otherwise they are removed from the submission.
* *
* Caution: Parameter names have to be formatted * Caution: Parameter names have to be formatted
* as they are in the form submission, not as they are interpreted by PHP. * as they are in the form submission, not as they are interpreted by PHP.
* Wrong: array('mycheckboxvalues' => array(1 => 'one', 2 => 'two')) * Wrong: array('mycheckboxvalues' => array(1 => 'one', 2 => 'two'))
* Right: array('mycheckboxvalues[1]' => 'one', 'mycheckboxvalues[2]' => 'two') * Right: array('mycheckboxvalues[1]' => 'one', 'mycheckboxvalues[2]' => 'two')
* *
* @see http://www.simpletest.org/en/form_testing_documentation.html * @see http://www.simpletest.org/en/form_testing_documentation.html
* *
* @param string $formID HTML 'id' attribute of a form (loaded through a previous response) * @param string $formID HTML 'id' attribute of a form (loaded through a previous response)
* @param string $button HTML 'name' attribute of the button (NOT the 'id' attribute) * @param string $button HTML 'name' attribute of the button (NOT the 'id' attribute)
* @param array $data Map of GET/POST data. * @param array $data Map of GET/POST data.
* @return HTTPResponse * @return HTTPResponse
*/ */
public function submitForm($formID, $button = null, $data = array()) public function submitForm($formID, $button = null, $data = array())
{ {
$this->cssParser = null; $this->cssParser = null;
$response = $this->mainSession->submitForm($formID, $button, $data); $response = $this->mainSession->submitForm($formID, $button, $data);
if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) { if($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
$response = $this->mainSession->followRedirection(); $response = $this->mainSession->followRedirection();
} }
return $response; return $response;
} }
/** /**
* Return the most recent content * Return the most recent content
* *
* @return string * @return string
*/ */
public function content() public function content()
{ {
return $this->mainSession->lastContent(); return $this->mainSession->lastContent();
} }
/** /**
* Find an attribute in a SimpleXMLElement object by name. * Find an attribute in a SimpleXMLElement object by name.
* @param SimpleXMLElement $object * @param SimpleXMLElement $object
* @param string $attribute Name of attribute to find * @param string $attribute Name of attribute to find
* @return SimpleXMLElement object of the attribute * @return SimpleXMLElement object of the attribute
*/ */
public function findAttribute($object, $attribute) public function findAttribute($object, $attribute)
{ {
$found = false; $found = false;
foreach ($object->attributes() as $a => $b) { foreach($object->attributes() as $a => $b) {
if ($a == $attribute) { if($a == $attribute) {
$found = $b; $found = $b;
} }
} }
return $found; return $found;
} }
/** /**
* Return a CSSContentParser for the most recent content. * Return a CSSContentParser for the most recent content.
* *
* @return CSSContentParser * @return CSSContentParser
*/ */
public function cssParser() public function cssParser()
{ {
if (!$this->cssParser) { if (!$this->cssParser) {
$this->cssParser = new CSSContentParser($this->mainSession->lastContent()); $this->cssParser = new CSSContentParser($this->mainSession->lastContent());
} }
return $this->cssParser; return $this->cssParser;
} }
/** /**
* Assert that the most recently queried page contains a number of content tags specified by a CSS selector. * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
* The given CSS selector will be applied to the HTML of the most recent page. The content of every matching tag * The given CSS selector will be applied to the HTML of the most recent page. The content of every matching tag
* will be examined. The assertion fails if one of the expectedMatches fails to appear. * will be examined. The assertion fails if one of the expectedMatches fails to appear.
* *
* Note:   characters are stripped from the content; make sure that your assertions take this into account. * Note:   characters are stripped from the content; make sure that your assertions take this into account.
* *
* @param string $selector A basic CSS selector, e.g. 'li.jobs h3' * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
* @param array|string $expectedMatches The content of at least one of the matched tags * @param array|string $expectedMatches The content of at least one of the matched tags
* @throws PHPUnit_Framework_AssertionFailedError * @throws PHPUnit_Framework_AssertionFailedError
* @return boolean * @return boolean
*/ */
public function assertPartialMatchBySelector($selector, $expectedMatches) public function assertPartialMatchBySelector($selector, $expectedMatches)
{ {
if (is_string($expectedMatches)) { if (is_string($expectedMatches)) {
$expectedMatches = array($expectedMatches); $expectedMatches = array($expectedMatches);
} }
$items = $this->cssParser()->getBySelector($selector); $items = $this->cssParser()->getBySelector($selector);
$actuals = array(); $actuals = array();
if ($items) { if($items) foreach($items as $item) $actuals[trim(preg_replace("/\s+/", " ", (string)$item))] = true;
foreach ($items as $item) {
$actuals[trim(preg_replace("/[ \n\r\t]+/", " ", $item. ''))] = true;
}
}
foreach ($expectedMatches as $match) { foreach($expectedMatches as $match) {
$this->assertTrue( $this->assertTrue(
isset($actuals[$match]), isset($actuals[$match]),
"Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'" "Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'"
. implode("'\n'", $expectedMatches) . "'\n\n" . implode("'\n'", $expectedMatches) . "'\n\n"
. "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'" . "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'"
); );
return false; return false;
} }
return true; return true;
} }
/** /**
* Assert that the most recently queried page contains a number of content tags specified by a CSS selector. * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
* The given CSS selector will be applied to the HTML of the most recent page. The full HTML of every matching tag * The given CSS selector will be applied to the HTML of the most recent page. The full HTML of every matching tag
* will be examined. The assertion fails if one of the expectedMatches fails to appear. * will be examined. The assertion fails if one of the expectedMatches fails to appear.
* *
* Note:   characters are stripped from the content; make sure that your assertions take this into account. * Note:   characters are stripped from the content; make sure that your assertions take this into account.
* *
* @param string $selector A basic CSS selector, e.g. 'li.jobs h3' * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
* @param array|string $expectedMatches The content of *all* matching tags as an array * @param array|string $expectedMatches The content of *all* matching tags as an array
* @throws PHPUnit_Framework_AssertionFailedError * @throws PHPUnit_Framework_AssertionFailedError
* @return boolean * @return boolean
*/ */
public function assertExactMatchBySelector($selector, $expectedMatches) public function assertExactMatchBySelector($selector, $expectedMatches)
{ {
if (is_string($expectedMatches)) { if (is_string($expectedMatches)) {
$expectedMatches = array($expectedMatches); $expectedMatches = array($expectedMatches);
} }
$items = $this->cssParser()->getBySelector($selector); $items = $this->cssParser()->getBySelector($selector);
$actuals = array(); $actuals = array();
if ($items) { if ($items) {
foreach ($items as $item) { foreach ($items as $item) {
$actuals[] = trim(preg_replace("/[ \n\r\t]+/", " ", $item. '')); $actuals[] = trim(preg_replace("/[ \n\r\t]+/", " ", $item. ''));
} }
} }
$this->assertTrue( $this->assertTrue(
$expectedMatches == $actuals, $expectedMatches == $actuals,
"Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'" "Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'"
. implode("'\n'", $expectedMatches) . "'\n\n" . implode("'\n'", $expectedMatches) . "'\n\n"
. "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'" . "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'"
); );
return true; return true;
} }
/** /**
* Assert that the most recently queried page contains a number of content tags specified by a CSS selector. * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
* The given CSS selector will be applied to the HTML of the most recent page. The content of every matching tag * The given CSS selector will be applied to the HTML of the most recent page. The content of every matching tag
* will be examined. The assertion fails if one of the expectedMatches fails to appear. * will be examined. The assertion fails if one of the expectedMatches fails to appear.
* *
* Note:   characters are stripped from the content; make sure that your assertions take this into account. * Note:   characters are stripped from the content; make sure that your assertions take this into account.
* *
* @param string $selector A basic CSS selector, e.g. 'li.jobs h3' * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
* @param array|string $expectedMatches The content of at least one of the matched tags * @param array|string $expectedMatches The content of at least one of the matched tags
* @throws PHPUnit_Framework_AssertionFailedError * @throws PHPUnit_Framework_AssertionFailedError
* @return boolean * @return boolean
*/ */
public function assertPartialHTMLMatchBySelector($selector, $expectedMatches) public function assertPartialHTMLMatchBySelector($selector, $expectedMatches)
{ {
if (is_string($expectedMatches)) { if (is_string($expectedMatches)) {
$expectedMatches = array($expectedMatches); $expectedMatches = array($expectedMatches);
} }
$items = $this->cssParser()->getBySelector($selector); $items = $this->cssParser()->getBySelector($selector);
$actuals = array(); $actuals = array();
if ($items) { if($items) {
/** @var SimpleXMLElement $item */ /** @var SimpleXMLElement $item */
foreach ($items as $item) { foreach($items as $item) {
$actuals[$item->asXML()] = true; $actuals[$item->asXML()] = true;
} }
} }
foreach ($expectedMatches as $match) { foreach($expectedMatches as $match) {
$this->assertTrue( $this->assertTrue(
isset($actuals[$match]), isset($actuals[$match]),
"Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'" "Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'"
. implode("'\n'", $expectedMatches) . "'\n\n" . implode("'\n'", $expectedMatches) . "'\n\n"
. "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'" . "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'"
); );
} }
return true; return true;
} }
/** /**
* Assert that the most recently queried page contains a number of content tags specified by a CSS selector. * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
* The given CSS selector will be applied to the HTML of the most recent page. The full HTML of every matching tag * The given CSS selector will be applied to the HTML of the most recent page. The full HTML of every matching tag
* will be examined. The assertion fails if one of the expectedMatches fails to appear. * will be examined. The assertion fails if one of the expectedMatches fails to appear.
* *
* Note:   characters are stripped from the content; make sure that your assertions take this into account. * Note:   characters are stripped from the content; make sure that your assertions take this into account.
* *
* @param string $selector A basic CSS selector, e.g. 'li.jobs h3' * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
* @param array|string $expectedMatches The content of *all* matched tags as an array * @param array|string $expectedMatches The content of *all* matched tags as an array
* @throws PHPUnit_Framework_AssertionFailedError * @throws PHPUnit_Framework_AssertionFailedError
*/ */
public function assertExactHTMLMatchBySelector($selector, $expectedMatches) public function assertExactHTMLMatchBySelector($selector, $expectedMatches)
{ {
$items = $this->cssParser()->getBySelector($selector); $items = $this->cssParser()->getBySelector($selector);
$actuals = array(); $actuals = array();
if ($items) { if($items) {
/** @var SimpleXMLElement $item */ /** @var SimpleXMLElement $item */
foreach ($items as $item) { foreach($items as $item) {
$actuals[] = $item->asXML(); $actuals[] = $item->asXML();
} }
} }
$this->assertTrue( $this->assertTrue(
$expectedMatches == $actuals, $expectedMatches == $actuals,
"Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'" "Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'"
. implode("'\n'", $expectedMatches) . "'\n\n" . implode("'\n'", $expectedMatches) . "'\n\n"
. "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'" . "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'"
); );
} }
/** /**
* Log in as the given member * Log in as the given member
* *
* @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in
*/ */
public function logInAs($member) public function logInAs($member)
{ {
if (is_object($member)) { if (is_object($member)) {
@ -415,40 +411,40 @@ class FunctionalTest extends SapphireTest
$memberID = $this->idFromFixture('SilverStripe\\Security\\Member', $member); $memberID = $this->idFromFixture('SilverStripe\\Security\\Member', $member);
} }
$this->session()->inst_set('loggedInAs', $memberID); $this->session()->inst_set('loggedInAs', $memberID);
} }
/** /**
* Use the draft (stage) site for testing. * Use the draft (stage) site for testing.
* This is helpful if you're not testing publication functionality and don't want "stage management" cluttering * This is helpful if you're not testing publication functionality and don't want "stage management" cluttering
* your test. * your test.
* *
* @param bool $enabled toggle the use of the draft site * @param bool $enabled toggle the use of the draft site
*/ */
public function useDraftSite($enabled = true) public function useDraftSite($enabled = true)
{ {
if ($enabled) { if($enabled) {
$this->session()->inst_set('readingMode', 'Stage.Stage'); $this->session()->inst_set('readingMode', 'Stage.Stage');
$this->session()->inst_set('unsecuredDraftSite', true); $this->session()->inst_set('unsecuredDraftSite', true);
} else { } else {
$this->session()->inst_set('readingMode', 'Stage.Live'); $this->session()->inst_set('readingMode', 'Stage.Live');
$this->session()->inst_set('unsecuredDraftSite', false); $this->session()->inst_set('unsecuredDraftSite', false);
} }
} }
/** /**
* @return bool * @return bool
*/ */
public static function get_disable_themes() public static function get_disable_themes()
{ {
return static::$disable_themes; return static::$disable_themes;
} }
/** /**
* @return bool * @return bool
*/ */
public static function get_use_draft_site() public static function get_use_draft_site()
{ {
return static::$use_draft_site; return static::$use_draft_site;
} }
} }

View File

@ -67,409 +67,405 @@ use SilverStripe\View\SSViewer;
class Form extends RequestHandler class Form extends RequestHandler
{ {
const ENC_TYPE_URLENCODED = 'application/x-www-form-urlencoded'; const ENC_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
const ENC_TYPE_MULTIPART = 'multipart/form-data'; const ENC_TYPE_MULTIPART = 'multipart/form-data';
/** /**
* Accessed by Form.ss; modified by {@link formHtmlContent()}. * Accessed by Form.ss; modified by {@link formHtmlContent()}.
* A performance enhancement over the generate-the-form-tag-and-then-remove-it code that was there previously * A performance enhancement over the generate-the-form-tag-and-then-remove-it code that was there previously
* *
* @var bool * @var bool
*/ */
public $IncludeFormTag = true; public $IncludeFormTag = true;
/** /**
* @var FieldList * @var FieldList
*/ */
protected $fields; protected $fields;
/** /**
* @var FieldList * @var FieldList
*/ */
protected $actions; protected $actions;
/** /**
* @var Controller * @var Controller
*/ */
protected $controller; protected $controller;
/** /**
* @var string * @var string
*/ */
protected $name; protected $name;
/** /**
* @var Validator * @var Validator
*/ */
protected $validator; protected $validator;
/** /**
* @var callable {@see setValidationResponseCallback()} * @var callable {@see setValidationResponseCallback()}
*/ */
protected $validationResponseCallback; protected $validationResponseCallback;
/** /**
* @var string * @var string
*/ */
protected $formMethod = "POST"; protected $formMethod = "POST";
/** /**
* @var boolean * @var boolean
*/ */
protected $strictFormMethodCheck = false; protected $strictFormMethodCheck = false;
/** /**
* @var DataObject|null $record Populated by {@link loadDataFrom()}. * @var DataObject|null $record Populated by {@link loadDataFrom()}.
*/ */
protected $record; protected $record;
/** /**
* Keeps track of whether this form has a default action or not. * Keeps track of whether this form has a default action or not.
* Set to false by $this->disableDefaultAction(); * Set to false by $this->disableDefaultAction();
* *
* @var boolean * @var boolean
*/ */
protected $hasDefaultAction = true; protected $hasDefaultAction = true;
/** /**
* Target attribute of form-tag. * Target attribute of form-tag.
* Useful to open a new window upon * Useful to open a new window upon
* form submission. * form submission.
* *
* @var string|null * @var string|null
*/ */
protected $target; protected $target;
/** /**
* Legend value, to be inserted into the * Legend value, to be inserted into the
* <legend> element before the <fieldset> * <legend> element before the <fieldset>
* in Form.ss template. * in Form.ss template.
* *
* @var string|null * @var string|null
*/ */
protected $legend; protected $legend;
/** /**
* The SS template to render this form HTML into. * The SS template to render this form HTML into.
* Default is "Form", but this can be changed to * Default is "Form", but this can be changed to
* another template for customisation. * another template for customisation.
* *
* @see Form->setTemplate() * @see Form->setTemplate()
* @var string|null * @var string|null
*/ */
protected $template; protected $template;
/** /**
* @var callable|null * @var callable|null
*/ */
protected $buttonClickedFunc; protected $buttonClickedFunc;
/** /**
* @var string|null * @var string|null
*/ */
protected $message; protected $message;
/** /**
* @var string|null * @var string|null
*/ */
protected $messageType; protected $messageType;
/** /**
* Should we redirect the user back down to the * Should we redirect the user back down to the
* the form on validation errors rather then just the page * the form on validation errors rather then just the page
* *
* @var bool * @var bool
*/ */
protected $redirectToFormOnValidationError = false; protected $redirectToFormOnValidationError = false;
/** /**
* @var bool * @var bool
*/ */
protected $security = true; protected $security = true;
/** /**
* @var SecurityToken|null * @var SecurityToken|null
*/ */
protected $securityToken = null; protected $securityToken = null;
/** /**
* @var array $extraClasses List of additional CSS classes for the form tag. * @var array $extraClasses List of additional CSS classes for the form tag.
*/ */
protected $extraClasses = array(); protected $extraClasses = array();
/** /**
* @config * @config
* @var array $default_classes The default classes to apply to the Form * @var array $default_classes The default classes to apply to the Form
*/ */
private static $default_classes = array(); private static $default_classes = array();
/** /**
* @var string|null * @var string|null
*/ */
protected $encType; protected $encType;
/** /**
* @var array Any custom form attributes set through {@link setAttributes()}. * @var array Any custom form attributes set through {@link setAttributes()}.
* Some attributes are calculated on the fly, so please use {@link getAttributes()} to access them. * Some attributes are calculated on the fly, so please use {@link getAttributes()} to access them.
*/ */
protected $attributes = array(); protected $attributes = array();
/** /**
* @var array * @var array
*/ */
protected $validationExemptActions = array(); protected $validationExemptActions = array();
private static $allowed_actions = array( private static $allowed_actions = array(
'handleField', 'handleField',
'httpSubmission', 'httpSubmission',
'forTemplate', 'forTemplate',
); );
private static $casting = array( private static $casting = array(
'AttributesHTML' => 'HTMLFragment', 'AttributesHTML' => 'HTMLFragment',
'FormAttributes' => 'HTMLFragment', 'FormAttributes' => 'HTMLFragment',
'MessageType' => 'Text', 'MessageType' => 'Text',
'Message' => 'HTMLFragment', 'Message' => 'HTMLFragment',
'FormName' => 'Text', 'FormName' => 'Text',
'Legend' => 'HTMLFragment', 'Legend' => 'HTMLFragment',
); );
/** /**
* @var FormTemplateHelper * @var FormTemplateHelper
*/ */
private $templateHelper = null; private $templateHelper = null;
/** /**
* @ignore * @ignore
*/ */
private $htmlID = null; private $htmlID = null;
/** /**
* @ignore * @ignore
*/ */
private $formActionPath = false; private $formActionPath = false;
/** /**
* @var bool * @var bool
*/ */
protected $securityTokenAdded = false; protected $securityTokenAdded = false;
/** /**
* Create a new form, with the given fields an action buttons. * Create a new form, with the given fields an action buttons.
* *
* @param Controller $controller The parent controller, necessary to create the appropriate form action tag. * @param Controller $controller The parent controller, necessary to create the appropriate form action tag.
* @param string $name The method on the controller that will return this form object. * @param string $name The method on the controller that will return this form object.
* @param FieldList $fields All of the fields in the form - a {@link FieldList} of {@link FormField} objects. * @param FieldList $fields All of the fields in the form - a {@link FieldList} of {@link FormField} objects.
* @param FieldList $actions All of the action buttons in the form - a {@link FieldLis} of * @param FieldList $actions All of the action buttons in the form - a {@link FieldLis} of
* {@link FormAction} objects * {@link FormAction} objects
* @param Validator|null $validator Override the default validator instance (Default: {@link RequiredFields}) * @param Validator|null $validator Override the default validator instance (Default: {@link RequiredFields})
*/ */
public function __construct($controller, $name, FieldList $fields, FieldList $actions, Validator $validator = null) public function __construct($controller, $name, FieldList $fields, FieldList $actions, Validator $validator = null)
{ {
parent::__construct(); parent::__construct();
$fields->setForm($this); $fields->setForm($this);
$actions->setForm($this); $actions->setForm($this);
$this->fields = $fields; $this->fields = $fields;
$this->actions = $actions; $this->actions = $actions;
$this->controller = $controller; $this->controller = $controller;
$this->setName($name); $this->setName($name);
if (!$this->controller) { if (!$this->controller) {
user_error("$this->class form created without a controller", E_USER_ERROR); user_error("$this->class form created without a controller", E_USER_ERROR);
} }
// Form validation // Form validation
$this->validator = ($validator) ? $validator : new RequiredFields(); $this->validator = ($validator) ? $validator : new RequiredFields();
$this->validator->setForm($this); $this->validator->setForm($this);
// Form error controls // Form error controls
$this->setupFormErrors(); $this->setupFormErrors();
// Check if CSRF protection is enabled, either on the parent controller or from the default setting. Note that // Check if CSRF protection is enabled, either on the parent controller or from the default setting. Note that
// method_exists() is used as some controllers (e.g. GroupTest) do not always extend from Object. // method_exists() is used as some controllers (e.g. GroupTest) do not always extend from Object.
if (method_exists($controller, 'securityTokenEnabled') || (method_exists($controller, 'hasMethod') if(method_exists($controller, 'securityTokenEnabled') || (method_exists($controller, 'hasMethod')
&& $controller->hasMethod('securityTokenEnabled'))) { && $controller->hasMethod('securityTokenEnabled'))) {
$securityEnabled = $controller->securityTokenEnabled(); $securityEnabled = $controller->securityTokenEnabled();
} else { } else {
$securityEnabled = SecurityToken::is_enabled(); $securityEnabled = SecurityToken::is_enabled();
} }
$this->securityToken = ($securityEnabled) ? new SecurityToken() : new NullSecurityToken(); $this->securityToken = ($securityEnabled) ? new SecurityToken() : new NullSecurityToken();
$this->setupDefaultClasses(); $this->setupDefaultClasses();
} }
/** /**
* @var array * @var array
*/ */
private static $url_handlers = array( private static $url_handlers = array(
'field/$FieldName!' => 'handleField', 'field/$FieldName!' => 'handleField',
'POST ' => 'httpSubmission', 'POST ' => 'httpSubmission',
'GET ' => 'httpSubmission', 'GET ' => 'httpSubmission',
'HEAD ' => 'httpSubmission', 'HEAD ' => 'httpSubmission',
); );
/** /**
* Set up current form errors in session to * Take errors from a ValidationResult and populate the form with the appropriate message.
* the current form if appropriate. *
* * @param ValidationResult $result The erroneous ValidationResult. If none passed, this will be atken
* @return $this * from the session
*/ */
public function setupFormErrors() public function setupFormErrors($result = null, $data = null) {
{ if(!$result) $result = Session::get("FormInfo.{$this->FormName()}.result");
$errorInfo = Session::get("FormInfo.{$this->FormName()}"); if(!$result) return;
if (isset($errorInfo['errors']) && is_array($errorInfo['errors'])) { foreach($result->fieldErrors() as $fieldName => $fieldError) {
foreach ($errorInfo['errors'] as $error) { $field = $this->fields->dataFieldByName($fieldName);
$field = $this->fields->dataFieldByName($error['fieldName']); $field->setError($fieldError['message'], $fieldError['messageType']);
}
if (!$field) { //don't escape the HTML as it should have been escaped when adding it to the validation result
$errorInfo['message'] = $error['message']; $this->setMessage($result->overallMessage(), $result->valid() ? 'good' : 'bad', false);
$errorInfo['type'] = $error['messageType'];
} else {
$field->setError($error['message'], $error['messageType']);
}
}
// load data in from previous submission upon error // load data in from previous submission upon error
if (isset($errorInfo['data'])) { if(!$data) $data = Session::get("FormInfo.{$this->FormName()}.data");
$this->loadDataFrom($errorInfo['data']); if($data) $this->loadDataFrom($data);
} }
}
if (isset($errorInfo['message']) && isset($errorInfo['type'])) { /**
$this->setMessage($errorInfo['message'], $errorInfo['type']); * Save information to the session to be picked up by {@link setUpFormErrors()}
} */
public function saveFormErrorsToSession($result = null, $data = null) {
Session::set("FormInfo.{$this->FormName()}.result", $result);
Session::set("FormInfo.{$this->FormName()}.data", $data);
}
return $this; /**
} * set up the default classes for the form. This is done on construct so that the default classes can be removed
* after instantiation
/** */
* set up the default classes for the form. This is done on construct so that the default classes can be removed
* after instantiation
*/
protected function setupDefaultClasses() protected function setupDefaultClasses()
{ {
$defaultClasses = self::config()->get('default_classes'); $defaultClasses = self::config()->get('default_classes');
if ($defaultClasses) { if ($defaultClasses) {
foreach ($defaultClasses as $class) { foreach ($defaultClasses as $class) {
$this->addExtraClass($class); $this->addExtraClass($class);
} }
} }
} }
/** /**
* Handle a form submission. GET and POST requests behave identically. * Handle a form submission. GET and POST requests behave identically.
* Populates the form with {@link loadDataFrom()}, calls {@link validate()}, * Populates the form with {@link loadDataFrom()}, calls {@link validate()},
* and only triggers the requested form action/method * and only triggers the requested form action/method
* if the form is valid. * if the form is valid.
* *
* @param HTTPRequest $request * @param HTTPRequest $request
* @throws HTTPResponse_Exception * @throws HTTPResponse_Exception
*/ */
public function httpSubmission($request) public function httpSubmission($request)
{ {
// Strict method check // Strict method check
if ($this->strictFormMethodCheck) { if($this->strictFormMethodCheck) {
// Throws an error if the method is bad... // Throws an error if the method is bad...
if ($this->formMethod != $request->httpMethod()) { if($this->formMethod != $request->httpMethod()) {
$response = Controller::curr()->getResponse(); $response = Controller::curr()->getResponse();
$response->addHeader('Allow', $this->formMethod); $response->addHeader('Allow', $this->formMethod);
$this->httpError(405, _t("Form.BAD_METHOD", "This form requires a ".$this->formMethod." submission")); $this->httpError(405, _t("Form.BAD_METHOD", "This form requires a ".$this->formMethod." submission"));
} }
// ...and only uses the variables corresponding to that method type // ...and only uses the variables corresponding to that method type
$vars = $this->formMethod == 'GET' ? $request->getVars() : $request->postVars(); $vars = $this->formMethod == 'GET' ? $request->getVars() : $request->postVars();
} else { } else {
$vars = $request->requestVars(); $vars = $request->requestVars();
} }
// Ensure we only process saveable fields (non structural, readonly, or disabled) // Ensure we only process saveable fields (non structural, readonly, or disabled)
$allowedFields = array_keys($this->Fields()->saveableFields()); $allowedFields = array_keys($this->Fields()->saveableFields());
// Populate the form // Populate the form
$this->loadDataFrom($vars, true, $allowedFields); $this->loadDataFrom($vars, true, $allowedFields);
// Protection against CSRF attacks // Protection against CSRF attacks
$token = $this->getSecurityToken(); $token = $this->getSecurityToken();
if (! $token->checkRequest($request)) { if( ! $token->checkRequest($request)) {
$securityID = $token->getName(); $securityID = $token->getName();
if (empty($vars[$securityID])) { if (empty($vars[$securityID])) {
$this->httpError(400, _t( $this->httpError(400, _t(
"Form.CSRF_FAILED_MESSAGE", "Form.CSRF_FAILED_MESSAGE",
"There seems to have been a technical problem. Please click the back button, ". "There seems to have been a technical problem. Please click the back button, ".
"refresh your browser, and try again." "refresh your browser, and try again."
)); ));
} else { } else {
// Clear invalid token on refresh // Clear invalid token on refresh
$data = $this->getData(); $data = $this->getData();
unset($data[$securityID]); unset($data[$securityID]);
Session::set("FormInfo.{$this->FormName()}.data", $data); Session::set("FormInfo.{$this->FormName()}.data", $data);
Session::set("FormInfo.{$this->FormName()}.errors", array()); Session::set("FormInfo.{$this->FormName()}.errors", array());
$this->sessionMessage( $this->sessionMessage(
_t("Form.CSRF_EXPIRED_MESSAGE", "Your session has expired. Please re-submit the form."), _t("Form.CSRF_EXPIRED_MESSAGE", "Your session has expired. Please re-submit the form."),
"warning" "warning"
); );
return $this->controller->redirectBack(); return $this->controller->redirectBack();
} }
} }
// Determine the action button clicked // Determine the action button clicked
$funcName = null; $funcName = null;
foreach ($vars as $paramName => $paramVal) { foreach($vars as $paramName => $paramVal) {
if (substr($paramName, 0, 7) == 'action_') { if(substr($paramName,0,7) == 'action_') {
// Break off querystring arguments included in the action // Break off querystring arguments included in the action
if (strpos($paramName, '?') !== false) { if(strpos($paramName,'?') !== false) {
list($paramName, $paramVars) = explode('?', $paramName, 2); list($paramName, $paramVars) = explode('?', $paramName, 2);
$newRequestParams = array(); $newRequestParams = array();
parse_str($paramVars, $newRequestParams); parse_str($paramVars, $newRequestParams);
$vars = array_merge((array)$vars, (array)$newRequestParams); $vars = array_merge((array)$vars, (array)$newRequestParams);
} }
// Cleanup action_, _x and _y from image fields // Cleanup action_, _x and _y from image fields
$funcName = preg_replace(array('/^action_/','/_x$|_y$/'), '', $paramName); $funcName = preg_replace(array('/^action_/','/_x$|_y$/'),'',$paramName);
break; break;
} }
} }
// If the action wasn't set, choose the default on the form. // If the action wasn't set, choose the default on the form.
if (!isset($funcName) && $defaultAction = $this->defaultAction()) { if(!isset($funcName) && $defaultAction = $this->defaultAction()){
$funcName = $defaultAction->actionName(); $funcName = $defaultAction->actionName();
} }
if (isset($funcName)) { if(isset($funcName)) {
$this->setButtonClicked($funcName); $this->setButtonClicked($funcName);
} }
// Permission checks (first on controller, then falling back to form) // Permission checks (first on controller, then falling back to form)
if (// Ensure that the action is actually a button or method on the form, if (// Ensure that the action is actually a button or method on the form,
// and not just a method on the controller. // and not just a method on the controller.
$this->controller->hasMethod($funcName) $this->controller->hasMethod($funcName)
&& !$this->controller->checkAccessAction($funcName) && !$this->controller->checkAccessAction($funcName)
// If a button exists, allow it on the controller // If a button exists, allow it on the controller
// buttonClicked() validates that the action set above is valid // buttonClicked() validates that the action set above is valid
&& !$this->buttonClicked() && !$this->buttonClicked()
) { ) {
return $this->httpError( return $this->httpError(
403, 403,
sprintf('Action "%s" not allowed on controller (Class: %s)', $funcName, get_class($this->controller)) sprintf('Action "%s" not allowed on controller (Class: %s)', $funcName, get_class($this->controller))
); );
} elseif ($this->hasMethod($funcName) } elseif ($this->hasMethod($funcName)
&& !$this->checkAccessAction($funcName) && !$this->checkAccessAction($funcName)
// No checks for button existence or $allowed_actions is performed - // No checks for button existence or $allowed_actions is performed -
// all form methods are callable (e.g. the legacy "callfieldmethod()") // all form methods are callable (e.g. the legacy "callfieldmethod()")
) { ) {
return $this->httpError( return $this->httpError(
403, 403,
sprintf('Action "%s" not allowed on form (Name: "%s")', $funcName, $this->name) sprintf('Action "%s" not allowed on form (Name: "%s")', $funcName, $this->name)
); );
} }
// TODO : Once we switch to a stricter policy regarding allowed_actions (meaning actions must be set // TODO : Once we switch to a stricter policy regarding allowed_actions (meaning actions must be set
// explicitly in allowed_actions in order to run) // explicitly in allowed_actions in order to run)
// Uncomment the following for checking security against running actions on form fields // Uncomment the following for checking security against running actions on form fields
/* else { /* else {
// Try to find a field that has the action, and allows it // Try to find a field that has the action, and allows it
$fieldsHaveMethod = false; $fieldsHaveMethod = false;
foreach ($this->Fields() as $field){ foreach ($this->Fields() as $field){
@ -485,1527 +481,1567 @@ class Form extends RequestHandler
} }
}*/ }*/
// Validate the form // Action handlers may throw ValidationExceptions.
if (!$this->validate()) { try {
return $this->getValidationErrorResponse(); // Or we can use the Valiator attached to the form
} $result = $this->validationResult();
if(!$result->valid()) {
return $this->getValidationErrorResponse($result);
}
// First, try a handler method on the controller (has been checked for allowed_actions above already) // First, try a handler method on the controller (has been checked for allowed_actions above already)
if ($this->controller->hasMethod($funcName)) { if($this->controller->hasMethod($funcName)) {
return $this->controller->$funcName($vars, $this, $request); return $this->controller->$funcName($vars, $this, $request);
// Otherwise, try a handler method on the form object. // Otherwise, try a handler method on the form object.
} elseif ($this->hasMethod($funcName)) { } elseif($this->hasMethod($funcName)) {
return $this->$funcName($vars, $this, $request); return $this->$funcName($vars, $this, $request);
} elseif ($field = $this->checkFieldsForAction($this->Fields(), $funcName)) { } elseif($field = $this->checkFieldsForAction($this->Fields(), $funcName)) {
return $field->$funcName($vars, $this, $request); return $field->$funcName($vars, $this, $request);
} }
return $this->httpError(404); } catch(ValidationException $e) {
} // The ValdiationResult contains all the relevant metadata
$result = $e->getResult();
return $this->getValidationErrorResponse($result);
}
/** // First, try a handler method on the controller (has been checked for allowed_actions above already)
* @param string $action if($this->controller->hasMethod($funcName)) {
* @return bool return $this->controller->$funcName($vars, $this, $request);
*/ // Otherwise, try a handler method on the form object.
} elseif($this->hasMethod($funcName)) {
return $this->$funcName($vars, $this, $request);
} elseif($field = $this->checkFieldsForAction($this->Fields(), $funcName)) {
return $field->$funcName($vars, $this, $request);
}
return $this->httpError(404);
}
/**
* @param string $action
* @return bool
*/
public function checkAccessAction($action) public function checkAccessAction($action)
{ {
if (parent::checkAccessAction($action)) { if (parent::checkAccessAction($action)) {
return true; return true;
} }
$actions = $this->getAllActions(); $actions = $this->getAllActions();
foreach ($actions as $formAction) { foreach ($actions as $formAction) {
if ($formAction->actionName() === $action) { if ($formAction->actionName() === $action) {
return true; return true;
} }
} }
// Always allow actions on fields // Always allow actions on fields
$field = $this->checkFieldsForAction($this->Fields(), $action); $field = $this->checkFieldsForAction($this->Fields(), $action);
if ($field && $field->checkAccessAction($action)) { if ($field && $field->checkAccessAction($action)) {
return true; return true;
} }
return false; return false;
} }
/** /**
* @return callable * @return callable
*/ */
public function getValidationResponseCallback() public function getValidationResponseCallback()
{ {
return $this->validationResponseCallback; return $this->validationResponseCallback;
} }
/** /**
* Overrules validation error behaviour in {@link httpSubmission()} * Overrules validation error behaviour in {@link httpSubmission()}
* when validation has failed. Useful for optional handling of a certain accepted content type. * when validation has failed. Useful for optional handling of a certain accepted content type.
* *
* The callback can opt out of handling specific responses by returning NULL, * The callback can opt out of handling specific responses by returning NULL,
* in which case the default form behaviour will kick in. * in which case the default form behaviour will kick in.
* *
* @param $callback * @param $callback
* @return self * @return self
*/ */
public function setValidationResponseCallback($callback) public function setValidationResponseCallback($callback)
{ {
$this->validationResponseCallback = $callback; $this->validationResponseCallback = $callback;
return $this; return $this;
} }
/** /**
* Returns the appropriate response up the controller chain * Returns the appropriate response up the controller chain
* if {@link validate()} fails (which is checked prior to executing any form actions). * if {@link validate()} fails (which is checked prior to executing any form actions).
* By default, returns different views for ajax/non-ajax request, and * By default, returns different views for ajax/non-ajax request, and
* handles 'application/json' requests with a JSON object containing the error messages. * handles 'application/json' requests with a JSON object containing the error messages.
* Behaviour can be influenced by setting {@link $redirectToFormOnValidationError}, * Behaviour can be influenced by setting {@link $redirectToFormOnValidationError},
* and can be overruled by setting {@link $validationResponseCallback}. * and can be overruled by setting {@link $validationResponseCallback}.
* *
* @return HTTPResponse|string * @param ValidationResult $result
*/ * @return HTTPResponse|string
protected function getValidationErrorResponse() */
{ protected function getValidationErrorResponse(ValidationResult $result) {
$callback = $this->getValidationResponseCallback(); $callback = $this->getValidationResponseCallback();
if ($callback && $callbackResponse = $callback()) { if($callback && $callbackResponse = $callback($result)) {
return $callbackResponse; return $callbackResponse;
} }
$request = $this->getRequest(); $request = $this->getRequest();
if ($request->isAjax()) { if($request->isAjax()) {
// Special case for legacy Validator.js implementation // Special case for legacy Validator.js implementation
// (assumes eval'ed javascript collected through FormResponse) // (assumes eval'ed javascript collected through FormResponse)
$acceptType = $request->getHeader('Accept'); $acceptType = $request->getHeader('Accept');
if (strpos($acceptType, 'application/json') !== false) { if (strpos($acceptType, 'application/json') !== false) {
// Send validation errors back as JSON with a flag at the start // Send validation errors back as JSON with a flag at the start
$response = new HTTPResponse(Convert::array2json($this->validator->getErrors())); $response = new HTTPResponse(Convert::array2json($result->getErrorMetaData()));
$response->addHeader('Content-Type', 'application/json'); $response->addHeader('Content-Type', 'application/json');
} else {
$this->setupFormErrors();
// Send the newly rendered form tag as HTML
$response = new HTTPResponse($this->forTemplate());
$response->addHeader('Content-Type', 'text/html');
}
return $response; } else {
} else { $this->setupFormErrors($result, $this->getData());
if ($this->getRedirectToFormOnValidationError()) { // Send the newly rendered form tag as HTML
if ($pageURL = $request->getHeader('Referer')) { $response = new HTTPResponse($this->forTemplate());
if (Director::is_site_url($pageURL)) { $response->addHeader('Content-Type', 'text/html');
// Remove existing pragmas }
$pageURL = preg_replace('/(#.*)/', '', $pageURL);
$pageURL = Director::absoluteURL($pageURL, true);
return $this->controller->redirect($pageURL . '#' . $this->FormName());
}
}
}
return $this->controller->redirectBack();
}
}
/** return $response;
* Fields can have action to, let's check if anyone of the responds to $funcname them
* } else {
* @param SS_List|array $fields // Save the relevant information in the session
* @param callable $funcName $this->saveFormErrorsToSession($result, $this->getData());
* @return FormField
*/ // Redirect back to the form
if($this->getRedirectToFormOnValidationError()) {
if($pageURL = $request->getHeader('Referer')) {
if(Director::is_site_url($pageURL)) {
// Remove existing pragmas
$pageURL = preg_replace('/(#.*)/', '', $pageURL);
$pageURL = Director::absoluteURL($pageURL, true);
return $this->controller->redirect($pageURL . '#' . $this->FormName());
}
}
}
return $this->controller->redirectBack();
}
}
/**
* Fields can have action to, let's check if anyone of the responds to $funcname them
*
* @param SS_List|array $fields
* @param callable $funcName
* @return FormField
*/
protected function checkFieldsForAction($fields, $funcName) protected function checkFieldsForAction($fields, $funcName)
{ {
foreach ($fields as $field) { foreach($fields as $field){
/** @skipUpgrade */ /** @skipUpgrade */
if (method_exists($field, 'FieldList')) { if(method_exists($field, 'FieldList')) {
if ($field = $this->checkFieldsForAction($field->FieldList(), $funcName)) { if($field = $this->checkFieldsForAction($field->FieldList(), $funcName)) {
return $field; return $field;
} }
} elseif ($field->hasMethod($funcName) && $field->checkAccessAction($funcName)) { } elseif ($field->hasMethod($funcName) && $field->checkAccessAction($funcName)) {
return $field; return $field;
} }
} }
return null; return null;
} }
/** /**
* Handle a field request. * Handle a field request.
* Uses {@link Form->dataFieldByName()} to find a matching field, * Uses {@link Form->dataFieldByName()} to find a matching field,
* and falls back to {@link FieldList->fieldByName()} to look * and falls back to {@link FieldList->fieldByName()} to look
* for tabs instead. This means that if you have a tab and a * for tabs instead. This means that if you have a tab and a
* formfield with the same name, this method gives priority * formfield with the same name, this method gives priority
* to the formfield. * to the formfield.
* *
* @param HTTPRequest $request * @param HTTPRequest $request
* @return FormField * @return FormField
*/ */
public function handleField($request) public function handleField($request)
{ {
$field = $this->Fields()->dataFieldByName($request->param('FieldName')); $field = $this->Fields()->dataFieldByName($request->param('FieldName'));
if ($field) { if($field) {
return $field; return $field;
} else { } else {
// falling back to fieldByName, e.g. for getting tabs // falling back to fieldByName, e.g. for getting tabs
return $this->Fields()->fieldByName($request->param('FieldName')); return $this->Fields()->fieldByName($request->param('FieldName'));
} }
} }
/** /**
* Convert this form into a readonly form * Convert this form into a readonly form
*/ */
public function makeReadonly() public function makeReadonly()
{ {
$this->transform(new ReadonlyTransformation()); $this->transform(new ReadonlyTransformation());
} }
/** /**
* Set whether the user should be redirected back down to the * Set whether the user should be redirected back down to the
* form on the page upon validation errors in the form or if * form on the page upon validation errors in the form or if
* they just need to redirect back to the page * they just need to redirect back to the page
* *
* @param bool $bool Redirect to form on error? * @param bool $bool Redirect to form on error?
* @return $this * @return $this
*/ */
public function setRedirectToFormOnValidationError($bool) public function setRedirectToFormOnValidationError($bool)
{ {
$this->redirectToFormOnValidationError = $bool; $this->redirectToFormOnValidationError = $bool;
return $this; return $this;
} }
/** /**
* Get whether the user should be redirected back down to the * Get whether the user should be redirected back down to the
* form on the page upon validation errors * form on the page upon validation errors
* *
* @return bool * @return bool
*/ */
public function getRedirectToFormOnValidationError() public function getRedirectToFormOnValidationError()
{ {
return $this->redirectToFormOnValidationError; return $this->redirectToFormOnValidationError;
} }
/** /**
* Add a plain text error message to a field on this form. It will be saved into the session * Add a plain text error message to a field on this form. It will be saved into the session
* and used the next time this form is displayed. * and used the next time this form is displayed.
* @param string $fieldName *
* @param string $message * @deprecated 3.2
* @param string $messageType */
* @param bool $escapeHtml public function addErrorMessage($fieldName, $message, $messageType) {
*/ Deprecation::notice('3.2', 'Throw a ValidationException instead.');
public function addErrorMessage($fieldName, $message, $messageType, $escapeHtml = true)
{
Session::add_to_array("FormInfo.{$this->FormName()}.errors", array(
'fieldName' => $fieldName,
'message' => $escapeHtml ? Convert::raw2xml($message) : $message,
'messageType' => $messageType,
));
}
/** $this->getSessionValidationResult()->addFieldError($fieldName, $message, $messageType);
* @param FormTransformation $trans }
*/
/**
* @param FormTransformation $trans
*/
public function transform(FormTransformation $trans) public function transform(FormTransformation $trans)
{ {
$newFields = new FieldList(); $newFields = new FieldList();
foreach ($this->fields as $field) { foreach($this->fields as $field) {
$newFields->push($field->transform($trans)); $newFields->push($field->transform($trans));
} }
$this->fields = $newFields; $this->fields = $newFields;
$newActions = new FieldList(); $newActions = new FieldList();
foreach ($this->actions as $action) { foreach($this->actions as $action) {
$newActions->push($action->transform($trans)); $newActions->push($action->transform($trans));
} }
$this->actions = $newActions; $this->actions = $newActions;
// We have to remove validation, if the fields are not editable ;-) // We have to remove validation, if the fields are not editable ;-)
if ($this->validator) { if ($this->validator) {
$this->validator->removeValidation(); $this->validator->removeValidation();
} }
} }
/** /**
* Get the {@link Validator} attached to this form. * Get the {@link Validator} attached to this form.
* @return Validator * @return Validator
*/ */
public function getValidator() public function getValidator()
{ {
return $this->validator; return $this->validator;
} }
/** /**
* Set the {@link Validator} on this form. * Set the {@link Validator} on this form.
* @param Validator $validator * @param Validator $validator
* @return $this * @return $this
*/ */
public function setValidator(Validator $validator) public function setValidator(Validator $validator)
{ {
if ($validator) { if($validator) {
$this->validator = $validator; $this->validator = $validator;
$this->validator->setForm($this); $this->validator->setForm($this);
} }
return $this; return $this;
} }
/** /**
* Remove the {@link Validator} from this from. * Remove the {@link Validator} from this from.
*/ */
public function unsetValidator() public function unsetValidator()
{ {
$this->validator = null; $this->validator = null;
return $this; return $this;
} }
/** /**
* Set actions that are exempt from validation * Set actions that are exempt from validation
* *
* @param array * @param array
* @return $this * @return $this
*/ */
public function setValidationExemptActions($actions) public function setValidationExemptActions($actions)
{ {
$this->validationExemptActions = $actions; $this->validationExemptActions = $actions;
return $this; return $this;
} }
/** /**
* Get a list of actions that are exempt from validation * Get a list of actions that are exempt from validation
* *
* @return array * @return array
*/ */
public function getValidationExemptActions() public function getValidationExemptActions()
{ {
return $this->validationExemptActions; return $this->validationExemptActions;
} }
/** /**
* Passed a FormAction, returns true if that action is exempt from Form validation * Passed a FormAction, returns true if that action is exempt from Form validation
* *
* @param FormAction $action * @param FormAction $action
* @return bool * @return bool
*/ */
public function actionIsValidationExempt($action) public function actionIsValidationExempt($action)
{ {
if ($action->getValidationExempt()) { if ($action->getValidationExempt()) {
return true; return true;
} }
if (in_array($action->actionName(), $this->getValidationExemptActions())) { if (in_array($action->actionName(), $this->getValidationExemptActions())) {
return true; return true;
} }
return false; return false;
} }
/** /**
* Generate extra special fields - namely the security token field (if required). * Generate extra special fields - namely the security token field (if required).
* *
* @return FieldList * @return FieldList
*/ */
public function getExtraFields() public function getExtraFields()
{ {
$extraFields = new FieldList(); $extraFields = new FieldList();
$token = $this->getSecurityToken(); $token = $this->getSecurityToken();
if ($token) { if ($token) {
$tokenField = $token->updateFieldSet($this->fields); $tokenField = $token->updateFieldSet($this->fields);
if ($tokenField) { if ($tokenField) {
$tokenField->setForm($this); $tokenField->setForm($this);
} }
} }
$this->securityTokenAdded = true; $this->securityTokenAdded = true;
// add the "real" HTTP method if necessary (for PUT, DELETE and HEAD) // add the "real" HTTP method if necessary (for PUT, DELETE and HEAD)
if (strtoupper($this->FormMethod()) != $this->FormHttpMethod()) { if (strtoupper($this->FormMethod()) != $this->FormHttpMethod()) {
$methodField = new HiddenField('_method', '', $this->FormHttpMethod()); $methodField = new HiddenField('_method', '', $this->FormHttpMethod());
$methodField->setForm($this); $methodField->setForm($this);
$extraFields->push($methodField); $extraFields->push($methodField);
} }
return $extraFields; return $extraFields;
} }
/** /**
* Return the form's fields - used by the templates * Return the form's fields - used by the templates
* *
* @return FieldList The form fields * @return FieldList The form fields
*/ */
public function Fields() public function Fields()
{ {
foreach ($this->getExtraFields() as $field) { foreach($this->getExtraFields() as $field) {
if (!$this->fields->fieldByName($field->getName())) { if (!$this->fields->fieldByName($field->getName())) {
$this->fields->push($field); $this->fields->push($field);
} }
} }
return $this->fields; return $this->fields;
} }
/** /**
* Return all <input type="hidden"> fields * Return all <input type="hidden"> fields
* in a form - including fields nested in {@link CompositeFields}. * in a form - including fields nested in {@link CompositeFields}.
* Useful when doing custom field layouts. * Useful when doing custom field layouts.
* *
* @return FieldList * @return FieldList
*/ */
public function HiddenFields() public function HiddenFields()
{ {
return $this->Fields()->HiddenFields(); return $this->Fields()->HiddenFields();
} }
/** /**
* Return all fields except for the hidden fields. * Return all fields except for the hidden fields.
* Useful when making your own simplified form layouts. * Useful when making your own simplified form layouts.
*/ */
public function VisibleFields() public function VisibleFields()
{ {
return $this->Fields()->VisibleFields(); return $this->Fields()->VisibleFields();
} }
/** /**
* Setter for the form fields. * Setter for the form fields.
* *
* @param FieldList $fields * @param FieldList $fields
* @return $this * @return $this
*/ */
public function setFields($fields) public function setFields($fields)
{ {
$this->fields = $fields; $this->fields = $fields;
return $this; return $this;
} }
/** /**
* Return the form's action buttons - used by the templates * Return the form's action buttons - used by the templates
* *
* @return FieldList The action list * @return FieldList The action list
*/ */
public function Actions() public function Actions()
{ {
return $this->actions; return $this->actions;
} }
/** /**
* Setter for the form actions. * Setter for the form actions.
* *
* @param FieldList $actions * @param FieldList $actions
* @return $this * @return $this
*/ */
public function setActions($actions) public function setActions($actions)
{ {
$this->actions = $actions; $this->actions = $actions;
return $this; return $this;
} }
/** /**
* Unset all form actions * Unset all form actions
*/ */
public function unsetAllActions() public function unsetAllActions()
{ {
$this->actions = new FieldList(); $this->actions = new FieldList();
return $this; return $this;
} }
/** /**
* @param string $name * @param string $name
* @param string $value * @param string $value
* @return $this * @return $this
*/ */
public function setAttribute($name, $value) public function setAttribute($name, $value)
{ {
$this->attributes[$name] = $value; $this->attributes[$name] = $value;
return $this; return $this;
} }
/** /**
* @param string $name * @param string $name
* @return string * @return string
*/ */
public function getAttribute($name) public function getAttribute($name)
{ {
if (isset($this->attributes[$name])) { if(isset($this->attributes[$name])) {
return $this->attributes[$name]; return $this->attributes[$name];
} }
return null; return null;
} }
/** /**
* @return array * @return array
*/ */
public function getAttributes() public function getAttributes()
{ {
$attrs = array( $attrs = array(
'id' => $this->FormName(), 'id' => $this->FormName(),
'action' => $this->FormAction(), 'action' => $this->FormAction(),
'method' => $this->FormMethod(), 'method' => $this->FormMethod(),
'enctype' => $this->getEncType(), 'enctype' => $this->getEncType(),
'target' => $this->target, 'target' => $this->target,
'class' => $this->extraClass(), 'class' => $this->extraClass(),
); );
if ($this->validator && $this->validator->getErrors()) { if($this->validator && $this->validator->getErrors()) {
if (!isset($attrs['class'])) { if (!isset($attrs['class'])) {
$attrs['class'] = ''; $attrs['class'] = '';
} }
$attrs['class'] .= ' validationerror'; $attrs['class'] .= ' validationerror';
} }
$attrs = array_merge($attrs, $this->attributes); $attrs = array_merge($attrs, $this->attributes);
return $attrs; return $attrs;
} }
/** /**
* Return the attributes of the form tag - used by the templates. * Return the attributes of the form tag - used by the templates.
* *
* @param array $attrs Custom attributes to process. Falls back to {@link getAttributes()}. * @param array $attrs Custom attributes to process. Falls back to {@link getAttributes()}.
* If at least one argument is passed as a string, all arguments act as excludes by name. * If at least one argument is passed as a string, all arguments act as excludes by name.
* *
* @return string HTML attributes, ready for insertion into an HTML tag * @return string HTML attributes, ready for insertion into an HTML tag
*/ */
public function getAttributesHTML($attrs = null) public function getAttributesHTML($attrs = null)
{ {
$exclude = (is_string($attrs)) ? func_get_args() : null; $exclude = (is_string($attrs)) ? func_get_args() : null;
// Figure out if we can cache this form // Figure out if we can cache this form
// - forms with validation shouldn't be cached, cos their error messages won't be shown // - forms with validation shouldn't be cached, cos their error messages won't be shown
// - forms with security tokens shouldn't be cached because security tokens expire // - forms with security tokens shouldn't be cached because security tokens expire
$needsCacheDisabled = false; $needsCacheDisabled = false;
if ($this->getSecurityToken()->isEnabled()) { if ($this->getSecurityToken()->isEnabled()) {
$needsCacheDisabled = true; $needsCacheDisabled = true;
} }
if ($this->FormMethod() != 'GET') { if ($this->FormMethod() != 'GET') {
$needsCacheDisabled = true; $needsCacheDisabled = true;
} }
if (!($this->validator instanceof RequiredFields) || count($this->validator->getRequired())) { if (!($this->validator instanceof RequiredFields) || count($this->validator->getRequired())) {
$needsCacheDisabled = true; $needsCacheDisabled = true;
} }
// If we need to disable cache, do it // If we need to disable cache, do it
if ($needsCacheDisabled) { if ($needsCacheDisabled) {
HTTP::set_cache_age(0); HTTP::set_cache_age(0);
} }
$attrs = $this->getAttributes(); $attrs = $this->getAttributes();
// Remove empty // Remove empty
$attrs = array_filter((array)$attrs, create_function('$v', 'return ($v || $v === 0);')); $attrs = array_filter((array)$attrs, create_function('$v', 'return ($v || $v === 0);'));
// Remove excluded // Remove excluded
if ($exclude) { if ($exclude) {
$attrs = array_diff_key($attrs, array_flip($exclude)); $attrs = array_diff_key($attrs, array_flip($exclude));
} }
// Prepare HTML-friendly 'method' attribute (lower-case) // Prepare HTML-friendly 'method' attribute (lower-case)
if (isset($attrs['method'])) { if (isset($attrs['method'])) {
$attrs['method'] = strtolower($attrs['method']); $attrs['method'] = strtolower($attrs['method']);
} }
// Create markup // Create markup
$parts = array(); $parts = array();
foreach ($attrs as $name => $value) { foreach($attrs as $name => $value) {
$parts[] = ($value === true) ? "{$name}=\"{$name}\"" : "{$name}=\"" . Convert::raw2att($value) . "\""; $parts[] = ($value === true) ? "{$name}=\"{$name}\"" : "{$name}=\"" . Convert::raw2att($value) . "\"";
} }
return implode(' ', $parts); return implode(' ', $parts);
} }
public function FormAttributes() public function FormAttributes()
{ {
return $this->getAttributesHTML(); return $this->getAttributesHTML();
} }
/** /**
* Set the target of this form to any value - useful for opening the form contents in a new window or refreshing * Set the target of this form to any value - useful for opening the form contents in a new window or refreshing
* another frame * another frame
* *
* @param string|FormTemplateHelper * @param string|FormTemplateHelper
*/ */
public function setTemplateHelper($helper) public function setTemplateHelper($helper)
{ {
$this->templateHelper = $helper; $this->templateHelper = $helper;
} }
/** /**
* Return a {@link FormTemplateHelper} for this form. If one has not been * Return a {@link FormTemplateHelper} for this form. If one has not been
* set, return the default helper. * set, return the default helper.
* *
* @return FormTemplateHelper * @return FormTemplateHelper
*/ */
public function getTemplateHelper() public function getTemplateHelper()
{ {
if ($this->templateHelper) { if($this->templateHelper) {
if (is_string($this->templateHelper)) { if(is_string($this->templateHelper)) {
return Injector::inst()->get($this->templateHelper); return Injector::inst()->get($this->templateHelper);
} }
return $this->templateHelper; return $this->templateHelper;
} }
return FormTemplateHelper::singleton(); return FormTemplateHelper::singleton();
} }
/** /**
* Set the target of this form to any value - useful for opening the form * Set the target of this form to any value - useful for opening the form
* contents in a new window or refreshing another frame. * contents in a new window or refreshing another frame.
* *
* @param string $target The value of the target * @param string $target The value of the target
* @return $this * @return $this
*/ */
public function setTarget($target) public function setTarget($target)
{ {
$this->target = $target; $this->target = $target;
return $this; return $this;
} }
/** /**
* Set the legend value to be inserted into * Set the legend value to be inserted into
* the <legend> element in the Form.ss template. * the <legend> element in the Form.ss template.
* @param string $legend * @param string $legend
* @return $this * @return $this
*/ */
public function setLegend($legend) public function setLegend($legend)
{ {
$this->legend = $legend; $this->legend = $legend;
return $this; return $this;
} }
/** /**
* Set the SS template that this form should use * Set the SS template that this form should use
* to render with. The default is "Form". * to render with. The default is "Form".
* *
* @param string $template The name of the template (without the .ss extension) * @param string $template The name of the template (without the .ss extension)
* @return $this * @return $this
*/ */
public function setTemplate($template) public function setTemplate($template)
{ {
$this->template = $template; $this->template = $template;
return $this; return $this;
} }
/** /**
* Return the template to render this form with. * Return the template to render this form with.
* *
* @return string * @return string
*/ */
public function getTemplate() public function getTemplate()
{ {
return $this->template; return $this->template;
} }
/** /**
* Returs the ordered list of preferred templates for rendering this form * Returs the ordered list of preferred templates for rendering this form
* If the template isn't set, then default to the * If the template isn't set, then default to the
* form class name e.g "Form". * form class name e.g "Form".
* *
* @return array * @return array
*/ */
public function getTemplates() public function getTemplates()
{ {
$templates = SSViewer::get_templates_by_class(get_class($this), '', __CLASS__); $templates = SSViewer::get_templates_by_class(get_class($this), '', __CLASS__);
// Prefer any custom template // Prefer any custom template
if ($this->getTemplate()) { if($this->getTemplate()) {
array_unshift($templates, $this->getTemplate()); array_unshift($templates, $this->getTemplate());
} }
return $templates; return $templates;
} }
/** /**
* Returns the encoding type for the form. * Returns the encoding type for the form.
* *
* By default this will be URL encoded, unless there is a file field present * By default this will be URL encoded, unless there is a file field present
* in which case multipart is used. You can also set the enc type using * in which case multipart is used. You can also set the enc type using
* {@link setEncType}. * {@link setEncType}.
*/ */
public function getEncType() public function getEncType()
{ {
if ($this->encType) { if ($this->encType) {
return $this->encType; return $this->encType;
} }
if ($fields = $this->fields->dataFields()) { if ($fields = $this->fields->dataFields()) {
foreach ($fields as $field) { foreach ($fields as $field) {
if ($field instanceof FileField) { if ($field instanceof FileField) {
return self::ENC_TYPE_MULTIPART; return self::ENC_TYPE_MULTIPART;
} }
} }
} }
return self::ENC_TYPE_URLENCODED; return self::ENC_TYPE_URLENCODED;
} }
/** /**
* Sets the form encoding type. The most common encoding types are defined * Sets the form encoding type. The most common encoding types are defined
* in {@link ENC_TYPE_URLENCODED} and {@link ENC_TYPE_MULTIPART}. * in {@link ENC_TYPE_URLENCODED} and {@link ENC_TYPE_MULTIPART}.
* *
* @param string $encType * @param string $encType
* @return $this * @return $this
*/ */
public function setEncType($encType) public function setEncType($encType)
{ {
$this->encType = $encType; $this->encType = $encType;
return $this; return $this;
} }
/** /**
* Returns the real HTTP method for the form: * Returns the real HTTP method for the form:
* GET, POST, PUT, DELETE or HEAD. * GET, POST, PUT, DELETE or HEAD.
* As most browsers only support GET and POST in * As most browsers only support GET and POST in
* form submissions, all other HTTP methods are * form submissions, all other HTTP methods are
* added as a hidden field "_method" that * added as a hidden field "_method" that
* gets evaluated in {@link Director::direct()}. * gets evaluated in {@link Director::direct()}.
* See {@link FormMethod()} to get a HTTP method * See {@link FormMethod()} to get a HTTP method
* for safe insertion into a <form> tag. * for safe insertion into a <form> tag.
* *
* @return string HTTP method * @return string HTTP method
*/ */
public function FormHttpMethod() public function FormHttpMethod()
{ {
return $this->formMethod; return $this->formMethod;
} }
/** /**
* Returns the form method to be used in the <form> tag. * Returns the form method to be used in the <form> tag.
* See {@link FormHttpMethod()} to get the "real" method. * See {@link FormHttpMethod()} to get the "real" method.
* *
* @return string Form HTTP method restricted to 'GET' or 'POST' * @return string Form HTTP method restricted to 'GET' or 'POST'
*/ */
public function FormMethod() public function FormMethod()
{ {
if (in_array($this->formMethod, array('GET','POST'))) { if(in_array($this->formMethod,array('GET','POST'))) {
return $this->formMethod; return $this->formMethod;
} else { } else {
return 'POST'; return 'POST';
} }
} }
/** /**
* Set the form method: GET, POST, PUT, DELETE. * Set the form method: GET, POST, PUT, DELETE.
* *
* @param string $method * @param string $method
* @param bool $strict If non-null, pass value to {@link setStrictFormMethodCheck()}. * @param bool $strict If non-null, pass value to {@link setStrictFormMethodCheck()}.
* @return $this * @return $this
*/ */
public function setFormMethod($method, $strict = null) public function setFormMethod($method, $strict = null)
{ {
$this->formMethod = strtoupper($method); $this->formMethod = strtoupper($method);
if ($strict !== null) { if ($strict !== null) {
$this->setStrictFormMethodCheck($strict); $this->setStrictFormMethodCheck($strict);
} }
return $this; return $this;
} }
/** /**
* If set to true, enforce the matching of the form method. * If set to true, enforce the matching of the form method.
* *
* This will mean two things: * This will mean two things:
* - GET vars will be ignored by a POST form, and vice versa * - GET vars will be ignored by a POST form, and vice versa
* - A submission where the HTTP method used doesn't match the form will return a 400 error. * - A submission where the HTTP method used doesn't match the form will return a 400 error.
* *
* If set to false (the default), then the form method is only used to construct the default * If set to false (the default), then the form method is only used to construct the default
* form. * form.
* *
* @param $bool boolean * @param $bool boolean
* @return $this * @return $this
*/ */
public function setStrictFormMethodCheck($bool) public function setStrictFormMethodCheck($bool)
{ {
$this->strictFormMethodCheck = (bool)$bool; $this->strictFormMethodCheck = (bool)$bool;
return $this; return $this;
} }
/** /**
* @return boolean * @return boolean
*/ */
public function getStrictFormMethodCheck() public function getStrictFormMethodCheck()
{ {
return $this->strictFormMethodCheck; return $this->strictFormMethodCheck;
} }
/** /**
* Return the form's action attribute. * Return the form's action attribute.
* This is build by adding an executeForm get variable to the parent controller's Link() value * This is build by adding an executeForm get variable to the parent controller's Link() value
* *
* @return string * @return string
*/ */
public function FormAction() public function FormAction()
{ {
if ($this->formActionPath) { if ($this->formActionPath) {
return $this->formActionPath; return $this->formActionPath;
} elseif ($this->controller->hasMethod("FormObjectLink")) { } elseif($this->controller->hasMethod("FormObjectLink")) {
return $this->controller->FormObjectLink($this->name); return $this->controller->FormObjectLink($this->name);
} else { } else {
return Controller::join_links($this->controller->Link(), $this->name); return Controller::join_links($this->controller->Link(), $this->name);
} }
} }
/** /**
* Set the form action attribute to a custom URL. * Set the form action attribute to a custom URL.
* *
* Note: For "normal" forms, you shouldn't need to use this method. It is * Note: For "normal" forms, you shouldn't need to use this method. It is
* recommended only for situations where you have two relatively distinct * recommended only for situations where you have two relatively distinct
* parts of the system trying to communicate via a form post. * parts of the system trying to communicate via a form post.
* *
* @param string $path * @param string $path
* @return $this * @return $this
*/ */
public function setFormAction($path) public function setFormAction($path)
{ {
$this->formActionPath = $path; $this->formActionPath = $path;
return $this; return $this;
} }
/** /**
* Returns the name of the form. * Returns the name of the form.
* *
* @return string * @return string
*/ */
public function FormName() public function FormName()
{ {
return $this->getTemplateHelper()->generateFormID($this); return $this->getTemplateHelper()->generateFormID($this);
} }
/** /**
* Set the HTML ID attribute of the form. * Set the HTML ID attribute of the form.
* *
* @param string $id * @param string $id
* @return $this * @return $this
*/ */
public function setHTMLID($id) public function setHTMLID($id)
{ {
$this->htmlID = $id; $this->htmlID = $id;
return $this; return $this;
} }
/** /**
* @return string * @return string
*/ */
public function getHTMLID() public function getHTMLID()
{ {
return $this->htmlID; return $this->htmlID;
} }
/** /**
* Get the controller. * Get the controller.
* *
* @return Controller * @return Controller
*/ */
public function getController() public function getController()
{ {
return $this->controller; return $this->controller;
} }
/** /**
* Set the controller. * Set the controller.
* *
* @param Controller $controller * @param Controller $controller
* @return Form * @return Form
*/ */
public function setController($controller) public function setController($controller)
{ {
$this->controller = $controller; $this->controller = $controller;
return $this; return $this;
} }
/** /**
* Get the name of the form. * Get the name of the form.
* *
* @return string * @return string
*/ */
public function getName() public function getName()
{ {
return $this->name; return $this->name;
} }
/** /**
* Set the name of the form. * Set the name of the form.
* *
* @param string $name * @param string $name
* @return Form * @return Form
*/ */
public function setName($name) public function setName($name)
{ {
$this->name = $name; $this->name = $name;
return $this; return $this;
} }
/** /**
* Returns an object where there is a method with the same name as each data * Returns an object where there is a method with the same name as each data
* field on the form. * field on the form.
* *
* That method will return the field itself. * That method will return the field itself.
* *
* It means that you can execute $firstName = $form->FieldMap()->FirstName() * It means that you can execute $firstName = $form->FieldMap()->FirstName()
*/ */
public function FieldMap() public function FieldMap()
{ {
return new Form_FieldMap($this); return new Form_FieldMap($this);
} }
/** /**
* The next functions store and modify the forms * The next functions store and modify the forms
* message attributes. messages are stored in session under * message attributes. messages are stored in session under
* $_SESSION[formname][message]; * $_SESSION[formname][message];
* *
* @return string * @return string
*/ */
public function Message() public function Message() {
{ return $this->message;
$this->getMessageFromSession(); }
return $this->message; /**
} * @return string
*/
public function MessageType() {
return $this->messageType;
}
/** /**
* @return string * Set a status message for the form.
*/ *
public function MessageType() * @param string $message the text of the message
{ * @param string $type Should be set to good, bad, or warning.
$this->getMessageFromSession(); * @param boolean $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
return $this->messageType; * user supplied data in the message.
} * @return $this
*/
/**
* @return string
*/
protected function getMessageFromSession()
{
if ($this->message || $this->messageType) {
return $this->message;
} else {
$this->message = Session::get("FormInfo.{$this->FormName()}.formError.message");
$this->messageType = Session::get("FormInfo.{$this->FormName()}.formError.type");
return $this->message;
}
}
/**
* Set a status message for the form.
*
* @param string $message the text of the message
* @param string $type Should be set to good, bad, or warning.
* @param boolean $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
* @return $this
*/
public function setMessage($message, $type, $escapeHtml = true) public function setMessage($message, $type, $escapeHtml = true)
{ {
$this->message = ($escapeHtml) ? Convert::raw2xml($message) : $message; $this->message = ($escapeHtml) ? Convert::raw2xml($message) : $message;
$this->messageType = $type; $this->messageType = $type;
return $this; return $this;
} }
/** /**
* Set a message to the session, for display next time this form is shown. * Set a message to the session, for display next time this form is shown.
* *
* @param string $message the text of the message * @param string $message the text of the message
* @param string $type Should be set to good, bad, or warning. * @param string $type Should be set to good, bad, or warning.
* @param boolean $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML. * @param boolean $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any * In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message. * user supplied data in the message.
*/ */
public function sessionMessage($message, $type, $escapeHtml = true) public function sessionMessage($message, $type, $escapeHtml = true)
{ {
Session::set( // Benign message
"FormInfo.{$this->FormName()}.formError.message", if($type == "good") {
$escapeHtml ? Convert::raw2xml($message) : $message $this->getSessionValidationResult()->addMessage($message, $type, null, $escapeHtml);
);
Session::set("FormInfo.{$this->FormName()}.formError.type", $type);
}
public static function messageForForm($formName, $message, $type, $escapeHtml = true) // Bad message causing a validation error
{ } else {
Session::set( $this->getSessionValidationResult()->addError($message, $type, null, $escapeHtml
"FormInfo.{$formName}.formError.message", );
$escapeHtml ? Convert::raw2xml($message) : $message }
); }
Session::set("FormInfo.{$formName}.formError.type", $type);
} /**
* @deprecated 3.1
*/
public static function messageForForm($formName, $message, $type) {
Deprecation::notice('3.1', 'Create an instance of the form you wish to attach a message to.');
}
/**
* Returns the ValidationResult stored in the session.
* You can use this to modify messages without throwing a ValidationException.
* If a ValidationResult doesn't yet exist, a new one will be created
*
* @return ValidationResult The ValidationResult object stored in the session
*/
public function getSessionValidationResult() {
$result = Session::get("FormInfo.{$this->FormName()}.result");
if(!$result || !($result instanceof ValidationResult)) {
$result = new ValidationResult;
Session::set("FormInfo.{$this->FormName()}.result", $result);
}
return $result;
}
/**
* Sets the ValidationResult in the session to be used with the next view of this form.
* @param ValidationResult $result The result to save
* @param boolean $combineWithExisting If true, then this will be added to the existing result.
*/
public function setSessionValidationResult(ValidationResult $result, $combineWithExisting = false) {
if($combineWithExisting) {
$existingResult = $this->getSessionValidationResult();
$existingResult->combineAnd($result);
} else {
Session::set("FormInfo.{$this->FormName()}.result", $result);
}
}
public function clearMessage() public function clearMessage()
{ {
$this->message = null; $this->message = null;
Session::clear("FormInfo.{$this->FormName()}.errors"); Session::clear("FormInfo.{$this->FormName()}.result");
Session::clear("FormInfo.{$this->FormName()}.formError"); Session::clear("FormInfo.{$this->FormName()}.data");
Session::clear("FormInfo.{$this->FormName()}.data"); }
}
public function resetValidation() public function resetValidation() {
{ Session::clear("FormInfo.{$this->FormName()}.data");
Session::clear("FormInfo.{$this->FormName()}.errors"); Session::clear("FormInfo.{$this->FormName()}.result");
Session::clear("FormInfo.{$this->FormName()}.data"); }
}
/** /**
* Returns the DataObject that has given this form its data * Returns the DataObject that has given this form its data
* through {@link loadDataFrom()}. * through {@link loadDataFrom()}.
* *
* @return DataObject * @return DataObject
*/ */
public function getRecord() public function getRecord()
{ {
return $this->record; return $this->record;
} }
/** /**
* Get the legend value to be inserted into the * Get the legend value to be inserted into the
* <legend> element in Form.ss * <legend> element in Form.ss
* *
* @return string * @return string
*/ */
public function getLegend() public function getLegend()
{ {
return $this->legend; return $this->legend;
} }
/** /**
* Processing that occurs before a form is executed. * Processing that occurs before a form is executed.
* *
* This includes form validation, if it fails, we redirect back * This includes form validation, if it fails, we throw a ValidationException
* to the form with appropriate error messages. *
* Always return true if the current form action is exempt from validation * This includes form validation, if it fails, we redirect back
* * to the form with appropriate error messages.
* Triggered through {@link httpSubmission()}. * Always return true if the current form action is exempt from validation
* *
* Note that CSRF protection takes place in {@link httpSubmission()}, * Triggered through {@link httpSubmission()}.
* if it fails the form data will never reach this method. *
* *
* @return boolean * Note that CSRF protection takes place in {@link httpSubmission()},
*/ * if it fails the form data will never reach this method.
public function validate() *
{ * @return boolean
$action = $this->buttonClicked(); */
if ($action && $this->actionIsValidationExempt($action)) { public function validate(){
return true; $result = $this->validationResult();
}
if ($this->validator) { // Valid
$errors = $this->validator->validate(); if($result->valid()) {
return true;
if ($errors) { // Invalid
// Load errors into session and post back } else {
$data = $this->getData(); $this->saveFormErrorsToSession($result, $this->getData());
return false;
}
}
// Encode validation messages as XML before saving into session state /**
// As per Form::addErrorMessage() * Experimental method - return a ValidationResult for the validator
$errors = array_map(function ($error) { * @return [type] [description]
// Encode message as XML by default */
if ($error['message'] instanceof DBField) { private function validationResult() {
$error['message'] = $error['message']->forTemplate(); // Start with a "valid" validation result
; $result = ValidationResult::create();
} else {
$error['message'] = Convert::raw2xml($error['message']);
}
return $error;
}, $errors);
Session::set("FormInfo.{$this->FormName()}.errors", $errors); // Opportunity to invalidate via validator
Session::set("FormInfo.{$this->FormName()}.data", $data); $action = $this->buttonClicked();
if($action && $this->actionIsValidationExempt($action)) {
return $result;
}
return false; if($this->validator){
} $errors = $this->validator->validate();
}
return true; // Convert the old-style Validator result into a ValidationResult
} if($errors){
foreach($errors as $error) {
$result->addFieldError($error['fieldName'], $error['message'], $error['messageType']);
}
}
}
const MERGE_DEFAULT = 0; return $result;
const MERGE_CLEAR_MISSING = 1; }
const MERGE_IGNORE_FALSEISH = 2;
/** const MERGE_DEFAULT = 0;
* Load data from the given DataObject or array. const MERGE_CLEAR_MISSING = 1;
* const MERGE_IGNORE_FALSEISH = 2;
* It will call $object->MyField to get the value of MyField.
* If you passed an array, it will call $object[MyField]. /**
* Doesn't save into dataless FormFields ({@link DatalessField}), * Load data from the given DataObject or array.
* as determined by {@link FieldList->dataFields()}. *
* * It will call $object->MyField to get the value of MyField.
* By default, if a field isn't set (as determined by isset()), * If you passed an array, it will call $object[MyField].
* its value will not be saved to the field, retaining * Doesn't save into dataless FormFields ({@link DatalessField}),
* potential existing values. * as determined by {@link FieldList->dataFields()}.
* *
* Passed data should not be escaped, and is saved to the FormField instances unescaped. * By default, if a field isn't set (as determined by isset()),
* Escaping happens automatically on saving the data through {@link saveInto()}. * its value will not be saved to the field, retaining
* * potential existing values.
* Escaping happens automatically on saving the data through *
* {@link saveInto()}. * Passed data should not be escaped, and is saved to the FormField instances unescaped.
* * Escaping happens automatically on saving the data through {@link saveInto()}.
* @uses FieldList->dataFields() *
* @uses FormField->setValue() * Escaping happens automatically on saving the data through
* * {@link saveInto()}.
* @param array|DataObject $data *
* @param int $mergeStrategy * @uses FieldList->dataFields()
* For every field, {@link $data} is interrogated whether it contains a relevant property/key, and * @uses FormField->setValue()
* what that property/key's value is. *
* * @param array|DataObject $data
* By default, if {@link $data} does contain a property/key, the fields value is always replaced by {@link $data}'s * @param int $mergeStrategy
* value, even if that value is null/false/etc. Fields which don't match any property/key in {@link $data} are * For every field, {@link $data} is interrogated whether it contains a relevant property/key, and
* "left alone", meaning they retain any previous value. * what that property/key's value is.
* *
* You can pass a bitmask here to change this behaviour. * By default, if {@link $data} does contain a property/key, the fields value is always replaced by {@link $data}'s
* * value, even if that value is null/false/etc. Fields which don't match any property/key in {@link $data} are
* Passing CLEAR_MISSING means that any fields that don't match any property/key in * "left alone", meaning they retain any previous value.
* {@link $data} are cleared. *
* * You can pass a bitmask here to change this behaviour.
* Passing IGNORE_FALSEISH means that any false-ish value in {@link $data} won't replace *
* a field's value. * Passing CLEAR_MISSING means that any fields that don't match any property/key in
* * {@link $data} are cleared.
* For backwards compatibility reasons, this parameter can also be set to === true, which is the same as passing *
* CLEAR_MISSING * Passing IGNORE_FALSEISH means that any false-ish value in {@link $data} won't replace
* * a field's value.
* @param array $fieldList An optional list of fields to process. This can be useful when you have a *
* form that has some fields that save to one object, and some that save to another. * For backwards compatibility reasons, this parameter can also be set to === true, which is the same as passing
* @return Form * CLEAR_MISSING
*/ *
* @param array $fieldList An optional list of fields to process. This can be useful when you have a
* form that has some fields that save to one object, and some that save to another.
* @return Form
*/
public function loadDataFrom($data, $mergeStrategy = 0, $fieldList = null) public function loadDataFrom($data, $mergeStrategy = 0, $fieldList = null)
{ {
if (!is_object($data) && !is_array($data)) { if(!is_object($data) && !is_array($data)) {
user_error("Form::loadDataFrom() not passed an array or an object", E_USER_WARNING); user_error("Form::loadDataFrom() not passed an array or an object", E_USER_WARNING);
return $this; return $this;
} }
// Handle the backwards compatible case of passing "true" as the second argument // Handle the backwards compatible case of passing "true" as the second argument
if ($mergeStrategy === true) { if ($mergeStrategy === true) {
$mergeStrategy = self::MERGE_CLEAR_MISSING; $mergeStrategy = self::MERGE_CLEAR_MISSING;
} elseif ($mergeStrategy === false) { } elseif ($mergeStrategy === false) {
$mergeStrategy = 0; $mergeStrategy = 0;
} }
// if an object is passed, save it for historical reference through {@link getRecord()} // if an object is passed, save it for historical reference through {@link getRecord()}
if (is_object($data)) { if (is_object($data)) {
$this->record = $data; $this->record = $data;
} }
// dont include fields without data // dont include fields without data
$dataFields = $this->Fields()->dataFields(); $dataFields = $this->Fields()->dataFields();
if ($dataFields) { if ($dataFields) {
foreach ($dataFields as $field) { foreach ($dataFields as $field) {
$name = $field->getName(); $name = $field->getName();
// Skip fields that have been excluded // Skip fields that have been excluded
if ($fieldList && !in_array($name, $fieldList)) { if($fieldList && !in_array($name, $fieldList)) {
continue; continue;
} }
// First check looks for (fieldname)_unchanged, an indicator that we shouldn't overwrite the field value // First check looks for (fieldname)_unchanged, an indicator that we shouldn't overwrite the field value
if (is_array($data) && isset($data[$name . '_unchanged'])) { if (is_array($data) && isset($data[$name . '_unchanged'])) {
continue; continue;
} }
// Does this property exist on $data? // Does this property exist on $data?
$exists = false; $exists = false;
// The value from $data for this field // The value from $data for this field
$val = null; $val = null;
if (is_object($data)) { if(is_object($data)) {
$exists = ( $exists = (
isset($data->$name) || isset($data->$name) ||
$data->hasMethod($name) || $data->hasMethod($name) ||
($data->hasMethod('hasField') && $data->hasField($name)) ($data->hasMethod('hasField') && $data->hasField($name))
); );
if ($exists) { if ($exists) {
$val = $data->__get($name); $val = $data->__get($name);
} }
} elseif (is_array($data)) { } elseif (is_array($data)) {
if (array_key_exists($name, $data)) { if(array_key_exists($name, $data)) {
$exists = true; $exists = true;
$val = $data[$name]; $val = $data[$name];
} // If field is in array-notation we need to access nested data } // If field is in array-notation we need to access nested data
elseif (strpos($name, '[')) { else if(strpos($name,'[')) {
// First encode data using PHP's method of converting nested arrays to form data // First encode data using PHP's method of converting nested arrays to form data
$flatData = urldecode(http_build_query($data)); $flatData = urldecode(http_build_query($data));
// Then pull the value out from that flattened string // Then pull the value out from that flattened string
preg_match('/' . addcslashes($name, '[]') . '=([^&]*)/', $flatData, $matches); preg_match('/' . addcslashes($name,'[]') . '=([^&]*)/', $flatData, $matches);
if (isset($matches[1])) { if (isset($matches[1])) {
$exists = true; $exists = true;
$val = $matches[1]; $val = $matches[1];
} }
} }
} }
// save to the field if either a value is given, or loading of blank/undefined values is forced // save to the field if either a value is given, or loading of blank/undefined values is forced
if ($exists) { if($exists){
if ($val != false || ($mergeStrategy & self::MERGE_IGNORE_FALSEISH) != self::MERGE_IGNORE_FALSEISH) { if ($val != false || ($mergeStrategy & self::MERGE_IGNORE_FALSEISH) != self::MERGE_IGNORE_FALSEISH){
// pass original data as well so composite fields can act on the additional information // pass original data as well so composite fields can act on the additional information
$field->setValue($val, $data); $field->setValue($val, $data);
} }
} elseif (($mergeStrategy & self::MERGE_CLEAR_MISSING) == self::MERGE_CLEAR_MISSING) { } elseif (($mergeStrategy & self::MERGE_CLEAR_MISSING) == self::MERGE_CLEAR_MISSING) {
$field->setValue($val, $data); $field->setValue($val, $data);
} }
} }
} }
return $this; return $this;
} }
/** /**
* Save the contents of this form into the given data object. * Save the contents of this form into the given data object.
* It will make use of setCastedField() to do this. * It will make use of setCastedField() to do this.
* *
* @param DataObjectInterface $dataObject The object to save data into * @param DataObjectInterface $dataObject The object to save data into
* @param FieldList $fieldList An optional list of fields to process. This can be useful when you have a * @param FieldList $fieldList An optional list of fields to process. This can be useful when you have a
* form that has some fields that save to one object, and some that save to another. * form that has some fields that save to one object, and some that save to another.
*/ */
public function saveInto(DataObjectInterface $dataObject, $fieldList = null) public function saveInto(DataObjectInterface $dataObject, $fieldList = null)
{ {
$dataFields = $this->fields->saveableFields(); $dataFields = $this->fields->saveableFields();
$lastField = null; $lastField = null;
if ($dataFields) { if ($dataFields) {
foreach ($dataFields as $field) { foreach ($dataFields as $field) {
// Skip fields that have been excluded // Skip fields that have been excluded
if ($fieldList && is_array($fieldList) && !in_array($field->getName(), $fieldList)) { if ($fieldList && is_array($fieldList) && !in_array($field->getName(), $fieldList)) {
continue; continue;
} }
$saveMethod = "save{$field->getName()}"; $saveMethod = "save{$field->getName()}";
if ($field->getName() == "ClassName") { if($field->getName() == "ClassName"){
$lastField = $field; $lastField = $field;
} elseif ($dataObject->hasMethod($saveMethod)) { }else if( $dataObject->hasMethod( $saveMethod ) ){
$dataObject->$saveMethod( $field->dataValue()); $dataObject->$saveMethod( $field->dataValue());
} elseif ($field->getName() != "ID") { } else if($field->getName() != "ID"){
$field->saveInto($dataObject); $field->saveInto($dataObject);
} }
} }
} }
if ($lastField) { if ($lastField) {
$lastField->saveInto($dataObject); $lastField->saveInto($dataObject);
} }
} }
/** /**
* Get the submitted data from this form through * Get the submitted data from this form through
* {@link FieldList->dataFields()}, which filters out * {@link FieldList->dataFields()}, which filters out
* any form-specific data like form-actions. * any form-specific data like form-actions.
* Calls {@link FormField->dataValue()} on each field, * Calls {@link FormField->dataValue()} on each field,
* which returns a value suitable for insertion into a DataObject * which returns a value suitable for insertion into a DataObject
* property. * property.
* *
* @return array * @return array
*/ */
public function getData() public function getData()
{ {
$dataFields = $this->fields->dataFields(); $dataFields = $this->fields->dataFields();
$data = array(); $data = array();
if ($dataFields) { if($dataFields){
foreach ($dataFields as $field) { foreach($dataFields as $field) {
if ($field->getName()) { if($field->getName()) {
$data[$field->getName()] = $field->dataValue(); $data[$field->getName()] = $field->dataValue();
} }
} }
} }
return $data; return $data;
} }
/** /**
* Return a rendered version of this form. * Return a rendered version of this form.
* *
* This is returned when you access a form as $FormObject rather * This is returned when you access a form as $FormObject rather
* than <% with FormObject %> * than <% with FormObject %>
* *
* @return DBHTMLText * @return DBHTMLText
*/ */
public function forTemplate() public function forTemplate()
{ {
$return = $this->renderWith($this->getTemplates()); $return = $this->renderWith($this->getTemplates());
// Now that we're rendered, clear message // Now that we're rendered, clear message
$this->clearMessage(); $this->clearMessage();
return $return; return $return;
} }
/** /**
* Return a rendered version of this form, suitable for ajax post-back. * Return a rendered version of this form, suitable for ajax post-back.
* *
* It triggers slightly different behaviour, such as disabling the rewriting * It triggers slightly different behaviour, such as disabling the rewriting
* of # links. * of # links.
* *
* @return DBHTMLText * @return DBHTMLText
*/ */
public function forAjaxTemplate() public function forAjaxTemplate()
{ {
$view = new SSViewer($this->getTemplates()); $view = new SSViewer($this->getTemplates());
$return = $view->dontRewriteHashlinks()->process($this); $return = $view->dontRewriteHashlinks()->process($this);
// Now that we're rendered, clear message // Now that we're rendered, clear message
$this->clearMessage(); $this->clearMessage();
return $return; return $return;
} }
/** /**
* Returns an HTML rendition of this form, without the <form> tag itself. * Returns an HTML rendition of this form, without the <form> tag itself.
* *
* Attaches 3 extra hidden files, _form_action, _form_name, _form_method, * Attaches 3 extra hidden files, _form_action, _form_name, _form_method,
* and _form_enctype. These are the attributes of the form. These fields * and _form_enctype. These are the attributes of the form. These fields
* can be used to send the form to Ajax. * can be used to send the form to Ajax.
* *
* @deprecated 5.0 * @deprecated 5.0
* @return string * @return string
*/ */
public function formHtmlContent() public function formHtmlContent()
{ {
Deprecation::notice('5.0'); Deprecation::notice('5.0');
$this->IncludeFormTag = false; $this->IncludeFormTag = false;
$content = $this->forTemplate(); $content = $this->forTemplate();
$this->IncludeFormTag = true; $this->IncludeFormTag = true;
$content .= "<input type=\"hidden\" name=\"_form_action\" id=\"" . $this->FormName . "_form_action\"" $content .= "<input type=\"hidden\" name=\"_form_action\" id=\"" . $this->FormName . "_form_action\""
. " value=\"" . $this->FormAction() . "\" />\n"; . " value=\"" . $this->FormAction() . "\" />\n";
$content .= "<input type=\"hidden\" name=\"_form_name\" value=\"" . $this->FormName() . "\" />\n"; $content .= "<input type=\"hidden\" name=\"_form_name\" value=\"" . $this->FormName() . "\" />\n";
$content .= "<input type=\"hidden\" name=\"_form_method\" value=\"" . $this->FormMethod() . "\" />\n"; $content .= "<input type=\"hidden\" name=\"_form_method\" value=\"" . $this->FormMethod() . "\" />\n";
$content .= "<input type=\"hidden\" name=\"_form_enctype\" value=\"" . $this->getEncType() . "\" />\n"; $content .= "<input type=\"hidden\" name=\"_form_enctype\" value=\"" . $this->getEncType() . "\" />\n";
return $content; return $content;
} }
/** /**
* Render this form using the given template, and return the result as a string * Render this form using the given template, and return the result as a string
* You can pass either an SSViewer or a template name * You can pass either an SSViewer or a template name
* @param string|array $template * @param string|array $template
* @return DBHTMLText * @return DBHTMLText
*/ */
public function renderWithoutActionButton($template) public function renderWithoutActionButton($template)
{ {
$custom = $this->customise(array( $custom = $this->customise(array(
"Actions" => "", "Actions" => "",
)); ));
if (is_string($template)) { if(is_string($template)) {
$template = new SSViewer($template); $template = new SSViewer($template);
} }
return $template->process($custom); return $template->process($custom);
} }
/** /**
* Sets the button that was clicked. This should only be called by the Controller. * Sets the button that was clicked. This should only be called by the Controller.
* *
* @param callable $funcName The name of the action method that will be called. * @param callable $funcName The name of the action method that will be called.
* @return $this * @return $this
*/ */
public function setButtonClicked($funcName) public function setButtonClicked($funcName)
{ {
$this->buttonClickedFunc = $funcName; $this->buttonClickedFunc = $funcName;
return $this; return $this;
} }
/** /**
* @return FormAction * @return FormAction
*/ */
public function buttonClicked() public function buttonClicked()
{ {
$actions = $this->getAllActions(); $actions = $this->getAllActions();
foreach ($actions as $action) { foreach ($actions as $action) {
if ($this->buttonClickedFunc === $action->actionName()) { if ($this->buttonClickedFunc === $action->actionName()) {
return $action; return $action;
} }
} }
return null; return null;
} }
/** /**
* Get a list of all actions, including those in the main "fields" FieldList * Get a list of all actions, including those in the main "fields" FieldList
* *
* @return array * @return array
*/ */
protected function getAllActions() protected function getAllActions()
{ {
$fields = $this->fields->dataFields() ?: array(); $fields = $this->fields->dataFields() ?: array();
$actions = $this->actions->dataFields() ?: array(); $actions = $this->actions->dataFields() ?: array();
$fieldsAndActions = array_merge($fields, $actions); $fieldsAndActions = array_merge($fields, $actions);
$actions = array_filter($fieldsAndActions, function ($fieldOrAction) { $actions = array_filter($fieldsAndActions, function($fieldOrAction) {
return $fieldOrAction instanceof FormAction; return $fieldOrAction instanceof FormAction;
}); });
return $actions; return $actions;
} }
/** /**
* Return the default button that should be clicked when another one isn't * Return the default button that should be clicked when another one isn't
* available. * available.
* *
* @return FormAction * @return FormAction
*/ */
public function defaultAction() public function defaultAction()
{ {
if ($this->hasDefaultAction && $this->actions) { if($this->hasDefaultAction && $this->actions) {
return $this->actions->first(); return $this->actions->first();
} }
return null; return null;
} }
/** /**
* Disable the default button. * Disable the default button.
* *
* Ordinarily, when a form is processed and no action_XXX button is * Ordinarily, when a form is processed and no action_XXX button is
* available, then the first button in the actions list will be pressed. * available, then the first button in the actions list will be pressed.
* However, if this is "delete", for example, this isn't such a good idea. * However, if this is "delete", for example, this isn't such a good idea.
* *
* @return Form * @return Form
*/ */
public function disableDefaultAction() public function disableDefaultAction()
{ {
$this->hasDefaultAction = false; $this->hasDefaultAction = false;
return $this; return $this;
} }
/** /**
* Disable the requirement of a security token on this form instance. This * Disable the requirement of a security token on this form instance. This
* security protects against CSRF attacks, but you should disable this if * security protects against CSRF attacks, but you should disable this if
* you don't want to tie a form to a session - eg a search form. * you don't want to tie a form to a session - eg a search form.
* *
* Check for token state with {@link getSecurityToken()} and * Check for token state with {@link getSecurityToken()} and
* {@link SecurityToken->isEnabled()}. * {@link SecurityToken->isEnabled()}.
* *
* @return Form * @return Form
*/ */
public function disableSecurityToken() public function disableSecurityToken()
{ {
$this->securityToken = new NullSecurityToken(); $this->securityToken = new NullSecurityToken();
return $this; return $this;
} }
/** /**
* Enable {@link SecurityToken} protection for this form instance. * Enable {@link SecurityToken} protection for this form instance.
* *
* Check for token state with {@link getSecurityToken()} and * Check for token state with {@link getSecurityToken()} and
* {@link SecurityToken->isEnabled()}. * {@link SecurityToken->isEnabled()}.
* *
* @return Form * @return Form
*/ */
public function enableSecurityToken() public function enableSecurityToken()
{ {
$this->securityToken = new SecurityToken(); $this->securityToken = new SecurityToken();
return $this; return $this;
} }
/** /**
* Returns the security token for this form (if any exists). * Returns the security token for this form (if any exists).
* *
* Doesn't check for {@link securityTokenEnabled()}. * Doesn't check for {@link securityTokenEnabled()}.
* *
* Use {@link SecurityToken::inst()} to get a global token. * Use {@link SecurityToken::inst()} to get a global token.
* *
* @return SecurityToken|null * @return SecurityToken|null
*/ */
public function getSecurityToken() public function getSecurityToken()
{ {
return $this->securityToken; return $this->securityToken;
} }
/** /**
* Compiles all CSS-classes. * Compiles all CSS-classes.
* *
* @return string * @return string
*/ */
public function extraClass() public function extraClass()
{ {
return implode(array_unique($this->extraClasses), ' '); return implode(array_unique($this->extraClasses), ' ');
} }
/** /**
* Add a CSS-class to the form-container. If needed, multiple classes can * Add a CSS-class to the form-container. If needed, multiple classes can
* be added by delimiting a string with spaces. * be added by delimiting a string with spaces.
* *
* @param string $class A string containing a classname or several class * @param string $class A string containing a classname or several class
* names delimited by a single space. * names delimited by a single space.
* @return $this * @return $this
*/ */
public function addExtraClass($class) public function addExtraClass($class)
{ {
//split at white space //split at white space
$classes = preg_split('/\s+/', $class); $classes = preg_split('/\s+/', $class);
foreach ($classes as $class) { foreach($classes as $class) {
//add classes one by one //add classes one by one
$this->extraClasses[$class] = $class; $this->extraClasses[$class] = $class;
} }
return $this; return $this;
} }
/** /**
* Remove a CSS-class from the form-container. Multiple class names can * Remove a CSS-class from the form-container. Multiple class names can
* be passed through as a space delimited string * be passed through as a space delimited string
* *
* @param string $class * @param string $class
* @return $this * @return $this
*/ */
public function removeExtraClass($class) public function removeExtraClass($class)
{ {
//split at white space //split at white space
$classes = preg_split('/\s+/', $class); $classes = preg_split('/\s+/', $class);
foreach ($classes as $class) { foreach ($classes as $class) {
//unset one by one //unset one by one
unset($this->extraClasses[$class]); unset($this->extraClasses[$class]);
} }
return $this; return $this;
} }
public function debug() public function debug()
{ {
$result = "<h3>$this->class</h3><ul>"; $result = "<h3>$this->class</h3><ul>";
foreach ($this->fields as $field) { foreach($this->fields as $field) {
$result .= "<li>$field" . $field->debug() . "</li>"; $result .= "<li>$field" . $field->debug() . "</li>";
} }
$result .= "</ul>"; $result .= "</ul>";
if ($this->validator) { if( $this->validator ) {
/** @skipUpgrade */ /** @skipUpgrade */
$result .= '<h3>' . _t('Form.VALIDATOR', 'Validator') . '</h3>' . $this->validator->debug(); $result .= '<h3>' . _t('Form.VALIDATOR', 'Validator') . '</h3>' . $this->validator->debug();
} }
return $result; return $result;
} }
///////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// TESTING HELPERS // TESTING HELPERS
///////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/** /**
* Test a submission of this form. * Test a submission of this form.
* @param string $action * @param string $action
* @param array $data * @param array $data
* @return HTTPResponse the response object that the handling controller produces. You can interrogate this in * @return HTTPResponse the response object that the handling controller produces. You can interrogate this in
* your unit test. * your unit test.
* @throws HTTPResponse_Exception * @throws HTTPResponse_Exception
*/ */
public function testSubmission($action, $data) public function testSubmission($action, $data)
{ {
$data['action_' . $action] = true; $data['action_' . $action] = true;
return Director::test($this->FormAction(), $data, Controller::curr()->getSession()); return Director::test($this->FormAction(), $data, Controller::curr()->getSession());
} }
/** /**
* Test an ajax submission of this form. * Test an ajax submission of this form.
* *
* @param string $action * @param string $action
* @param array $data * @param array $data
* @return HTTPResponse the response object that the handling controller produces. You can interrogate this in * @return HTTPResponse the response object that the handling controller produces. You can interrogate this in
* your unit test. * your unit test.
*/ */
public function testAjaxSubmission($action, $data) public function testAjaxSubmission($action, $data)
{ {
$data['ajax'] = 1; $data['ajax'] = 1;
return $this->testSubmission($action, $data); return $this->testSubmission($action, $data);
} }
} }

View File

@ -24,101 +24,101 @@ use SilverStripe\ORM\ValidationException;
class GridFieldDeleteAction implements GridField_ColumnProvider, GridField_ActionProvider class GridFieldDeleteAction implements GridField_ColumnProvider, GridField_ActionProvider
{ {
/** /**
* If this is set to true, this {@link GridField_ActionProvider} will * If this is set to true, this {@link GridField_ActionProvider} will
* remove the object from the list, instead of deleting. * remove the object from the list, instead of deleting.
* *
* In the case of a has one, has many or many many list it will uncouple * In the case of a has one, has many or many many list it will uncouple
* the item from the list. * the item from the list.
* *
* @var boolean * @var boolean
*/ */
protected $removeRelation = false; protected $removeRelation = false;
/** /**
* *
* @param boolean $removeRelation - true if removing the item from the list, but not deleting it * @param boolean $removeRelation - true if removing the item from the list, but not deleting it
*/ */
public function __construct($removeRelation = false) public function __construct($removeRelation = false)
{ {
$this->removeRelation = $removeRelation; $this->removeRelation = $removeRelation;
} }
/** /**
* Add a column 'Delete' * Add a column 'Delete'
* *
* @param GridField $gridField * @param GridField $gridField
* @param array $columns * @param array $columns
*/ */
public function augmentColumns($gridField, &$columns) public function augmentColumns($gridField, &$columns)
{ {
if (!in_array('Actions', $columns)) { if(!in_array('Actions', $columns)) {
$columns[] = 'Actions'; $columns[] = 'Actions';
} }
} }
/** /**
* Return any special attributes that will be used for FormField::create_tag() * Return any special attributes that will be used for FormField::create_tag()
* *
* @param GridField $gridField * @param GridField $gridField
* @param DataObject $record * @param DataObject $record
* @param string $columnName * @param string $columnName
* @return array * @return array
*/ */
public function getColumnAttributes($gridField, $record, $columnName) public function getColumnAttributes($gridField, $record, $columnName)
{ {
return array('class' => 'grid-field__col-compact'); return array('class' => 'grid-field__col-compact');
} }
/** /**
* Add the title * Add the title
* *
* @param GridField $gridField * @param GridField $gridField
* @param string $columnName * @param string $columnName
* @return array * @return array
*/ */
public function getColumnMetadata($gridField, $columnName) public function getColumnMetadata($gridField, $columnName)
{ {
if ($columnName == 'Actions') { if($columnName == 'Actions') {
return array('title' => ''); return array('title' => '');
} }
} }
/** /**
* Which columns are handled by this component * Which columns are handled by this component
* *
* @param GridField $gridField * @param GridField $gridField
* @return array * @return array
*/ */
public function getColumnsHandled($gridField) public function getColumnsHandled($gridField)
{ {
return array('Actions'); return array('Actions');
} }
/** /**
* Which GridField actions are this component handling * Which GridField actions are this component handling
* *
* @param GridField $gridField * @param GridField $gridField
* @return array * @return array
*/ */
public function getActions($gridField) public function getActions($gridField)
{ {
return array('deleterecord', 'unlinkrelation'); return array('deleterecord', 'unlinkrelation');
} }
/** /**
* *
* @param GridField $gridField * @param GridField $gridField
* @param DataObject $record * @param DataObject $record
* @param string $columnName * @param string $columnName
* @return string the HTML for the column * @return string the HTML for the column
*/ */
public function getColumnContent($gridField, $record, $columnName) public function getColumnContent($gridField, $record, $columnName)
{ {
if ($this->removeRelation) { if($this->removeRelation) {
if (!$record->canEdit()) { if(!$record->canEdit()) {
return null; return null;
} }
$field = GridField_FormAction::create( $field = GridField_FormAction::create(
$gridField, $gridField,
@ -127,12 +127,12 @@ class GridFieldDeleteAction implements GridField_ColumnProvider, GridField_Actio
"unlinkrelation", "unlinkrelation",
array('RecordID' => $record->ID) array('RecordID' => $record->ID)
) )
->addExtraClass('btn btn--no-text btn--icon-md font-icon-link-broken grid-field__icon-action gridfield-button-unlink') ->addExtraClass('btn btn--no-text btn--icon-md font-icon-link-broken grid-field__icon-action gridfield-button-unlink')
->setAttribute('title', _t('GridAction.UnlinkRelation', "Unlink")); ->setAttribute('title', _t('GridAction.UnlinkRelation', "Unlink"));
} else { } else {
if (!$record->canDelete()) { if(!$record->canDelete()) {
return null; return null;
} }
$field = GridField_FormAction::create( $field = GridField_FormAction::create(
$gridField, $gridField,
@ -141,50 +141,46 @@ class GridFieldDeleteAction implements GridField_ColumnProvider, GridField_Actio
"deleterecord", "deleterecord",
array('RecordID' => $record->ID) array('RecordID' => $record->ID)
) )
->addExtraClass('gridfield-button-delete btn--icon-md font-icon-trash-bin btn--no-text grid-field__icon-action') ->addExtraClass('gridfield-button-delete btn--icon-md font-icon-trash-bin btn--no-text grid-field__icon-action')
->setAttribute('title', _t('GridAction.Delete', "Delete")) ->setAttribute('title', _t('GridAction.Delete', "Delete"))
->setDescription(_t('GridAction.DELETE_DESCRIPTION', 'Delete')); ->setDescription(_t('GridAction.DELETE_DESCRIPTION','Delete'));
} }
return $field->Field(); return $field->Field();
} }
/** /**
* Handle the actions and apply any changes to the GridField * Handle the actions and apply any changes to the GridField
* *
* @param GridField $gridField * @param GridField $gridField
* @param string $actionName * @param string $actionName
* @param mixed $arguments * @param mixed $arguments
* @param array $data - form data * @param array $data - form data
* @throws ValidationException * @throws ValidationException
*/ */
public function handleAction(GridField $gridField, $actionName, $arguments, $data) public function handleAction(GridField $gridField, $actionName, $arguments, $data)
{ {
if ($actionName == 'deleterecord' || $actionName == 'unlinkrelation') { if($actionName == 'deleterecord' || $actionName == 'unlinkrelation') {
/** @var DataObject $item */ /** @var DataObject $item */
$item = $gridField->getList()->byID($arguments['RecordID']); $item = $gridField->getList()->byID($arguments['RecordID']);
if (!$item) { if(!$item) {
return; return;
} }
if ($actionName == 'deleterecord') { if($actionName == 'deleterecord') {
if (!$item->canDelete()) { if(!$item->canDelete()) {
throw new ValidationException( throw new ValidationException(
_t('GridFieldAction_Delete.DeletePermissionsFailure', "No delete permissions"), _t('GridFieldAction_Delete.DeletePermissionsFailure',"No delete permissions"));
0 }
);
}
$item->delete(); $item->delete();
} else { } else {
if (!$item->canEdit()) { if(!$item->canEdit()) {
throw new ValidationException( throw new ValidationException(
_t('GridFieldAction_Delete.EditPermissionsFailure', "No permission to unlink record"), _t('GridFieldAction_Delete.EditPermissionsFailure',"No permission to unlink record"));
0 }
);
}
$gridField->getList()->remove($item); $gridField->getList()->remove($item);
} }
} }
} }
} }

View File

@ -26,622 +26,620 @@ use SilverStripe\View\SSViewer;
class GridFieldDetailForm_ItemRequest extends RequestHandler class GridFieldDetailForm_ItemRequest extends RequestHandler
{ {
private static $allowed_actions = array( private static $allowed_actions = array(
'edit', 'edit',
'view', 'view',
'ItemEditForm' 'ItemEditForm'
); );
/** /**
* *
* @var GridField * @var GridField
*/ */
protected $gridField; protected $gridField;
/** /**
* *
* @var GridFieldDetailForm * @var GridFieldDetailForm
*/ */
protected $component; protected $component;
/** /**
* *
* @var DataObject * @var DataObject
*/ */
protected $record; protected $record;
/** /**
* This represents the current parent RequestHandler (which does not necessarily need to be a Controller). * This represents the current parent RequestHandler (which does not necessarily need to be a Controller).
* It allows us to traverse the RequestHandler chain upwards to reach the Controller stack. * It allows us to traverse the RequestHandler chain upwards to reach the Controller stack.
* *
* @var RequestHandler * @var RequestHandler
*/ */
protected $popupController; protected $popupController;
/** /**
* *
* @var string * @var string
*/ */
protected $popupFormName; protected $popupFormName;
/** /**
* @var String * @var String
*/ */
protected $template = null; protected $template = null;
private static $url_handlers = array( private static $url_handlers = array(
'$Action!' => '$Action', '$Action!' => '$Action',
'' => 'edit', '' => 'edit',
); );
/** /**
* *
* @param GridField $gridField * @param GridField $gridField
* @param GridFieldDetailForm $component * @param GridFieldDetailForm $component
* @param DataObject $record * @param DataObject $record
* @param RequestHandler $requestHandler * @param RequestHandler $requestHandler
* @param string $popupFormName * @param string $popupFormName
*/ */
public function __construct($gridField, $component, $record, $requestHandler, $popupFormName) public function __construct($gridField, $component, $record, $requestHandler, $popupFormName)
{ {
$this->gridField = $gridField; $this->gridField = $gridField;
$this->component = $component; $this->component = $component;
$this->record = $record; $this->record = $record;
$this->popupController = $requestHandler; $this->popupController = $requestHandler;
$this->popupFormName = $popupFormName; $this->popupFormName = $popupFormName;
parent::__construct(); parent::__construct();
} }
public function Link($action = null) public function Link($action = null)
{ {
return Controller::join_links( return Controller::join_links(
$this->gridField->Link('item'), $this->gridField->Link('item'),
$this->record->ID ? $this->record->ID : 'new', $this->record->ID ? $this->record->ID : 'new',
$action $action
); );
} }
/** /**
* @param HTTPRequest $request * @param HTTPRequest $request
* @return mixed * @return mixed
*/ */
public function view($request) public function view($request)
{ {
if (!$this->record->canView()) { if (!$this->record->canView()) {
$this->httpError(403); $this->httpError(403);
} }
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
$form = $this->ItemEditForm(); $form = $this->ItemEditForm();
$form->makeReadonly(); $form->makeReadonly();
$data = new ArrayData(array( $data = new ArrayData(array(
'Backlink' => $controller->Link(), 'Backlink' => $controller->Link(),
'ItemEditForm' => $form 'ItemEditForm' => $form
)); ));
$return = $data->renderWith($this->getTemplates()); $return = $data->renderWith($this->getTemplates());
if ($request->isAjax()) { if ($request->isAjax()) {
return $return; return $return;
} else { } else {
return $controller->customise(array('Content' => $return)); return $controller->customise(array('Content' => $return));
} }
} }
/** /**
* @param HTTPRequest $request * @param HTTPRequest $request
* @return mixed * @return mixed
*/ */
public function edit($request) public function edit($request)
{ {
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
$form = $this->ItemEditForm(); $form = $this->ItemEditForm();
$return = $this->customise(array( $return = $this->customise(array(
'Backlink' => $controller->hasMethod('Backlink') ? $controller->Backlink() : $controller->Link(), 'Backlink' => $controller->hasMethod('Backlink') ? $controller->Backlink() : $controller->Link(),
'ItemEditForm' => $form, 'ItemEditForm' => $form,
))->renderWith($this->getTemplates()); ))->renderWith($this->getTemplates());
if ($request->isAjax()) { if ($request->isAjax()) {
return $return; return $return;
} else { } else {
// If not requested by ajax, we need to render it within the controller context+template // If not requested by ajax, we need to render it within the controller context+template
return $controller->customise(array( return $controller->customise(array(
// TODO CMS coupling // TODO CMS coupling
'Content' => $return, 'Content' => $return,
)); ));
} }
} }
/** /**
* Builds an item edit form. The arguments to getCMSFields() are the popupController and * Builds an item edit form. The arguments to getCMSFields() are the popupController and
* popupFormName, however this is an experimental API and may change. * popupFormName, however this is an experimental API and may change.
* *
* @todo In the future, we will probably need to come up with a tigher object representing a partially * @todo In the future, we will probably need to come up with a tigher object representing a partially
* complete controller with gaps for extra functionality. This, for example, would be a better way * complete controller with gaps for extra functionality. This, for example, would be a better way
* of letting Security/login put its log-in form inside a UI specified elsewhere. * of letting Security/login put its log-in form inside a UI specified elsewhere.
* *
* @return Form * @return Form
*/ */
public function ItemEditForm() public function ItemEditForm()
{ {
$list = $this->gridField->getList(); $list = $this->gridField->getList();
if (empty($this->record)) { if (empty($this->record)) {
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
$url = $controller->getRequest()->getURL(); $url = $controller->getRequest()->getURL();
$noActionURL = $controller->removeAction($url); $noActionURL = $controller->removeAction($url);
$controller->getResponse()->removeHeader('Location'); //clear the existing redirect $controller->getResponse()->removeHeader('Location'); //clear the existing redirect
return $controller->redirect($noActionURL, 302); return $controller->redirect($noActionURL, 302);
} }
$canView = $this->record->canView(); $canView = $this->record->canView();
$canEdit = $this->record->canEdit(); $canEdit = $this->record->canEdit();
$canDelete = $this->record->canDelete(); $canDelete = $this->record->canDelete();
$canCreate = $this->record->canCreate(); $canCreate = $this->record->canCreate();
if (!$canView) { if (!$canView) {
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
// TODO More friendly error // TODO More friendly error
return $controller->httpError(403); return $controller->httpError(403);
} }
// Build actions // Build actions
$actions = $this->getFormActions(); $actions = $this->getFormActions();
// If we are creating a new record in a has-many list, then // If we are creating a new record in a has-many list, then
// pre-populate the record's foreign key. // pre-populate the record's foreign key.
if ($list instanceof HasManyList && !$this->record->isInDB()) { if ($list instanceof HasManyList && !$this->record->isInDB()) {
$key = $list->getForeignKey(); $key = $list->getForeignKey();
$id = $list->getForeignID(); $id = $list->getForeignID();
$this->record->$key = $id; $this->record->$key = $id;
} }
$fields = $this->component->getFields(); $fields = $this->component->getFields();
if (!$fields) { if (!$fields) {
$fields = $this->record->getCMSFields(); $fields = $this->record->getCMSFields();
} }
// If we are creating a new record in a has-many list, then // If we are creating a new record in a has-many list, then
// Disable the form field as it has no effect. // Disable the form field as it has no effect.
if ($list instanceof HasManyList) { if ($list instanceof HasManyList) {
$key = $list->getForeignKey(); $key = $list->getForeignKey();
if ($field = $fields->dataFieldByName($key)) { if ($field = $fields->dataFieldByName($key)) {
$fields->makeFieldReadonly($field); $fields->makeFieldReadonly($field);
} }
} }
// Caution: API violation. Form expects a Controller, but we are giving it a RequestHandler instead. // Caution: API violation. Form expects a Controller, but we are giving it a RequestHandler instead.
// Thanks to this however, we are able to nest GridFields, and also access the initial Controller by // Thanks to this however, we are able to nest GridFields, and also access the initial Controller by
// dereferencing GridFieldDetailForm_ItemRequest->getController() multiple times. See getToplevelController // dereferencing GridFieldDetailForm_ItemRequest->getController() multiple times. See getToplevelController
// below. // below.
$form = new Form( $form = new Form(
$this, $this,
'ItemEditForm', 'ItemEditForm',
$fields, $fields,
$actions, $actions,
$this->component->getValidator() $this->component->getValidator()
); );
$form->loadDataFrom($this->record, $this->record->ID == 0 ? Form::MERGE_IGNORE_FALSEISH : Form::MERGE_DEFAULT); $form->loadDataFrom($this->record, $this->record->ID == 0 ? Form::MERGE_IGNORE_FALSEISH : Form::MERGE_DEFAULT);
if ($this->record->ID && !$canEdit) { if ($this->record->ID && !$canEdit) {
// Restrict editing of existing records // Restrict editing of existing records
$form->makeReadonly(); $form->makeReadonly();
// Hack to re-enable delete button if user can delete // Hack to re-enable delete button if user can delete
if ($canDelete) { if ($canDelete) {
$form->Actions()->fieldByName('action_doDelete')->setReadonly(false); $form->Actions()->fieldByName('action_doDelete')->setReadonly(false);
} }
} elseif (!$this->record->ID && !$canCreate) { } elseif (!$this->record->ID && !$canCreate) {
// Restrict creation of new records // Restrict creation of new records
$form->makeReadonly(); $form->makeReadonly();
} }
// Load many_many extraData for record. // Load many_many extraData for record.
// Fields with the correct 'ManyMany' namespace need to be added manually through getCMSFields(). // Fields with the correct 'ManyMany' namespace need to be added manually through getCMSFields().
if ($list instanceof ManyManyList) { if ($list instanceof ManyManyList) {
$extraData = $list->getExtraData('', $this->record->ID); $extraData = $list->getExtraData('', $this->record->ID);
$form->loadDataFrom(array('ManyMany' => $extraData)); $form->loadDataFrom(array('ManyMany' => $extraData));
} }
// TODO Coupling with CMS // TODO Coupling with CMS
$toplevelController = $this->getToplevelController(); $toplevelController = $this->getToplevelController();
if ($toplevelController && $toplevelController instanceof LeftAndMain) { if ($toplevelController && $toplevelController instanceof LeftAndMain) {
// Always show with base template (full width, no other panels), // Always show with base template (full width, no other panels),
// regardless of overloaded CMS controller templates. // regardless of overloaded CMS controller templates.
// TODO Allow customization, e.g. to display an edit form alongside a search form from the CMS controller // TODO Allow customization, e.g. to display an edit form alongside a search form from the CMS controller
$form->setTemplate([ $form->setTemplate([
'type' => 'Includes', 'type' => 'Includes',
'SilverStripe\\Admin\\LeftAndMain_EditForm', 'SilverStripe\\Admin\\LeftAndMain_EditForm',
]); ]);
$form->addExtraClass('cms-content cms-edit-form center fill-height flexbox-area-grow'); $form->addExtraClass('cms-content cms-edit-form center fill-height flexbox-area-grow');
$form->setAttribute('data-pjax-fragment', 'CurrentForm Content'); $form->setAttribute('data-pjax-fragment', 'CurrentForm Content');
if ($form->Fields()->hasTabSet()) { if ($form->Fields()->hasTabSet()) {
$form->Fields()->findOrMakeTab('Root')->setTemplate('SilverStripe\\Forms\\CMSTabSet'); $form->Fields()->findOrMakeTab('Root')->setTemplate('SilverStripe\\Forms\\CMSTabSet');
$form->addExtraClass('cms-tabset'); $form->addExtraClass('cms-tabset');
} }
$form->Backlink = $this->getBackLink(); $form->Backlink = $this->getBackLink();
} }
$cb = $this->component->getItemEditFormCallback(); $cb = $this->component->getItemEditFormCallback();
if ($cb) { if ($cb) {
$cb($form, $this); $cb($form, $this);
} }
$this->extend("updateItemEditForm", $form); $this->extend("updateItemEditForm", $form);
return $form; return $form;
} }
/** /**
* Build the set of form field actions for this DataObject * Build the set of form field actions for this DataObject
* *
* @return FieldList * @return FieldList
*/ */
protected function getFormActions() protected function getFormActions()
{ {
$canEdit = $this->record->canEdit(); $canEdit = $this->record->canEdit();
$canDelete = $this->record->canDelete(); $canDelete = $this->record->canDelete();
$actions = new FieldList(); $actions = new FieldList();
if ($this->record->ID !== 0) { if ($this->record->ID !== 0) {
if ($canEdit) { if ($canEdit) {
$actions->push(FormAction::create('doSave', _t('GridFieldDetailForm.Save', 'Save')) $actions->push(FormAction::create('doSave', _t('GridFieldDetailForm.Save', 'Save'))
->setUseButtonTag(true) ->setUseButtonTag(true)
->addExtraClass('ss-ui-action-constructive') ->addExtraClass('ss-ui-action-constructive')
->setAttribute('data-icon', 'accept')); ->setAttribute('data-icon', 'accept'));
} }
if ($canDelete) { if ($canDelete) {
$actions->push(FormAction::create('doDelete', _t('GridFieldDetailForm.Delete', 'Delete')) $actions->push(FormAction::create('doDelete', _t('GridFieldDetailForm.Delete', 'Delete'))
->setUseButtonTag(true) ->setUseButtonTag(true)
->addExtraClass('ss-ui-action-destructive action-delete')); ->addExtraClass('ss-ui-action-destructive action-delete'));
} }
} else { // adding new record } else { // adding new record
//Change the Save label to 'Create' //Change the Save label to 'Create'
$actions->push(FormAction::create('doSave', _t('GridFieldDetailForm.Create', 'Create')) $actions->push(FormAction::create('doSave', _t('GridFieldDetailForm.Create', 'Create'))
->setUseButtonTag(true) ->setUseButtonTag(true)
->addExtraClass('ss-ui-action-constructive') ->addExtraClass('ss-ui-action-constructive')
->setAttribute('data-icon', 'add')); ->setAttribute('data-icon', 'add'));
// Add a Cancel link which is a button-like link and link back to one level up. // Add a Cancel link which is a button-like link and link back to one level up.
$crumbs = $this->Breadcrumbs(); $crumbs = $this->Breadcrumbs();
if ($crumbs && $crumbs->count() >= 2) { if ($crumbs && $crumbs->count() >= 2) {
$oneLevelUp = $crumbs->offsetGet($crumbs->count() - 2); $oneLevelUp = $crumbs->offsetGet($crumbs->count() - 2);
$text = sprintf( $text = sprintf(
"<a class=\"%s\" href=\"%s\">%s</a>", "<a class=\"%s\" href=\"%s\">%s</a>",
"crumb ss-ui-button ss-ui-action-destructive cms-panel-link ui-corner-all", // CSS classes "crumb ss-ui-button ss-ui-action-destructive cms-panel-link ui-corner-all", // CSS classes
$oneLevelUp->Link, // url $oneLevelUp->Link, // url
_t('GridFieldDetailForm.CancelBtn', 'Cancel') // label _t('GridFieldDetailForm.CancelBtn', 'Cancel') // label
); );
$actions->push(new LiteralField('cancelbutton', $text)); $actions->push(new LiteralField('cancelbutton', $text));
} }
} }
$this->extend('updateFormActions', $actions); $this->extend('updateFormActions', $actions);
return $actions; return $actions;
} }
/** /**
* Traverse the nested RequestHandlers until we reach something that's not GridFieldDetailForm_ItemRequest. * Traverse the nested RequestHandlers until we reach something that's not GridFieldDetailForm_ItemRequest.
* This allows us to access the Controller responsible for invoking the top-level GridField. * This allows us to access the Controller responsible for invoking the top-level GridField.
* This should be equivalent to getting the controller off the top of the controller stack via Controller::curr(), * This should be equivalent to getting the controller off the top of the controller stack via Controller::curr(),
* but allows us to avoid accessing the global state. * but allows us to avoid accessing the global state.
* *
* GridFieldDetailForm_ItemRequests are RequestHandlers, and as such they are not part of the controller stack. * GridFieldDetailForm_ItemRequests are RequestHandlers, and as such they are not part of the controller stack.
* *
* @return Controller * @return Controller
*/ */
protected function getToplevelController() protected function getToplevelController()
{ {
$c = $this->popupController; $c = $this->popupController;
while ($c && $c instanceof GridFieldDetailForm_ItemRequest) { while ($c && $c instanceof GridFieldDetailForm_ItemRequest) {
$c = $c->getController(); $c = $c->getController();
} }
return $c; return $c;
} }
protected function getBackLink() protected function getBackLink()
{ {
// TODO Coupling with CMS // TODO Coupling with CMS
$backlink = ''; $backlink = '';
$toplevelController = $this->getToplevelController(); $toplevelController = $this->getToplevelController();
if ($toplevelController && $toplevelController instanceof LeftAndMain) { if ($toplevelController && $toplevelController instanceof LeftAndMain) {
if ($toplevelController->hasMethod('Backlink')) { if ($toplevelController->hasMethod('Backlink')) {
$backlink = $toplevelController->Backlink(); $backlink = $toplevelController->Backlink();
} elseif ($this->popupController->hasMethod('Breadcrumbs')) { } elseif ($this->popupController->hasMethod('Breadcrumbs')) {
$parents = $this->popupController->Breadcrumbs(false)->items; $parents = $this->popupController->Breadcrumbs(false)->items;
$backlink = array_pop($parents)->Link; $backlink = array_pop($parents)->Link;
} }
} }
if (!$backlink) { if (!$backlink) {
$backlink = $toplevelController->Link(); $backlink = $toplevelController->Link();
} }
return $backlink; return $backlink;
} }
/** /**
* Get the list of extra data from the $record as saved into it by * Get the list of extra data from the $record as saved into it by
* {@see Form::saveInto()} * {@see Form::saveInto()}
* *
* Handles detection of falsey values explicitly saved into the * Handles detection of falsey values explicitly saved into the
* DataObject by formfields * DataObject by formfields
* *
* @param DataObject $record * @param DataObject $record
* @param SS_List $list * @param SS_List $list
* @return array List of data to write to the relation * @return array List of data to write to the relation
*/ */
protected function getExtraSavedData($record, $list) protected function getExtraSavedData($record, $list)
{ {
// Skip extra data if not ManyManyList // Skip extra data if not ManyManyList
if (!($list instanceof ManyManyList)) { if (!($list instanceof ManyManyList)) {
return null; return null;
} }
$data = array(); $data = array();
foreach ($list->getExtraFields() as $field => $dbSpec) { foreach ($list->getExtraFields() as $field => $dbSpec) {
$savedField = "ManyMany[{$field}]"; $savedField = "ManyMany[{$field}]";
if ($record->hasField($savedField)) { if ($record->hasField($savedField)) {
$data[$field] = $record->getField($savedField); $data[$field] = $record->getField($savedField);
} }
} }
return $data; return $data;
} }
public function doSave($data, $form) public function doSave($data, $form)
{ {
$isNewRecord = $this->record->ID == 0; $isNewRecord = $this->record->ID == 0;
// Check permission // Check permission
if (!$this->record->canEdit()) { if (!$this->record->canEdit()) {
return $this->httpError(403); return $this->httpError(403);
} }
// Save from form data // Save from form data
try { try {
$this->saveFormIntoRecord($data, $form); $this->saveFormIntoRecord($data, $form);
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->generateValidationResponse($form, $e); return $this->generateValidationResponse($form, $e);
} }
$link = '<a href="' . $this->Link('edit') . '">"' $link = '<a href="' . $this->Link('edit') . '">"'
. htmlspecialchars($this->record->Title, ENT_QUOTES) . htmlspecialchars($this->record->Title, ENT_QUOTES)
. '"</a>'; . '"</a>';
$message = _t( $message = _t(
'GridFieldDetailForm.Saved', 'GridFieldDetailForm.Saved',
'Saved {name} {link}', 'Saved {name} {link}',
array( array(
'name' => $this->record->i18n_singular_name(), 'name' => $this->record->i18n_singular_name(),
'link' => $link 'link' => $link
) )
); );
$form->sessionMessage($message, 'good', false); $form->sessionMessage($message, 'good', false);
// Redirect after save // Redirect after save
return $this->redirectAfterSave($isNewRecord); return $this->redirectAfterSave($isNewRecord);
} }
/** /**
* Response object for this request after a successful save * Response object for this request after a successful save
* *
* @param bool $isNewRecord True if this record was just created * @param bool $isNewRecord True if this record was just created
* @return HTTPResponse|DBHTMLText * @return HTTPResponse|DBHTMLText
*/ */
protected function redirectAfterSave($isNewRecord) protected function redirectAfterSave($isNewRecord)
{ {
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
if ($isNewRecord) { if ($isNewRecord) {
return $controller->redirect($this->Link()); return $controller->redirect($this->Link());
} elseif ($this->gridField->getList()->byID($this->record->ID)) { } elseif ($this->gridField->getList()->byID($this->record->ID)) {
// Return new view, as we can't do a "virtual redirect" via the CMS Ajax // Return new view, as we can't do a "virtual redirect" via the CMS Ajax
// to the same URL (it assumes that its content is already current, and doesn't reload) // to the same URL (it assumes that its content is already current, and doesn't reload)
return $this->edit($controller->getRequest()); return $this->edit($controller->getRequest());
} else { } else {
// Changes to the record properties might've excluded the record from // Changes to the record properties might've excluded the record from
// a filtered list, so return back to the main view if it can't be found // a filtered list, so return back to the main view if it can't be found
$url = $controller->getRequest()->getURL(); $url = $controller->getRequest()->getURL();
$noActionURL = $controller->removeAction($url); $noActionURL = $controller->removeAction($url);
$controller->getRequest()->addHeader('X-Pjax', 'Content'); $controller->getRequest()->addHeader('X-Pjax', 'Content');
return $controller->redirect($noActionURL, 302); return $controller->redirect($noActionURL, 302);
} }
} }
public function httpError($errorCode, $errorMessage = null) public function httpError($errorCode, $errorMessage = null)
{ {
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
return $controller->httpError($errorCode, $errorMessage); return $controller->httpError($errorCode, $errorMessage);
} }
/** /**
* Loads the given form data into the underlying dataobject and relation * Loads the given form data into the underlying dataobject and relation
* *
* @param array $data * @param array $data
* @param Form $form * @param Form $form
* @throws ValidationException On error * @throws ValidationException On error
* @return DataObject Saved record * @return DataObject Saved record
*/ */
protected function saveFormIntoRecord($data, $form) protected function saveFormIntoRecord($data, $form)
{ {
$list = $this->gridField->getList(); $list = $this->gridField->getList();
// Check object matches the correct classname // Check object matches the correct classname
if (isset($data['ClassName']) && $data['ClassName'] != $this->record->ClassName) { if (isset($data['ClassName']) && $data['ClassName'] != $this->record->ClassName) {
$newClassName = $data['ClassName']; $newClassName = $data['ClassName'];
// The records originally saved attribute was overwritten by $form->saveInto($record) before. // The records originally saved attribute was overwritten by $form->saveInto($record) before.
// This is necessary for newClassInstance() to work as expected, and trigger change detection // This is necessary for newClassInstance() to work as expected, and trigger change detection
// on the ClassName attribute // on the ClassName attribute
$this->record->setClassName($this->record->ClassName); $this->record->setClassName($this->record->ClassName);
// Replace $record with a new instance // Replace $record with a new instance
$this->record = $this->record->newClassInstance($newClassName); $this->record = $this->record->newClassInstance($newClassName);
} }
// Save form and any extra saved data into this dataobject // Save form and any extra saved data into this dataobject
$form->saveInto($this->record); $form->saveInto($this->record);
$this->record->write(); $this->record->write();
$extraData = $this->getExtraSavedData($this->record, $list); $extraData = $this->getExtraSavedData($this->record, $list);
$list->add($this->record, $extraData); $list->add($this->record, $extraData);
return $this->record; return $this->record;
} }
/** /**
* Generate a response object for a form validation error * Generate a response object for a form validation error
* *
* @param Form $form The source form * @param Form $form The source form
* @param ValidationException $e The validation error message * @param ValidationException $e The validation error message
* @return HTTPResponse * @return HTTPResponse
* @throws HTTPResponse_Exception * @throws HTTPResponse_Exception
*/ */
protected function generateValidationResponse($form, $e) protected function generateValidationResponse($form, $e)
{ {
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
$form->sessionMessage($e->getResult()->message(), 'bad', false); $form->sessionMessage($e->getResult()->message(), 'bad', false);
$responseNegotiator = new PjaxResponseNegotiator(array( $responseNegotiator = new PjaxResponseNegotiator(array(
'CurrentForm' => function () use (&$form) { 'CurrentForm' => function () use (&$form) {
return $form->forTemplate(); return $form->forTemplate();
}, },
'default' => function () use (&$controller) { 'default' => function () use (&$controller) {
return $controller->redirectBack(); return $controller->redirectBack();
} }
)); ));
if ($controller->getRequest()->isAjax()) { if ($controller->getRequest()->isAjax()) {
$controller->getRequest()->addHeader('X-Pjax', 'CurrentForm'); $controller->getRequest()->addHeader('X-Pjax', 'CurrentForm');
} }
return $responseNegotiator->respond($controller->getRequest()); return $responseNegotiator->respond($controller->getRequest());
} }
/** /**
* @param array $data * @param array $data
* @param Form $form * @param Form $form
* @return HTTPResponse * @return HTTPResponse
*/ */
public function doDelete($data, $form) public function doDelete($data, $form)
{ {
$title = $this->record->Title; $title = $this->record->Title;
try { try {
if (!$this->record->canDelete()) { if (!$this->record->canDelete()) {
throw new ValidationException( throw new ValidationException(
_t('GridFieldDetailForm.DeletePermissionsFailure', "No delete permissions"), _t('GridFieldDetailForm.DeletePermissionsFailure',"No delete permissions"));
0 }
);
}
$this->record->delete(); $this->record->delete();
} catch (ValidationException $e) { } catch (ValidationException $e) {
$form->sessionMessage($e->getResult()->message(), 'bad', false); $form->sessionMessage($e->getResult()->message(), 'bad', false);
return $this->getToplevelController()->redirectBack(); return $this->getToplevelController()->redirectBack();
} }
$message = sprintf( $message = sprintf(
_t('GridFieldDetailForm.Deleted', 'Deleted %s %s'), _t('GridFieldDetailForm.Deleted', 'Deleted %s %s'),
$this->record->i18n_singular_name(), $this->record->i18n_singular_name(),
htmlspecialchars($title, ENT_QUOTES) htmlspecialchars($title, ENT_QUOTES)
); );
$toplevelController = $this->getToplevelController(); $toplevelController = $this->getToplevelController();
if ($toplevelController && $toplevelController instanceof LeftAndMain) { if ($toplevelController && $toplevelController instanceof LeftAndMain) {
$backForm = $toplevelController->getEditForm(); $backForm = $toplevelController->getEditForm();
$backForm->sessionMessage($message, 'good', false); $backForm->sessionMessage($message, 'good', false);
} else { } else {
$form->sessionMessage($message, 'good', false); $form->sessionMessage($message, 'good', false);
} }
//when an item is deleted, redirect to the parent controller //when an item is deleted, redirect to the parent controller
$controller = $this->getToplevelController(); $controller = $this->getToplevelController();
$controller->getRequest()->addHeader('X-Pjax', 'Content'); // Force a content refresh $controller->getRequest()->addHeader('X-Pjax', 'Content'); // Force a content refresh
return $controller->redirect($this->getBackLink(), 302); //redirect back to admin section return $controller->redirect($this->getBackLink(), 302); //redirect back to admin section
} }
/** /**
* @param string $template * @param string $template
* @return $this * @return $this
*/ */
public function setTemplate($template) public function setTemplate($template)
{ {
$this->template = $template; $this->template = $template;
return $this; return $this;
} }
/** /**
* @return string * @return string
*/ */
public function getTemplate() public function getTemplate()
{ {
return $this->template; return $this->template;
} }
/** /**
* Get list of templates to use * Get list of templates to use
* *
* @return array * @return array
*/ */
public function getTemplates() public function getTemplates()
{ {
$templates = SSViewer::get_templates_by_class($this, '', __CLASS__); $templates = SSViewer::get_templates_by_class($this, '', __CLASS__);
// Prefer any custom template // Prefer any custom template
if ($this->getTemplate()) { if($this->getTemplate()) {
array_unshift($templates, $this->getTemplate()); array_unshift($templates, $this->getTemplate());
} }
return $templates; return $templates;
} }
/** /**
* @return Controller * @return Controller
*/ */
public function getController() public function getController()
{ {
return $this->popupController; return $this->popupController;
} }
/** /**
* @return GridField * @return GridField
*/ */
public function getGridField() public function getGridField()
{ {
return $this->gridField; return $this->gridField;
} }
/** /**
* @return DataObject * @return DataObject
*/ */
public function getRecord() public function getRecord()
{ {
return $this->record; return $this->record;
} }
/** /**
* CMS-specific functionality: Passes through navigation breadcrumbs * CMS-specific functionality: Passes through navigation breadcrumbs
* to the template, and includes the currently edited record (if any). * to the template, and includes the currently edited record (if any).
* see {@link LeftAndMain->Breadcrumbs()} for details. * see {@link LeftAndMain->Breadcrumbs()} for details.
* *
* @param boolean $unlinked * @param boolean $unlinked
* @return ArrayList * @return ArrayList
*/ */
public function Breadcrumbs($unlinked = false) public function Breadcrumbs($unlinked = false)
{ {
if (!$this->popupController->hasMethod('Breadcrumbs')) { if (!$this->popupController->hasMethod('Breadcrumbs')) {
return null; return null;
} }
/** @var ArrayList $items */ /** @var ArrayList $items */
$items = $this->popupController->Breadcrumbs($unlinked); $items = $this->popupController->Breadcrumbs($unlinked);
if ($this->record && $this->record->ID) { if ($this->record && $this->record->ID) {
$title = ($this->record->Title) ? $this->record->Title : "#{$this->record->ID}"; $title = ($this->record->Title) ? $this->record->Title : "#{$this->record->ID}";
$items->push(new ArrayData(array( $items->push(new ArrayData(array(
'Title' => $title, 'Title' => $title,
'Link' => $this->Link() 'Link' => $this->Link()
))); )));
} else { } else {
$items->push(new ArrayData(array( $items->push(new ArrayData(array(
'Title' => sprintf(_t('GridField.NewRecord', 'New %s'), $this->record->i18n_singular_name()), 'Title' => sprintf(_t('GridField.NewRecord', 'New %s'), $this->record->i18n_singular_name()),
'Link' => false 'Link' => false
))); )));
} }
return $items; return $items;
} }
} }

View File

@ -25,198 +25,199 @@ use Exception;
class Hierarchy extends DataExtension class Hierarchy extends DataExtension
{ {
protected $markedNodes; protected $markedNodes;
protected $markingFilter; protected $markingFilter;
/** @var int */ /** @var int */
protected $_cache_numChildren; protected $_cache_numChildren;
/** /**
* The lower bounds for the amount of nodes to mark. If set, the logic will expand nodes until it reaches at least * The lower bounds for the amount of nodes to mark. If set, the logic will expand nodes until it reaches at least
* this number, and then stops. Root nodes will always show regardless of this settting. Further nodes can be * this number, and then stops. Root nodes will always show regardless of this settting. Further nodes can be
* lazy-loaded via ajax. This isn't a hard limit. Example: On a value of 10, with 20 root nodes, each having 30 * lazy-loaded via ajax. This isn't a hard limit. Example: On a value of 10, with 20 root nodes, each having 30
* children, the actual node count will be 50 (all root nodes plus first expanded child). * children, the actual node count will be 50 (all root nodes plus first expanded child).
* *
* @config * @config
* @var int * @var int
*/ */
private static $node_threshold_total = 50; private static $node_threshold_total = 50;
/** /**
* Limit on the maximum children a specific node can display. Serves as a hard limit to avoid exceeding available * Limit on the maximum children a specific node can display. Serves as a hard limit to avoid exceeding available
* server resources in generating the tree, and browser resources in rendering it. Nodes with children exceeding * server resources in generating the tree, and browser resources in rendering it. Nodes with children exceeding
* this value typically won't display any children, although this is configurable through the $nodeCountCallback * this value typically won't display any children, although this is configurable through the $nodeCountCallback
* parameter in {@link getChildrenAsUL()}. "Root" nodes will always show all children, regardless of this setting. * parameter in {@link getChildrenAsUL()}. "Root" nodes will always show all children, regardless of this setting.
* *
* @config * @config
* @var int * @var int
*/ */
private static $node_threshold_leaf = 250; private static $node_threshold_leaf = 250;
/** /**
* A list of classnames to exclude from display in both the CMS and front end * A list of classnames to exclude from display in both the CMS and front end
* displays. ->Children() and ->AllChildren affected. * displays. ->Children() and ->AllChildren affected.
* Especially useful for big sets of pages like listings * Especially useful for big sets of pages like listings
* If you use this, and still need the classes to be editable * If you use this, and still need the classes to be editable
* then add a model admin for the class * then add a model admin for the class
* Note: Does not filter subclasses (non-inheriting) * Note: Does not filter subclasses (non-inheriting)
* *
* @var array * @var array
* @config * @config
*/ */
private static $hide_from_hierarchy = array(); private static $hide_from_hierarchy = array();
/** /**
* A list of classnames to exclude from display in the page tree views of the CMS, * A list of classnames to exclude from display in the page tree views of the CMS,
* unlike $hide_from_hierarchy above which effects both CMS and front end. * unlike $hide_from_hierarchy above which effects both CMS and front end.
* Especially useful for big sets of pages like listings * Especially useful for big sets of pages like listings
* If you use this, and still need the classes to be editable * If you use this, and still need the classes to be editable
* then add a model admin for the class * then add a model admin for the class
* Note: Does not filter subclasses (non-inheriting) * Note: Does not filter subclasses (non-inheriting)
* *
* @var array * @var array
* @config * @config
*/ */
private static $hide_from_cms_tree = array(); private static $hide_from_cms_tree = array();
public static function get_extra_config($class, $extension, $args) public static function get_extra_config($class, $extension, $args)
{ {
return array( return array(
'has_one' => array('Parent' => $class) 'has_one' => array('Parent' => $class)
); );
} }
/** /**
* Validate the owner object - check for existence of infinite loops. * Validate the owner object - check for existence of infinite loops.
* *
* @param ValidationResult $validationResult * @param ValidationResult $validationResult
*/ */
public function validate(ValidationResult $validationResult) public function validate(ValidationResult $validationResult)
{ {
// The object is new, won't be looping. // The object is new, won't be looping.
if (!$this->owner->ID) { if (!$this->owner->ID) {
return; return;
} }
// The object has no parent, won't be looping. // The object has no parent, won't be looping.
if (!$this->owner->ParentID) { if (!$this->owner->ParentID) {
return; return;
} }
// The parent has not changed, skip the check for performance reasons. // The parent has not changed, skip the check for performance reasons.
if (!$this->owner->isChanged('ParentID')) { if (!$this->owner->isChanged('ParentID')) {
return; return;
} }
// Walk the hierarchy upwards until we reach the top, or until we reach the originating node again. // Walk the hierarchy upwards until we reach the top, or until we reach the originating node again.
$node = $this->owner; $node = $this->owner;
while ($node) { while($node) {
if ($node->ParentID==$this->owner->ID) { if ($node->ParentID==$this->owner->ID) {
// Hierarchy is looping. // Hierarchy is looping.
$validationResult->error( $validationResult->addError(
_t( _t(
'Hierarchy.InfiniteLoopNotAllowed', 'Hierarchy.InfiniteLoopNotAllowed',
'Infinite loop found within the "{type}" hierarchy. Please change the parent to resolve this', 'Infinite loop found within the "{type}" hierarchy. Please change the parent to resolve this',
'First argument is the class that makes up the hierarchy.', 'First argument is the class that makes up the hierarchy.',
array('type' => $this->owner->class) array('type' => $this->owner->class)
), ),
'INFINITE_LOOP' 'bad',
); 'INFINITE_LOOP'
break; );
} break;
$node = $node->ParentID ? $node->Parent() : null; }
} $node = $node->ParentID ? $node->Parent() : null;
}
// At this point the $validationResult contains the response. // At this point the $validationResult contains the response.
} }
/** /**
* Returns the children of this DataObject as an XHTML UL. This will be called recursively on each child, so if they * Returns the children of this DataObject as an XHTML UL. This will be called recursively on each child, so if they
* have children they will be displayed as a UL inside a LI. * have children they will be displayed as a UL inside a LI.
* *
* @param string $attributes Attributes to add to the UL * @param string $attributes Attributes to add to the UL
* @param string|callable $titleEval PHP code to evaluate to start each child - this should include '<li>' * @param string|callable $titleEval PHP code to evaluate to start each child - this should include '<li>'
* @param string $extraArg Extra arguments that will be passed on to children, for if they overload this function * @param string $extraArg Extra arguments that will be passed on to children, for if they overload this function
* @param bool $limitToMarked Display only marked children * @param bool $limitToMarked Display only marked children
* @param string $childrenMethod The name of the method used to get children from each object * @param string $childrenMethod The name of the method used to get children from each object
* @param string $numChildrenMethod * @param string $numChildrenMethod
* @param bool $rootCall Set to true for this first call, and then to false for calls inside the recursion. * @param bool $rootCall Set to true for this first call, and then to false for calls inside the recursion.
* You should not change this. * You should not change this.
* @param int $nodeCountThreshold See {@link self::$node_threshold_total} * @param int $nodeCountThreshold See {@link self::$node_threshold_total}
* @param callable $nodeCountCallback Called with the node count, which gives the callback an opportunity to * @param callable $nodeCountCallback Called with the node count, which gives the callback an opportunity to
* intercept the query. Useful e.g. to avoid excessive children listings (Arguments: $parent, $numChildren) * intercept the query. Useful e.g. to avoid excessive children listings (Arguments: $parent, $numChildren)
* @return string * @return string
*/ */
public function getChildrenAsUL( public function getChildrenAsUL(
$attributes = "", $attributes = "",
$titleEval = '"<li>" . $child->Title', $titleEval = '"<li>" . $child->Title',
$extraArg = null, $extraArg = null,
$limitToMarked = false, $limitToMarked = false,
$childrenMethod = "AllChildrenIncludingDeleted", $childrenMethod = "AllChildrenIncludingDeleted",
$numChildrenMethod = "numChildren", $numChildrenMethod = "numChildren",
$rootCall = true, $rootCall = true,
$nodeCountThreshold = null, $nodeCountThreshold = null,
$nodeCountCallback = null $nodeCountCallback = null
) { ) {
if (!is_numeric($nodeCountThreshold)) { if(!is_numeric($nodeCountThreshold)) {
$nodeCountThreshold = Config::inst()->get(__CLASS__, 'node_threshold_total'); $nodeCountThreshold = Config::inst()->get(__CLASS__, 'node_threshold_total');
} }
if ($limitToMarked && $rootCall) { if($limitToMarked && $rootCall) {
$this->markingFinished($numChildrenMethod); $this->markingFinished($numChildrenMethod);
} }
if ($nodeCountCallback) { if($nodeCountCallback) {
$nodeCountWarning = $nodeCountCallback($this->owner, $this->owner->$numChildrenMethod()); $nodeCountWarning = $nodeCountCallback($this->owner, $this->owner->$numChildrenMethod());
if ($nodeCountWarning) { if ($nodeCountWarning) {
return $nodeCountWarning; return $nodeCountWarning;
} }
} }
if ($this->owner->hasMethod($childrenMethod)) { if($this->owner->hasMethod($childrenMethod)) {
$children = $this->owner->$childrenMethod($extraArg); $children = $this->owner->$childrenMethod($extraArg);
} else { } else {
$children = null; $children = null;
user_error(sprintf( user_error(sprintf(
"Can't find the method '%s' on class '%s' for getting tree children", "Can't find the method '%s' on class '%s' for getting tree children",
$childrenMethod, $childrenMethod,
get_class($this->owner) get_class($this->owner)
), E_USER_ERROR); ), E_USER_ERROR);
} }
$output = null; $output = null;
if ($children) { if($children) {
if ($attributes) { if($attributes) {
$attributes = " $attributes"; $attributes = " $attributes";
} }
$output = "<ul$attributes>\n"; $output = "<ul$attributes>\n";
foreach ($children as $child) { foreach($children as $child) {
if (!$limitToMarked || $child->isMarked()) { if(!$limitToMarked || $child->isMarked()) {
$foundAChild = true; $foundAChild = true;
if (is_callable($titleEval)) { if(is_callable($titleEval)) {
$output .= $titleEval($child, $numChildrenMethod); $output .= $titleEval($child, $numChildrenMethod);
} else { } else {
$output .= eval("return $titleEval;"); $output .= eval("return $titleEval;");
} }
$output .= "\n"; $output .= "\n";
$numChildren = $child->$numChildrenMethod(); $numChildren = $child->$numChildrenMethod();
if (// Always traverse into opened nodes (they might be exposed as parents of search results) if (// Always traverse into opened nodes (they might be exposed as parents of search results)
$child->isExpanded() $child->isExpanded()
// Only traverse into children if we haven't reached the maximum node count already. // Only traverse into children if we haven't reached the maximum node count already.
// Otherwise, the remaining nodes are lazy loaded via ajax. // Otherwise, the remaining nodes are lazy loaded via ajax.
&& $child->isMarked() && $child->isMarked()
) { ) {
// Additionally check if node count requirements are met // Additionally check if node count requirements are met
$nodeCountWarning = $nodeCountCallback ? $nodeCountCallback($child, $numChildren) : null; $nodeCountWarning = $nodeCountCallback ? $nodeCountCallback($child, $numChildren) : null;
if ($nodeCountWarning) { if($nodeCountWarning) {
$output .= $nodeCountWarning; $output .= $nodeCountWarning;
$child->markClosed(); $child->markClosed();
} else { } else {
$output .= $child->getChildrenAsUL( $output .= $child->getChildrenAsUL(
"", "",
$titleEval, $titleEval,
@ -227,770 +228,770 @@ class Hierarchy extends DataExtension
false, false,
$nodeCountThreshold $nodeCountThreshold
); );
} }
} elseif ($child->isTreeOpened()) { } elseif($child->isTreeOpened()) {
// Since we're not loading children, don't mark it as open either // Since we're not loading children, don't mark it as open either
$child->markClosed(); $child->markClosed();
} }
$output .= "</li>\n"; $output .= "</li>\n";
} }
} }
$output .= "</ul>\n"; $output .= "</ul>\n";
} }
if (isset($foundAChild) && $foundAChild) { if(isset($foundAChild) && $foundAChild) {
return $output; return $output;
} }
return null; return null;
} }
/** /**
* Mark a segment of the tree, by calling mark(). * Mark a segment of the tree, by calling mark().
* *
* The method performs a breadth-first traversal until the number of nodes is more than minCount. This is used to * The method performs a breadth-first traversal until the number of nodes is more than minCount. This is used to
* get a limited number of tree nodes to show in the CMS initially. * get a limited number of tree nodes to show in the CMS initially.
* *
* This method returns the number of nodes marked. After this method is called other methods can check * This method returns the number of nodes marked. After this method is called other methods can check
* {@link isExpanded()} and {@link isMarked()} on individual nodes. * {@link isExpanded()} and {@link isMarked()} on individual nodes.
* *
* @param int $nodeCountThreshold See {@link getChildrenAsUL()} * @param int $nodeCountThreshold See {@link getChildrenAsUL()}
* @param mixed $context * @param mixed $context
* @param string $childrenMethod * @param string $childrenMethod
* @param string $numChildrenMethod * @param string $numChildrenMethod
* @return int The actual number of nodes marked. * @return int The actual number of nodes marked.
*/ */
public function markPartialTree( public function markPartialTree(
$nodeCountThreshold = 30, $nodeCountThreshold = 30,
$context = null, $context = null,
$childrenMethod = "AllChildrenIncludingDeleted", $childrenMethod = "AllChildrenIncludingDeleted",
$numChildrenMethod = "numChildren" $numChildrenMethod = "numChildren"
) { ) {
if (!is_numeric($nodeCountThreshold)) { if (!is_numeric($nodeCountThreshold)) {
$nodeCountThreshold = 30; $nodeCountThreshold = 30;
} }
$this->markedNodes = array($this->owner->ID => $this->owner); $this->markedNodes = array($this->owner->ID => $this->owner);
$this->owner->markUnexpanded(); $this->owner->markUnexpanded();
// foreach can't handle an ever-growing $nodes list // foreach can't handle an ever-growing $nodes list
while (list($id, $node) = each($this->markedNodes)) { while(list($id, $node) = each($this->markedNodes)) {
$children = $this->markChildren($node, $context, $childrenMethod, $numChildrenMethod); $children = $this->markChildren($node, $context, $childrenMethod, $numChildrenMethod);
if ($nodeCountThreshold && sizeof($this->markedNodes) > $nodeCountThreshold) { if($nodeCountThreshold && sizeof($this->markedNodes) > $nodeCountThreshold) {
// Undo marking children as opened since they're lazy loaded // Undo marking children as opened since they're lazy loaded
if ($children) { if ($children) {
foreach ($children as $child) { foreach ($children as $child) {
$child->markClosed(); $child->markClosed();
} }
} }
break; break;
} }
} }
return sizeof($this->markedNodes); return sizeof($this->markedNodes);
} }
/** /**
* Filter the marking to only those object with $node->$parameterName == $parameterValue * Filter the marking to only those object with $node->$parameterName == $parameterValue
* *
* @param string $parameterName The parameter on each node to check when marking. * @param string $parameterName The parameter on each node to check when marking.
* @param mixed $parameterValue The value the parameter must be to be marked. * @param mixed $parameterValue The value the parameter must be to be marked.
*/ */
public function setMarkingFilter($parameterName, $parameterValue) public function setMarkingFilter($parameterName, $parameterValue)
{ {
$this->markingFilter = array( $this->markingFilter = array(
"parameter" => $parameterName, "parameter" => $parameterName,
"value" => $parameterValue "value" => $parameterValue
); );
} }
/** /**
* Filter the marking to only those where the function returns true. The node in question will be passed to the * Filter the marking to only those where the function returns true. The node in question will be passed to the
* function. * function.
* *
* @param string $funcName The name of the function to call * @param string $funcName The name of the function to call
*/ */
public function setMarkingFilterFunction($funcName) public function setMarkingFilterFunction($funcName)
{ {
$this->markingFilter = array( $this->markingFilter = array(
"func" => $funcName, "func" => $funcName,
); );
} }
/** /**
* Returns true if the marking filter matches on the given node. * Returns true if the marking filter matches on the given node.
* *
* @param DataObject $node Node to check * @param DataObject $node Node to check
* @return bool * @return bool
*/ */
public function markingFilterMatches($node) public function markingFilterMatches($node)
{ {
if (!$this->markingFilter) { if(!$this->markingFilter) {
return true; return true;
} }
if (isset($this->markingFilter['parameter']) && $parameterName = $this->markingFilter['parameter']) { if(isset($this->markingFilter['parameter']) && $parameterName = $this->markingFilter['parameter']) {
if (is_array($this->markingFilter['value'])) { if(is_array($this->markingFilter['value'])){
$ret = false; $ret = false;
foreach ($this->markingFilter['value'] as $value) { foreach($this->markingFilter['value'] as $value) {
$ret = $ret||$node->$parameterName==$value; $ret = $ret||$node->$parameterName==$value;
if ($ret == true) { if($ret == true) {
break; break;
} }
} }
return $ret; return $ret;
} else { } else {
return ($node->$parameterName == $this->markingFilter['value']); return ($node->$parameterName == $this->markingFilter['value']);
} }
} elseif ($func = $this->markingFilter['func']) { } else if ($func = $this->markingFilter['func']) {
return call_user_func($func, $node); return call_user_func($func, $node);
} }
} }
/** /**
* Mark all children of the given node that match the marking filter. * Mark all children of the given node that match the marking filter.
* *
* @param DataObject $node Parent node * @param DataObject $node Parent node
* @param mixed $context * @param mixed $context
* @param string $childrenMethod The name of the instance method to call to get the object's list of children * @param string $childrenMethod The name of the instance method to call to get the object's list of children
* @param string $numChildrenMethod The name of the instance method to call to count the object's children * @param string $numChildrenMethod The name of the instance method to call to count the object's children
* @return DataList * @return DataList
*/ */
public function markChildren( public function markChildren(
$node, $node,
$context = null, $context = null,
$childrenMethod = "AllChildrenIncludingDeleted", $childrenMethod = "AllChildrenIncludingDeleted",
$numChildrenMethod = "numChildren" $numChildrenMethod = "numChildren"
) { ) {
if ($node->hasMethod($childrenMethod)) { if($node->hasMethod($childrenMethod)) {
$children = $node->$childrenMethod($context); $children = $node->$childrenMethod($context);
} else { } else {
$children = null; $children = null;
user_error(sprintf( user_error(sprintf(
"Can't find the method '%s' on class '%s' for getting tree children", "Can't find the method '%s' on class '%s' for getting tree children",
$childrenMethod, $childrenMethod,
get_class($node) get_class($node)
), E_USER_ERROR); ), E_USER_ERROR);
} }
$node->markExpanded(); $node->markExpanded();
if ($children) { if($children) {
foreach ($children as $child) { foreach($children as $child) {
$markingMatches = $this->markingFilterMatches($child); $markingMatches = $this->markingFilterMatches($child);
if ($markingMatches) { if($markingMatches) {
// Mark a child node as unexpanded if it has children and has not already been expanded // Mark a child node as unexpanded if it has children and has not already been expanded
if ($child->$numChildrenMethod() && !$child->isExpanded()) { if($child->$numChildrenMethod() && !$child->isExpanded()) {
$child->markUnexpanded(); $child->markUnexpanded();
} else { } else {
$child->markExpanded(); $child->markExpanded();
} }
$this->markedNodes[$child->ID] = $child; $this->markedNodes[$child->ID] = $child;
} }
} }
} }
return $children; return $children;
} }
/** /**
* Ensure marked nodes that have children are also marked expanded. Call this after marking but before iterating * Ensure marked nodes that have children are also marked expanded. Call this after marking but before iterating
* over the tree. * over the tree.
* *
* @param string $numChildrenMethod The name of the instance method to call to count the object's children * @param string $numChildrenMethod The name of the instance method to call to count the object's children
*/ */
protected function markingFinished($numChildrenMethod = "numChildren") protected function markingFinished($numChildrenMethod = "numChildren")
{ {
// Mark childless nodes as expanded. // Mark childless nodes as expanded.
if ($this->markedNodes) { if($this->markedNodes) {
foreach ($this->markedNodes as $id => $node) { foreach($this->markedNodes as $id => $node) {
if (!$node->isExpanded() && !$node->$numChildrenMethod()) { if(!$node->isExpanded() && !$node->$numChildrenMethod()) {
$node->markExpanded(); $node->markExpanded();
} }
} }
} }
} }
/** /**
* Return CSS classes of 'unexpanded', 'closed', both, or neither, as well as a 'jstree-*' state depending on the * Return CSS classes of 'unexpanded', 'closed', both, or neither, as well as a 'jstree-*' state depending on the
* marking of this DataObject. * marking of this DataObject.
* *
* @param string $numChildrenMethod The name of the instance method to call to count the object's children * @param string $numChildrenMethod The name of the instance method to call to count the object's children
* @return string * @return string
*/ */
public function markingClasses($numChildrenMethod = "numChildren") public function markingClasses($numChildrenMethod = "numChildren")
{ {
$classes = ''; $classes = '';
if (!$this->isExpanded()) { if(!$this->isExpanded()) {
$classes .= " unexpanded"; $classes .= " unexpanded";
} }
// Set jstree open state, or mark it as a leaf (closed) if there are no children // Set jstree open state, or mark it as a leaf (closed) if there are no children
if (!$this->owner->$numChildrenMethod()) { if(!$this->owner->$numChildrenMethod()) {
$classes .= " jstree-leaf closed"; $classes .= " jstree-leaf closed";
} elseif ($this->isTreeOpened()) { } elseif($this->isTreeOpened()) {
$classes .= " jstree-open"; $classes .= " jstree-open";
} else { } else {
$classes .= " jstree-closed closed"; $classes .= " jstree-closed closed";
} }
return $classes; return $classes;
} }
/** /**
* Mark the children of the DataObject with the given ID. * Mark the children of the DataObject with the given ID.
* *
* @param int $id ID of parent node * @param int $id ID of parent node
* @param bool $open If this is true, mark the parent node as opened * @param bool $open If this is true, mark the parent node as opened
* @return bool * @return bool
*/ */
public function markById($id, $open = false) public function markById($id, $open = false)
{ {
if (isset($this->markedNodes[$id])) { if(isset($this->markedNodes[$id])) {
$this->markChildren($this->markedNodes[$id]); $this->markChildren($this->markedNodes[$id]);
if ($open) { if($open) {
$this->markedNodes[$id]->markOpened(); $this->markedNodes[$id]->markOpened();
} }
return true; return true;
} else { } else {
return false; return false;
} }
} }
/** /**
* Expose the given object in the tree, by marking this page and all it ancestors. * Expose the given object in the tree, by marking this page and all it ancestors.
* *
* @param DataObject $childObj * @param DataObject $childObj
*/ */
public function markToExpose($childObj) public function markToExpose($childObj)
{ {
if (is_object($childObj)) { if(is_object($childObj)){
$stack = array_reverse($childObj->parentStack()); $stack = array_reverse($childObj->parentStack());
foreach ($stack as $stackItem) { foreach($stack as $stackItem) {
$this->markById($stackItem->ID, true); $this->markById($stackItem->ID, true);
} }
} }
} }
/** /**
* Return the IDs of all the marked nodes. * Return the IDs of all the marked nodes.
* *
* @return array * @return array
*/ */
public function markedNodeIDs() public function markedNodeIDs()
{ {
return array_keys($this->markedNodes); return array_keys($this->markedNodes);
} }
/** /**
* Return an array of this page and its ancestors, ordered item -> root. * Return an array of this page and its ancestors, ordered item -> root.
* *
* @return SiteTree[] * @return SiteTree[]
*/ */
public function parentStack() public function parentStack()
{ {
$p = $this->owner; $p = $this->owner;
while ($p) { while($p) {
$stack[] = $p; $stack[] = $p;
$p = $p->ParentID ? $p->Parent() : null; $p = $p->ParentID ? $p->Parent() : null;
} }
return $stack; return $stack;
} }
/** /**
* Cache of DataObjects' marked statuses: [ClassName][ID] = bool * Cache of DataObjects' marked statuses: [ClassName][ID] = bool
* @var array * @var array
*/ */
protected static $marked = array(); protected static $marked = array();
/** /**
* Cache of DataObjects' expanded statuses: [ClassName][ID] = bool * Cache of DataObjects' expanded statuses: [ClassName][ID] = bool
* @var array * @var array
*/ */
protected static $expanded = array(); protected static $expanded = array();
/** /**
* Cache of DataObjects' opened statuses: [ClassName][ID] = bool * Cache of DataObjects' opened statuses: [ClassName][ID] = bool
* @var array * @var array
*/ */
protected static $treeOpened = array(); protected static $treeOpened = array();
/** /**
* Mark this DataObject as expanded. * Mark this DataObject as expanded.
*/ */
public function markExpanded() public function markExpanded()
{ {
self::$marked[$this->owner->baseClass()][$this->owner->ID] = true; self::$marked[$this->owner->baseClass()][$this->owner->ID] = true;
self::$expanded[$this->owner->baseClass()][$this->owner->ID] = true; self::$expanded[$this->owner->baseClass()][$this->owner->ID] = true;
} }
/** /**
* Mark this DataObject as unexpanded. * Mark this DataObject as unexpanded.
*/ */
public function markUnexpanded() public function markUnexpanded()
{ {
self::$marked[$this->owner->baseClass()][$this->owner->ID] = true; self::$marked[$this->owner->baseClass()][$this->owner->ID] = true;
self::$expanded[$this->owner->baseClass()][$this->owner->ID] = false; self::$expanded[$this->owner->baseClass()][$this->owner->ID] = false;
} }
/** /**
* Mark this DataObject's tree as opened. * Mark this DataObject's tree as opened.
*/ */
public function markOpened() public function markOpened()
{ {
self::$marked[$this->owner->baseClass()][$this->owner->ID] = true; self::$marked[$this->owner->baseClass()][$this->owner->ID] = true;
self::$treeOpened[$this->owner->baseClass()][$this->owner->ID] = true; self::$treeOpened[$this->owner->baseClass()][$this->owner->ID] = true;
} }
/** /**
* Mark this DataObject's tree as closed. * Mark this DataObject's tree as closed.
*/ */
public function markClosed() public function markClosed()
{ {
if (isset(self::$treeOpened[$this->owner->baseClass()][$this->owner->ID])) { if(isset(self::$treeOpened[$this->owner->baseClass()][$this->owner->ID])) {
unset(self::$treeOpened[$this->owner->baseClass()][$this->owner->ID]); unset(self::$treeOpened[$this->owner->baseClass()][$this->owner->ID]);
} }
} }
/** /**
* Check if this DataObject is marked. * Check if this DataObject is marked.
* *
* @return bool * @return bool
*/ */
public function isMarked() public function isMarked()
{ {
$baseClass = $this->owner->baseClass(); $baseClass = $this->owner->baseClass();
$id = $this->owner->ID; $id = $this->owner->ID;
return isset(self::$marked[$baseClass][$id]) ? self::$marked[$baseClass][$id] : false; return isset(self::$marked[$baseClass][$id]) ? self::$marked[$baseClass][$id] : false;
} }
/** /**
* Check if this DataObject is expanded. * Check if this DataObject is expanded.
* *
* @return bool * @return bool
*/ */
public function isExpanded() public function isExpanded()
{ {
$baseClass = $this->owner->baseClass(); $baseClass = $this->owner->baseClass();
$id = $this->owner->ID; $id = $this->owner->ID;
return isset(self::$expanded[$baseClass][$id]) ? self::$expanded[$baseClass][$id] : false; return isset(self::$expanded[$baseClass][$id]) ? self::$expanded[$baseClass][$id] : false;
} }
/** /**
* Check if this DataObject's tree is opened. * Check if this DataObject's tree is opened.
* *
* @return bool * @return bool
*/ */
public function isTreeOpened() public function isTreeOpened()
{ {
$baseClass = $this->owner->baseClass(); $baseClass = $this->owner->baseClass();
$id = $this->owner->ID; $id = $this->owner->ID;
return isset(self::$treeOpened[$baseClass][$id]) ? self::$treeOpened[$baseClass][$id] : false; return isset(self::$treeOpened[$baseClass][$id]) ? self::$treeOpened[$baseClass][$id] : false;
} }
/** /**
* Get a list of this DataObject's and all it's descendants IDs. * Get a list of this DataObject's and all it's descendants IDs.
* *
* @return int[] * @return int[]
*/ */
public function getDescendantIDList() public function getDescendantIDList()
{ {
$idList = array(); $idList = array();
$this->loadDescendantIDListInto($idList); $this->loadDescendantIDListInto($idList);
return $idList; return $idList;
} }
/** /**
* Get a list of this DataObject's and all it's descendants ID, and put them in $idList. * Get a list of this DataObject's and all it's descendants ID, and put them in $idList.
* *
* @param array $idList Array to put results in. * @param array $idList Array to put results in.
*/ */
public function loadDescendantIDListInto(&$idList) public function loadDescendantIDListInto(&$idList)
{ {
if ($children = $this->AllChildren()) { if($children = $this->AllChildren()) {
foreach ($children as $child) { foreach($children as $child) {
if (in_array($child->ID, $idList)) { if(in_array($child->ID, $idList)) {
continue; continue;
} }
$idList[] = $child->ID; $idList[] = $child->ID;
/** @var Hierarchy $ext */ /** @var Hierarchy $ext */
$ext = $child->getExtensionInstance('SilverStripe\ORM\Hierarchy\Hierarchy'); $ext = $child->getExtensionInstance('SilverStripe\ORM\Hierarchy\Hierarchy');
$ext->setOwner($child); $ext->setOwner($child);
$ext->loadDescendantIDListInto($idList); $ext->loadDescendantIDListInto($idList);
$ext->clearOwner(); $ext->clearOwner();
} }
} }
} }
/** /**
* Get the children for this DataObject. * Get the children for this DataObject.
* *
* @return DataList * @return DataList
*/ */
public function Children() public function Children()
{ {
if (!(isset($this->_cache_children) && $this->_cache_children)) { if(!(isset($this->_cache_children) && $this->_cache_children)) {
$result = $this->owner->stageChildren(false); $result = $this->owner->stageChildren(false);
$children = array(); $children = array();
foreach ($result as $record) { foreach ($result as $record) {
if ($record->canView()) { if ($record->canView()) {
$children[] = $record; $children[] = $record;
} }
} }
$this->_cache_children = new ArrayList($children); $this->_cache_children = new ArrayList($children);
} }
return $this->_cache_children; return $this->_cache_children;
} }
/** /**
* Return all children, including those 'not in menus'. * Return all children, including those 'not in menus'.
* *
* @return DataList * @return DataList
*/ */
public function AllChildren() public function AllChildren()
{ {
return $this->owner->stageChildren(true); return $this->owner->stageChildren(true);
} }
/** /**
* Return all children, including those that have been deleted but are still in live. * Return all children, including those that have been deleted but are still in live.
* - Deleted children will be marked as "DeletedFromStage" * - Deleted children will be marked as "DeletedFromStage"
* - Added children will be marked as "AddedToStage" * - Added children will be marked as "AddedToStage"
* - Modified children will be marked as "ModifiedOnStage" * - Modified children will be marked as "ModifiedOnStage"
* - Everything else has "SameOnStage" set, as an indicator that this information has been looked up. * - Everything else has "SameOnStage" set, as an indicator that this information has been looked up.
* *
* @param mixed $context * @param mixed $context
* @return ArrayList * @return ArrayList
*/ */
public function AllChildrenIncludingDeleted($context = null) public function AllChildrenIncludingDeleted($context = null)
{ {
return $this->doAllChildrenIncludingDeleted($context); return $this->doAllChildrenIncludingDeleted($context);
} }
/** /**
* @see AllChildrenIncludingDeleted * @see AllChildrenIncludingDeleted
* *
* @param mixed $context * @param mixed $context
* @return ArrayList * @return ArrayList
*/ */
public function doAllChildrenIncludingDeleted($context = null) public function doAllChildrenIncludingDeleted($context = null)
{ {
if (!$this->owner) { if (!$this->owner) {
user_error('Hierarchy::doAllChildrenIncludingDeleted() called without $this->owner'); user_error('Hierarchy::doAllChildrenIncludingDeleted() called without $this->owner');
} }
$baseClass = $this->owner->baseClass(); $baseClass = $this->owner->baseClass();
if ($baseClass) { if($baseClass) {
$stageChildren = $this->owner->stageChildren(true); $stageChildren = $this->owner->stageChildren(true);
// Add live site content that doesn't exist on the stage site, if required. // Add live site content that doesn't exist on the stage site, if required.
if ($this->owner->hasExtension('SilverStripe\ORM\Versioning\Versioned')) { if($this->owner->hasExtension('SilverStripe\ORM\Versioning\Versioned')) {
// Next, go through the live children. Only some of these will be listed // Next, go through the live children. Only some of these will be listed
$liveChildren = $this->owner->liveChildren(true, true); $liveChildren = $this->owner->liveChildren(true, true);
if ($liveChildren) { if($liveChildren) {
$merged = new ArrayList(); $merged = new ArrayList();
$merged->merge($stageChildren); $merged->merge($stageChildren);
$merged->merge($liveChildren); $merged->merge($liveChildren);
$stageChildren = $merged; $stageChildren = $merged;
} }
} }
$this->owner->extend("augmentAllChildrenIncludingDeleted", $stageChildren, $context); $this->owner->extend("augmentAllChildrenIncludingDeleted", $stageChildren, $context);
} else { } else {
user_error( user_error(
"Hierarchy::AllChildren() Couldn't determine base class for '{$this->owner->class}'", "Hierarchy::AllChildren() Couldn't determine base class for '{$this->owner->class}'",
E_USER_ERROR E_USER_ERROR
); );
} }
return $stageChildren; return $stageChildren;
} }
/** /**
* Return all the children that this page had, including pages that were deleted from both stage & live. * Return all the children that this page had, including pages that were deleted from both stage & live.
* *
* @return DataList * @return DataList
* @throws Exception * @throws Exception
*/ */
public function AllHistoricalChildren() public function AllHistoricalChildren()
{ {
if (!$this->owner->hasExtension('SilverStripe\ORM\Versioning\Versioned')) { if(!$this->owner->hasExtension('SilverStripe\ORM\Versioning\Versioned')) {
throw new Exception('Hierarchy->AllHistoricalChildren() only works with Versioned extension applied'); throw new Exception('Hierarchy->AllHistoricalChildren() only works with Versioned extension applied');
} }
$baseTable = $this->owner->baseTable(); $baseTable = $this->owner->baseTable();
$parentIDColumn = $this->owner->getSchema()->sqlColumnForField($this->owner, 'ParentID'); $parentIDColumn = $this->owner->getSchema()->sqlColumnForField($this->owner, 'ParentID');
return Versioned::get_including_deleted( return Versioned::get_including_deleted(
$this->owner->baseClass(), $this->owner->baseClass(),
[ $parentIDColumn => $this->owner->ID ], [ $parentIDColumn => $this->owner->ID ],
"\"{$baseTable}\".\"ID\" ASC" "\"{$baseTable}\".\"ID\" ASC"
); );
} }
/** /**
* Return the number of children that this page ever had, including pages that were deleted. * Return the number of children that this page ever had, including pages that were deleted.
* *
* @return int * @return int
* @throws Exception * @throws Exception
*/ */
public function numHistoricalChildren() public function numHistoricalChildren()
{ {
if (!$this->owner->hasExtension('SilverStripe\ORM\Versioning\Versioned')) { if(!$this->owner->hasExtension('SilverStripe\ORM\Versioning\Versioned')) {
throw new Exception('Hierarchy->AllHistoricalChildren() only works with Versioned extension applied'); throw new Exception('Hierarchy->AllHistoricalChildren() only works with Versioned extension applied');
} }
return $this->AllHistoricalChildren()->count(); return $this->AllHistoricalChildren()->count();
} }
/** /**
* Return the number of direct children. By default, values are cached after the first invocation. Can be * Return the number of direct children. By default, values are cached after the first invocation. Can be
* augumented by {@link augmentNumChildrenCountQuery()}. * augumented by {@link augmentNumChildrenCountQuery()}.
* *
* @param bool $cache Whether to retrieve values from cache * @param bool $cache Whether to retrieve values from cache
* @return int * @return int
*/ */
public function numChildren($cache = true) public function numChildren($cache = true)
{ {
// Build the cache for this class if it doesn't exist. // Build the cache for this class if it doesn't exist.
if (!$cache || !is_numeric($this->_cache_numChildren)) { if(!$cache || !is_numeric($this->_cache_numChildren)) {
// Hey, this is efficient now! // Hey, this is efficient now!
// We call stageChildren(), because Children() has canView() filtering // We call stageChildren(), because Children() has canView() filtering
$this->_cache_numChildren = (int)$this->owner->stageChildren(true)->Count(); $this->_cache_numChildren = (int)$this->owner->stageChildren(true)->Count();
} }
// If theres no value in the cache, it just means that it doesn't have any children. // If theres no value in the cache, it just means that it doesn't have any children.
return $this->_cache_numChildren; return $this->_cache_numChildren;
} }
/** /**
* Checks if we're on a controller where we should filter. ie. Are we loading the SiteTree? * Checks if we're on a controller where we should filter. ie. Are we loading the SiteTree?
* *
* @return bool * @return bool
*/ */
public function showingCMSTree() public function showingCMSTree()
{ {
if (!Controller::has_curr()) { if (!Controller::has_curr()) {
return false; return false;
} }
$controller = Controller::curr(); $controller = Controller::curr();
return $controller instanceof LeftAndMain return $controller instanceof LeftAndMain
&& in_array($controller->getAction(), array("treeview", "listview", "getsubtree")); && in_array($controller->getAction(), array("treeview", "listview", "getsubtree"));
} }
/** /**
* Return children in the stage site. * Return children in the stage site.
* *
* @param bool $showAll Include all of the elements, even those not shown in the menus. Only applicable when * @param bool $showAll Include all of the elements, even those not shown in the menus. Only applicable when
* extension is applied to {@link SiteTree}. * extension is applied to {@link SiteTree}.
* @return DataList * @return DataList
*/ */
public function stageChildren($showAll = false) public function stageChildren($showAll = false)
{ {
$baseClass = $this->owner->baseClass(); $baseClass = $this->owner->baseClass();
$hide_from_hierarchy = $this->owner->config()->hide_from_hierarchy; $hide_from_hierarchy = $this->owner->config()->hide_from_hierarchy;
$hide_from_cms_tree = $this->owner->config()->hide_from_cms_tree; $hide_from_cms_tree = $this->owner->config()->hide_from_cms_tree;
$staged = $baseClass::get() $staged = $baseClass::get()
->filter('ParentID', (int)$this->owner->ID) ->filter('ParentID', (int)$this->owner->ID)
->exclude('ID', (int)$this->owner->ID); ->exclude('ID', (int)$this->owner->ID);
if ($hide_from_hierarchy) { if ($hide_from_hierarchy) {
$staged = $staged->exclude('ClassName', $hide_from_hierarchy); $staged = $staged->exclude('ClassName', $hide_from_hierarchy);
} }
if ($hide_from_cms_tree && $this->showingCMSTree()) { if ($hide_from_cms_tree && $this->showingCMSTree()) {
$staged = $staged->exclude('ClassName', $hide_from_cms_tree); $staged = $staged->exclude('ClassName', $hide_from_cms_tree);
} }
if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) { if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) {
$staged = $staged->filter('ShowInMenus', 1); $staged = $staged->filter('ShowInMenus', 1);
} }
$this->owner->extend("augmentStageChildren", $staged, $showAll); $this->owner->extend("augmentStageChildren", $staged, $showAll);
return $staged; return $staged;
} }
/** /**
* Return children in the live site, if it exists. * Return children in the live site, if it exists.
* *
* @param bool $showAll Include all of the elements, even those not shown in the menus. Only * @param bool $showAll Include all of the elements, even those not shown in the menus. Only
* applicable when extension is applied to {@link SiteTree}. * applicable when extension is applied to {@link SiteTree}.
* @param bool $onlyDeletedFromStage Only return items that have been deleted from stage * @param bool $onlyDeletedFromStage Only return items that have been deleted from stage
* @return DataList * @return DataList
* @throws Exception * @throws Exception
*/ */
public function liveChildren($showAll = false, $onlyDeletedFromStage = false) public function liveChildren($showAll = false, $onlyDeletedFromStage = false)
{ {
if (!$this->owner->hasExtension(Versioned::class)) { if(!$this->owner->hasExtension(Versioned::class)) {
throw new Exception('Hierarchy->liveChildren() only works with Versioned extension applied'); throw new Exception('Hierarchy->liveChildren() only works with Versioned extension applied');
} }
$baseClass = $this->owner->baseClass(); $baseClass = $this->owner->baseClass();
$hide_from_hierarchy = $this->owner->config()->hide_from_hierarchy; $hide_from_hierarchy = $this->owner->config()->hide_from_hierarchy;
$hide_from_cms_tree = $this->owner->config()->hide_from_cms_tree; $hide_from_cms_tree = $this->owner->config()->hide_from_cms_tree;
$children = $baseClass::get() $children = $baseClass::get()
->filter('ParentID', (int)$this->owner->ID) ->filter('ParentID', (int)$this->owner->ID)
->exclude('ID', (int)$this->owner->ID) ->exclude('ID', (int)$this->owner->ID)
->setDataQueryParam(array( ->setDataQueryParam(array(
'Versioned.mode' => $onlyDeletedFromStage ? 'stage_unique' : 'stage', 'Versioned.mode' => $onlyDeletedFromStage ? 'stage_unique' : 'stage',
'Versioned.stage' => 'Live' 'Versioned.stage' => 'Live'
)); ));
if ($hide_from_hierarchy) { if ($hide_from_hierarchy) {
$children = $children->exclude('ClassName', $hide_from_hierarchy); $children = $children->exclude('ClassName', $hide_from_hierarchy);
} }
if ($hide_from_cms_tree && $this->showingCMSTree()) { if ($hide_from_cms_tree && $this->showingCMSTree()) {
$children = $children->exclude('ClassName', $hide_from_cms_tree); $children = $children->exclude('ClassName', $hide_from_cms_tree);
} }
if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) { if(!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) {
$children = $children->filter('ShowInMenus', 1); $children = $children->filter('ShowInMenus', 1);
} }
return $children; return $children;
} }
/** /**
* Get this object's parent, optionally filtered by an SQL clause. If the clause doesn't match the parent, nothing * Get this object's parent, optionally filtered by an SQL clause. If the clause doesn't match the parent, nothing
* is returned. * is returned.
* *
* @param string $filter * @param string $filter
* @return DataObject * @return DataObject
*/ */
public function getParent($filter = null) public function getParent($filter = null)
{ {
$parentID = $this->owner->ParentID; $parentID = $this->owner->ParentID;
if (empty($parentID)) { if(empty($parentID)) {
return null; return null;
} }
$idSQL = $this->owner->getSchema()->sqlColumnForField($this->owner, 'ID'); $idSQL = $this->owner->getSchema()->sqlColumnForField($this->owner, 'ID');
return DataObject::get_one($this->owner->class, array( return DataObject::get_one($this->owner->class, array(
array($idSQL => $parentID), array($idSQL => $parentID),
$filter $filter
)); ));
} }
/** /**
* Return all the parents of this class in a set ordered from the lowest to highest parent. * Return all the parents of this class in a set ordered from the lowest to highest parent.
* *
* @return ArrayList * @return ArrayList
*/ */
public function getAncestors() public function getAncestors()
{ {
$ancestors = new ArrayList(); $ancestors = new ArrayList();
$object = $this->owner; $object = $this->owner;
while ($object = $object->getParent()) { while($object = $object->getParent()) {
$ancestors->push($object); $ancestors->push($object);
} }
return $ancestors; return $ancestors;
} }
/** /**
* Returns a human-readable, flattened representation of the path to the object, using its {@link Title} attribute. * Returns a human-readable, flattened representation of the path to the object, using its {@link Title} attribute.
* *
* @param string $separator * @param string $separator
* @return string * @return string
*/ */
public function getBreadcrumbs($separator = ' &raquo; ') public function getBreadcrumbs($separator = ' &raquo; ')
{ {
$crumbs = array(); $crumbs = array();
$ancestors = array_reverse($this->owner->getAncestors()->toArray()); $ancestors = array_reverse($this->owner->getAncestors()->toArray());
foreach ($ancestors as $ancestor) { foreach ($ancestors as $ancestor) {
$crumbs[] = $ancestor->Title; $crumbs[] = $ancestor->Title;
} }
$crumbs[] = $this->owner->Title; $crumbs[] = $this->owner->Title;
return implode($separator, $crumbs); return implode($separator, $crumbs);
} }
/** /**
* Get the next node in the tree of the type. If there is no instance of the className descended from this node, * Get the next node in the tree of the type. If there is no instance of the className descended from this node,
* then search the parents. * then search the parents.
* *
* @todo Write! * @todo Write!
* *
* @param string $className Class name of the node to find * @param string $className Class name of the node to find
* @param DataObject $afterNode Used for recursive calls to this function * @param DataObject $afterNode Used for recursive calls to this function
* @return DataObject * @return DataObject
*/ */
public function naturalPrev($className, $afterNode = null) public function naturalPrev($className, $afterNode = null)
{ {
return null; return null;
} }
/** /**
* Get the next node in the tree of the type. If there is no instance of the className descended from this node, * Get the next node in the tree of the type. If there is no instance of the className descended from this node,
* then search the parents. * then search the parents.
* @param string $className Class name of the node to find. * @param string $className Class name of the node to find.
* @param string|int $root ID/ClassName of the node to limit the search to * @param string|int $root ID/ClassName of the node to limit the search to
* @param DataObject $afterNode Used for recursive calls to this function * @param DataObject $afterNode Used for recursive calls to this function
* @return DataObject * @return DataObject
*/ */
public function naturalNext($className = null, $root = 0, $afterNode = null) public function naturalNext($className = null, $root = 0, $afterNode = null)
{ {
// If this node is not the node we are searching from, then we can possibly return this node as a solution // If this node is not the node we are searching from, then we can possibly return this node as a solution
if ($afterNode && $afterNode->ID != $this->owner->ID) { if($afterNode && $afterNode->ID != $this->owner->ID) {
if (!$className || ($className && $this->owner->class == $className)) { if(!$className || ($className && $this->owner->class == $className)) {
return $this->owner; return $this->owner;
} }
} }
$nextNode = null; $nextNode = null;
$baseClass = $this->owner->baseClass(); $baseClass = $this->owner->baseClass();
$children = $baseClass::get() $children = $baseClass::get()
->filter('ParentID', (int)$this->owner->ID) ->filter('ParentID', (int)$this->owner->ID)
->sort('"Sort"', 'ASC'); ->sort('"Sort"', 'ASC');
if ($afterNode) { if ($afterNode) {
$children = $children->filter('Sort:GreaterThan', $afterNode->Sort); $children = $children->filter('Sort:GreaterThan', $afterNode->Sort);
} }
// Try all the siblings of this node after the given node // Try all the siblings of this node after the given node
/*if( $siblings = DataObject::get( $this->owner->baseClass(), /*if( $siblings = DataObject::get( $this->owner->baseClass(),
"\"ParentID\"={$this->owner->ParentID}" . ( $afterNode ) ? "\"Sort\" "\"ParentID\"={$this->owner->ParentID}" . ( $afterNode ) ? "\"Sort\"
> {$afterNode->Sort}" : "" , '\"Sort\" ASC' ) ) $searchNodes->merge( $siblings );*/ > {$afterNode->Sort}" : "" , '\"Sort\" ASC' ) ) $searchNodes->merge( $siblings );*/
if ($children) { if($children) {
foreach ($children as $node) { foreach($children as $node) {
if ($nextNode = $node->naturalNext($className, $node->ID, $this->owner)) { if($nextNode = $node->naturalNext($className, $node->ID, $this->owner)) {
break; break;
} }
} }
if ($nextNode) { if($nextNode) {
return $nextNode; return $nextNode;
} }
} }
// if this is not an instance of the root class or has the root id, search the parent // if this is not an instance of the root class or has the root id, search the parent
if (!(is_numeric($root) && $root == $this->owner->ID || $root == $this->owner->class) if(!(is_numeric($root) && $root == $this->owner->ID || $root == $this->owner->class)
&& ($parent = $this->owner->Parent())) { && ($parent = $this->owner->Parent())) {
return $parent->naturalNext($className, $root, $this->owner); return $parent->naturalNext( $className, $root, $this->owner );
} }
return null; return null;
} }
/** /**
* Flush all Hierarchy caches: * Flush all Hierarchy caches:
* - Children (instance) * - Children (instance)
* - NumChildren (instance) * - NumChildren (instance)
* - Marked (global) * - Marked (global)
* - Expanded (global) * - Expanded (global)
* - TreeOpened (global) * - TreeOpened (global)
*/ */
public function flushCache() public function flushCache()
{ {
$this->_cache_children = null; $this->_cache_children = null;
$this->_cache_numChildren = null; $this->_cache_numChildren = null;
self::$marked = array(); self::$marked = array();
self::$expanded = array(); self::$expanded = array();
self::$treeOpened = array(); self::$treeOpened = array();
} }
/** /**
* Reset global Hierarchy caches: * Reset global Hierarchy caches:
* - Marked * - Marked
* - Expanded * - Expanded
* - TreeOpened * - TreeOpened
*/ */
public static function reset() public static function reset()
{ {
self::$marked = array(); self::$marked = array();
self::$expanded = array(); self::$expanded = array();
self::$treeOpened = array(); self::$treeOpened = array();
} }
} }

View File

@ -12,52 +12,72 @@ use Exception;
class ValidationException extends Exception class ValidationException extends Exception
{ {
/** /**
* The contained ValidationResult related to this error * The contained ValidationResult related to this error
* *
* @var ValidationResult * @var ValidationResult
*/ */
protected $result; protected $result;
/** /**
* Construct a new ValidationException with an optional ValidationResult object * Construct a new ValidationException with an optional ValidationResult object
* *
* @param ValidationResult|string $result The ValidationResult containing the * @param ValidationResult|string $result The ValidationResult containing the
* failed result. Can be substituted with an error message instead if no * failed result. Can be substituted with an error message instead if no
* ValidationResult exists. * ValidationResult exists.
* @param string|integer $message The error message. If $result was given the * @param string|integer $message The error message. If $result was given the
* message string rather than a ValidationResult object then this will have * message string rather than a ValidationResult object then this will have
* the error code number. * the error code number.
* @param integer $code The error code number, if not given in the second parameter * @param integer $code The error code number, if not given in the second parameter
*/ */
public function __construct($result = null, $message = null, $code = 0) public function __construct($result = null, $code = 0, $dummy = null) {
{ $exceptionMessage = null;
// Check arguments // Backwards compatibiliy failover. The 2nd argument used to be $message, and $code the 3rd.
if (!($result instanceof ValidationResult)) { // For callers using that, we ditch the message
// Shift parameters if no ValidationResult is given if(!is_numeric($code)) {
$code = $message; $exceptionMessage = $code;
$message = $result; if($dummy) $code = $dummy;
}
// Infer ValidationResult from parameters if($result instanceof ValidationResult) {
$result = new ValidationResult(false, $message); $this->result = $result;
} elseif (empty($message)) {
// Infer message if not given
$message = $result->message();
}
// Construct } else if(is_string($result)) {
$this->result = $result; $this->result = ValidationResult::create()->addError($result);
parent::__construct($message, $code);
}
/** } else if(!$result) {
* Retrieves the ValidationResult related to this error $this->result = ValidationResult::create()->addError(_t("ValdiationExcetpion.DEFAULT_ERROR", "Validation error"));
*
* @return ValidationResult } else {
*/ throw new InvalidArgumentException(
"ValidationExceptions must be passed a ValdiationResult, a string, or nothing at all");
}
// Construct
parent::__construct($exceptionMessage ? $exceptionMessage : $this->result->message(), $code);
}
/**
* Create a ValidationException with a message for a single field-specific error message.
*
* @param string $field The field name
* @param string $message The error message
* @return ValidationException
*/
static function create_for_field($field, $message) {
$result = new ValidationResult;
$result->addFieldError($field, $message);
return new ValidationException($result);
}
/**
* Retrieves the ValidationResult related to this error
*
* @return ValidationResult
*/
public function getResult() public function getResult()
{ {
return $this->result; return $this->result;
} }
} }

View File

@ -10,121 +10,251 @@ use SilverStripe\Core\Object;
*/ */
class ValidationResult extends Object class ValidationResult extends Object
{ {
/** /**
* @var bool - is the result valid or not * @var bool - is the result valid or not
*/ */
protected $isValid; protected $isValid = true;
/** /**
* @var array of errors * @var array of errors
*/ */
protected $errorList = array(); protected $errorList = array();
/** /**
* Create a new ValidationResult. * Create a new ValidationResult.
* By default, it is a successful result. Call $this->error() to record errors. * By default, it is a successful result. Call $this->error() to record errors.
* @param bool $valid *
* @param string|null $message * @param void $valid @deprecated
*/ * @param void $message @deprecated
public function __construct($valid = true, $message = null) */
{ public function __construct($valid = null, $message = null) {
$this->isValid = $valid; if ($message !== null) {
Deprecation::notice('3.2', '$message parameter is deprecated please use addMessage or addError instead', false);
$this->addError($message);
}
if ($valid !== null) {
Deprecation::notice('3.2', '$valid parameter is deprecated please addError to mark the result as invalid', false);
$this->isValid = $valid;
if ($message) { if ($message) {
$this->errorList[] = $message; $this->errorList[] = $message;
} }
parent::__construct(); parent::__construct();
} }
/** /**
* Record an error against this validation result, * Return the full error meta-data, suitable for combining with another ValidationResult.
* @param string $message The validation error message */
* @param string $code An optional error code string, that can be accessed with {@link $this->codeList()}. function getErrorMetaData() {
* @return $this return $this->errorList;
*/ }
public function error($message, $code = null)
{
$this->isValid = false;
if ($code) { /**
if (!is_numeric($code)) { * Record a
$this->errorList[$code] = $message; * against this validation result.
} else { *
user_error("ValidationResult::error() - Don't use a numeric code '$code'. Use a string." * It's better to use addError, addFeildError, addMessage, or addFieldMessage instead.
. "I'm going to ignore it.", E_USER_WARNING); *
$this->errorList[$code] = $message; * @param string $message The message string.
} * @param string $code A codename for this error. Only one message per codename will be added.
} else { * This can be usedful for ensuring no duplicate messages
$this->errorList[] = $message; * @param string $fieldName The field to link the message to. If omitted; a form-wide message is assumed.
} * @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*
* @deprecated 3.2
*/
public function error($message, $code = null, $fieldName = null, $messageType = "bad", $escapeHtml = true) {
Deprecation::notice('3.2', 'Use addError or addFieldError instead.');
return $this; return $this->addFieldError($fieldName, $message, $messageType, $code, $escapeHtml);
} }
/** /**
* Returns true if the result is valid. * Record an error against this validation result,
* @return boolean *
*/ * @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addError($message, $messageType = "bad", $code = null, $escapeHtml = true) {
return $this->addFieldError(null, $message, $messageType, $code, $escapeHtml);
}
/**
* Record an error against this validation result,
*
* @param string $fieldName The field to link the message to. If omitted; a form-wide message is assumed.
* @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addFieldError($fieldName = null, $message, $messageType = "bad", $code = null, $escapeHtml = true) {
$this->isValid = false;
return $this->addFieldMessage($fieldName, $message, $messageType, $code, $escapeHtml);
}
/**
* Add a message to this ValidationResult without necessarily marking it as an error
*
* @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addMessage($message, $messageType = "bad", $code = null, $escapeHtml = true) {
return $this->addFieldMessage(null, $message, $messageType, $code, $escapeHtml);
}
/**
* Add a message to this ValidationResult without necessarily marking it as an error
*
* @param string $fieldName The field to link the message to. If omitted; a form-wide message is assumed.
* @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addFieldMessage($fieldName, $message, $messageType = "bad", $code = null, $escapeHtml = true) {
$metadata = array(
'message' => $escapeHtml ? Convert::raw2xml($message) : $message,
'fieldName' => $fieldName,
'messageType' => $messageType,
);
if($code) {
if(!is_numeric($code)) {
$this->errorList[$code] = $metadata;
} else {
throw new InvalidArgumentException(
"ValidationResult::error() - Don't use a numeric code '$code'. Use a string.");
}
} else {
$this->errorList[] = $metadata;
}
return $this;
}
/**
* Returns true if the result is valid.
* @return boolean
*/
public function valid() public function valid()
{ {
return $this->isValid; return $this->isValid;
} }
/** /**
* Get an array of errors * Get an array of errors
* @return array * @return array
*/ */
public function messageList() public function messageList()
{ {
return $this->errorList; $list = array();
} foreach($this->errorList as $key => $item) {
if(is_numeric($key)) $list[] = $item['message'];
else $list[$key] = $item['message'];
}
return $list;
}
/** /**
* Get an array of error codes * Get the field-specific messages as a map.
* @return array * Keys will be field names, and values will be a 2 element map with keys 'messsage', and 'messageType'
*/ */
public function fieldErrors() {
$output = array();
foreach($this->errorList as $key => $item) {
if($item['fieldName']) {
$output[$item['fieldName']] = array(
'message' => $item['message'],
'messageType' => $item['messageType']
);
}
}
return $output;
}
/**
* Get an array of error codes
* @return array
*/
public function codeList() public function codeList()
{ {
$codeList = array(); $codeList = array();
foreach ($this->errorList as $k => $v) { foreach ($this->errorList as $k => $v) {
if (!is_numeric($k)) { if (!is_numeric($k)) {
$codeList[] = $k; $codeList[] = $k;
} }
} }
return $codeList; return $codeList;
} }
/** /**
* Get the error message as a string. * Get the error message as a string.
* @return string * @return string
*/ */
public function message() public function message()
{ {
return implode("; ", $this->errorList); return implode("; ", $this->messageList());
} }
/** /**
* Get a starred list of all messages * The the error message that's not related to a field as a string
* @return string */
*/ public function overallMessage() {
$messages = array();
foreach($this->errorList as $item) {
if(!$item['fieldName']) $messages[] = $item['message'];
}
return implode("; ", $messages);
}
/**
* Get a starred list of all messages
* @return string
*/
public function starredList() public function starredList()
{ {
return " * " . implode("\n * ", $this->errorList); return " * " . implode("\n * ", $this->messageList());
} }
/** /**
* Combine this Validation Result with the ValidationResult given in other. * Combine this Validation Result with the ValidationResult given in other.
* It will be valid if both this and the other result are valid. * It will be valid if both this and the other result are valid.
* This object will be modified to contain the new validation information. * This object will be modified to contain the new validation information.
* *
* @param ValidationResult $other the validation result object to combine * @param ValidationResult $other the validation result object to combine
* @return $this * @return $this
*/ */
public function combineAnd(ValidationResult $other) public function combineAnd(ValidationResult $other)
{ {
$this->isValid = $this->isValid && $other->valid(); $this->isValid = $this->isValid && $other->valid();
$this->errorList = array_merge($this->errorList, $other->messageList()); $this->errorList = array_merge($this->errorList, $other->getErrorMetaData());
return $this;
return $this; }
}
} }

View File

@ -53,436 +53,436 @@ use SilverStripe\View\Requirements;
class Group extends DataObject class Group extends DataObject
{ {
private static $db = array( private static $db = array(
"Title" => "Varchar(255)", "Title" => "Varchar(255)",
"Description" => "Text", "Description" => "Text",
"Code" => "Varchar(255)", "Code" => "Varchar(255)",
"Locked" => "Boolean", "Locked" => "Boolean",
"Sort" => "Int", "Sort" => "Int",
"HtmlEditorConfig" => "Text" "HtmlEditorConfig" => "Text"
); );
private static $has_one = array( private static $has_one = array(
"Parent" => "SilverStripe\\Security\\Group", "Parent" => "SilverStripe\\Security\\Group",
); );
private static $has_many = array( private static $has_many = array(
"Permissions" => "SilverStripe\\Security\\Permission", "Permissions" => "SilverStripe\\Security\\Permission",
"Groups" => "SilverStripe\\Security\\Group" "Groups" => "SilverStripe\\Security\\Group"
); );
private static $many_many = array( private static $many_many = array(
"Members" => "SilverStripe\\Security\\Member", "Members" => "SilverStripe\\Security\\Member",
"Roles" => "SilverStripe\\Security\\PermissionRole", "Roles" => "SilverStripe\\Security\\PermissionRole",
); );
private static $extensions = array( private static $extensions = array(
"SilverStripe\\ORM\\Hierarchy\\Hierarchy", "SilverStripe\\ORM\\Hierarchy\\Hierarchy",
); );
private static $table_name = "Group"; private static $table_name = "Group";
public function populateDefaults() public function populateDefaults()
{ {
parent::populateDefaults(); parent::populateDefaults();
if (!$this->Title) { if (!$this->Title) {
$this->Title = _t('SecurityAdmin.NEWGROUP', "New Group"); $this->Title = _t('SecurityAdmin.NEWGROUP', "New Group");
} }
} }
public function getAllChildren() public function getAllChildren()
{ {
$doSet = new ArrayList(); $doSet = new ArrayList();
$children = Group::get()->filter("ParentID", $this->ID); $children = Group::get()->filter("ParentID", $this->ID);
foreach ($children as $child) { foreach($children as $child) {
$doSet->push($child); $doSet->push($child);
$doSet->merge($child->getAllChildren()); $doSet->merge($child->getAllChildren());
} }
return $doSet; return $doSet;
} }
/** /**
* Caution: Only call on instances, not through a singleton. * Caution: Only call on instances, not through a singleton.
* The "root group" fields will be created through {@link SecurityAdmin->EditForm()}. * The "root group" fields will be created through {@link SecurityAdmin->EditForm()}.
* *
* @return FieldList * @return FieldList
*/ */
public function getCMSFields() public function getCMSFields()
{ {
$fields = new FieldList( $fields = new FieldList(
new TabSet( new TabSet(
"Root", "Root",
new Tab( new Tab(
'Members', 'Members',
_t('SecurityAdmin.MEMBERS', 'Members'), _t('SecurityAdmin.MEMBERS', 'Members'),
new TextField("Title", $this->fieldLabel('Title')), new TextField("Title", $this->fieldLabel('Title')),
$parentidfield = DropdownField::create( $parentidfield = DropdownField::create(
'ParentID', 'ParentID',
$this->fieldLabel('Parent'), $this->fieldLabel('Parent'),
Group::get()->exclude('ID', $this->ID)->map('ID', 'Breadcrumbs') Group::get()->exclude('ID', $this->ID)->map('ID', 'Breadcrumbs')
)->setEmptyString(' '), )->setEmptyString(' '),
new TextareaField('Description', $this->fieldLabel('Description')) new TextareaField('Description', $this->fieldLabel('Description'))
), ),
$permissionsTab = new Tab( $permissionsTab = new Tab(
'Permissions', 'Permissions',
_t('SecurityAdmin.PERMISSIONS', 'Permissions'), _t('SecurityAdmin.PERMISSIONS', 'Permissions'),
$permissionsField = new PermissionCheckboxSetField( $permissionsField = new PermissionCheckboxSetField(
'Permissions', 'Permissions',
false, false,
'SilverStripe\\Security\\Permission', 'SilverStripe\\Security\\Permission',
'GroupID', 'GroupID',
$this $this
) )
) )
) )
); );
$parentidfield->setDescription( $parentidfield->setDescription(
_t('Group.GroupReminder', 'If you choose a parent group, this group will take all it\'s roles') _t('Group.GroupReminder', 'If you choose a parent group, this group will take all it\'s roles')
); );
// Filter permissions // Filter permissions
// TODO SecurityAdmin coupling, not easy to get to the form fields through GridFieldDetailForm // TODO SecurityAdmin coupling, not easy to get to the form fields through GridFieldDetailForm
$permissionsField->setHiddenPermissions((array)Config::inst()->get('SilverStripe\\Admin\\SecurityAdmin', 'hidden_permissions')); $permissionsField->setHiddenPermissions((array)Config::inst()->get('SilverStripe\\Admin\\SecurityAdmin', 'hidden_permissions'));
if ($this->ID) { if($this->ID) {
$group = $this; $group = $this;
$config = GridFieldConfig_RelationEditor::create(); $config = GridFieldConfig_RelationEditor::create();
$config->addComponent(new GridFieldButtonRow('after')); $config->addComponent(new GridFieldButtonRow('after'));
$config->addComponents(new GridFieldExportButton('buttons-after-left')); $config->addComponents(new GridFieldExportButton('buttons-after-left'));
$config->addComponents(new GridFieldPrintButton('buttons-after-left')); $config->addComponents(new GridFieldPrintButton('buttons-after-left'));
/** @var GridFieldAddExistingAutocompleter $autocompleter */ /** @var GridFieldAddExistingAutocompleter $autocompleter */
$autocompleter = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldAddExistingAutocompleter'); $autocompleter = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldAddExistingAutocompleter');
/** @skipUpgrade */ /** @skipUpgrade */
$autocompleter $autocompleter
->setResultsFormat('$Title ($Email)') ->setResultsFormat('$Title ($Email)')
->setSearchFields(array('FirstName', 'Surname', 'Email')); ->setSearchFields(array('FirstName', 'Surname', 'Email'));
/** @var GridFieldDetailForm $detailForm */ /** @var GridFieldDetailForm $detailForm */
$detailForm = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldDetailForm'); $detailForm = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldDetailForm');
$detailForm $detailForm
->setValidator(Member_Validator::create()) ->setValidator(Member_Validator::create())
->setItemEditFormCallback(function ($form, $component) use ($group) { ->setItemEditFormCallback(function($form, $component) use($group) {
/** @var Form $form */ /** @var Form $form */
$record = $form->getRecord(); $record = $form->getRecord();
$groupsField = $form->Fields()->dataFieldByName('DirectGroups'); $groupsField = $form->Fields()->dataFieldByName('DirectGroups');
if ($groupsField) { if($groupsField) {
// If new records are created in a group context, // If new records are created in a group context,
// set this group by default. // set this group by default.
if ($record && !$record->ID) { if($record && !$record->ID) {
$groupsField->setValue($group->ID); $groupsField->setValue($group->ID);
} elseif ($record && $record->ID) { } elseif($record && $record->ID) {
// TODO Mark disabled once chosen.js supports it // TODO Mark disabled once chosen.js supports it
// $groupsField->setDisabledItems(array($group->ID)); // $groupsField->setDisabledItems(array($group->ID));
$form->Fields()->replaceField( $form->Fields()->replaceField(
'DirectGroups', 'DirectGroups',
$groupsField->performReadonlyTransformation() $groupsField->performReadonlyTransformation()
); );
} }
} }
}); });
$memberList = GridField::create('Members', false, $this->DirectMembers(), $config) $memberList = GridField::create('Members',false, $this->DirectMembers(), $config)
->addExtraClass('members_grid'); ->addExtraClass('members_grid');
// @todo Implement permission checking on GridField // @todo Implement permission checking on GridField
//$memberList->setPermissions(array('edit', 'delete', 'export', 'add', 'inlineadd')); //$memberList->setPermissions(array('edit', 'delete', 'export', 'add', 'inlineadd'));
$fields->addFieldToTab('Root.Members', $memberList); $fields->addFieldToTab('Root.Members', $memberList);
} }
// Only add a dropdown for HTML editor configurations if more than one is available. // Only add a dropdown for HTML editor configurations if more than one is available.
// Otherwise Member->getHtmlEditorConfigForCMS() will default to the 'cms' configuration. // Otherwise Member->getHtmlEditorConfigForCMS() will default to the 'cms' configuration.
$editorConfigMap = HTMLEditorConfig::get_available_configs_map(); $editorConfigMap = HTMLEditorConfig::get_available_configs_map();
if (count($editorConfigMap) > 1) { if(count($editorConfigMap) > 1) {
$fields->addFieldToTab( $fields->addFieldToTab(
'Root.Permissions', 'Root.Permissions',
new DropdownField( new DropdownField(
'HtmlEditorConfig', 'HtmlEditorConfig',
'HTML Editor Configuration', 'HTML Editor Configuration',
$editorConfigMap $editorConfigMap
), ),
'Permissions' 'Permissions'
); );
} }
if (!Permission::check('EDIT_PERMISSIONS')) { if(!Permission::check('EDIT_PERMISSIONS')) {
$fields->removeFieldFromTab('Root', 'Permissions'); $fields->removeFieldFromTab('Root', 'Permissions');
} }
// Only show the "Roles" tab if permissions are granted to edit them, // Only show the "Roles" tab if permissions are granted to edit them,
// and at least one role exists // and at least one role exists
if (Permission::check('APPLY_ROLES') && DataObject::get('SilverStripe\\Security\\PermissionRole')) { if(Permission::check('APPLY_ROLES') && DataObject::get('SilverStripe\\Security\\PermissionRole')) {
$fields->findOrMakeTab('Root.Roles', _t('SecurityAdmin.ROLES', 'Roles')); $fields->findOrMakeTab('Root.Roles', _t('SecurityAdmin.ROLES', 'Roles'));
$fields->addFieldToTab( $fields->addFieldToTab(
'Root.Roles', 'Root.Roles',
new LiteralField( new LiteralField(
"", "",
"<p>" . "<p>" .
_t( _t(
'SecurityAdmin.ROLESDESCRIPTION', 'SecurityAdmin.ROLESDESCRIPTION',
"Roles are predefined sets of permissions, and can be assigned to groups.<br />" "Roles are predefined sets of permissions, and can be assigned to groups.<br />"
. "They are inherited from parent groups if required." . "They are inherited from parent groups if required."
) . '<br />' . ) . '<br />' .
sprintf( sprintf(
'<a href="%s" class="add-role">%s</a>', '<a href="%s" class="add-role">%s</a>',
SecurityAdmin::singleton()->Link('show/root#Root_Roles'), SecurityAdmin::singleton()->Link('show/root#Root_Roles'),
// TODO This should include #Root_Roles to switch directly to the tab, // TODO This should include #Root_Roles to switch directly to the tab,
// but tabstrip.js doesn't display tabs when directly adressed through a URL pragma // but tabstrip.js doesn't display tabs when directly adressed through a URL pragma
_t('Group.RolesAddEditLink', 'Manage roles') _t('Group.RolesAddEditLink', 'Manage roles')
) . ) .
"</p>" "</p>"
) )
); );
// Add roles (and disable all checkboxes for inherited roles) // Add roles (and disable all checkboxes for inherited roles)
$allRoles = PermissionRole::get(); $allRoles = PermissionRole::get();
if (!Permission::check('ADMIN')) { if(!Permission::check('ADMIN')) {
$allRoles = $allRoles->filter("OnlyAdminCanApply", 0); $allRoles = $allRoles->filter("OnlyAdminCanApply", 0);
} }
if ($this->ID) { if($this->ID) {
$groupRoles = $this->Roles(); $groupRoles = $this->Roles();
$inheritedRoles = new ArrayList(); $inheritedRoles = new ArrayList();
$ancestors = $this->getAncestors(); $ancestors = $this->getAncestors();
foreach ($ancestors as $ancestor) { foreach($ancestors as $ancestor) {
$ancestorRoles = $ancestor->Roles(); $ancestorRoles = $ancestor->Roles();
if ($ancestorRoles) { if ($ancestorRoles) {
$inheritedRoles->merge($ancestorRoles); $inheritedRoles->merge($ancestorRoles);
} }
} }
$groupRoleIDs = $groupRoles->column('ID') + $inheritedRoles->column('ID'); $groupRoleIDs = $groupRoles->column('ID') + $inheritedRoles->column('ID');
$inheritedRoleIDs = $inheritedRoles->column('ID'); $inheritedRoleIDs = $inheritedRoles->column('ID');
} else { } else {
$groupRoleIDs = array(); $groupRoleIDs = array();
$inheritedRoleIDs = array(); $inheritedRoleIDs = array();
} }
$rolesField = ListboxField::create('Roles', false, $allRoles->map()->toArray()) $rolesField = ListboxField::create('Roles', false, $allRoles->map()->toArray())
->setDefaultItems($groupRoleIDs) ->setDefaultItems($groupRoleIDs)
->setAttribute('data-placeholder', _t('Group.AddRole', 'Add a role for this group')) ->setAttribute('data-placeholder', _t('Group.AddRole', 'Add a role for this group'))
->setDisabledItems($inheritedRoleIDs); ->setDisabledItems($inheritedRoleIDs);
if (!$allRoles->count()) { if(!$allRoles->count()) {
$rolesField->setAttribute('data-placeholder', _t('Group.NoRoles', 'No roles found')); $rolesField->setAttribute('data-placeholder', _t('Group.NoRoles', 'No roles found'));
} }
$fields->addFieldToTab('Root.Roles', $rolesField); $fields->addFieldToTab('Root.Roles', $rolesField);
} }
$fields->push($idField = new HiddenField("ID")); $fields->push($idField = new HiddenField("ID"));
$this->extend('updateCMSFields', $fields); $this->extend('updateCMSFields', $fields);
return $fields; return $fields;
} }
/** /**
* @param bool $includerelations Indicate if the labels returned include relation fields * @param bool $includerelations Indicate if the labels returned include relation fields
* @return array * @return array
*/ */
public function fieldLabels($includerelations = true) public function fieldLabels($includerelations = true)
{ {
$labels = parent::fieldLabels($includerelations); $labels = parent::fieldLabels($includerelations);
$labels['Title'] = _t('SecurityAdmin.GROUPNAME', 'Group name'); $labels['Title'] = _t('SecurityAdmin.GROUPNAME', 'Group name');
$labels['Description'] = _t('Group.Description', 'Description'); $labels['Description'] = _t('Group.Description', 'Description');
$labels['Code'] = _t('Group.Code', 'Group Code', 'Programmatical code identifying a group'); $labels['Code'] = _t('Group.Code', 'Group Code', 'Programmatical code identifying a group');
$labels['Locked'] = _t('Group.Locked', 'Locked?', 'Group is locked in the security administration area'); $labels['Locked'] = _t('Group.Locked', 'Locked?', 'Group is locked in the security administration area');
$labels['Sort'] = _t('Group.Sort', 'Sort Order'); $labels['Sort'] = _t('Group.Sort', 'Sort Order');
if ($includerelations) { if($includerelations){
$labels['Parent'] = _t('Group.Parent', 'Parent Group', 'One group has one parent group'); $labels['Parent'] = _t('Group.Parent', 'Parent Group', 'One group has one parent group');
$labels['Permissions'] = _t('Group.has_many_Permissions', 'Permissions', 'One group has many permissions'); $labels['Permissions'] = _t('Group.has_many_Permissions', 'Permissions', 'One group has many permissions');
$labels['Members'] = _t('Group.many_many_Members', 'Members', 'One group has many members'); $labels['Members'] = _t('Group.many_many_Members', 'Members', 'One group has many members');
} }
return $labels; return $labels;
} }
/** /**
* Get many-many relation to {@link Member}, * Get many-many relation to {@link Member},
* including all members which are "inherited" from children groups of this record. * including all members which are "inherited" from children groups of this record.
* See {@link DirectMembers()} for retrieving members without any inheritance. * See {@link DirectMembers()} for retrieving members without any inheritance.
* *
* @param String $filter * @param String $filter
* @return ManyManyList * @return ManyManyList
*/ */
public function Members($filter = '') public function Members($filter = '')
{ {
// First get direct members as a base result // First get direct members as a base result
$result = $this->DirectMembers(); $result = $this->DirectMembers();
// Unsaved group cannot have child groups because its ID is still 0. // Unsaved group cannot have child groups because its ID is still 0.
if (!$this->exists()) { if (!$this->exists()) {
return $result; return $result;
} }
// Remove the default foreign key filter in prep for re-applying a filter containing all children groups. // Remove the default foreign key filter in prep for re-applying a filter containing all children groups.
// Filters are conjunctive in DataQuery by default, so this filter would otherwise overrule any less specific // Filters are conjunctive in DataQuery by default, so this filter would otherwise overrule any less specific
// ones. // ones.
if (!($result instanceof UnsavedRelationList)) { if(!($result instanceof UnsavedRelationList)) {
$result = $result->alterDataQuery(function ($query) { $result = $result->alterDataQuery(function($query){
/** @var DataQuery $query */ /** @var DataQuery $query */
$query->removeFilterOn('Group_Members'); $query->removeFilterOn('Group_Members');
}); });
} }
// Now set all children groups as a new foreign key // Now set all children groups as a new foreign key
$groups = Group::get()->byIDs($this->collateFamilyIDs()); $groups = Group::get()->byIDs($this->collateFamilyIDs());
$result = $result->forForeignID($groups->column('ID'))->where($filter); $result = $result->forForeignID($groups->column('ID'))->where($filter);
return $result; return $result;
} }
/** /**
* Return only the members directly added to this group * Return only the members directly added to this group
*/ */
public function DirectMembers() public function DirectMembers()
{ {
return $this->getManyManyComponents('Members'); return $this->getManyManyComponents('Members');
} }
/** /**
* Return a set of this record's "family" of IDs - the IDs of * Return a set of this record's "family" of IDs - the IDs of
* this record and all its descendants. * this record and all its descendants.
* *
* @return array * @return array
*/ */
public function collateFamilyIDs() public function collateFamilyIDs()
{ {
if (!$this->exists()) { if (!$this->exists()) {
throw new \InvalidArgumentException("Cannot call collateFamilyIDs on unsaved Group."); throw new \InvalidArgumentException("Cannot call collateFamilyIDs on unsaved Group.");
} }
$familyIDs = array(); $familyIDs = array();
$chunkToAdd = array($this->ID); $chunkToAdd = array($this->ID);
while ($chunkToAdd) { while($chunkToAdd) {
$familyIDs = array_merge($familyIDs, $chunkToAdd); $familyIDs = array_merge($familyIDs,$chunkToAdd);
// Get the children of *all* the groups identified in the previous chunk. // Get the children of *all* the groups identified in the previous chunk.
// This minimises the number of SQL queries necessary // This minimises the number of SQL queries necessary
$chunkToAdd = Group::get()->filter("ParentID", $chunkToAdd)->column('ID'); $chunkToAdd = Group::get()->filter("ParentID", $chunkToAdd)->column('ID');
} }
return $familyIDs; return $familyIDs;
} }
/** /**
* Returns an array of the IDs of this group and all its parents * Returns an array of the IDs of this group and all its parents
* *
* @return array * @return array
*/ */
public function collateAncestorIDs() public function collateAncestorIDs()
{ {
$parent = $this; $parent = $this;
$items = []; $items = [];
while (isset($parent) && $parent instanceof Group) { while(isset($parent) && $parent instanceof Group) {
$items[] = $parent->ID; $items[] = $parent->ID;
$parent = $parent->Parent; $parent = $parent->Parent;
} }
return $items; return $items;
} }
/** /**
* This isn't a decendant of SiteTree, but needs this in case * This isn't a decendant of SiteTree, but needs this in case
* the group is "reorganised"; * the group is "reorganised";
*/ */
public function cmsCleanup_parentChanged() public function cmsCleanup_parentChanged()
{ {
} }
/** /**
* Override this so groups are ordered in the CMS * Override this so groups are ordered in the CMS
*/ */
public function stageChildren() public function stageChildren()
{ {
return Group::get() return Group::get()
->filter("ParentID", $this->ID) ->filter("ParentID", $this->ID)
->exclude("ID", $this->ID) ->exclude("ID", $this->ID)
->sort('"Sort"'); ->sort('"Sort"');
} }
public function getTreeTitle() public function getTreeTitle()
{ {
if ($this->hasMethod('alternateTreeTitle')) { if($this->hasMethod('alternateTreeTitle')) {
return $this->alternateTreeTitle(); return $this->alternateTreeTitle();
} }
return htmlspecialchars($this->Title, ENT_QUOTES); return htmlspecialchars($this->Title, ENT_QUOTES);
} }
/** /**
* Overloaded to ensure the code is always descent. * Overloaded to ensure the code is always descent.
* *
* @param string * @param string
*/ */
public function setCode($val) public function setCode($val)
{ {
$this->setField("Code", Convert::raw2url($val)); $this->setField("Code", Convert::raw2url($val));
} }
public function validate() public function validate()
{ {
$result = parent::validate(); $result = parent::validate();
// Check if the new group hierarchy would add certain "privileged permissions", // Check if the new group hierarchy would add certain "privileged permissions",
// and require an admin to perform this change in case it does. // and require an admin to perform this change in case it does.
// This prevents "sub-admin" users with group editing permissions to increase their privileges. // This prevents "sub-admin" users with group editing permissions to increase their privileges.
if ($this->Parent()->exists() && !Permission::check('ADMIN')) { if($this->Parent()->exists() && !Permission::check('ADMIN')) {
$inheritedCodes = Permission::get() $inheritedCodes = Permission::get()
->filter('GroupID', $this->Parent()->collateAncestorIDs()) ->filter('GroupID', $this->Parent()->collateAncestorIDs())
->column('Code'); ->column('Code');
$privilegedCodes = Config::inst()->get('SilverStripe\\Security\\Permission', 'privileged_permissions'); $privilegedCodes = Config::inst()->get('SilverStripe\\Security\\Permission', 'privileged_permissions');
if (array_intersect($inheritedCodes, $privilegedCodes)) { if(array_intersect($inheritedCodes, $privilegedCodes)) {
$result->error(sprintf( $result->addError(sprintf(
_t( _t(
'Group.HierarchyPermsError', 'Group.HierarchyPermsError',
'Can\'t assign parent group "%s" with privileged permissions (requires ADMIN access)' 'Can\'t assign parent group "%s" with privileged permissions (requires ADMIN access)'
), ),
$this->Parent()->Title $this->Parent()->Title
)); ));
} }
} }
return $result; return $result;
} }
public function onBeforeWrite() public function onBeforeWrite()
{ {
parent::onBeforeWrite(); parent::onBeforeWrite();
// Only set code property when the group has a custom title, and no code exists. // Only set code property when the group has a custom title, and no code exists.
// The "Code" attribute is usually treated as a more permanent identifier than database IDs // The "Code" attribute is usually treated as a more permanent identifier than database IDs
// in custom application logic, so can't be changed after its first set. // in custom application logic, so can't be changed after its first set.
if (!$this->Code && $this->Title != _t('SecurityAdmin.NEWGROUP', "New Group")) { if(!$this->Code && $this->Title != _t('SecurityAdmin.NEWGROUP',"New Group")) {
$this->setCode($this->Title); $this->setCode($this->Title);
} }
} }
public function onBeforeDelete() public function onBeforeDelete()
{ {
parent::onBeforeDelete(); parent::onBeforeDelete();
// if deleting this group, delete it's children as well // if deleting this group, delete it's children as well
foreach ($this->Groups() as $group) { foreach($this->Groups() as $group) {
$group->delete(); $group->delete();
} }
// Delete associated permissions // Delete associated permissions
foreach ($this->Permissions() as $permission) { foreach($this->Permissions() as $permission) {
$permission->delete(); $permission->delete();
} }
} }
/** /**
* Checks for permission-code CMS_ACCESS_SecurityAdmin. * Checks for permission-code CMS_ACCESS_SecurityAdmin.
* If the group has ADMIN permissions, it requires the user to have ADMIN permissions as well. * If the group has ADMIN permissions, it requires the user to have ADMIN permissions as well.
* *
* @param $member Member * @param $member Member
* @return boolean * @return boolean
*/ */
public function canEdit($member = null) public function canEdit($member = null)
{ {
if (!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) { if (!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) {
$member = Member::currentUser(); $member = Member::currentUser();
} }
// extended access checks // extended access checks
$results = $this->extend('canEdit', $member); $results = $this->extend('canEdit', $member);
if ($results && is_array($results)) { if ($results && is_array($results)) {
if (!min($results)) { if (!min($results)) {
return false; return false;
@ -490,48 +490,48 @@ class Group extends DataObject
} }
if (// either we have an ADMIN if (// either we have an ADMIN
(bool)Permission::checkMember($member, "ADMIN") (bool)Permission::checkMember($member, "ADMIN")
|| ( || (
// or a privileged CMS user and a group without ADMIN permissions. // or a privileged CMS user and a group without ADMIN permissions.
// without this check, a user would be able to add himself to an administrators group // without this check, a user would be able to add himself to an administrators group
// with just access to the "Security" admin interface // with just access to the "Security" admin interface
Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin") && Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin") &&
!Permission::get()->filter(array('GroupID' => $this->ID, 'Code' => 'ADMIN'))->exists() !Permission::get()->filter(array('GroupID' => $this->ID, 'Code' => 'ADMIN'))->exists()
) )
) { ) {
return true; return true;
} }
return false; return false;
} }
/** /**
* Checks for permission-code CMS_ACCESS_SecurityAdmin. * Checks for permission-code CMS_ACCESS_SecurityAdmin.
* *
* @param $member Member * @param $member Member
* @return boolean * @return boolean
*/ */
public function canView($member = null) public function canView($member = null)
{ {
if (!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) { if (!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) {
$member = Member::currentUser(); $member = Member::currentUser();
} }
// extended access checks // extended access checks
$results = $this->extend('canView', $member); $results = $this->extend('canView', $member);
if ($results && is_array($results)) { if ($results && is_array($results)) {
if (!min($results)) { if (!min($results)) {
return false; return false;
} }
} }
// user needs access to CMS_ACCESS_SecurityAdmin // user needs access to CMS_ACCESS_SecurityAdmin
if (Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin")) { if (Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin")) {
return true; return true;
} }
return false; return false;
} }
public function canDelete($member = null) public function canDelete($member = null)
{ {
@ -539,30 +539,30 @@ class Group extends DataObject
$member = Member::currentUser(); $member = Member::currentUser();
} }
// extended access checks // extended access checks
$results = $this->extend('canDelete', $member); $results = $this->extend('canDelete', $member);
if ($results && is_array($results)) { if ($results && is_array($results)) {
if (!min($results)) { if (!min($results)) {
return false; return false;
} }
} }
return $this->canEdit($member); return $this->canEdit($member);
} }
/** /**
* Returns all of the children for the CMS Tree. * Returns all of the children for the CMS Tree.
* Filters to only those groups that the current user can edit * Filters to only those groups that the current user can edit
*/ */
public function AllChildrenIncludingDeleted() public function AllChildrenIncludingDeleted()
{ {
/** @var Hierarchy $extInstance */ /** @var Hierarchy $extInstance */
$extInstance = $this->getExtensionInstance('SilverStripe\\ORM\\Hierarchy\\Hierarchy'); $extInstance = $this->getExtensionInstance('SilverStripe\\ORM\\Hierarchy\\Hierarchy');
$extInstance->setOwner($this); $extInstance->setOwner($this);
$children = $extInstance->AllChildrenIncludingDeleted(); $children = $extInstance->AllChildrenIncludingDeleted();
$extInstance->clearOwner(); $extInstance->clearOwner();
$filteredChildren = new ArrayList(); $filteredChildren = new ArrayList();
if ($children) { if ($children) {
foreach ($children as $child) { foreach ($children as $child) {
@ -570,46 +570,46 @@ class Group extends DataObject
$filteredChildren->push($child); $filteredChildren->push($child);
} }
} }
} }
return $filteredChildren; return $filteredChildren;
} }
/** /**
* Add default records to database. * Add default records to database.
* *
* This function is called whenever the database is built, after the * This function is called whenever the database is built, after the
* database tables have all been created. * database tables have all been created.
*/ */
public function requireDefaultRecords() public function requireDefaultRecords()
{ {
parent::requireDefaultRecords(); parent::requireDefaultRecords();
// Add default author group if no other group exists // Add default author group if no other group exists
$allGroups = DataObject::get('SilverStripe\\Security\\Group'); $allGroups = DataObject::get('SilverStripe\\Security\\Group');
if (!$allGroups->count()) { if(!$allGroups->count()) {
$authorGroup = new Group(); $authorGroup = new Group();
$authorGroup->Code = 'content-authors'; $authorGroup->Code = 'content-authors';
$authorGroup->Title = _t('Group.DefaultGroupTitleContentAuthors', 'Content Authors'); $authorGroup->Title = _t('Group.DefaultGroupTitleContentAuthors', 'Content Authors');
$authorGroup->Sort = 1; $authorGroup->Sort = 1;
$authorGroup->write(); $authorGroup->write();
Permission::grant($authorGroup->ID, 'CMS_ACCESS_CMSMain'); Permission::grant($authorGroup->ID, 'CMS_ACCESS_CMSMain');
Permission::grant($authorGroup->ID, 'CMS_ACCESS_AssetAdmin'); Permission::grant($authorGroup->ID, 'CMS_ACCESS_AssetAdmin');
Permission::grant($authorGroup->ID, 'CMS_ACCESS_ReportAdmin'); Permission::grant($authorGroup->ID, 'CMS_ACCESS_ReportAdmin');
Permission::grant($authorGroup->ID, 'SITETREE_REORGANISE'); Permission::grant($authorGroup->ID, 'SITETREE_REORGANISE');
} }
// Add default admin group if none with permission code ADMIN exists // Add default admin group if none with permission code ADMIN exists
$adminGroups = Permission::get_groups_by_permission('ADMIN'); $adminGroups = Permission::get_groups_by_permission('ADMIN');
if (!$adminGroups->count()) { if(!$adminGroups->count()) {
$adminGroup = new Group(); $adminGroup = new Group();
$adminGroup->Code = 'administrators'; $adminGroup->Code = 'administrators';
$adminGroup->Title = _t('Group.DefaultGroupTitleAdministrators', 'Administrators'); $adminGroup->Title = _t('Group.DefaultGroupTitleAdministrators', 'Administrators');
$adminGroup->Sort = 0; $adminGroup->Sort = 0;
$adminGroup->write(); $adminGroup->write();
Permission::grant($adminGroup->ID, 'ADMIN'); Permission::grant($adminGroup->ID, 'ADMIN');
} }
// Members are populated through Member->requireDefaultRecords() // Members are populated through Member->requireDefaultRecords()
} }
} }

View File

@ -63,380 +63,380 @@ use Zend_Locale_Format;
class Member extends DataObject implements TemplateGlobalProvider class Member extends DataObject implements TemplateGlobalProvider
{ {
private static $db = array( private static $db = array(
'FirstName' => 'Varchar', 'FirstName' => 'Varchar',
'Surname' => 'Varchar', 'Surname' => 'Varchar',
'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character) 'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character)
'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication 'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication
'TempIDExpired' => 'Datetime', // Expiry of temp login 'TempIDExpired' => 'Datetime', // Expiry of temp login
'Password' => 'Varchar(160)', 'Password' => 'Varchar(160)',
'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset 'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset
'AutoLoginExpired' => 'Datetime', 'AutoLoginExpired' => 'Datetime',
// This is an arbitrary code pointing to a PasswordEncryptor instance, // This is an arbitrary code pointing to a PasswordEncryptor instance,
// not an actual encryption algorithm. // not an actual encryption algorithm.
// Warning: Never change this field after its the first password hashing without // Warning: Never change this field after its the first password hashing without
// providing a new cleartext password as well. // providing a new cleartext password as well.
'PasswordEncryption' => "Varchar(50)", 'PasswordEncryption' => "Varchar(50)",
'Salt' => 'Varchar(50)', 'Salt' => 'Varchar(50)',
'PasswordExpiry' => 'Date', 'PasswordExpiry' => 'Date',
'LockedOutUntil' => 'Datetime', 'LockedOutUntil' => 'Datetime',
'Locale' => 'Varchar(6)', 'Locale' => 'Varchar(6)',
// handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set // handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set
'FailedLoginCount' => 'Int', 'FailedLoginCount' => 'Int',
// In ISO format // In ISO format
'DateFormat' => 'Varchar(30)', 'DateFormat' => 'Varchar(30)',
'TimeFormat' => 'Varchar(30)', 'TimeFormat' => 'Varchar(30)',
); );
private static $belongs_many_many = array( private static $belongs_many_many = array(
'Groups' => 'SilverStripe\\Security\\Group', 'Groups' => 'SilverStripe\\Security\\Group',
); );
private static $has_many = array( private static $has_many = array(
'LoggedPasswords' => 'SilverStripe\\Security\\MemberPassword', 'LoggedPasswords' => 'SilverStripe\\Security\\MemberPassword',
'RememberLoginHashes' => 'SilverStripe\\Security\\RememberLoginHash' 'RememberLoginHashes' => 'SilverStripe\\Security\\RememberLoginHash'
); );
private static $table_name = "Member"; private static $table_name = "Member";
private static $default_sort = '"Surname", "FirstName"'; private static $default_sort = '"Surname", "FirstName"';
private static $indexes = array( private static $indexes = array(
'Email' => true, 'Email' => true,
//Removed due to duplicate null values causing MSSQL problems //Removed due to duplicate null values causing MSSQL problems
//'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true) //'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true)
); );
/** /**
* @config * @config
* @var boolean * @var boolean
*/ */
private static $notify_password_change = false; private static $notify_password_change = false;
/** /**
* All searchable database columns * All searchable database columns
* in this object, currently queried * in this object, currently queried
* with a "column LIKE '%keywords%' * with a "column LIKE '%keywords%'
* statement. * statement.
* *
* @var array * @var array
* @todo Generic implementation of $searchable_fields on DataObject, * @todo Generic implementation of $searchable_fields on DataObject,
* with definition for different searching algorithms * with definition for different searching algorithms
* (LIKE, FULLTEXT) and default FormFields to construct a searchform. * (LIKE, FULLTEXT) and default FormFields to construct a searchform.
*/ */
private static $searchable_fields = array( private static $searchable_fields = array(
'FirstName', 'FirstName',
'Surname', 'Surname',
'Email', 'Email',
); );
/** /**
* @config * @config
* @var array * @var array
*/ */
private static $summary_fields = array( private static $summary_fields = array(
'FirstName', 'FirstName',
'Surname', 'Surname',
'Email', 'Email',
); );
/** /**
* @config * @config
* @var array * @var array
*/ */
private static $casting = array( private static $casting = array(
'Name' => 'Varchar', 'Name' => 'Varchar',
); );
/** /**
* Internal-use only fields * Internal-use only fields
* *
* @config * @config
* @var array * @var array
*/ */
private static $hidden_fields = array( private static $hidden_fields = array(
'AutoLoginHash', 'AutoLoginHash',
'AutoLoginExpired', 'AutoLoginExpired',
'PasswordEncryption', 'PasswordEncryption',
'PasswordExpiry', 'PasswordExpiry',
'LockedOutUntil', 'LockedOutUntil',
'TempIDHash', 'TempIDHash',
'TempIDExpired', 'TempIDExpired',
'Salt', 'Salt',
); );
/** /**
* @config * @config
* @var array See {@link set_title_columns()} * @var array See {@link set_title_columns()}
*/ */
private static $title_format = null; private static $title_format = null;
/** /**
* The unique field used to identify this member. * The unique field used to identify this member.
* By default, it's "Email", but another common * By default, it's "Email", but another common
* field could be Username. * field could be Username.
* *
* @config * @config
* @var string * @var string
* @skipUpgrade * @skipUpgrade
*/ */
private static $unique_identifier_field = 'Email'; private static $unique_identifier_field = 'Email';
/** /**
* Object for validating user's password * Object for validating user's password
* *
* @config * @config
* @var PasswordValidator * @var PasswordValidator
*/ */
private static $password_validator = null; private static $password_validator = null;
/** /**
* @config * @config
* The number of days that a password should be valid for. * The number of days that a password should be valid for.
* By default, this is null, which means that passwords never expire * By default, this is null, which means that passwords never expire
*/ */
private static $password_expiry_days = null; private static $password_expiry_days = null;
/** /**
* @config * @config
* @var Int Number of incorrect logins after which * @var Int Number of incorrect logins after which
* the user is blocked from further attempts for the timespan * the user is blocked from further attempts for the timespan
* defined in {@link $lock_out_delay_mins}. * defined in {@link $lock_out_delay_mins}.
*/ */
private static $lock_out_after_incorrect_logins = 10; private static $lock_out_after_incorrect_logins = 10;
/** /**
* @config * @config
* @var integer Minutes of enforced lockout after incorrect password attempts. * @var integer Minutes of enforced lockout after incorrect password attempts.
* Only applies if {@link $lock_out_after_incorrect_logins} greater than 0. * Only applies if {@link $lock_out_after_incorrect_logins} greater than 0.
*/ */
private static $lock_out_delay_mins = 15; private static $lock_out_delay_mins = 15;
/** /**
* @config * @config
* @var String If this is set, then a session cookie with the given name will be set on log-in, * @var String If this is set, then a session cookie with the given name will be set on log-in,
* and cleared on logout. * and cleared on logout.
*/ */
private static $login_marker_cookie = null; private static $login_marker_cookie = null;
/** /**
* Indicates that when a {@link Member} logs in, Member:session_regenerate_id() * Indicates that when a {@link Member} logs in, Member:session_regenerate_id()
* should be called as a security precaution. * should be called as a security precaution.
* *
* This doesn't always work, especially if you're trying to set session cookies * This doesn't always work, especially if you're trying to set session cookies
* across an entire site using the domain parameter to session_set_cookie_params() * across an entire site using the domain parameter to session_set_cookie_params()
* *
* @config * @config
* @var boolean * @var boolean
*/ */
private static $session_regenerate_id = true; private static $session_regenerate_id = true;
/** /**
* Default lifetime of temporary ids. * Default lifetime of temporary ids.
* *
* This is the period within which a user can be re-authenticated within the CMS by entering only their password * This is the period within which a user can be re-authenticated within the CMS by entering only their password
* and without losing their workspace. * and without losing their workspace.
* *
* Any session expiration outside of this time will require them to login from the frontend using their full * Any session expiration outside of this time will require them to login from the frontend using their full
* username and password. * username and password.
* *
* Defaults to 72 hours. Set to zero to disable expiration. * Defaults to 72 hours. Set to zero to disable expiration.
* *
* @config * @config
* @var int Lifetime in seconds * @var int Lifetime in seconds
*/ */
private static $temp_id_lifetime = 259200; private static $temp_id_lifetime = 259200;
/** /**
* Ensure the locale is set to something sensible by default. * Ensure the locale is set to something sensible by default.
*/ */
public function populateDefaults() public function populateDefaults()
{ {
parent::populateDefaults(); parent::populateDefaults();
$this->Locale = i18n::get_closest_translation(i18n::get_locale()); $this->Locale = i18n::get_closest_translation(i18n::get_locale());
} }
public function requireDefaultRecords() public function requireDefaultRecords()
{ {
parent::requireDefaultRecords(); parent::requireDefaultRecords();
// Default groups should've been built by Group->requireDefaultRecords() already // Default groups should've been built by Group->requireDefaultRecords() already
static::default_admin(); static::default_admin();
} }
/** /**
* Get the default admin record if it exists, or creates it otherwise if enabled * Get the default admin record if it exists, or creates it otherwise if enabled
* *
* @return Member * @return Member
*/ */
public static function default_admin() public static function default_admin()
{ {
// Check if set // Check if set
if (!Security::has_default_admin()) { if (!Security::has_default_admin()) {
return null; return null;
} }
// Find or create ADMIN group // Find or create ADMIN group
Group::singleton()->requireDefaultRecords(); Group::singleton()->requireDefaultRecords();
$adminGroup = Permission::get_groups_by_permission('ADMIN')->first(); $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
// Find member // Find member
/** @skipUpgrade */ /** @skipUpgrade */
$admin = Member::get() $admin = Member::get()
->filter('Email', Security::default_admin_username()) ->filter('Email', Security::default_admin_username())
->first(); ->first();
if (!$admin) { if(!$admin) {
// 'Password' is not set to avoid creating // 'Password' is not set to avoid creating
// persistent logins in the database. See Security::setDefaultAdmin(). // persistent logins in the database. See Security::setDefaultAdmin().
// Set 'Email' to identify this as the default admin // Set 'Email' to identify this as the default admin
$admin = Member::create(); $admin = Member::create();
$admin->FirstName = _t('Member.DefaultAdminFirstname', 'Default Admin'); $admin->FirstName = _t('Member.DefaultAdminFirstname', 'Default Admin');
$admin->Email = Security::default_admin_username(); $admin->Email = Security::default_admin_username();
$admin->write(); $admin->write();
} }
// Ensure this user is in the admin group // Ensure this user is in the admin group
if (!$admin->inGroup($adminGroup)) { if(!$admin->inGroup($adminGroup)) {
// Add member to group instead of adding group to member // Add member to group instead of adding group to member
// This bypasses the privilege escallation code in Member_GroupSet // This bypasses the privilege escallation code in Member_GroupSet
$adminGroup $adminGroup
->DirectMembers() ->DirectMembers()
->add($admin); ->add($admin);
} }
return $admin; return $admin;
} }
/** /**
* Check if the passed password matches the stored one (if the member is not locked out). * Check if the passed password matches the stored one (if the member is not locked out).
* *
* @param string $password * @param string $password
* @return ValidationResult * @return ValidationResult
*/ */
public function checkPassword($password) public function checkPassword($password)
{ {
$result = $this->canLogIn(); $result = $this->canLogIn();
// Short-circuit the result upon failure, no further checks needed. // Short-circuit the result upon failure, no further checks needed.
if (!$result->valid()) { if (!$result->valid()) {
return $result; return $result;
} }
// Allow default admin to login as self // Allow default admin to login as self
if ($this->isDefaultAdmin() && Security::check_default_admin($this->Email, $password)) { if($this->isDefaultAdmin() && Security::check_default_admin($this->Email, $password)) {
return $result; return $result;
} }
// Check a password is set on this member // Check a password is set on this member
if (empty($this->Password) && $this->exists()) { if(empty($this->Password) && $this->exists()) {
$result->error(_t('Member.NoPassword', 'There is no password on this member.')); $result->addError(_t('Member.NoPassword','There is no password on this member.'));
return $result; return $result;
} }
$e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
if (!$e->check($this->Password, $password, $this->Salt, $this)) { if(!$e->check($this->Password, $password, $this->Salt, $this)) {
$result->error(_t( $result->addError(_t (
'Member.ERRORWRONGCRED', 'Member.ERRORWRONGCRED',
'The provided details don\'t seem to be correct. Please try again.' 'The provided details don\'t seem to be correct. Please try again.'
)); ));
} }
return $result; return $result;
} }
/** /**
* Check if this user is the currently configured default admin * Check if this user is the currently configured default admin
* *
* @return bool * @return bool
*/ */
public function isDefaultAdmin() public function isDefaultAdmin()
{ {
return Security::has_default_admin() return Security::has_default_admin()
&& $this->Email === Security::default_admin_username(); && $this->Email === Security::default_admin_username();
} }
/** /**
* Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
* one with error messages to display if the member is locked out. * one with error messages to display if the member is locked out.
* *
* You can hook into this with a "canLogIn" method on an attached extension. * You can hook into this with a "canLogIn" method on an attached extension.
* *
* @return ValidationResult * @return ValidationResult
*/ */
public function canLogIn() public function canLogIn()
{ {
$result = ValidationResult::create(); $result = ValidationResult::create();
if ($this->isLockedOut()) { if($this->isLockedOut()) {
$result->error( $result->addError(
_t( _t(
'Member.ERRORLOCKEDOUT2', 'Member.ERRORLOCKEDOUT2',
'Your account has been temporarily disabled because of too many failed attempts at ' . 'Your account has been temporarily disabled because of too many failed attempts at ' .
'logging in. Please try again in {count} minutes.', 'logging in. Please try again in {count} minutes.',
null, null,
array('count' => $this->config()->lock_out_delay_mins) array('count' => $this->config()->lock_out_delay_mins)
) )
); );
} }
$this->extend('canLogIn', $result); $this->extend('canLogIn', $result);
return $result; return $result;
} }
/** /**
* Returns true if this user is locked out * Returns true if this user is locked out
*/ */
public function isLockedOut() public function isLockedOut()
{ {
return $this->LockedOutUntil && DBDatetime::now()->Format('U') < strtotime($this->LockedOutUntil); return $this->LockedOutUntil && DBDatetime::now()->Format('U') < strtotime($this->LockedOutUntil);
} }
/** /**
* Regenerate the session_id. * Regenerate the session_id.
* This wrapper is here to make it easier to disable calls to session_regenerate_id(), should you need to. * This wrapper is here to make it easier to disable calls to session_regenerate_id(), should you need to.
* They have caused problems in certain * They have caused problems in certain
* quirky problems (such as using the Windmill 0.3.6 proxy). * quirky problems (such as using the Windmill 0.3.6 proxy).
*/ */
public static function session_regenerate_id() public static function session_regenerate_id()
{ {
if (!self::config()->session_regenerate_id) { if (!self::config()->session_regenerate_id) {
return; return;
} }
// This can be called via CLI during testing. // This can be called via CLI during testing.
if (Director::is_cli()) { if (Director::is_cli()) {
return; return;
} }
$file = ''; $file = '';
$line = ''; $line = '';
// @ is to supress win32 warnings/notices when session wasn't cleaned up properly // @ is to supress win32 warnings/notices when session wasn't cleaned up properly
// There's nothing we can do about this, because it's an operating system function! // There's nothing we can do about this, because it's an operating system function!
if (!headers_sent($file, $line)) { if (!headers_sent($file, $line)) {
@session_regenerate_id(true); @session_regenerate_id(true);
} }
} }
/** /**
* Set a {@link PasswordValidator} object to use to validate member's passwords. * Set a {@link PasswordValidator} object to use to validate member's passwords.
* *
* @param PasswordValidator $pv * @param PasswordValidator $pv
*/ */
public static function set_password_validator($pv) public static function set_password_validator($pv)
{ {
self::$password_validator = $pv; self::$password_validator = $pv;
} }
/** /**
* Returns the current {@link PasswordValidator} * Returns the current {@link PasswordValidator}
* *
* @return PasswordValidator * @return PasswordValidator
*/ */
public static function password_validator() public static function password_validator()
{ {
return self::$password_validator; return self::$password_validator;
} }
public function isPasswordExpired() public function isPasswordExpired()
@ -444,176 +444,176 @@ class Member extends DataObject implements TemplateGlobalProvider
if (!$this->PasswordExpiry) { if (!$this->PasswordExpiry) {
return false; return false;
} }
return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry); return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
} }
/** /**
* Logs this member in * Logs this member in
* *
* @param bool $remember If set to TRUE, the member will be logged in automatically the next time. * @param bool $remember If set to TRUE, the member will be logged in automatically the next time.
*/ */
public function logIn($remember = false) public function logIn($remember = false)
{ {
$this->extend('beforeMemberLoggedIn'); $this->extend('beforeMemberLoggedIn');
self::session_regenerate_id(); self::session_regenerate_id();
Session::set("loggedInAs", $this->ID); Session::set("loggedInAs", $this->ID);
// This lets apache rules detect whether the user has logged in // This lets apache rules detect whether the user has logged in
if (Member::config()->login_marker_cookie) { if (Member::config()->login_marker_cookie) {
Cookie::set(Member::config()->login_marker_cookie, 1, 0); Cookie::set(Member::config()->login_marker_cookie, 1, 0);
} }
if (Security::config()->autologin_enabled) { if (Security::config()->autologin_enabled) {
// Cleans up any potential previous hash for this member on this device // Cleans up any potential previous hash for this member on this device
if ($alcDevice = Cookie::get('alc_device')) { if ($alcDevice = Cookie::get('alc_device')) {
RememberLoginHash::get()->filter('DeviceID', $alcDevice)->removeAll(); RememberLoginHash::get()->filter('DeviceID', $alcDevice)->removeAll();
} }
if ($remember) { if($remember) {
$rememberLoginHash = RememberLoginHash::generate($this); $rememberLoginHash = RememberLoginHash::generate($this);
$tokenExpiryDays = Config::inst()->get( $tokenExpiryDays = Config::inst()->get(
'SilverStripe\\Security\\RememberLoginHash', 'SilverStripe\\Security\\RememberLoginHash',
'token_expiry_days' 'token_expiry_days'
); );
$deviceExpiryDays = Config::inst()->get( $deviceExpiryDays = Config::inst()->get(
'SilverStripe\\Security\\RememberLoginHash', 'SilverStripe\\Security\\RememberLoginHash',
'device_expiry_days' 'device_expiry_days'
); );
Cookie::set( Cookie::set(
'alc_enc', 'alc_enc',
$this->ID . ':' . $rememberLoginHash->getToken(), $this->ID . ':' . $rememberLoginHash->getToken(),
$tokenExpiryDays, $tokenExpiryDays,
null, null,
null, null,
null, null,
true true
); );
Cookie::set('alc_device', $rememberLoginHash->DeviceID, $deviceExpiryDays, null, null, null, true); Cookie::set('alc_device', $rememberLoginHash->DeviceID, $deviceExpiryDays, null, null, null, true);
} else { } else {
Cookie::set('alc_enc', null); Cookie::set('alc_enc', null);
Cookie::set('alc_device', null); Cookie::set('alc_device', null);
Cookie::force_expiry('alc_enc'); Cookie::force_expiry('alc_enc');
Cookie::force_expiry('alc_device'); Cookie::force_expiry('alc_device');
} }
} }
// Clear the incorrect log-in count // Clear the incorrect log-in count
$this->registerSuccessfulLogin(); $this->registerSuccessfulLogin();
$this->LockedOutUntil = null; $this->LockedOutUntil = null;
$this->regenerateTempID(); $this->regenerateTempID();
$this->write(); $this->write();
// Audit logging hook // Audit logging hook
$this->extend('memberLoggedIn'); $this->extend('memberLoggedIn');
} }
/** /**
* Trigger regeneration of TempID. * Trigger regeneration of TempID.
* *
* This should be performed any time the user presents their normal identification (normally Email) * This should be performed any time the user presents their normal identification (normally Email)
* and is successfully authenticated. * and is successfully authenticated.
*/ */
public function regenerateTempID() public function regenerateTempID()
{ {
$generator = new RandomGenerator(); $generator = new RandomGenerator();
$this->TempIDHash = $generator->randomToken('sha1'); $this->TempIDHash = $generator->randomToken('sha1');
$this->TempIDExpired = self::config()->temp_id_lifetime $this->TempIDExpired = self::config()->temp_id_lifetime
? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + self::config()->temp_id_lifetime) ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + self::config()->temp_id_lifetime)
: null; : null;
$this->write(); $this->write();
} }
/** /**
* Check if the member ID logged in session actually * Check if the member ID logged in session actually
* has a database record of the same ID. If there is * has a database record of the same ID. If there is
* no logged in user, FALSE is returned anyway. * no logged in user, FALSE is returned anyway.
* *
* @return boolean TRUE record found FALSE no record found * @return boolean TRUE record found FALSE no record found
*/ */
public static function logged_in_session_exists() public static function logged_in_session_exists()
{ {
if ($id = Member::currentUserID()) { if($id = Member::currentUserID()) {
if ($member = DataObject::get_by_id('SilverStripe\\Security\\Member', $id)) { if($member = DataObject::get_by_id('SilverStripe\\Security\\Member', $id)) {
if ($member->exists()) { if ($member->exists()) {
return true; return true;
} }
} }
} }
return false; return false;
} }
/** /**
* Log the user in if the "remember login" cookie is set * Log the user in if the "remember login" cookie is set
* *
* The <i>remember login token</i> will be changed on every successful * The <i>remember login token</i> will be changed on every successful
* auto-login. * auto-login.
*/ */
public static function autoLogin() public static function autoLogin()
{ {
// Don't bother trying this multiple times // Don't bother trying this multiple times
if (!class_exists('SilverStripe\\Dev\\SapphireTest', false) || !SapphireTest::is_running_test()) { if (!class_exists('SilverStripe\\Dev\\SapphireTest', false) || !SapphireTest::is_running_test()) {
self::$_already_tried_to_auto_log_in = true; self::$_already_tried_to_auto_log_in = true;
} }
if (!Security::config()->autologin_enabled if(!Security::config()->autologin_enabled
|| strpos(Cookie::get('alc_enc'), ':') === false || strpos(Cookie::get('alc_enc'), ':') === false
|| Session::get("loggedInAs") || Session::get("loggedInAs")
|| !Security::database_is_ready() || !Security::database_is_ready()
) { ) {
return; return;
} }
if (strpos(Cookie::get('alc_enc'), ':') && Cookie::get('alc_device') && !Session::get("loggedInAs")) { if(strpos(Cookie::get('alc_enc'), ':') && Cookie::get('alc_device') && !Session::get("loggedInAs")) {
list($uid, $token) = explode(':', Cookie::get('alc_enc'), 2); list($uid, $token) = explode(':', Cookie::get('alc_enc'), 2);
if (!$uid || !$token) { if (!$uid || !$token) {
return; return;
} }
$deviceID = Cookie::get('alc_device'); $deviceID = Cookie::get('alc_device');
/** @var Member $member */ /** @var Member $member */
$member = Member::get()->byID($uid); $member = Member::get()->byID($uid);
/** @var RememberLoginHash $rememberLoginHash */ /** @var RememberLoginHash $rememberLoginHash */
$rememberLoginHash = null; $rememberLoginHash = null;
// check if autologin token matches // check if autologin token matches
if ($member) { if($member) {
$hash = $member->encryptWithUserSettings($token); $hash = $member->encryptWithUserSettings($token);
$rememberLoginHash = RememberLoginHash::get() $rememberLoginHash = RememberLoginHash::get()
->filter(array( ->filter(array(
'MemberID' => $member->ID, 'MemberID' => $member->ID,
'DeviceID' => $deviceID, 'DeviceID' => $deviceID,
'Hash' => $hash 'Hash' => $hash
))->first(); ))->first();
if (!$rememberLoginHash) { if(!$rememberLoginHash) {
$member = null; $member = null;
} else { } else {
// Check for expired token // Check for expired token
$expiryDate = new DateTime($rememberLoginHash->ExpiryDate); $expiryDate = new DateTime($rememberLoginHash->ExpiryDate);
$now = DBDatetime::now(); $now = DBDatetime::now();
$now = new DateTime($now->Rfc2822()); $now = new DateTime($now->Rfc2822());
if ($now > $expiryDate) { if ($now > $expiryDate) {
$member = null; $member = null;
} }
} }
} }
if ($member) { if($member) {
self::session_regenerate_id(); self::session_regenerate_id();
Session::set("loggedInAs", $member->ID); Session::set("loggedInAs", $member->ID);
// This lets apache rules detect whether the user has logged in // This lets apache rules detect whether the user has logged in
if (Member::config()->login_marker_cookie) { if(Member::config()->login_marker_cookie) {
Cookie::set(Member::config()->login_marker_cookie, 1, 0, null, null, false, true); Cookie::set(Member::config()->login_marker_cookie, 1, 0, null, null, false, true);
} }
if ($rememberLoginHash) { if ($rememberLoginHash) {
$rememberLoginHash->renew(); $rememberLoginHash->renew();
$tokenExpiryDays = RememberLoginHash::config()->get('token_expiry_days'); $tokenExpiryDays = RememberLoginHash::config()->get('token_expiry_days');
Cookie::set( Cookie::set(
'alc_enc', 'alc_enc',
$member->ID . ':' . $rememberLoginHash->getToken(), $member->ID . ':' . $rememberLoginHash->getToken(),
@ -623,272 +623,272 @@ class Member extends DataObject implements TemplateGlobalProvider
false, false,
true true
); );
} }
$member->write(); $member->write();
// Audit logging hook // Audit logging hook
$member->extend('memberAutoLoggedIn'); $member->extend('memberAutoLoggedIn');
} }
} }
} }
/** /**
* Logs this member out. * Logs this member out.
*/ */
public function logOut() public function logOut()
{ {
$this->extend('beforeMemberLoggedOut'); $this->extend('beforeMemberLoggedOut');
Session::clear("loggedInAs"); Session::clear("loggedInAs");
if (Member::config()->login_marker_cookie) { if (Member::config()->login_marker_cookie) {
Cookie::set(Member::config()->login_marker_cookie, null, 0); Cookie::set(Member::config()->login_marker_cookie, null, 0);
} }
Session::destroy(); Session::destroy();
$this->extend('memberLoggedOut'); $this->extend('memberLoggedOut');
// Clears any potential previous hashes for this member // Clears any potential previous hashes for this member
RememberLoginHash::clear($this, Cookie::get('alc_device')); RememberLoginHash::clear($this, Cookie::get('alc_device'));
Cookie::set('alc_enc', null); // // Clear the Remember Me cookie Cookie::set('alc_enc', null); // // Clear the Remember Me cookie
Cookie::force_expiry('alc_enc'); Cookie::force_expiry('alc_enc');
Cookie::set('alc_device', null); Cookie::set('alc_device', null);
Cookie::force_expiry('alc_device'); Cookie::force_expiry('alc_device');
// Switch back to live in order to avoid infinite loops when // Switch back to live in order to avoid infinite loops when
// redirecting to the login screen (if this login screen is versioned) // redirecting to the login screen (if this login screen is versioned)
Session::clear('readingMode'); Session::clear('readingMode');
$this->write(); $this->write();
// Audit logging hook // Audit logging hook
$this->extend('memberLoggedOut'); $this->extend('memberLoggedOut');
} }
/** /**
* Utility for generating secure password hashes for this member. * Utility for generating secure password hashes for this member.
* *
* @param string $string * @param string $string
* @return string * @return string
* @throws PasswordEncryptor_NotFoundException * @throws PasswordEncryptor_NotFoundException
*/ */
public function encryptWithUserSettings($string) public function encryptWithUserSettings($string)
{ {
if (!$string) { if (!$string) {
return null; return null;
} }
// If the algorithm or salt is not available, it means we are operating // If the algorithm or salt is not available, it means we are operating
// on legacy account with unhashed password. Do not hash the string. // on legacy account with unhashed password. Do not hash the string.
if (!$this->PasswordEncryption) { if (!$this->PasswordEncryption) {
return $string; return $string;
} }
// We assume we have PasswordEncryption and Salt available here. // We assume we have PasswordEncryption and Salt available here.
$e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
return $e->encrypt($string, $this->Salt); return $e->encrypt($string, $this->Salt);
} }
/** /**
* Generate an auto login token which can be used to reset the password, * Generate an auto login token which can be used to reset the password,
* at the same time hashing it and storing in the database. * at the same time hashing it and storing in the database.
* *
* @param int $lifetime The lifetime of the auto login hash in days (by default 2 days) * @param int $lifetime The lifetime of the auto login hash in days (by default 2 days)
* *
* @returns string Token that should be passed to the client (but NOT persisted). * @returns string Token that should be passed to the client (but NOT persisted).
* *
* @todo Make it possible to handle database errors such as a "duplicate key" error * @todo Make it possible to handle database errors such as a "duplicate key" error
*/ */
public function generateAutologinTokenAndStoreHash($lifetime = 2) public function generateAutologinTokenAndStoreHash($lifetime = 2)
{ {
do { do {
$generator = new RandomGenerator(); $generator = new RandomGenerator();
$token = $generator->randomToken(); $token = $generator->randomToken();
$hash = $this->encryptWithUserSettings($token); $hash = $this->encryptWithUserSettings($token);
} while (DataObject::get_one('SilverStripe\\Security\\Member', array( } while(DataObject::get_one('SilverStripe\\Security\\Member', array(
'"Member"."AutoLoginHash"' => $hash '"Member"."AutoLoginHash"' => $hash
))); )));
$this->AutoLoginHash = $hash; $this->AutoLoginHash = $hash;
$this->AutoLoginExpired = date('Y-m-d H:i:s', time() + (86400 * $lifetime)); $this->AutoLoginExpired = date('Y-m-d H:i:s', time() + (86400 * $lifetime));
$this->write(); $this->write();
return $token; return $token;
} }
/** /**
* Check the token against the member. * Check the token against the member.
* *
* @param string $autologinToken * @param string $autologinToken
* *
* @returns bool Is token valid? * @returns bool Is token valid?
*/ */
public function validateAutoLoginToken($autologinToken) public function validateAutoLoginToken($autologinToken)
{ {
$hash = $this->encryptWithUserSettings($autologinToken); $hash = $this->encryptWithUserSettings($autologinToken);
$member = self::member_from_autologinhash($hash, false); $member = self::member_from_autologinhash($hash, false);
return (bool)$member; return (bool)$member;
} }
/** /**
* Return the member for the auto login hash * Return the member for the auto login hash
* *
* @param string $hash The hash key * @param string $hash The hash key
* @param bool $login Should the member be logged in? * @param bool $login Should the member be logged in?
* *
* @return Member the matching member, if valid * @return Member the matching member, if valid
* @return Member * @return Member
*/ */
public static function member_from_autologinhash($hash, $login = false) public static function member_from_autologinhash($hash, $login = false)
{ {
$nowExpression = DB::get_conn()->now(); $nowExpression = DB::get_conn()->now();
/** @var Member $member */ /** @var Member $member */
$member = DataObject::get_one('SilverStripe\\Security\\Member', array( $member = DataObject::get_one('SilverStripe\\Security\\Member', array(
"\"Member\".\"AutoLoginHash\"" => $hash, "\"Member\".\"AutoLoginHash\"" => $hash,
"\"Member\".\"AutoLoginExpired\" > $nowExpression" // NOW() can't be parameterised "\"Member\".\"AutoLoginExpired\" > $nowExpression" // NOW() can't be parameterised
)); ));
if ($login && $member) { if ($login && $member) {
$member->logIn(); $member->logIn();
} }
return $member; return $member;
} }
/** /**
* Find a member record with the given TempIDHash value * Find a member record with the given TempIDHash value
* *
* @param string $tempid * @param string $tempid
* @return Member * @return Member
*/ */
public static function member_from_tempid($tempid) public static function member_from_tempid($tempid)
{ {
$members = Member::get() $members = Member::get()
->filter('TempIDHash', $tempid); ->filter('TempIDHash', $tempid);
// Exclude expired // Exclude expired
if (static::config()->temp_id_lifetime) { if(static::config()->temp_id_lifetime) {
$members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue()); $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue());
} }
return $members->first(); return $members->first();
} }
/** /**
* Returns the fields for the member form - used in the registration/profile module. * Returns the fields for the member form - used in the registration/profile module.
* It should return fields that are editable by the admin and the logged-in user. * It should return fields that are editable by the admin and the logged-in user.
* *
* @return FieldList Returns a {@link FieldList} containing the fields for * @return FieldList Returns a {@link FieldList} containing the fields for
* the member form. * the member form.
*/ */
public function getMemberFormFields() public function getMemberFormFields()
{ {
$fields = parent::getFrontEndFields(); $fields = parent::getFrontEndFields();
$fields->replaceField('Password', $this->getMemberPasswordField()); $fields->replaceField('Password', $this->getMemberPasswordField());
$fields->replaceField('Locale', new DropdownField( $fields->replaceField('Locale', new DropdownField (
'Locale', 'Locale',
$this->fieldLabel('Locale'), $this->fieldLabel('Locale'),
i18n::get_existing_translations() i18n::get_existing_translations()
)); ));
$fields->removeByName(static::config()->hidden_fields); $fields->removeByName(static::config()->hidden_fields);
$fields->removeByName('FailedLoginCount'); $fields->removeByName('FailedLoginCount');
$this->extend('updateMemberFormFields', $fields); $this->extend('updateMemberFormFields', $fields);
return $fields; return $fields;
} }
/** /**
* Builds "Change / Create Password" field for this member * Builds "Change / Create Password" field for this member
* *
* @return ConfirmedPasswordField * @return ConfirmedPasswordField
*/ */
public function getMemberPasswordField() public function getMemberPasswordField()
{ {
$editingPassword = $this->isInDB(); $editingPassword = $this->isInDB();
$label = $editingPassword $label = $editingPassword
? _t('Member.EDIT_PASSWORD', 'New Password') ? _t('Member.EDIT_PASSWORD', 'New Password')
: $this->fieldLabel('Password'); : $this->fieldLabel('Password');
/** @var ConfirmedPasswordField $password */ /** @var ConfirmedPasswordField $password */
$password = ConfirmedPasswordField::create( $password = ConfirmedPasswordField::create(
'Password', 'Password',
$label, $label,
null, null,
null, null,
$editingPassword $editingPassword
); );
// If editing own password, require confirmation of existing // If editing own password, require confirmation of existing
if ($editingPassword && $this->ID == Member::currentUserID()) { if($editingPassword && $this->ID == Member::currentUserID()) {
$password->setRequireExistingPassword(true); $password->setRequireExistingPassword(true);
} }
$password->setCanBeEmpty(true); $password->setCanBeEmpty(true);
$this->extend('updateMemberPasswordField', $password); $this->extend('updateMemberPasswordField', $password);
return $password; return $password;
} }
/** /**
* Returns the {@link RequiredFields} instance for the Member object. This * Returns the {@link RequiredFields} instance for the Member object. This
* Validator is used when saving a {@link CMSProfileController} or added to * Validator is used when saving a {@link CMSProfileController} or added to
* any form responsible for saving a users data. * any form responsible for saving a users data.
* *
* To customize the required fields, add a {@link DataExtension} to member * To customize the required fields, add a {@link DataExtension} to member
* calling the `updateValidator()` method. * calling the `updateValidator()` method.
* *
* @return Member_Validator * @return Member_Validator
*/ */
public function getValidator() public function getValidator()
{ {
$validator = Injector::inst()->create('SilverStripe\\Security\\Member_Validator'); $validator = Injector::inst()->create('SilverStripe\\Security\\Member_Validator');
$validator->setForMember($this); $validator->setForMember($this);
$this->extend('updateValidator', $validator); $this->extend('updateValidator', $validator);
return $validator; return $validator;
} }
/** /**
* Returns the current logged in user * Returns the current logged in user
* *
* @return Member * @return Member
*/ */
public static function currentUser() public static function currentUser()
{ {
$id = Member::currentUserID(); $id = Member::currentUserID();
if ($id) { if($id) {
return DataObject::get_by_id('SilverStripe\\Security\\Member', $id); return DataObject::get_by_id('SilverStripe\\Security\\Member', $id);
} }
} }
/** /**
* Get the ID of the current logged in user * Get the ID of the current logged in user
* *
* @return int Returns the ID of the current logged in user or 0. * @return int Returns the ID of the current logged in user or 0.
*/ */
public static function currentUserID() public static function currentUserID()
{ {
$id = Session::get("loggedInAs"); $id = Session::get("loggedInAs");
if (!$id && !self::$_already_tried_to_auto_log_in) { if(!$id && !self::$_already_tried_to_auto_log_in) {
self::autoLogin(); self::autoLogin();
$id = Session::get("loggedInAs"); $id = Session::get("loggedInAs");
} }
return is_numeric($id) ? $id : 0; return is_numeric($id) ? $id : 0;
} }
private static $_already_tried_to_auto_log_in = false; private static $_already_tried_to_auto_log_in = false;
/* /*
* Generate a random password, with randomiser to kick in if there's no words file on the * Generate a random password, with randomiser to kick in if there's no words file on the
* filesystem. * filesystem.
* *
@ -896,178 +896,178 @@ class Member extends DataObject implements TemplateGlobalProvider
*/ */
public static function create_new_password() public static function create_new_password()
{ {
$words = Config::inst()->get('SilverStripe\\Security\\Security', 'word_list'); $words = Config::inst()->get('SilverStripe\\Security\\Security', 'word_list');
if ($words && file_exists($words)) { if($words && file_exists($words)) {
$words = file($words); $words = file($words);
list($usec, $sec) = explode(' ', microtime()); list($usec, $sec) = explode(' ', microtime());
srand($sec + ((float) $usec * 100000)); srand($sec + ((float) $usec * 100000));
$word = trim($words[rand(0, sizeof($words)-1)]); $word = trim($words[rand(0,sizeof($words)-1)]);
$number = rand(10, 999); $number = rand(10,999);
return $word . $number; return $word . $number;
} else { } else {
$random = rand(); $random = rand();
$string = md5($random); $string = md5($random);
$output = substr($string, 0, 8); $output = substr($string, 0, 8);
return $output; return $output;
} }
} }
/** /**
* Event handler called before writing to the database. * Event handler called before writing to the database.
*/ */
public function onBeforeWrite() public function onBeforeWrite()
{ {
if ($this->SetPassword) { if ($this->SetPassword) {
$this->Password = $this->SetPassword; $this->Password = $this->SetPassword;
} }
// If a member with the same "unique identifier" already exists with a different ID, don't allow merging. // If a member with the same "unique identifier" already exists with a different ID, don't allow merging.
// Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form), // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form),
// but rather a last line of defense against data inconsistencies. // but rather a last line of defense against data inconsistencies.
$identifierField = Member::config()->unique_identifier_field; $identifierField = Member::config()->unique_identifier_field;
if ($this->$identifierField) { if($this->$identifierField) {
// Note: Same logic as Member_Validator class // Note: Same logic as Member_Validator class
$filter = array("\"$identifierField\"" => $this->$identifierField); $filter = array("\"$identifierField\"" => $this->$identifierField);
if ($this->ID) { if($this->ID) {
$filter[] = array('"Member"."ID" <> ?' => $this->ID); $filter[] = array('"Member"."ID" <> ?' => $this->ID);
} }
$existingRecord = DataObject::get_one('SilverStripe\\Security\\Member', $filter); $existingRecord = DataObject::get_one('SilverStripe\\Security\\Member', $filter);
if ($existingRecord) { if($existingRecord) {
throw new ValidationException(ValidationResult::create(false, _t( throw new ValidationException(ValidationResult::create()->adderror(_t(
'Member.ValidationIdentifierFailed', 'Member.ValidationIdentifierFailed',
'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))', 'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
'Values in brackets show "fieldname = value", usually denoting an existing email address', 'Values in brackets show "fieldname = value", usually denoting an existing email address',
array( array(
'id' => $existingRecord->ID, 'id' => $existingRecord->ID,
'name' => $identifierField, 'name' => $identifierField,
'value' => $this->$identifierField 'value' => $this->$identifierField
) )
))); )));
} }
} }
// We don't send emails out on dev/tests sites to prevent accidentally spamming users. // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
// However, if TestMailer is in use this isn't a risk. // However, if TestMailer is in use this isn't a risk.
if ((Director::isLive() || Email::mailer() instanceof TestMailer) if ((Director::isLive() || Email::mailer() instanceof TestMailer)
&& $this->isChanged('Password') && $this->isChanged('Password')
&& $this->record['Password'] && $this->record['Password']
&& $this->config()->notify_password_change && $this->config()->notify_password_change
) { ) {
/** @var Email $e */ /** @var Email $e */
$e = Email::create(); $e = Email::create();
$e->setSubject(_t('Member.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject')); $e->setSubject(_t('Member.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject'));
$e->setTemplate('ChangePasswordEmail'); $e->setTemplate('ChangePasswordEmail');
$e->populateTemplate($this); $e->populateTemplate($this);
$e->setTo($this->Email); $e->setTo($this->Email);
$e->send(); $e->send();
} }
// The test on $this->ID is used for when records are initially created. // The test on $this->ID is used for when records are initially created.
// Note that this only works with cleartext passwords, as we can't rehash // Note that this only works with cleartext passwords, as we can't rehash
// existing passwords. // existing passwords.
if ((!$this->ID && $this->Password) || $this->isChanged('Password')) { if((!$this->ID && $this->Password) || $this->isChanged('Password')) {
//reset salt so that it gets regenerated - this will invalidate any persistant login cookies //reset salt so that it gets regenerated - this will invalidate any persistant login cookies
// or other information encrypted with this Member's settings (see self::encryptWithUserSettings) // or other information encrypted with this Member's settings (see self::encryptWithUserSettings)
$this->Salt = ''; $this->Salt = '';
// Password was changed: encrypt the password according the settings // Password was changed: encrypt the password according the settings
$encryption_details = Security::encrypt_password( $encryption_details = Security::encrypt_password(
$this->Password, // this is assumed to be cleartext $this->Password, // this is assumed to be cleartext
$this->Salt, $this->Salt,
($this->PasswordEncryption) ? ($this->PasswordEncryption) ?
$this->PasswordEncryption : Security::config()->password_encryption_algorithm, $this->PasswordEncryption : Security::config()->password_encryption_algorithm,
$this $this
); );
// Overwrite the Password property with the hashed value // Overwrite the Password property with the hashed value
$this->Password = $encryption_details['password']; $this->Password = $encryption_details['password'];
$this->Salt = $encryption_details['salt']; $this->Salt = $encryption_details['salt'];
$this->PasswordEncryption = $encryption_details['algorithm']; $this->PasswordEncryption = $encryption_details['algorithm'];
// If we haven't manually set a password expiry // If we haven't manually set a password expiry
if (!$this->isChanged('PasswordExpiry')) { if(!$this->isChanged('PasswordExpiry')) {
// then set it for us // then set it for us
if (self::config()->password_expiry_days) { if(self::config()->password_expiry_days) {
$this->PasswordExpiry = date('Y-m-d', time() + 86400 * self::config()->password_expiry_days); $this->PasswordExpiry = date('Y-m-d', time() + 86400 * self::config()->password_expiry_days);
} else { } else {
$this->PasswordExpiry = null; $this->PasswordExpiry = null;
} }
} }
} }
// save locale // save locale
if (!$this->Locale) { if(!$this->Locale) {
$this->Locale = i18n::get_locale(); $this->Locale = i18n::get_locale();
} }
parent::onBeforeWrite(); parent::onBeforeWrite();
} }
public function onAfterWrite() public function onAfterWrite()
{ {
parent::onAfterWrite(); parent::onAfterWrite();
Permission::flush_permission_cache(); Permission::flush_permission_cache();
if ($this->isChanged('Password')) { if($this->isChanged('Password')) {
MemberPassword::log($this); MemberPassword::log($this);
} }
} }
public function onAfterDelete() public function onAfterDelete()
{ {
parent::onAfterDelete(); parent::onAfterDelete();
//prevent orphaned records remaining in the DB //prevent orphaned records remaining in the DB
$this->deletePasswordLogs(); $this->deletePasswordLogs();
} }
/** /**
* Delete the MemberPassword objects that are associated to this user * Delete the MemberPassword objects that are associated to this user
* *
* @return $this * @return $this
*/ */
protected function deletePasswordLogs() protected function deletePasswordLogs()
{ {
foreach ($this->LoggedPasswords() as $password) { foreach ($this->LoggedPasswords() as $password) {
$password->delete(); $password->delete();
$password->destroy(); $password->destroy();
} }
return $this; return $this;
} }
/** /**
* Filter out admin groups to avoid privilege escalation, * Filter out admin groups to avoid privilege escalation,
* If any admin groups are requested, deny the whole save operation. * If any admin groups are requested, deny the whole save operation.
* *
* @param array $ids Database IDs of Group records * @param array $ids Database IDs of Group records
* @return bool True if the change can be accepted * @return bool True if the change can be accepted
*/ */
public function onChangeGroups($ids) public function onChangeGroups($ids)
{ {
// unless the current user is an admin already OR the logged in user is an admin // unless the current user is an admin already OR the logged in user is an admin
if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) { if(Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
return true; return true;
} }
// If there are no admin groups in this set then it's ok // If there are no admin groups in this set then it's ok
$adminGroups = Permission::get_groups_by_permission('ADMIN'); $adminGroups = Permission::get_groups_by_permission('ADMIN');
$adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array(); $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array();
return count(array_intersect($ids, $adminGroupIDs)) == 0; return count(array_intersect($ids, $adminGroupIDs)) == 0;
} }
/** /**
* Check if the member is in one of the given groups. * Check if the member is in one of the given groups.
* *
* @param array|SS_List $groups Collection of {@link Group} DataObjects to check * @param array|SS_List $groups Collection of {@link Group} DataObjects to check
* @param boolean $strict Only determine direct group membership if set to true (Default: false) * @param boolean $strict Only determine direct group membership if set to true (Default: false)
* @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE. * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
*/ */
public function inGroups($groups, $strict = false) public function inGroups($groups, $strict = false)
{ {
if ($groups) { if ($groups) {
@ -1076,608 +1076,608 @@ class Member extends DataObject implements TemplateGlobalProvider
return true; return true;
} }
} }
} }
return false; return false;
} }
/** /**
* Check if the member is in the given group or any parent groups. * Check if the member is in the given group or any parent groups.
* *
* @param int|Group|string $group Group instance, Group Code or ID * @param int|Group|string $group Group instance, Group Code or ID
* @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE) * @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE)
* @return bool Returns TRUE if the member is in the given group, otherwise FALSE. * @return bool Returns TRUE if the member is in the given group, otherwise FALSE.
*/ */
public function inGroup($group, $strict = false) public function inGroup($group, $strict = false)
{ {
if (is_numeric($group)) { if(is_numeric($group)) {
$groupCheckObj = DataObject::get_by_id('SilverStripe\\Security\\Group', $group); $groupCheckObj = DataObject::get_by_id('SilverStripe\\Security\\Group', $group);
} elseif (is_string($group)) { } elseif(is_string($group)) {
$groupCheckObj = DataObject::get_one('SilverStripe\\Security\\Group', array( $groupCheckObj = DataObject::get_one('SilverStripe\\Security\\Group', array(
'"Group"."Code"' => $group '"Group"."Code"' => $group
)); ));
} elseif ($group instanceof Group) { } elseif($group instanceof Group) {
$groupCheckObj = $group; $groupCheckObj = $group;
} else { } else {
user_error('Member::inGroup(): Wrong format for $group parameter', E_USER_ERROR); user_error('Member::inGroup(): Wrong format for $group parameter', E_USER_ERROR);
} }
if (!$groupCheckObj) { if (!$groupCheckObj) {
return false; return false;
} }
$groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups(); $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
if ($groupCandidateObjs) { if ($groupCandidateObjs) {
foreach ($groupCandidateObjs as $groupCandidateObj) { foreach ($groupCandidateObjs as $groupCandidateObj) {
if ($groupCandidateObj->ID == $groupCheckObj->ID) { if ($groupCandidateObj->ID == $groupCheckObj->ID) {
return true; return true;
} }
} }
} }
return false; return false;
} }
/** /**
* Adds the member to a group. This will create the group if the given * Adds the member to a group. This will create the group if the given
* group code does not return a valid group object. * group code does not return a valid group object.
* *
* @param string $groupcode * @param string $groupcode
* @param string $title Title of the group * @param string $title Title of the group
*/ */
public function addToGroupByCode($groupcode, $title = "") public function addToGroupByCode($groupcode, $title = "")
{ {
$group = DataObject::get_one('SilverStripe\\Security\\Group', array( $group = DataObject::get_one('SilverStripe\\Security\\Group', array(
'"Group"."Code"' => $groupcode '"Group"."Code"' => $groupcode
)); ));
if ($group) { if($group) {
$this->Groups()->add($group); $this->Groups()->add($group);
} else { } else {
if (!$title) { if (!$title) {
$title = $groupcode; $title = $groupcode;
} }
$group = new Group(); $group = new Group();
$group->Code = $groupcode; $group->Code = $groupcode;
$group->Title = $title; $group->Title = $title;
$group->write(); $group->write();
$this->Groups()->add($group); $this->Groups()->add($group);
} }
} }
/** /**
* Removes a member from a group. * Removes a member from a group.
* *
* @param string $groupcode * @param string $groupcode
*/ */
public function removeFromGroupByCode($groupcode) public function removeFromGroupByCode($groupcode)
{ {
$group = Group::get()->filter(array('Code' => $groupcode))->first(); $group = Group::get()->filter(array('Code' => $groupcode))->first();
if ($group) { if($group) {
$this->Groups()->remove($group); $this->Groups()->remove($group);
} }
} }
/** /**
* @param array $columns Column names on the Member record to show in {@link getTitle()}. * @param array $columns Column names on the Member record to show in {@link getTitle()}.
* @param String $sep Separator * @param String $sep Separator
*/ */
public static function set_title_columns($columns, $sep = ' ') public static function set_title_columns($columns, $sep = ' ')
{ {
if (!is_array($columns)) { if (!is_array($columns)) {
$columns = array($columns); $columns = array($columns);
} }
self::config()->title_format = array('columns' => $columns, 'sep' => $sep); self::config()->title_format = array('columns' => $columns, 'sep' => $sep);
} }
//------------------- HELPER METHODS -----------------------------------// //------------------- HELPER METHODS -----------------------------------//
/** /**
* Get the complete name of the member, by default in the format "<Surname>, <FirstName>". * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
* Falls back to showing either field on its own. * Falls back to showing either field on its own.
* *
* You can overload this getter with {@link set_title_format()} * You can overload this getter with {@link set_title_format()}
* and {@link set_title_sql()}. * and {@link set_title_sql()}.
* *
* @return string Returns the first- and surname of the member. If the ID * @return string Returns the first- and surname of the member. If the ID
* of the member is equal 0, only the surname is returned. * of the member is equal 0, only the surname is returned.
*/ */
public function getTitle() public function getTitle()
{ {
$format = $this->config()->title_format; $format = $this->config()->title_format;
if ($format) { if ($format) {
$values = array(); $values = array();
foreach ($format['columns'] as $col) { foreach($format['columns'] as $col) {
$values[] = $this->getField($col); $values[] = $this->getField($col);
} }
return join($format['sep'], $values); return join($format['sep'], $values);
} }
if ($this->getField('ID') === 0) { if ($this->getField('ID') === 0) {
return $this->getField('Surname'); return $this->getField('Surname');
} else { } else {
if ($this->getField('Surname') && $this->getField('FirstName')) { if($this->getField('Surname') && $this->getField('FirstName')){
return $this->getField('Surname') . ', ' . $this->getField('FirstName'); return $this->getField('Surname') . ', ' . $this->getField('FirstName');
} elseif ($this->getField('Surname')) { }elseif($this->getField('Surname')){
return $this->getField('Surname'); return $this->getField('Surname');
} elseif ($this->getField('FirstName')) { }elseif($this->getField('FirstName')){
return $this->getField('FirstName'); return $this->getField('FirstName');
} else { }else{
return null; return null;
} }
} }
} }
/** /**
* Return a SQL CONCAT() fragment suitable for a SELECT statement. * Return a SQL CONCAT() fragment suitable for a SELECT statement.
* Useful for custom queries which assume a certain member title format. * Useful for custom queries which assume a certain member title format.
* *
* @return String SQL * @return String SQL
*/ */
public static function get_title_sql() public static function get_title_sql()
{ {
// This should be abstracted to SSDatabase concatOperator or similar. // This should be abstracted to SSDatabase concatOperator or similar.
$op = (DB::get_conn() instanceof MSSQLDatabase) ? " + " : " || "; $op = (DB::get_conn() instanceof MSSQLDatabase) ? " + " : " || ";
// Get title_format with fallback to default // Get title_format with fallback to default
$format = static::config()->title_format; $format = static::config()->title_format;
if (!$format) { if (!$format) {
$format = [ $format = [
'columns' => ['Surname', 'FirstName'], 'columns' => ['Surname', 'FirstName'],
'sep' => ' ', 'sep' => ' ',
]; ];
} }
$columnsWithTablename = array(); $columnsWithTablename = array();
foreach ($format['columns'] as $column) { foreach($format['columns'] as $column) {
$columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column); $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column);
} }
$sepSQL = Convert::raw2sql($format['sep'], true); $sepSQL = Convert::raw2sql($format['sep'], true);
return "(".join(" $op $sepSQL $op ", $columnsWithTablename).")"; return "(".join(" $op $sepSQL $op ", $columnsWithTablename).")";
} }
/** /**
* Get the complete name of the member * Get the complete name of the member
* *
* @return string Returns the first- and surname of the member. * @return string Returns the first- and surname of the member.
*/ */
public function getName() public function getName()
{ {
return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName; return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName;
} }
/** /**
* Set first- and surname * Set first- and surname
* *
* This method assumes that the last part of the name is the surname, e.g. * This method assumes that the last part of the name is the surname, e.g.
* <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i> * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
* *
* @param string $name The name * @param string $name The name
*/ */
public function setName($name) public function setName($name)
{ {
$nameParts = explode(' ', $name); $nameParts = explode(' ', $name);
$this->Surname = array_pop($nameParts); $this->Surname = array_pop($nameParts);
$this->FirstName = join(' ', $nameParts); $this->FirstName = join(' ', $nameParts);
} }
/** /**
* Alias for {@link setName} * Alias for {@link setName}
* *
* @param string $name The name * @param string $name The name
* @see setName() * @see setName()
*/ */
public function splitName($name) public function splitName($name)
{ {
return $this->setName($name); return $this->setName($name);
} }
/** /**
* Override the default getter for DateFormat so the * Override the default getter for DateFormat so the
* default format for the user's locale is used * default format for the user's locale is used
* if the user has not defined their own. * if the user has not defined their own.
* *
* @return string ISO date format * @return string ISO date format
*/ */
public function getDateFormat() public function getDateFormat()
{ {
if ($this->getField('DateFormat')) { if($this->getField('DateFormat')) {
return $this->getField('DateFormat'); return $this->getField('DateFormat');
} else { } else {
return i18n::config()->get('date_format'); return i18n::config()->get('date_format');
} }
} }
/** /**
* Override the default getter for TimeFormat so the * Override the default getter for TimeFormat so the
* default format for the user's locale is used * default format for the user's locale is used
* if the user has not defined their own. * if the user has not defined their own.
* *
* @return string ISO date format * @return string ISO date format
*/ */
public function getTimeFormat() public function getTimeFormat()
{ {
if ($this->getField('TimeFormat')) { if($this->getField('TimeFormat')) {
return $this->getField('TimeFormat'); return $this->getField('TimeFormat');
} else { } else {
return i18n::config()->get('time_format'); return i18n::config()->get('time_format');
} }
} }
//---------------------------------------------------------------------// //---------------------------------------------------------------------//
/** /**
* Get a "many-to-many" map that holds for all members their group memberships, * Get a "many-to-many" map that holds for all members their group memberships,
* including any parent groups where membership is implied. * including any parent groups where membership is implied.
* Use {@link DirectGroups()} to only retrieve the group relations without inheritance. * Use {@link DirectGroups()} to only retrieve the group relations without inheritance.
* *
* @todo Push all this logic into Member_GroupSet's getIterator()? * @todo Push all this logic into Member_GroupSet's getIterator()?
* @return Member_Groupset * @return Member_Groupset
*/ */
public function Groups() public function Groups()
{ {
$groups = Member_GroupSet::create('SilverStripe\\Security\\Group', 'Group_Members', 'GroupID', 'MemberID'); $groups = Member_GroupSet::create('SilverStripe\\Security\\Group', 'Group_Members', 'GroupID', 'MemberID');
$groups = $groups->forForeignID($this->ID); $groups = $groups->forForeignID($this->ID);
$this->extend('updateGroups', $groups); $this->extend('updateGroups', $groups);
return $groups; return $groups;
} }
/** /**
* @return ManyManyList * @return ManyManyList
*/ */
public function DirectGroups() public function DirectGroups()
{ {
return $this->getManyManyComponents('Groups'); return $this->getManyManyComponents('Groups');
} }
/** /**
* Get a member SQLMap of members in specific groups * Get a member SQLMap of members in specific groups
* *
* If no $groups is passed, all members will be returned * If no $groups is passed, all members will be returned
* *
* @param mixed $groups - takes a SS_List, an array or a single Group.ID * @param mixed $groups - takes a SS_List, an array or a single Group.ID
* @return Map Returns an Map that returns all Member data. * @return Map Returns an Map that returns all Member data.
*/ */
public static function map_in_groups($groups = null) public static function map_in_groups($groups = null)
{ {
$groupIDList = array(); $groupIDList = array();
if ($groups instanceof SS_List) { if($groups instanceof SS_List) {
foreach ($groups as $group) { foreach( $groups as $group ) {
$groupIDList[] = $group->ID; $groupIDList[] = $group->ID;
} }
} elseif (is_array($groups)) { } elseif(is_array($groups)) {
$groupIDList = $groups; $groupIDList = $groups;
} elseif ($groups) { } elseif($groups) {
$groupIDList[] = $groups; $groupIDList[] = $groups;
} }
// No groups, return all Members // No groups, return all Members
if (!$groupIDList) { if(!$groupIDList) {
return Member::get()->sort(array('Surname'=>'ASC', 'FirstName'=>'ASC'))->map(); return Member::get()->sort(array('Surname'=>'ASC', 'FirstName'=>'ASC'))->map();
} }
$membersList = new ArrayList(); $membersList = new ArrayList();
// This is a bit ineffective, but follow the ORM style // This is a bit ineffective, but follow the ORM style
foreach (Group::get()->byIDs($groupIDList) as $group) { foreach(Group::get()->byIDs($groupIDList) as $group) {
$membersList->merge($group->Members()); $membersList->merge($group->Members());
} }
$membersList->removeDuplicates('ID'); $membersList->removeDuplicates('ID');
return $membersList->map(); return $membersList->map();
} }
/** /**
* Get a map of all members in the groups given that have CMS permissions * Get a map of all members in the groups given that have CMS permissions
* *
* If no groups are passed, all groups with CMS permissions will be used. * If no groups are passed, all groups with CMS permissions will be used.
* *
* @param array $groups Groups to consider or NULL to use all groups with * @param array $groups Groups to consider or NULL to use all groups with
* CMS permissions. * CMS permissions.
* @return Map Returns a map of all members in the groups given that * @return Map Returns a map of all members in the groups given that
* have CMS permissions. * have CMS permissions.
*/ */
public static function mapInCMSGroups($groups = null) public static function mapInCMSGroups($groups = null)
{ {
if (!$groups || $groups->Count() == 0) { if(!$groups || $groups->Count() == 0) {
$perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin'); $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
if (class_exists('SilverStripe\\CMS\\Controllers\\CMSMain')) { if (class_exists('SilverStripe\\CMS\\Controllers\\CMSMain')) {
$cmsPerms = CMSMain::singleton()->providePermissions(); $cmsPerms = CMSMain::singleton()->providePermissions();
} else { } else {
$cmsPerms = LeftAndMain::singleton()->providePermissions(); $cmsPerms = LeftAndMain::singleton()->providePermissions();
} }
if (!empty($cmsPerms)) { if(!empty($cmsPerms)) {
$perms = array_unique(array_merge($perms, array_keys($cmsPerms))); $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
} }
$permsClause = DB::placeholders($perms); $permsClause = DB::placeholders($perms);
/** @skipUpgrade */ /** @skipUpgrade */
$groups = Group::get() $groups = Group::get()
->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"') ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"')
->where(array( ->where(array(
"\"Permission\".\"Code\" IN ($permsClause)" => $perms "\"Permission\".\"Code\" IN ($permsClause)" => $perms
)); ));
} }
$groupIDList = array(); $groupIDList = array();
if ($groups instanceof SS_List) { if($groups instanceof SS_List) {
foreach ($groups as $group) { foreach($groups as $group) {
$groupIDList[] = $group->ID; $groupIDList[] = $group->ID;
} }
} elseif (is_array($groups)) { } elseif(is_array($groups)) {
$groupIDList = $groups; $groupIDList = $groups;
} }
/** @skipUpgrade */ /** @skipUpgrade */
$members = Member::get() $members = Member::get()
->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"') ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"')
->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"'); ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"');
if ($groupIDList) { if($groupIDList) {
$groupClause = DB::placeholders($groupIDList); $groupClause = DB::placeholders($groupIDList);
$members = $members->where(array( $members = $members->where(array(
"\"Group\".\"ID\" IN ($groupClause)" => $groupIDList "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList
)); ));
} }
return $members->sort('"Member"."Surname", "Member"."FirstName"')->map(); return $members->sort('"Member"."Surname", "Member"."FirstName"')->map();
} }
/** /**
* Get the groups in which the member is NOT in * Get the groups in which the member is NOT in
* *
* When passed an array of groups, and a component set of groups, this * When passed an array of groups, and a component set of groups, this
* function will return the array of groups the member is NOT in. * function will return the array of groups the member is NOT in.
* *
* @param array $groupList An array of group code names. * @param array $groupList An array of group code names.
* @param array $memberGroups A component set of groups (if set to NULL, * @param array $memberGroups A component set of groups (if set to NULL,
* $this->groups() will be used) * $this->groups() will be used)
* @return array Groups in which the member is NOT in. * @return array Groups in which the member is NOT in.
*/ */
public function memberNotInGroups($groupList, $memberGroups = null) public function memberNotInGroups($groupList, $memberGroups = null)
{ {
if (!$memberGroups) { if (!$memberGroups) {
$memberGroups = $this->Groups(); $memberGroups = $this->Groups();
} }
foreach ($memberGroups as $group) { foreach($memberGroups as $group) {
if (in_array($group->Code, $groupList)) { if(in_array($group->Code, $groupList)) {
$index = array_search($group->Code, $groupList); $index = array_search($group->Code, $groupList);
unset($groupList[$index]); unset($groupList[$index]);
} }
} }
return $groupList; return $groupList;
} }
/** /**
* Return a {@link FieldList} of fields that would appropriate for editing * Return a {@link FieldList} of fields that would appropriate for editing
* this member. * this member.
* *
* @return FieldList Return a FieldList of fields that would appropriate for * @return FieldList Return a FieldList of fields that would appropriate for
* editing this member. * editing this member.
*/ */
public function getCMSFields() public function getCMSFields()
{ {
require_once 'Zend/Date.php'; require_once 'Zend/Date.php';
$self = $this; $self = $this;
$this->beforeUpdateCMSFields(function (FieldList $fields) use ($self) { $this->beforeUpdateCMSFields(function(FieldList $fields) use ($self) {
/** @var FieldList $mainFields */ /** @var FieldList $mainFields */
$mainFields = $fields->fieldByName("Root")->fieldByName("Main")->getChildren(); $mainFields = $fields->fieldByName("Root")->fieldByName("Main")->getChildren();
// Build change password field // Build change password field
$mainFields->replaceField('Password', $self->getMemberPasswordField()); $mainFields->replaceField('Password', $self->getMemberPasswordField());
$mainFields->replaceField('Locale', new DropdownField( $mainFields->replaceField('Locale', new DropdownField(
"Locale", "Locale",
_t('Member.INTERFACELANG', "Interface Language", 'Language of the CMS'), _t('Member.INTERFACELANG', "Interface Language", 'Language of the CMS'),
i18n::get_existing_translations() i18n::get_existing_translations()
)); ));
$mainFields->removeByName($self->config()->hidden_fields); $mainFields->removeByName($self->config()->hidden_fields);
if (! $self->config()->lock_out_after_incorrect_logins) { if( ! $self->config()->lock_out_after_incorrect_logins) {
$mainFields->removeByName('FailedLoginCount'); $mainFields->removeByName('FailedLoginCount');
} }
// Groups relation will get us into logical conflicts because // Groups relation will get us into logical conflicts because
// Members are displayed within group edit form in SecurityAdmin // Members are displayed within group edit form in SecurityAdmin
$fields->removeByName('Groups'); $fields->removeByName('Groups');
// Members shouldn't be able to directly view/edit logged passwords // Members shouldn't be able to directly view/edit logged passwords
$fields->removeByName('LoggedPasswords'); $fields->removeByName('LoggedPasswords');
$fields->removeByName('RememberLoginHashes'); $fields->removeByName('RememberLoginHashes');
if (Permission::check('EDIT_PERMISSIONS')) { if(Permission::check('EDIT_PERMISSIONS')) {
$groupsMap = array(); $groupsMap = array();
foreach (Group::get() as $group) { foreach(Group::get() as $group) {
// Listboxfield values are escaped, use ASCII char instead of &raquo; // Listboxfield values are escaped, use ASCII char instead of &raquo;
$groupsMap[$group->ID] = $group->getBreadcrumbs(' > '); $groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
} }
asort($groupsMap); asort($groupsMap);
$fields->addFieldToTab( $fields->addFieldToTab(
'Root.Main', 'Root.Main',
ListboxField::create('DirectGroups', singleton('SilverStripe\\Security\\Group')->i18n_plural_name()) ListboxField::create('DirectGroups', singleton('SilverStripe\\Security\\Group')->i18n_plural_name())
->setSource($groupsMap) ->setSource($groupsMap)
->setAttribute( ->setAttribute(
'data-placeholder', 'data-placeholder',
_t('Member.ADDGROUP', 'Add group', 'Placeholder text for a dropdown') _t('Member.ADDGROUP', 'Add group', 'Placeholder text for a dropdown')
) )
); );
// Add permission field (readonly to avoid complicated group assignment logic). // Add permission field (readonly to avoid complicated group assignment logic).
// This should only be available for existing records, as new records start // This should only be available for existing records, as new records start
// with no permissions until they have a group assignment anyway. // with no permissions until they have a group assignment anyway.
if ($self->ID) { if($self->ID) {
$permissionsField = new PermissionCheckboxSetField_Readonly( $permissionsField = new PermissionCheckboxSetField_Readonly(
'Permissions', 'Permissions',
false, false,
'SilverStripe\\Security\\Permission', 'SilverStripe\\Security\\Permission',
'GroupID', 'GroupID',
// we don't want parent relationships, they're automatically resolved in the field // we don't want parent relationships, they're automatically resolved in the field
$self->getManyManyComponents('Groups') $self->getManyManyComponents('Groups')
); );
$fields->findOrMakeTab('Root.Permissions', singleton('SilverStripe\\Security\\Permission')->i18n_plural_name()); $fields->findOrMakeTab('Root.Permissions', singleton('SilverStripe\\Security\\Permission')->i18n_plural_name());
$fields->addFieldToTab('Root.Permissions', $permissionsField); $fields->addFieldToTab('Root.Permissions', $permissionsField);
} }
} }
$permissionsTab = $fields->fieldByName("Root")->fieldByName('Permissions'); $permissionsTab = $fields->fieldByName("Root")->fieldByName('Permissions');
if ($permissionsTab) { if ($permissionsTab) {
$permissionsTab->addExtraClass('readonly'); $permissionsTab->addExtraClass('readonly');
} }
$defaultDateFormat = Zend_Locale_Format::getDateFormat(new Zend_Locale($self->Locale)); $defaultDateFormat = Zend_Locale_Format::getDateFormat(new Zend_Locale($self->Locale));
$dateFormatMap = array( $dateFormatMap = array(
'MMM d, yyyy' => Zend_Date::now()->toString('MMM d, yyyy'), 'MMM d, yyyy' => Zend_Date::now()->toString('MMM d, yyyy'),
'yyyy/MM/dd' => Zend_Date::now()->toString('yyyy/MM/dd'), 'yyyy/MM/dd' => Zend_Date::now()->toString('yyyy/MM/dd'),
'MM/dd/yyyy' => Zend_Date::now()->toString('MM/dd/yyyy'), 'MM/dd/yyyy' => Zend_Date::now()->toString('MM/dd/yyyy'),
'dd/MM/yyyy' => Zend_Date::now()->toString('dd/MM/yyyy'), 'dd/MM/yyyy' => Zend_Date::now()->toString('dd/MM/yyyy'),
); );
$dateFormatMap[$defaultDateFormat] = Zend_Date::now()->toString($defaultDateFormat) $dateFormatMap[$defaultDateFormat] = Zend_Date::now()->toString($defaultDateFormat)
. sprintf(' (%s)', _t('Member.DefaultDateTime', 'default')); . sprintf(' (%s)', _t('Member.DefaultDateTime', 'default'));
$mainFields->push( $mainFields->push(
$dateFormatField = new MemberDatetimeOptionsetField( $dateFormatField = new MemberDatetimeOptionsetField(
'DateFormat', 'DateFormat',
$self->fieldLabel('DateFormat'), $self->fieldLabel('DateFormat'),
$dateFormatMap $dateFormatMap
) )
); );
$formatClass = get_class($dateFormatField); $formatClass = get_class($dateFormatField);
$dateFormatField->setValue($self->DateFormat); $dateFormatField->setValue($self->DateFormat);
$dateTemplate = SSViewer::get_templates_by_class($formatClass, '_description_date', $formatClass); $dateTemplate = SSViewer::get_templates_by_class($formatClass, '_description_date', $formatClass);
$dateFormatField->setDescriptionTemplate($dateTemplate); $dateFormatField->setDescriptionTemplate($dateTemplate);
$defaultTimeFormat = Zend_Locale_Format::getTimeFormat(new Zend_Locale($self->Locale)); $defaultTimeFormat = Zend_Locale_Format::getTimeFormat(new Zend_Locale($self->Locale));
$timeFormatMap = array( $timeFormatMap = array(
'h:mm a' => Zend_Date::now()->toString('h:mm a'), 'h:mm a' => Zend_Date::now()->toString('h:mm a'),
'H:mm' => Zend_Date::now()->toString('H:mm'), 'H:mm' => Zend_Date::now()->toString('H:mm'),
); );
$timeFormatMap[$defaultTimeFormat] = Zend_Date::now()->toString($defaultTimeFormat) $timeFormatMap[$defaultTimeFormat] = Zend_Date::now()->toString($defaultTimeFormat)
. sprintf(' (%s)', _t('Member.DefaultDateTime', 'default')); . sprintf(' (%s)', _t('Member.DefaultDateTime', 'default'));
$mainFields->push( $mainFields->push(
$timeFormatField = new MemberDatetimeOptionsetField( $timeFormatField = new MemberDatetimeOptionsetField(
'TimeFormat', 'TimeFormat',
$self->fieldLabel('TimeFormat'), $self->fieldLabel('TimeFormat'),
$timeFormatMap $timeFormatMap
) )
); );
$timeFormatField->setValue($self->TimeFormat); $timeFormatField->setValue($self->TimeFormat);
$timeTemplate = SSViewer::get_templates_by_class($formatClass, '_description_time', $formatClass); $timeTemplate = SSViewer::get_templates_by_class($formatClass,'_description_time', $formatClass);
$timeFormatField->setDescriptionTemplate($timeTemplate); $timeFormatField->setDescriptionTemplate($timeTemplate);
}); });
return parent::getCMSFields(); return parent::getCMSFields();
} }
/** /**
* @param bool $includerelations Indicate if the labels returned include relation fields * @param bool $includerelations Indicate if the labels returned include relation fields
* @return array * @return array
*/ */
public function fieldLabels($includerelations = true) public function fieldLabels($includerelations = true)
{ {
$labels = parent::fieldLabels($includerelations); $labels = parent::fieldLabels($includerelations);
$labels['FirstName'] = _t('Member.FIRSTNAME', 'First Name'); $labels['FirstName'] = _t('Member.FIRSTNAME', 'First Name');
$labels['Surname'] = _t('Member.SURNAME', 'Surname'); $labels['Surname'] = _t('Member.SURNAME', 'Surname');
/** @skipUpgrade */ /** @skipUpgrade */
$labels['Email'] = _t('Member.EMAIL', 'Email'); $labels['Email'] = _t('Member.EMAIL', 'Email');
$labels['Password'] = _t('Member.db_Password', 'Password'); $labels['Password'] = _t('Member.db_Password', 'Password');
$labels['PasswordExpiry'] = _t('Member.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date'); $labels['PasswordExpiry'] = _t('Member.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date');
$labels['LockedOutUntil'] = _t('Member.db_LockedOutUntil', 'Locked out until', 'Security related date'); $labels['LockedOutUntil'] = _t('Member.db_LockedOutUntil', 'Locked out until', 'Security related date');
$labels['Locale'] = _t('Member.db_Locale', 'Interface Locale'); $labels['Locale'] = _t('Member.db_Locale', 'Interface Locale');
$labels['DateFormat'] = _t('Member.DATEFORMAT', 'Date format'); $labels['DateFormat'] = _t('Member.DATEFORMAT', 'Date format');
$labels['TimeFormat'] = _t('Member.TIMEFORMAT', 'Time format'); $labels['TimeFormat'] = _t('Member.TIMEFORMAT', 'Time format');
if ($includerelations) { if($includerelations){
$labels['Groups'] = _t( $labels['Groups'] = _t(
'Member.belongs_many_many_Groups', 'Member.belongs_many_many_Groups',
'Groups', 'Groups',
'Security Groups this member belongs to' 'Security Groups this member belongs to'
); );
} }
return $labels; return $labels;
} }
/** /**
* Users can view their own record. * Users can view their own record.
* Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions. * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions.
* This is likely to be customized for social sites etc. with a looser permission model. * This is likely to be customized for social sites etc. with a looser permission model.
* *
* @param Member $member * @param Member $member
* @return bool * @return bool
*/ */
public function canView($member = null) public function canView($member = null)
{ {
//get member //get member
if (!($member instanceof Member)) { if(!($member instanceof Member)) {
$member = Member::currentUser(); $member = Member::currentUser();
} }
//check for extensions, we do this first as they can overrule everything //check for extensions, we do this first as they can overrule everything
$extended = $this->extendedCan(__FUNCTION__, $member); $extended = $this->extendedCan(__FUNCTION__, $member);
if ($extended !== null) { if($extended !== null) {
return $extended; return $extended;
} }
//need to be logged in and/or most checks below rely on $member being a Member //need to be logged in and/or most checks below rely on $member being a Member
if (!$member) { if(!$member) {
return false; return false;
} }
// members can usually view their own record // members can usually view their own record
if ($this->ID == $member->ID) { if($this->ID == $member->ID) {
return true; return true;
} }
//standard check //standard check
return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
} }
/** /**
* Users can edit their own record. * Users can edit their own record.
* Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
* *
* @param Member $member * @param Member $member
* @return bool * @return bool
*/ */
public function canEdit($member = null) public function canEdit($member = null)
{ {
//get member //get member
if (!($member instanceof Member)) { if(!($member instanceof Member)) {
$member = Member::currentUser(); $member = Member::currentUser();
} }
//check for extensions, we do this first as they can overrule everything //check for extensions, we do this first as they can overrule everything
$extended = $this->extendedCan(__FUNCTION__, $member); $extended = $this->extendedCan(__FUNCTION__, $member);
if ($extended !== null) { if($extended !== null) {
return $extended; return $extended;
} }
//need to be logged in and/or most checks below rely on $member being a Member //need to be logged in and/or most checks below rely on $member being a Member
if (!$member) { if(!$member) {
return false; return false;
} }
// HACK: we should not allow for an non-Admin to edit an Admin // HACK: we should not allow for an non-Admin to edit an Admin
if (!Permission::checkMember($member, 'ADMIN') && Permission::checkMember($this, 'ADMIN')) { if(!Permission::checkMember($member, 'ADMIN') && Permission::checkMember($this, 'ADMIN')) {
return false; return false;
} }
// members can usually edit their own record // members can usually edit their own record
if ($this->ID == $member->ID) { if($this->ID == $member->ID) {
return true; return true;
} }
//standard check //standard check
@ -1686,36 +1686,36 @@ class Member extends DataObject implements TemplateGlobalProvider
/** /**
* Users can edit their own record. * Users can edit their own record.
* Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
* *
* @param Member $member * @param Member $member
* @return bool * @return bool
*/ */
public function canDelete($member = null) public function canDelete($member = null)
{ {
if (!($member instanceof Member)) { if(!($member instanceof Member)) {
$member = Member::currentUser(); $member = Member::currentUser();
} }
//check for extensions, we do this first as they can overrule everything //check for extensions, we do this first as they can overrule everything
$extended = $this->extendedCan(__FUNCTION__, $member); $extended = $this->extendedCan(__FUNCTION__, $member);
if ($extended !== null) { if($extended !== null) {
return $extended; return $extended;
} }
//need to be logged in and/or most checks below rely on $member being a Member //need to be logged in and/or most checks below rely on $member being a Member
if (!$member) { if(!$member) {
return false; return false;
} }
// Members are not allowed to remove themselves, // Members are not allowed to remove themselves,
// since it would create inconsistencies in the admin UIs. // since it would create inconsistencies in the admin UIs.
if ($this->ID && $member->ID == $this->ID) { if($this->ID && $member->ID == $this->ID) {
return false; return false;
} }
// HACK: if you want to delete a member, you have to be a member yourself. // HACK: if you want to delete a member, you have to be a member yourself.
// this is a hack because what this should do is to stop a user // this is a hack because what this should do is to stop a user
// deleting a member who has more privileges (e.g. a non-Admin deleting an Admin) // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin)
if (Permission::checkMember($this, 'ADMIN')) { if(Permission::checkMember($this, 'ADMIN')) {
if (! Permission::checkMember($member, 'ADMIN')) { if( ! Permission::checkMember($member, 'ADMIN')) {
return false; return false;
} }
} }
@ -1723,111 +1723,111 @@ class Member extends DataObject implements TemplateGlobalProvider
return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
} }
/** /**
* Validate this member object. * Validate this member object.
*/ */
public function validate() public function validate()
{ {
$valid = parent::validate(); $valid = parent::validate();
if (!$this->ID || $this->isChanged('Password')) { if(!$this->ID || $this->isChanged('Password')) {
if ($this->Password && self::$password_validator) { if($this->Password && self::$password_validator) {
$valid->combineAnd(self::$password_validator->validate($this->Password, $this)); $valid->combineAnd(self::$password_validator->validate($this->Password, $this));
} }
} }
if ((!$this->ID && $this->SetPassword) || $this->isChanged('SetPassword')) { if((!$this->ID && $this->SetPassword) || $this->isChanged('SetPassword')) {
if ($this->SetPassword && self::$password_validator) { if($this->SetPassword && self::$password_validator) {
$valid->combineAnd(self::$password_validator->validate($this->SetPassword, $this)); $valid->combineAnd(self::$password_validator->validate($this->SetPassword, $this));
} }
} }
return $valid; return $valid;
} }
/** /**
* Change password. This will cause rehashing according to * Change password. This will cause rehashing according to
* the `PasswordEncryption` property. * the `PasswordEncryption` property.
* *
* @param string $password Cleartext password * @param string $password Cleartext password
* @return ValidationResult * @return ValidationResult
*/ */
public function changePassword($password) public function changePassword($password)
{ {
$this->Password = $password; $this->Password = $password;
$valid = $this->validate(); $valid = $this->validate();
if ($valid->valid()) { if($valid->valid()) {
$this->AutoLoginHash = null; $this->AutoLoginHash = null;
$this->write(); $this->write();
} }
return $valid; return $valid;
} }
/** /**
* Tell this member that someone made a failed attempt at logging in as them. * Tell this member that someone made a failed attempt at logging in as them.
* This can be used to lock the user out temporarily if too many failed attempts are made. * This can be used to lock the user out temporarily if too many failed attempts are made.
*/ */
public function registerFailedLogin() public function registerFailedLogin()
{ {
if (self::config()->lock_out_after_incorrect_logins) { if(self::config()->lock_out_after_incorrect_logins) {
// Keep a tally of the number of failed log-ins so that we can lock people out // Keep a tally of the number of failed log-ins so that we can lock people out
$this->FailedLoginCount = $this->FailedLoginCount + 1; $this->FailedLoginCount = $this->FailedLoginCount + 1;
if ($this->FailedLoginCount >= self::config()->lock_out_after_incorrect_logins) { if($this->FailedLoginCount >= self::config()->lock_out_after_incorrect_logins) {
$lockoutMins = self::config()->lock_out_delay_mins; $lockoutMins = self::config()->lock_out_delay_mins;
$this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->Format('U') + $lockoutMins*60); $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->Format('U') + $lockoutMins*60);
$this->FailedLoginCount = 0; $this->FailedLoginCount = 0;
} }
} }
$this->extend('registerFailedLogin'); $this->extend('registerFailedLogin');
$this->write(); $this->write();
} }
/** /**
* Tell this member that a successful login has been made * Tell this member that a successful login has been made
*/ */
public function registerSuccessfulLogin() public function registerSuccessfulLogin()
{ {
if (self::config()->lock_out_after_incorrect_logins) { if(self::config()->lock_out_after_incorrect_logins) {
// Forgive all past login failures // Forgive all past login failures
$this->FailedLoginCount = 0; $this->FailedLoginCount = 0;
$this->write(); $this->write();
} }
} }
/** /**
* Get the HtmlEditorConfig for this user to be used in the CMS. * Get the HtmlEditorConfig for this user to be used in the CMS.
* This is set by the group. If multiple configurations are set, * This is set by the group. If multiple configurations are set,
* the one with the highest priority wins. * the one with the highest priority wins.
* *
* @return string * @return string
*/ */
public function getHtmlEditorConfigForCMS() public function getHtmlEditorConfigForCMS()
{ {
$currentName = ''; $currentName = '';
$currentPriority = 0; $currentPriority = 0;
foreach ($this->Groups() as $group) { foreach($this->Groups() as $group) {
$configName = $group->HtmlEditorConfig; $configName = $group->HtmlEditorConfig;
if ($configName) { if($configName) {
$config = HTMLEditorConfig::get($group->HtmlEditorConfig); $config = HTMLEditorConfig::get($group->HtmlEditorConfig);
if ($config && $config->getOption('priority') > $currentPriority) { if($config && $config->getOption('priority') > $currentPriority) {
$currentName = $configName; $currentName = $configName;
$currentPriority = $config->getOption('priority'); $currentPriority = $config->getOption('priority');
} }
} }
} }
// If can't find a suitable editor, just default to cms // If can't find a suitable editor, just default to cms
return $currentName ? $currentName : 'cms'; return $currentName ? $currentName : 'cms';
} }
public static function get_template_global_variables() public static function get_template_global_variables()
{ {
return array( return array(
'CurrentMember' => 'currentUser', 'CurrentMember' => 'currentUser',
'currentUser', 'currentUser',
); );
} }
} }

View File

@ -16,212 +16,208 @@ use InvalidArgumentException;
class MemberAuthenticator extends Authenticator class MemberAuthenticator extends Authenticator
{ {
/** /**
* Contains encryption algorithm identifiers. * Contains encryption algorithm identifiers.
* If set, will migrate to new precision-safe password hashing * If set, will migrate to new precision-safe password hashing
* upon login. See http://open.silverstripe.org/ticket/3004 * upon login. See http://open.silverstripe.org/ticket/3004
* *
* @var array * @var array
*/ */
private static $migrate_legacy_hashes = array( private static $migrate_legacy_hashes = array(
'md5' => 'md5_v2.4', 'md5' => 'md5_v2.4',
'sha1' => 'sha1_v2.4' 'sha1' => 'sha1_v2.4'
); );
/** /**
* Attempt to find and authenticate member if possible from the given data * Attempt to find and authenticate member if possible from the given data
* *
* @param array $data * @param array $data
* @param Form $form * @param Form $form
* @param bool &$success Success flag * @param bool &$success Success flag
* @return Member Found member, regardless of successful login * @return Member Found member, regardless of successful login
*/ */
protected static function authenticate_member($data, $form, &$success) protected static function authenticate_member($data, $form, &$success)
{ {
// Default success to false // Default success to false
$success = false; $success = false;
// Attempt to identify by temporary ID // Attempt to identify by temporary ID
$member = null; $member = null;
$email = null; $email = null;
if (!empty($data['tempid'])) { if(!empty($data['tempid'])) {
// Find user by tempid, in case they are re-validating an existing session // Find user by tempid, in case they are re-validating an existing session
$member = Member::member_from_tempid($data['tempid']); $member = Member::member_from_tempid($data['tempid']);
if ($member) { if ($member) {
$email = $member->Email; $email = $member->Email;
} }
} }
// Otherwise, get email from posted value instead // Otherwise, get email from posted value instead
/** @skipUpgrade */ /** @skipUpgrade */
if (!$member && !empty($data['Email'])) { if(!$member && !empty($data['Email'])) {
$email = $data['Email']; $email = $data['Email'];
} }
// Check default login (see Security::setDefaultAdmin()) // Check default login (see Security::setDefaultAdmin())
$asDefaultAdmin = $email === Security::default_admin_username(); $asDefaultAdmin = $email === Security::default_admin_username();
if ($asDefaultAdmin) { if($asDefaultAdmin) {
// If logging is as default admin, ensure record is setup correctly // If logging is as default admin, ensure record is setup correctly
$member = Member::default_admin(); $member = Member::default_admin();
$success = !$member->isLockedOut() && Security::check_default_admin($email, $data['Password']); $success = !$member->isLockedOut() && Security::check_default_admin($email, $data['Password']);
//protect against failed login //protect against failed login
if ($success) { if($success) {
return $member; return $member;
} }
} }
// Attempt to identify user by email // Attempt to identify user by email
if (!$member && $email) { if(!$member && $email) {
// Find user by email // Find user by email
$member = Member::get() $member = Member::get()
->filter(Member::config()->unique_identifier_field, $email) ->filter(Member::config()->unique_identifier_field, $email)
->first(); ->first();
} }
// Validate against member if possible // Validate against member if possible
if ($member && !$asDefaultAdmin) { if($member && !$asDefaultAdmin) {
$result = $member->checkPassword($data['Password']); $result = $member->checkPassword($data['Password']);
$success = $result->valid(); $success = $result->valid();
} else { } else {
$result = new ValidationResult(false, _t('Member.ERRORWRONGCRED')); $result = ValidationResult::create()->addError(_t('Member.ERRORWRONGCRED'));
} }
// Emit failure to member and form (if available) // Emit failure to member and form (if available)
if (!$success) { if(!$success) {
if ($member) { if($member) $member->registerFailedLogin();
$member->registerFailedLogin(); if($form) $form->setSessionValidationResult($result, true);
} } else {
if ($form) {
$form->sessionMessage($result->message(), 'bad');
}
} else {
if ($member) { if ($member) {
$member->registerSuccessfulLogin(); $member->registerSuccessfulLogin();
} }
} }
return $member; return $member;
} }
/** /**
* Log login attempt * Log login attempt
* TODO We could handle this with an extension * TODO We could handle this with an extension
* *
* @param array $data * @param array $data
* @param Member $member * @param Member $member
* @param bool $success * @param bool $success
*/ */
protected static function record_login_attempt($data, $member, $success) protected static function record_login_attempt($data, $member, $success)
{ {
if (!Security::config()->login_recording) { if (!Security::config()->login_recording) {
return; return;
} }
// Check email is valid // Check email is valid
/** @skipUpgrade */ /** @skipUpgrade */
$email = isset($data['Email']) ? $data['Email'] : null; $email = isset($data['Email']) ? $data['Email'] : null;
if (is_array($email)) { if(is_array($email)) {
throw new InvalidArgumentException("Bad email passed to MemberAuthenticator::authenticate(): $email"); throw new InvalidArgumentException("Bad email passed to MemberAuthenticator::authenticate(): $email");
} }
$attempt = new LoginAttempt(); $attempt = new LoginAttempt();
if ($success) { if($success) {
// successful login (member is existing with matching password) // successful login (member is existing with matching password)
$attempt->MemberID = $member->ID; $attempt->MemberID = $member->ID;
$attempt->Status = 'Success'; $attempt->Status = 'Success';
// Audit logging hook // Audit logging hook
$member->extend('authenticated'); $member->extend('authenticated');
} else { } else {
// Failed login - we're trying to see if a user exists with this email (disregarding wrong passwords) // Failed login - we're trying to see if a user exists with this email (disregarding wrong passwords)
$attempt->Status = 'Failure'; $attempt->Status = 'Failure';
if ($member) { if($member) {
// Audit logging hook // Audit logging hook
$attempt->MemberID = $member->ID; $attempt->MemberID = $member->ID;
$member->extend('authenticationFailed'); $member->extend('authenticationFailed');
} else { } else {
// Audit logging hook // Audit logging hook
Member::singleton()->extend('authenticationFailedUnknownUser', $data); Member::singleton()->extend('authenticationFailedUnknownUser', $data);
} }
} }
$attempt->Email = $email; $attempt->Email = $email;
$attempt->IP = Controller::curr()->getRequest()->getIP(); $attempt->IP = Controller::curr()->getRequest()->getIP();
$attempt->write(); $attempt->write();
} }
/** /**
* Method to authenticate an user * Method to authenticate an user
* *
* @param array $data Raw data to authenticate the user * @param array $data Raw data to authenticate the user
* @param Form $form Optional: If passed, better error messages can be * @param Form $form Optional: If passed, better error messages can be
* produced by using * produced by using
* {@link Form::sessionMessage()} * {@link Form::sessionMessage()}
* @return bool|Member Returns FALSE if authentication fails, otherwise * @return bool|Member Returns FALSE if authentication fails, otherwise
* the member object * the member object
* @see Security::setDefaultAdmin() * @see Security::setDefaultAdmin()
*/ */
public static function authenticate($data, Form $form = null) public static function authenticate($data, Form $form = null)
{ {
// Find authenticated member // Find authenticated member
$member = static::authenticate_member($data, $form, $success); $member = static::authenticate_member($data, $form, $success);
// Optionally record every login attempt as a {@link LoginAttempt} object // Optionally record every login attempt as a {@link LoginAttempt} object
static::record_login_attempt($data, $member, $success); static::record_login_attempt($data, $member, $success);
// Legacy migration to precision-safe password hashes. // Legacy migration to precision-safe password hashes.
// A login-event with cleartext passwords is the only time // A login-event with cleartext passwords is the only time
// when we can rehash passwords to a different hashing algorithm, // when we can rehash passwords to a different hashing algorithm,
// bulk-migration doesn't work due to the nature of hashing. // bulk-migration doesn't work due to the nature of hashing.
// See PasswordEncryptor_LegacyPHPHash class. // See PasswordEncryptor_LegacyPHPHash class.
if ($success && $member && isset(self::$migrate_legacy_hashes[$member->PasswordEncryption])) { if($success && $member && isset(self::$migrate_legacy_hashes[$member->PasswordEncryption])) {
$member->Password = $data['Password']; $member->Password = $data['Password'];
$member->PasswordEncryption = self::$migrate_legacy_hashes[$member->PasswordEncryption]; $member->PasswordEncryption = self::$migrate_legacy_hashes[$member->PasswordEncryption];
$member->write(); $member->write();
} }
if ($success) { if ($success) {
Session::clear('BackURL'); Session::clear('BackURL');
} }
return $success ? $member : null; return $success ? $member : null;
} }
/** /**
* Method that creates the login form for this authentication method * Method that creates the login form for this authentication method
* *
* @param Controller $controller The parent controller, necessary to create the * @param Controller $controller The parent controller, necessary to create the
* appropriate form action tag * appropriate form action tag
* @return Form Returns the login form to use with this authentication * @return Form Returns the login form to use with this authentication
* method * method
*/ */
public static function get_login_form(Controller $controller) public static function get_login_form(Controller $controller)
{ {
/** @skipUpgrade */ /** @skipUpgrade */
return MemberLoginForm::create($controller, "LoginForm"); return MemberLoginForm::create($controller, "LoginForm");
} }
public static function get_cms_login_form(Controller $controller) public static function get_cms_login_form(Controller $controller)
{ {
/** @skipUpgrade */ /** @skipUpgrade */
return CMSMemberLoginForm::create($controller, "LoginForm"); return CMSMemberLoginForm::create($controller, "LoginForm");
} }
public static function supports_cms() public static function supports_cms()
{ {
// Don't automatically support subclasses of MemberAuthenticator // Don't automatically support subclasses of MemberAuthenticator
return get_called_class() === __CLASS__; return get_called_class() === __CLASS__;
} }
/** /**
* Get the name of the authentication method * Get the name of the authentication method
* *
* @return string Returns the name of the authentication method. * @return string Returns the name of the authentication method.
*/ */
public static function get_name() public static function get_name()
{ {
return _t('MemberAuthenticator.TITLE', "E-mail &amp; Password"); return _t('MemberAuthenticator.TITLE', "E-mail &amp; Password");
} }
} }

View File

@ -20,128 +20,131 @@ use SilverStripe\ORM\ValidationResult;
class PasswordValidator extends Object class PasswordValidator extends Object
{ {
private static $character_strength_tests = array( private static $character_strength_tests = array(
'lowercase' => '/[a-z]/', 'lowercase' => '/[a-z]/',
'uppercase' => '/[A-Z]/', 'uppercase' => '/[A-Z]/',
'digits' => '/[0-9]/', 'digits' => '/[0-9]/',
'punctuation' => '/[^A-Za-z0-9]/', 'punctuation' => '/[^A-Za-z0-9]/',
); );
protected $minLength, $minScore, $testNames, $historicalPasswordCount; protected $minLength, $minScore, $testNames, $historicalPasswordCount;
/** /**
* Minimum password length * Minimum password length
* *
* @param int $minLength * @param int $minLength
* @return $this * @return $this
*/ */
public function minLength($minLength) public function minLength($minLength)
{ {
$this->minLength = $minLength; $this->minLength = $minLength;
return $this; return $this;
} }
/** /**
* Check the character strength of the password. * Check the character strength of the password.
* *
* Eg: $this->characterStrength(3, array("lowercase", "uppercase", "digits", "punctuation")) * Eg: $this->characterStrength(3, array("lowercase", "uppercase", "digits", "punctuation"))
* *
* @param int $minScore The minimum number of character tests that must pass * @param int $minScore The minimum number of character tests that must pass
* @param array $testNames The names of the tests to perform * @param array $testNames The names of the tests to perform
* @return $this * @return $this
*/ */
public function characterStrength($minScore, $testNames) public function characterStrength($minScore, $testNames)
{ {
$this->minScore = $minScore; $this->minScore = $minScore;
$this->testNames = $testNames; $this->testNames = $testNames;
return $this; return $this;
} }
/** /**
* Check a number of previous passwords that the user has used, and don't let them change to that. * Check a number of previous passwords that the user has used, and don't let them change to that.
* *
* @param int $count * @param int $count
* @return $this * @return $this
*/ */
public function checkHistoricalPasswords($count) public function checkHistoricalPasswords($count)
{ {
$this->historicalPasswordCount = $count; $this->historicalPasswordCount = $count;
return $this; return $this;
} }
/** /**
* @param String $password * @param String $password
* @param Member $member * @param Member $member
* @return ValidationResult * @return ValidationResult
*/ */
public function validate($password, $member) public function validate($password, $member)
{ {
$valid = ValidationResult::create(); $valid = ValidationResult::create();
if ($this->minLength) { if($this->minLength) {
if (strlen($password) < $this->minLength) { if(strlen($password) < $this->minLength) {
$valid->error( $valid->addError(
sprintf( sprintf(
_t( _t(
'PasswordValidator.TOOSHORT', 'PasswordValidator.TOOSHORT',
'Password is too short, it must be %s or more characters long' 'Password is too short, it must be %s or more characters long'
), ),
$this->minLength $this->minLength
), ),
'TOO_SHORT' 'bad',
); 'TOO_SHORT'
} );
} }
}
if ($this->minScore) { if($this->minScore) {
$score = 0; $score = 0;
$missedTests = array(); $missedTests = array();
foreach ($this->testNames as $name) { foreach($this->testNames as $name) {
if (preg_match(self::config()->character_strength_tests[$name], $password)) { if(preg_match(self::config()->character_strength_tests[$name], $password)) {
$score++; $score++;
} else { } else {
$missedTests[] = _t( $missedTests[] = _t(
'PasswordValidator.STRENGTHTEST' . strtoupper($name), 'PasswordValidator.STRENGTHTEST' . strtoupper($name),
$name, $name,
'The user needs to add this to their password for more complexity' 'The user needs to add this to their password for more complexity'
); );
} }
} }
if ($score < $this->minScore) { if($score < $this->minScore) {
$valid->error( $valid->addError(
sprintf( sprintf(
_t( _t(
'PasswordValidator.LOWCHARSTRENGTH', 'PasswordValidator.LOWCHARSTRENGTH',
'Please increase password strength by adding some of the following characters: %s' 'Please increase password strength by adding some of the following characters: %s'
), ),
implode(', ', $missedTests) implode(', ', $missedTests)
), ),
'LOW_CHARACTER_STRENGTH' 'bad',
); 'LOW_CHARACTER_STRENGTH'
} );
} }
}
if ($this->historicalPasswordCount) { if($this->historicalPasswordCount) {
$previousPasswords = MemberPassword::get() $previousPasswords = MemberPassword::get()
->where(array('"MemberPassword"."MemberID"' => $member->ID)) ->where(array('"MemberPassword"."MemberID"' => $member->ID))
->sort('"Created" DESC, "ID" DESC') ->sort('"Created" DESC, "ID" DESC')
->limit($this->historicalPasswordCount); ->limit($this->historicalPasswordCount);
/** @var MemberPassword $previousPassword */ /** @var MemberPassword $previousPassword */
foreach ($previousPasswords as $previousPassword) { foreach($previousPasswords as $previousPassword) {
if ($previousPassword->checkPassword($password)) { if($previousPassword->checkPassword($password)) {
$valid->error( $valid->addError(
_t( _t(
'PasswordValidator.PREVPASSWORD', 'PasswordValidator.PREVPASSWORD',
'You\'ve already used that password in the past, please choose a new password' 'You\'ve already used that password in the past, please choose a new password'
), ),
'PREVIOUS_PASSWORD' 'bad',
); 'PREVIOUS_PASSWORD'
break; );
} break;
} }
} }
}
return $valid; return $valid;
} }
} }

View File

@ -13,50 +13,50 @@ use SilverStripe\ORM\DataObject;
*/ */
class PermissionRoleCode extends DataObject class PermissionRoleCode extends DataObject
{ {
private static $db = array( private static $db = array(
"Code" => "Varchar", "Code" => "Varchar",
); );
private static $has_one = array( private static $has_one = array(
"Role" => "SilverStripe\\Security\\PermissionRole", "Role" => "SilverStripe\\Security\\PermissionRole",
); );
private static $table_name = "PermissionRoleCode"; private static $table_name = "PermissionRoleCode";
public function validate() public function validate()
{ {
$result = parent::validate(); $result = parent::validate();
// Check that new code doesn't increase privileges, unless an admin is editing. // Check that new code doesn't increase privileges, unless an admin is editing.
$privilegedCodes = Permission::config()->privileged_permissions; $privilegedCodes = Permission::config()->privileged_permissions;
if ($this->Code if ($this->Code
&& in_array($this->Code, $privilegedCodes) && in_array($this->Code, $privilegedCodes)
&& !Permission::check('ADMIN') && !Permission::check('ADMIN')
) { ) {
$result->error(sprintf( $result->addError(sprintf(
_t( _t(
'PermissionRoleCode.PermsError', 'PermissionRoleCode.PermsError',
'Can\'t assign code "%s" with privileged permissions (requires ADMIN access)' 'Can\'t assign code "%s" with privileged permissions (requires ADMIN access)'
), ),
$this->Code $this->Code
)); ));
} }
return $result; return $result;
} }
public function canCreate($member = null, $context = array()) public function canCreate($member = null, $context = array())
{ {
return Permission::check('APPLY_ROLES', 'any', $member); return Permission::check('APPLY_ROLES', 'any', $member);
} }
public function canEdit($member = null) public function canEdit($member = null)
{ {
return Permission::check('APPLY_ROLES', 'any', $member); return Permission::check('APPLY_ROLES', 'any', $member);
} }
public function canDelete($member = null) public function canDelete($member = null)
{ {
return Permission::check('APPLY_ROLES', 'any', $member); return Permission::check('APPLY_ROLES', 'any', $member);
} }
} }

View File

@ -436,6 +436,34 @@ class FormTest extends FunctionalTest {
); );
} }
public function testValidationException() {
$this->get('FormTest_Controller');
$response = $this->post(
'FormTest_Controller/Form',
array(
'Email' => 'test@test.com',
'SomeRequiredField' => 'test',
'action_triggerException' => 1,
)
);
$this->assertPartialMatchBySelector(
'#Form_Form_Email_Holder span.message',
array(
'Error on Email field'
),
'Formfield validation shows note on field if invalid'
);
$this->assertPartialMatchBySelector(
'#Form_Form_error',
array(
'Error at top of form'
),
'Required fields show a notification on field when left blank'
);
}
public function testGloballyDisabledSecurityTokenInheritsToNewForm() { public function testGloballyDisabledSecurityTokenInheritsToNewForm() {
SecurityToken::enable(); SecurityToken::enable();
@ -758,6 +786,7 @@ class FormTest extends FunctionalTest {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->sessionMessage('<em>Escaped HTML</em>', 'good', true); $form->sessionMessage('<em>Escaped HTML</em>', 'good', true);
$form->setupFormErrors();
$parser = new CSSContentParser($form->forTemplate()); $parser = new CSSContentParser($form->forTemplate());
$messageEls = $parser->getBySelector('.message'); $messageEls = $parser->getBySelector('.message');
$this->assertContains( $this->assertContains(
@ -768,6 +797,7 @@ class FormTest extends FunctionalTest {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->sessionMessage('<em>Unescaped HTML</em>', 'good', false); $form->sessionMessage('<em>Unescaped HTML</em>', 'good', false);
$form->setupFormErrors();
$parser = new CSSContentParser($form->forTemplate()); $parser = new CSSContentParser($form->forTemplate());
$messageEls = $parser->getBySelector('.message'); $messageEls = $parser->getBySelector('.message');
$this->assertContains( $this->assertContains(
@ -779,7 +809,7 @@ class FormTest extends FunctionalTest {
function testFieldMessageEscapeHtml() { function testFieldMessageEscapeHtml() {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->addErrorMessage('key1', '<em>Escaped HTML</em>', 'good', true); $form->getSessionValidationResult()->addFieldMessage('key1', '<em>Escaped HTML</em>', 'good');
$form->setupFormErrors(); $form->setupFormErrors();
$parser = new CSSContentParser($result = $form->forTemplate()); $parser = new CSSContentParser($result = $form->forTemplate());
$messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message'); $messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message');
@ -790,7 +820,7 @@ class FormTest extends FunctionalTest {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->addErrorMessage('key1', '<em>Unescaped HTML</em>', 'good', false); $form->getSessionValidationResult()->addFieldMessage('key1', '<em>Unescaped HTML</em>', 'good', null, false);
$form->setupFormErrors(); $form->setupFormErrors();
$parser = new CSSContentParser($form->forTemplate()); $parser = new CSSContentParser($form->forTemplate());
$messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message'); $messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message');

View File

@ -1762,6 +1762,6 @@ class DataObjectTest extends SapphireTest {
$staff->Salary = PHP_INT_MAX; $staff->Salary = PHP_INT_MAX;
$staff->write(); $staff->write();
$this->assertEquals(PHP_INT_MAX, DataObjectTest\Staff::get()->byID($staff->ID)->Salary); $this->assertEquals(PHP_INT_MAX, DataObjectTest\Staff::get()->byID($staff->ID)->Salary);
} }
} }

View File

@ -31,7 +31,17 @@ class HierarchyTest extends SapphireTest {
$obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa');
$obj2->ParentID = $obj2aa->ID; $obj2->ParentID = $obj2aa->ID;
$obj2->write(); $obj2->write();
}
catch (ValidationException $e) {
$this->assertContains(
Convert::raw2xml('Infinite loop found within the "HierarchyTest_Object" hierarchy'),
$e->getMessage()
);
return;
}
$this->fail('Failed to prevent infinite loop in hierarchy.');
} }
/** /**

View File

@ -13,12 +13,14 @@ class ValidationExceptionTest extends SapphireTest
*/ */
public function testCreateFromValidationResult() { public function testCreateFromValidationResult() {
$result = new ValidationResult(false, 'Not a valid result'); $result = new ValidationResult();
$result->addError('Not a valid result');
$exception = new ValidationException($result); $exception = new ValidationException($result);
$this->assertEquals(0, $exception->getCode()); $this->assertEquals(0, $exception->getCode());
$this->assertEquals('Not a valid result', $exception->getMessage()); $this->assertEquals('Not a valid result', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid()); $this->assertFalse($exception->getResult()->valid());
$this->assertEquals('Not a valid result', $exception->getResult()->message()); $this->assertEquals('Not a valid result', $exception->getResult()->message());
} }
@ -29,8 +31,8 @@ class ValidationExceptionTest extends SapphireTest
*/ */
public function testCreateFromComplexValidationResult() { public function testCreateFromComplexValidationResult() {
$result = new ValidationResult(); $result = new ValidationResult();
$result->error('Invalid type') $result->addError('Invalid type')
->error('Out of kiwis'); ->addError('Out of kiwis');
$exception = new ValidationException($result); $exception = new ValidationException($result);
$this->assertEquals(0, $exception->getCode()); $this->assertEquals(0, $exception->getCode());
@ -48,7 +50,7 @@ class ValidationExceptionTest extends SapphireTest
$this->assertEquals(E_USER_ERROR, $exception->getCode()); $this->assertEquals(E_USER_ERROR, $exception->getCode());
$this->assertEquals('Error inferred from message', $exception->getMessage()); $this->assertEquals('Error inferred from message', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid()); $this->assertFalse($exception->getResult()->valid());
$this->assertEquals('Error inferred from message', $exception->getResult()->message()); $this->assertEquals('Error inferred from message', $exception->getResult()->message());
} }
@ -57,12 +59,13 @@ class ValidationExceptionTest extends SapphireTest
* and a custom message * and a custom message
*/ */
public function testCreateWithValidationResultAndMessage() { public function testCreateWithValidationResultAndMessage() {
$result = new ValidationResult(false, 'Incorrect placement of cutlery'); $result = new ValidationResult();
$result->addError('Incorrect placement of cutlery');
$exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING); $exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING);
$this->assertEquals(E_USER_WARNING, $exception->getCode()); $this->assertEquals(E_USER_WARNING, $exception->getCode());
$this->assertEquals('An error has occurred', $exception->getMessage()); $this->assertEquals('An error has occurred', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid()); $this->assertFalse($exception->getResult()->valid());
$this->assertEquals('Incorrect placement of cutlery', $exception->getResult()->message()); $this->assertEquals('Incorrect placement of cutlery', $exception->getResult()->message());
} }
@ -73,8 +76,8 @@ class ValidationExceptionTest extends SapphireTest
*/ */
public function testCreateWithComplexValidationResultAndMessage() { public function testCreateWithComplexValidationResultAndMessage() {
$result = new ValidationResult(); $result = new ValidationResult();
$result->error('A spork is not a knife') $result->addError('A spork is not a knife')
->error('A knife is not a back scratcher'); ->addError('A knife is not a back scratcher');
$exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING); $exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING);
$this->assertEquals(E_USER_WARNING, $exception->getCode()); $this->assertEquals(E_USER_WARNING, $exception->getCode());
@ -91,8 +94,8 @@ class ValidationExceptionTest extends SapphireTest
$result = new ValidationResult(); $result = new ValidationResult();
$anotherresult = new ValidationResult(); $anotherresult = new ValidationResult();
$yetanotherresult = new ValidationResult(); $yetanotherresult = new ValidationResult();
$anotherresult->error("Eat with your mouth closed", "EATING101"); $anotherresult->addError("Eat with your mouth closed", 'bad', "EATING101");
$yetanotherresult->error("You didn't wash your hands", "BECLEAN"); $yetanotherresult->addError("You didn't wash your hands", 'bad', "BECLEAN", false);
$this->assertTrue($result->valid()); $this->assertTrue($result->valid());
$this->assertFalse($anotherresult->valid()); $this->assertFalse($anotherresult->valid());
@ -104,7 +107,53 @@ class ValidationExceptionTest extends SapphireTest
$this->assertEquals(array( $this->assertEquals(array(
"EATING101" => "Eat with your mouth closed", "EATING101" => "Eat with your mouth closed",
"BECLEAN" => "You didn't wash your hands" "BECLEAN" => "You didn't wash your hands"
),$result->messageList()); ), $result->messageList());
}
/**
* Test that a ValidationException created with no contained ValidationResult
* will correctly populate itself with an inferred version
*/
public function testCreateForField() {
$exception = ValidationException::create_for_field('Content', 'Content is required');
$this->assertEquals('Content is required', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid());
$this->assertEquals(array(
'Content' => array(
'message' => 'Content is required',
'messageType' => 'bad',
),
), $exception->getResult()->fieldErrors());
}
/**
* Test that a ValidationException created with no contained ValidationResult
* will correctly populate itself with an inferred version
*/
public function testValidationResultAddMethods() {
$result = new ValidationResult();
$result->addMessage('A spork is not a knife', 'bad');
$result->addError('A knife is not a back scratcher');
$result->addFieldMessage('Title', 'Title is good', 'good');
$result->addFieldError('Content', 'Content is bad');
$this->assertEquals(array(
'Title' => array(
'message' => 'Title is good',
'messageType' => 'good'
),
'Content' => array(
'message' => 'Content is bad',
'messageType' => 'bad'
)
), $result->fieldErrors());
$this->assertEquals('A spork is not a knife; A knife is not a back scratcher', $result->overallMessage());
$exception = ValidationException::create_for_field('Content', 'Content is required');
} }
} }

View File

@ -139,6 +139,7 @@ class MemberAuthenticatorTest extends SapphireTest {
'tempid' => $tempID, 'tempid' => $tempID,
'Password' => 'mypassword' 'Password' => 'mypassword'
), $form); ), $form);
$form->setupFormErrors();
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals($result->ID, $member->ID); $this->assertEquals($result->ID, $member->ID);
$this->assertEmpty($form->Message()); $this->assertEmpty($form->Message());
@ -149,8 +150,9 @@ class MemberAuthenticatorTest extends SapphireTest {
'tempid' => $tempID, 'tempid' => $tempID,
'Password' => 'notmypassword' 'Password' => 'notmypassword'
), $form); ), $form);
$form->setupFormErrors();
$this->assertEmpty($result); $this->assertEmpty($result);
$this->assertEquals('The provided details don&#039;t seem to be correct. Please try again.', $form->Message()); $this->assertEquals(Convert::raw2xml(_t('Member.ERRORWRONGCRED')), $form->Message());
$this->assertEquals('bad', $form->MessageType()); $this->assertEquals('bad', $form->MessageType());
} }
@ -168,6 +170,7 @@ class MemberAuthenticatorTest extends SapphireTest {
'Email' => 'admin', 'Email' => 'admin',
'Password' => 'password' 'Password' => 'password'
), $form); ), $form);
$form->setupFormErrors();
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals($result->Email, Security::default_admin_username()); $this->assertEquals($result->Email, Security::default_admin_username());
$this->assertEmpty($form->Message()); $this->assertEmpty($form->Message());
@ -178,6 +181,7 @@ class MemberAuthenticatorTest extends SapphireTest {
'Email' => 'admin', 'Email' => 'admin',
'Password' => 'notmypassword' 'Password' => 'notmypassword'
), $form); ), $form);
$form->setupFormErrors();
$this->assertEmpty($result); $this->assertEmpty($result);
$this->assertEquals('The provided details don&#039;t seem to be correct. Please try again.', $form->Message()); $this->assertEquals('The provided details don&#039;t seem to be correct. Please try again.', $form->Message());
$this->assertEquals('bad', $form->MessageType()); $this->assertEquals('bad', $form->MessageType());

View File

@ -444,7 +444,7 @@ class SecurityTest extends FunctionalTest {
$member->LockedOutUntil, $member->LockedOutUntil,
'User does not have a lockout time set if under threshold for failed attempts' 'User does not have a lockout time set if under threshold for failed attempts'
); );
$this->assertContains($this->loginErrorMessage(), Convert::raw2xml(_t('Member.ERRORWRONGCRED'))); $this->assertContains(Convert::raw2xml(_t('Member.ERRORWRONGCRED')), $this->loginErrorMessage());
} else { } else {
// Fuzzy matching for time to avoid side effects from slow running tests // Fuzzy matching for time to avoid side effects from slow running tests
$this->assertGreaterThan( $this->assertGreaterThan(
@ -644,7 +644,8 @@ class SecurityTest extends FunctionalTest {
* Get the error message on the login form * Get the error message on the login form
*/ */
public function loginErrorMessage() { public function loginErrorMessage() {
return $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.formError.message'); $result = $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.result');
return $result->message();
} }
} }