Commit 50e33812 by Qiang Xue

Improved action filter and action execution flow by supporting installing action…

Improved action filter and action execution flow by supporting installing action filters at controller, module and application levels
parent 104c4fc3
...@@ -40,42 +40,6 @@ The return value will be handled by the `response` application ...@@ -40,42 +40,6 @@ The return value will be handled by the `response` application
component which can convert the output to different formats such as JSON for example. The default behavior component which can convert the output to different formats such as JSON for example. The default behavior
is to output the value unchanged though. is to output the value unchanged though.
You also can disable CSRF validation per controller and/or action, by setting its property:
```php
namespace app\controllers;
use yii\web\Controller;
class SiteController extends Controller
{
public $enableCsrfValidation = false;
public function actionIndex()
{
// CSRF validation will not be applied to this and other actions
}
}
```
To disable CSRF validation per custom actions you can do:
```php
namespace app\controllers;
use yii\web\Controller;
class SiteController extends Controller
{
public function beforeAction($action)
{
// ...set `$this->enableCsrfValidation` here based on some conditions...
// call parent method that will check CSRF if such property is true.
return parent::beforeAction($action);
}
}
```
Routes Routes
------ ------
...@@ -222,27 +186,49 @@ After doing so you can access your action as `http://example.com/?r=site/about`. ...@@ -222,27 +186,49 @@ After doing so you can access your action as `http://example.com/?r=site/about`.
Action Filters Action Filters
-------------- --------------
Action filters are implemented via behaviors. You should extend from `ActionFilter` to You may apply some action filters to controller actions to accomplish tasks such as determining
define a new filter. To use a filter, you should attach the filter class to the controller who can access the current action, decorating the result of the action, etc.
as a behavior. For example, to use the [[yii\web\AccessControl]] filter, you should have the following
code in a controller: An action filter is an instance of a class extending [[yii\base\ActionFilter]].
To use an action filter, attach it as a behavior to a controller or a module. The following
example shows how to enable HTTP caching for the `index` action:
```php ```php
public function behaviors() public function behaviors()
{ {
return [ return [
'access' => [ 'httpCache' => [
'class' => 'yii\web\AccessControl', 'class' => \yii\web\HttpCache::className(),
'rules' => [ 'only' => ['index'],
['allow' => true, 'actions' => ['admin'], 'roles' => ['@']], 'lastModified' => function ($action, $params) {
], $q = new \yii\db\Query();
return $q->from('user')->max('updated_at');
},
], ],
]; ];
} }
``` ```
In order to learn more about access control check the [authorization](authorization.md) section of the guide. You may use multiple action filters at the same time. These filters will be applied in the
Two other filters, [[yii\web\PageCache]] and [[yii\web\HttpCache]] are described in the [caching](caching.md) section of the guide. order they are declared in `behaviors()`. If any of the filter cancels the action execution,
the filters after it will be skipped.
When you attach a filter to a controller, it can be applied to all actions of that controller;
If you attach a filter to a module (or application), it can be applied to the actions of any controller
within that module (or application).
To create a new action filter, extend from [[yii\base\ActionFilter]] and override the
[[yii\base\ActionFilter::beforeAction()|beforeAction()]] and [[yii\base\ActionFilter::afterAction()|afterAction()]]
methods. The former will be executed before an action runs while the latter after an action runs.
The return value of [[yii\base\ActionFilter::beforeAction()|beforeAction()]] determines whether
an action should be executed or not. If `beforeAction()` of a filter returns false, the filters after this one
will be skipped and the action will not be executed.
The [authorization](authorization.md) section of this guide shows how to use the [[yii\web\AccessControl]] filter,
and the [caching](caching.md) section gives more details about the [[yii\web\PageCache]] and [[yii\web\HttpCache]] filters.
These built-in filters are also good references when you learn to create your own filters.
Catching all incoming requests Catching all incoming requests
------------------------------ ------------------------------
...@@ -252,7 +238,7 @@ when website is in maintenance mode. In order to do it you should configure web ...@@ -252,7 +238,7 @@ when website is in maintenance mode. In order to do it you should configure web
dynamically or via application config: dynamically or via application config:
```php ```php
$config = [ return [
'id' => 'basic', 'id' => 'basic',
'basePath' => dirname(__DIR__), 'basePath' => dirname(__DIR__),
// ... // ...
...@@ -261,6 +247,7 @@ $config = [ ...@@ -261,6 +247,7 @@ $config = [
'param1' => 'value1', 'param1' => 'value1',
'param2' => 'value2', 'param2' => 'value2',
], ],
]
``` ```
In the above `offline/notice` refer to `OfflineController::actionNotice()`. `param1` and `param2` are parameters passed In the above `offline/notice` refer to `OfflineController::actionNotice()`. `param1` and `param2` are parameters passed
......
...@@ -3,6 +3,7 @@ Security ...@@ -3,6 +3,7 @@ Security
Good security is vital to the health and success of any application. Unfortunately, many developers cut corners when it comes to security, either due to a lack of understanding or because implementation is too much of a hurdle. To make your Yii powered application as secure as possible, Yii has included several excellent and easy to use security features. Good security is vital to the health and success of any application. Unfortunately, many developers cut corners when it comes to security, either due to a lack of understanding or because implementation is too much of a hurdle. To make your Yii powered application as secure as possible, Yii has included several excellent and easy to use security features.
Hashing and verifying passwords Hashing and verifying passwords
------------------------------- -------------------------------
...@@ -86,6 +87,45 @@ $data = \yii\helpers\Security::validateData($data, $secretKey); ...@@ -86,6 +87,45 @@ $data = \yii\helpers\Security::validateData($data, $secretKey);
``` ```
todo: XSS prevention, CSRF prevention, cookie protection, refer to 1.1 guide
You also can disable CSRF validation per controller and/or action, by setting its property:
```php
namespace app\controllers;
use yii\web\Controller;
class SiteController extends Controller
{
public $enableCsrfValidation = false;
public function actionIndex()
{
// CSRF validation will not be applied to this and other actions
}
}
```
To disable CSRF validation per custom actions you can do:
```php
namespace app\controllers;
use yii\web\Controller;
class SiteController extends Controller
{
public function beforeAction($action)
{
// ...set `$this->enableCsrfValidation` here based on some conditions...
// call parent method that will check CSRF if such property is true.
return parent::beforeAction($action);
}
}
```
Securing Cookies Securing Cookies
---------------- ----------------
......
...@@ -187,6 +187,7 @@ Yii Framework 2 Change Log ...@@ -187,6 +187,7 @@ Yii Framework 2 Change Log
- Enh: Added summaryOptions and emptyTextOptions to BaseListView (johonunu) - Enh: Added summaryOptions and emptyTextOptions to BaseListView (johonunu)
- Enh: Implemented Oracle column comment reading from another schema (gureedo, samdark) - Enh: Implemented Oracle column comment reading from another schema (gureedo, samdark)
- Enh: Added support to allow an event handler to be inserted at the beginning of the existing event handler list (qiangxue) - Enh: Added support to allow an event handler to be inserted at the beginning of the existing event handler list (qiangxue)
- Enh: Improved action filter and action execution flow by supporting installing action filters at controller, module and application levels (qiangxue)
- Chg #47: Changed Markdown library to cebe/markdown and adjusted Markdown helper API (cebe) - Chg #47: Changed Markdown library to cebe/markdown and adjusted Markdown helper API (cebe)
- Chg #735: Added back `ActiveField::hiddenInput()` (qiangxue) - Chg #735: Added back `ActiveField::hiddenInput()` (qiangxue)
- Chg #1186: Changed `Sort` to use comma to separate multiple sort fields and use negative sign to indicate descending sort (qiangxue) - Chg #1186: Changed `Sort` to use comma to separate multiple sort fields and use negative sign to indicate descending sort (qiangxue)
......
...@@ -8,8 +8,10 @@ ...@@ -8,8 +8,10 @@
namespace yii\base; namespace yii\base;
/** /**
* ActionFilter provides a base implementation for action filters that can be added to a controller * ActionFilter is the base class for action filters.
* to handle the `beforeAction` event. *
* An action filter will participate in the action execution workflow by responding to
* the `beforeAction` and `afterAction` events triggered by modules and controllers.
* *
* Check implementation of [[\yii\web\AccessControl]], [[\yii\web\PageCache]] and [[\yii\web\HttpCache]] as examples on how to use it. * Check implementation of [[\yii\web\AccessControl]], [[\yii\web\PageCache]] and [[\yii\web\HttpCache]] as examples on how to use it.
* *
...@@ -31,43 +33,54 @@ class ActionFilter extends Behavior ...@@ -31,43 +33,54 @@ class ActionFilter extends Behavior
*/ */
public $except = []; public $except = [];
/** /**
* Declares event handlers for the [[owner]]'s events. * @inheritdoc
* @return array events (array keys) and the corresponding event handler methods (array values).
*/ */
public function events() public function attach($owner)
{ {
return [ $this->owner = $owner;
Controller::EVENT_BEFORE_ACTION => 'beforeFilter', $owner->on(Controller::EVENT_BEFORE_ACTION, [$this, 'beforeFilter']);
Controller::EVENT_AFTER_ACTION => 'afterFilter', }
];
/**
* @inheritdoc
*/
public function detach()
{
if ($this->owner) {
$this->owner->off(Controller::EVENT_BEFORE_ACTION, [$this, 'beforeFilter']);
$this->owner->off(Controller::EVENT_AFTER_ACTION, [$this, 'afterFilter']);
$this->owner = null;
}
} }
/** /**
* @param ActionEvent $event * @param ActionEvent $event
* @return boolean
*/ */
public function beforeFilter($event) public function beforeFilter($event)
{ {
if ($this->isActive($event->action)) { if (!$this->isActive($event->action)) {
$event->isValid = $this->beforeAction($event->action); return;
if (!$event->isValid) {
$event->handled = true;
}
} }
return $event->isValid; $event->isValid = $this->beforeAction($event->action);
if ($event->isValid) {
// call afterFilter only if beforeFilter succeeds
// beforeFilter and afterFilter should be properly nested
$this->owner->on(Controller::EVENT_AFTER_ACTION, [$this, 'afterFilter'], null, false);
} else {
$event->handled = true;
}
} }
/** /**
* @param ActionEvent $event * @param ActionEvent $event
* @return boolean
*/ */
public function afterFilter($event) public function afterFilter($event)
{ {
if ($this->isActive($event->action)) { $event->result = $this->afterAction($event->action, $event->result);
$event->result = $this->afterAction($event->action, $event->result); $this->owner->off(Controller::EVENT_AFTER_ACTION, [$this, 'afterFilter']);
}
} }
/** /**
...@@ -100,6 +113,16 @@ class ActionFilter extends Behavior ...@@ -100,6 +113,16 @@ class ActionFilter extends Behavior
*/ */
protected function isActive($action) protected function isActive($action)
{ {
return !in_array($action->id, $this->except, true) && (empty($this->only) || in_array($action->id, $this->only, true)); if ($this->owner instanceof Module) {
// convert action uniqueId into an ID relative to the module
$mid = $this->owner->getUniqueId();
$id = $action->getUniqueId();
if ($mid !== '' && strpos($id, $mid) === 0) {
$id = substr($id, strlen($mid) + 1);
}
} else {
$id = $action->id;
}
return !in_array($id, $this->except, true) && (empty($this->only) || in_array($id, $this->only, true));
} }
} }
...@@ -50,16 +50,6 @@ abstract class Application extends Module ...@@ -50,16 +50,6 @@ abstract class Application extends Module
*/ */
const EVENT_AFTER_REQUEST = 'afterRequest'; const EVENT_AFTER_REQUEST = 'afterRequest';
/** /**
* @event ActionEvent an event raised before executing a controller action.
* You may set [[ActionEvent::isValid]] to be false to cancel the action execution.
*/
const EVENT_BEFORE_ACTION = 'beforeAction';
/**
* @event ActionEvent an event raised after executing a controller action.
*/
const EVENT_AFTER_ACTION = 'afterAction';
/**
* Application state used by [[state]]: application just started. * Application state used by [[state]]: application just started.
*/ */
const STATE_BEGIN = 0; const STATE_BEGIN = 0;
......
...@@ -113,31 +113,48 @@ class Controller extends Component implements ViewContextInterface ...@@ -113,31 +113,48 @@ class Controller extends Component implements ViewContextInterface
public function runAction($id, $params = []) public function runAction($id, $params = [])
{ {
$action = $this->createAction($id); $action = $this->createAction($id);
if ($action !== null) { if ($action === null) {
Yii::trace("Route to run: " . $action->getUniqueId(), __METHOD__); throw new InvalidRouteException('Unable to resolve the request: ' . $this->getUniqueId() . '/' . $id);
if (Yii::$app->requestedAction === null) { }
Yii::$app->requestedAction = $action;
Yii::trace("Route to run: " . $action->getUniqueId(), __METHOD__);
if (Yii::$app->requestedAction === null) {
Yii::$app->requestedAction = $action;
}
$oldAction = $this->action;
$this->action = $action;
$modules = [];
$runAction = true;
foreach ($this->getModules() as $module) {
if ($module->beforeAction($action)) {
array_unshift($modules, $module);
} else {
$runAction = false;
break;
} }
$oldAction = $this->action; }
$this->action = $action;
$result = null; $result = null;
$event = new ActionEvent($action);
Yii::$app->trigger(Application::EVENT_BEFORE_ACTION, $event); if ($runAction) {
if ($event->isValid && $this->module->beforeAction($action) && $this->beforeAction($action)) { if ($this->beforeAction($action)) {
$result = $action->runWithParams($params); $result = $action->runWithParams($params);
$result = $this->afterAction($action, $result); $result = $this->afterAction($action, $result);
$result = $this->module->afterAction($action, $result);
$event = new ActionEvent($action);
$event->result = $result;
Yii::$app->trigger(Application::EVENT_AFTER_ACTION, $event);
$result = $event->result;
} }
$this->action = $oldAction; }
return $result; foreach ($modules as $module) {
} else { /** @var Module $module */
throw new InvalidRouteException('Unable to resolve the request: ' . $this->getUniqueId() . '/' . $id); $result = $module->afterAction($action, $result);
} }
$this->action = $oldAction;
return $result;
} }
/** /**
...@@ -207,25 +224,52 @@ class Controller extends Component implements ViewContextInterface ...@@ -207,25 +224,52 @@ class Controller extends Component implements ViewContextInterface
} }
/** /**
* This method is invoked right before an action is to be executed (after all possible filters). * This method is invoked right before an action is executed.
* You may override this method to do last-minute preparation for the action. *
* If you override this method, please make sure you call the parent implementation first. * The method will trigger the [[EVENT_BEFORE_ACTION]] event. The return value of the method
* will determine whether the action should continue to run.
*
* If you override this method, your code should look like the following:
*
* ```php
* public function beforeAction($action)
* {
* if (parent::beforeAction($action)) {
* // your custom code here
* return true; // or false if needed
* } else {
* return false;
* }
* }
* ```
*
* @param Action $action the action to be executed. * @param Action $action the action to be executed.
* @return boolean whether the action should continue to be executed. * @return boolean whether the action should continue to run.
*/ */
public function beforeAction($action) public function beforeAction($action)
{ {
$event = new ActionEvent($action); $event = new ActionEvent($action);
$this->trigger(self::EVENT_BEFORE_ACTION, $event); $this->trigger(self::EVENT_BEFORE_ACTION, $event);
return $event->isValid; return $event->isValid;
} }
/** /**
* This method is invoked right after an action is executed. * This method is invoked right after an action is executed.
* You may override this method to do some postprocessing for the action. *
* If you override this method, please make sure you call the parent implementation first. * The method will trigger the [[EVENT_AFTER_ACTION]] event. The return value of the method
* Also make sure you return the action result, whether it is processed or not. * will be used as the action return value.
*
* If you override this method, your code should look like the following:
*
* ```php
* public function afterAction($action, $result)
* {
* $result = parent::afterAction($action, $result);
* // your custom code here
* return $result;
* }
* ```
*
* @param Action $action the action just executed. * @param Action $action the action just executed.
* @param mixed $result the action return result. * @param mixed $result the action return result.
* @return mixed the processed action result. * @return mixed the processed action result.
...@@ -235,11 +279,27 @@ class Controller extends Component implements ViewContextInterface ...@@ -235,11 +279,27 @@ class Controller extends Component implements ViewContextInterface
$event = new ActionEvent($action); $event = new ActionEvent($action);
$event->result = $result; $event->result = $result;
$this->trigger(self::EVENT_AFTER_ACTION, $event); $this->trigger(self::EVENT_AFTER_ACTION, $event);
return $event->result; return $event->result;
} }
/** /**
* Returns all ancestor modules of this controller.
* The first module in the array is the outermost one (i.e., the application instance),
* while the last is the innermost one.
* @return Module[] all ancestor modules that this controller is located within.
*/
public function getModules()
{
$modules = [$this->module];
$module = $this->module;
while ($module->module !== null) {
array_unshift($modules, $module->module);
$module = $module->module;
}
return $modules;
}
/**
* @return string the controller ID that is prefixed with the module ID (if any). * @return string the controller ID that is prefixed with the module ID (if any).
*/ */
public function getUniqueId() public function getUniqueId()
......
...@@ -38,6 +38,16 @@ use yii\di\ServiceLocator; ...@@ -38,6 +38,16 @@ use yii\di\ServiceLocator;
class Module extends ServiceLocator class Module extends ServiceLocator
{ {
/** /**
* @event ActionEvent an event raised before executing a controller action.
* You may set [[ActionEvent::isValid]] to be false to cancel the action execution.
*/
const EVENT_BEFORE_ACTION = 'beforeAction';
/**
* @event ActionEvent an event raised after executing a controller action.
*/
const EVENT_AFTER_ACTION = 'afterAction';
/**
* @var array custom module parameters (name => value). * @var array custom module parameters (name => value).
*/ */
public $params = []; public $params = [];
...@@ -551,28 +561,61 @@ class Module extends ServiceLocator ...@@ -551,28 +561,61 @@ class Module extends ServiceLocator
} }
/** /**
* This method is invoked right before an action of this module is to be executed (after all possible filters.) * This method is invoked right before an action within this module is executed.
* You may override this method to do last-minute preparation for the action. *
* Make sure you call the parent implementation so that the relevant event is triggered. * The method will trigger the [[EVENT_BEFORE_ACTION]] event. The return value of the method
* will determine whether the action should continue to run.
*
* If you override this method, your code should look like the following:
*
* ```php
* public function beforeAction($action)
* {
* if (parent::beforeAction($action)) {
* // your custom code here
* return true; // or false if needed
* } else {
* return false;
* }
* }
* ```
*
* @param Action $action the action to be executed. * @param Action $action the action to be executed.
* @return boolean whether the action should continue to be executed. * @return boolean whether the action should continue to be executed.
*/ */
public function beforeAction($action) public function beforeAction($action)
{ {
return true; $event = new ActionEvent($action);
$this->trigger(self::EVENT_BEFORE_ACTION, $event);
return $event->isValid;
} }
/** /**
* This method is invoked right after an action of this module has been executed. * This method is invoked right after an action within this module is executed.
* You may override this method to do some postprocessing for the action. *
* Make sure you call the parent implementation so that the relevant event is triggered. * The method will trigger the [[EVENT_AFTER_ACTION]] event. The return value of the method
* Also make sure you return the action result, whether it is processed or not. * will be used as the action return value.
*
* If you override this method, your code should look like the following:
*
* ```php
* public function afterAction($action, $result)
* {
* $result = parent::afterAction($action, $result);
* // your custom code here
* return $result;
* }
* ```
*
* @param Action $action the action just executed. * @param Action $action the action just executed.
* @param mixed $result the action return result. * @param mixed $result the action return result.
* @return mixed the processed action result. * @return mixed the processed action result.
*/ */
public function afterAction($action, $result) public function afterAction($action, $result)
{ {
return $result; $event = new ActionEvent($action);
$event->result = $result;
$this->trigger(self::EVENT_AFTER_ACTION, $event);
return $event->result;
} }
} }
...@@ -106,12 +106,8 @@ class Controller extends \yii\web\Controller ...@@ -106,12 +106,8 @@ class Controller extends \yii\web\Controller
public function beforeAction($action) public function beforeAction($action)
{ {
$this->authenticate($action); $this->authenticate($action);
if (parent::beforeAction($action)) { $this->checkRateLimit($action);
$this->checkRateLimit($action); return parent::beforeAction($action);
return true;
} else {
return false;
}
} }
/** /**
......
...@@ -26,10 +26,10 @@ use yii\base\Action; ...@@ -26,10 +26,10 @@ use yii\base\Action;
* return [ * return [
* 'httpCache' => [ * 'httpCache' => [
* 'class' => \yii\web\HttpCache::className(), * 'class' => \yii\web\HttpCache::className(),
* 'only' => ['list'], * 'only' => ['index'],
* 'lastModified' => function ($action, $params) { * 'lastModified' => function ($action, $params) {
* $q = new Query(); * $q = new \yii\db\Query();
* return strtotime($q->from('users')->max('updated_timestamp')); * return $q->from('user')->max('updated_at');
* }, * },
* // 'etagSeed' => function ($action, $params) { * // 'etagSeed' => function ($action, $params) {
* // return // generate etag seed here * // return // generate etag seed here
......
...@@ -31,7 +31,7 @@ use yii\caching\Dependency; ...@@ -31,7 +31,7 @@ use yii\caching\Dependency;
* 'class' => \yii\web\PageCache::className(), * 'class' => \yii\web\PageCache::className(),
* 'only' => ['list'], * 'only' => ['list'],
* 'duration' => 60, * 'duration' => 60,
* 'dependecy' => [ * 'dependency' => [
* 'class' => 'yii\caching\DbDependency', * 'class' => 'yii\caching\DbDependency',
* 'sql' => 'SELECT COUNT(*) FROM post', * 'sql' => 'SELECT COUNT(*) FROM post',
* ], * ],
......
<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yiiunit\framework\base;
use Yii;
use yii\base\ActionFilter;
use yii\base\Controller;
use yiiunit\TestCase;
/**
* @group base
*/
class ActionFilterTest extends TestCase
{
protected function setUp()
{
parent::setUp();
$this->mockApplication();
}
public function testFilter()
{
// no filters
$controller = new FakeController('fake', Yii::$app);
$this->assertNull($controller->result);
$result = $controller->runAction('test');
$this->assertEquals('x', $result);
$this->assertNull($controller->result);
// all filters pass
$controller = new FakeController('fake', Yii::$app, [
'behaviors' => [
'filter1' => Filter1::className(),
'filter3' => Filter3::className(),
],
]);
$this->assertNull($controller->result);
$result = $controller->runAction('test');
$this->assertEquals('x-3-1', $result);
$this->assertEquals([1, 3], $controller->result);
// a filter stops in the middle
$controller = new FakeController('fake', Yii::$app, [
'behaviors' => [
'filter1' => Filter1::className(),
'filter2' => Filter2::className(),
'filter3' => Filter3::className(),
],
]);
$this->assertNull($controller->result);
$result = $controller->runAction('test');
$this->assertNull($result);
$this->assertEquals([1, 2], $controller->result);
// the first filter stops
$controller = new FakeController('fake', Yii::$app, [
'behaviors' => [
'filter2' => Filter2::className(),
'filter1' => Filter1::className(),
'filter3' => Filter3::className(),
],
]);
$this->assertNull($controller->result);
$result = $controller->runAction('test');
$this->assertNull($result);
$this->assertEquals([2], $controller->result);
// the last filter stops
$controller = new FakeController('fake', Yii::$app, [
'behaviors' => [
'filter1' => Filter1::className(),
'filter3' => Filter3::className(),
'filter2' => Filter2::className(),
],
]);
$this->assertNull($controller->result);
$result = $controller->runAction('test');
$this->assertNull($result);
$this->assertEquals([1, 3, 2], $controller->result);
}
}
class FakeController extends Controller
{
public $result;
public $behaviors = [];
public function behaviors()
{
return $this->behaviors;
}
public function actionTest()
{
return 'x';
}
}
class Filter1 extends ActionFilter
{
/**
* @inheritdoc
*/
public function beforeAction($action)
{
$action->controller->result[] = 1;
return true;
}
/**
* @inheritdoc
*/
public function afterAction($action, $result)
{
return $result . '-1';
}
}
class Filter2 extends ActionFilter
{
/**
* @inheritdoc
*/
public function beforeAction($action)
{
$action->controller->result[] = 2;
return false;
}
/**
* @inheritdoc
*/
public function afterAction($action, $result)
{
return $result . '-2';
}
}
class Filter3 extends ActionFilter
{
/**
* @inheritdoc
*/
public function beforeAction($action)
{
$action->controller->result[] = 3;
return true;
}
/**
* @inheritdoc
*/
public function afterAction($action, $result)
{
return $result . '-3';
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment