Version: current

      1 <?php
      2
      3 /**
      4  * This class is the base class of any Sapphire object that can be used to handle HTTP requests.
      5  *
      6  * Any RequestHandler object can be made responsible for handling its own segment of the URL namespace.
      7  * The {@link Director} begins the URL parsing process; it will parse the beginning of the URL to identify which
      8  * controller is being used.  It will then call {@link handleRequest()} on that Controller, passing it the parameters that it
      9  * parsed from the URL, and the {@link SS_HTTPRequest} that contains the remainder of the URL to be parsed.
     10  *
     11  * You can use ?debug_request=1 to view information about the different components and rule matches for a specific URL.
     12  *
     13  * In Sapphire, URL parsing is distributed throughout the object graph.  For example, suppose that we have a search form
     14  * that contains a {@link TreeMultiSelectField} named "Groups".  We want to use ajax to load segments of this tree as they are needed
     15  * rather than downloading the tree right at the beginning.  We could use this URL to get the tree segment that appears underneath
     16  * Group #36: "admin/crm/SearchForm/field/Groups/treesegment/36"
     17  *  - Director will determine that admin/crm is controlled by a new ModelAdmin object, and pass control to that.
     18  *    Matching Director Rule: "admin/crm" => "ModelAdmin" (defined in mysite/_config.php)
     19  *  - ModelAdmin will determine that SearchForm is controlled by a Form object returned by $this->SearchForm(), and pass control to that.
     20  *    Matching $url_handlers: "$Action" => "$Action" (defined in RequestHandler class)
     21  *  - Form will determine that field/Groups is controlled by the Groups field, a TreeMultiselectField, and pass control to that.
     22  *    Matching $url_handlers: 'field/$FieldName!' => 'handleField' (defined in Form class)
     23  *  - TreeMultiselectField will determine that treesegment/36 is handled by its treesegment() method.  This method will return an HTML fragment that is output to the screen.
     24  *    Matching $url_handlers: "$Action/$ID" => "handleItem" (defined in TreeMultiSelectField class)
     25  *
     26  * {@link RequestHandler::handleRequest()} is where this behaviour is implemented.
     27  *
     28  * @package sapphire
     29  * @subpackage control
     30  */
     31 class RequestHandler extends ViewableData {
     32     protected $request = null;
     33
     34     /**
     35      * This variable records whether RequestHandler::__construct()
     36      * was called or not. Useful for checking if subclasses have
     37      * called parent::__construct()
     38      *
     39      * @var boolean
     40      */
     41     protected $brokenOnConstruct = true;
     42
     43     /**
     44      * The default URL handling rules.  This specifies that the next component of the URL corresponds to a method to
     45      * be called on this RequestHandlingData object.
     46      *
     47      * The keys of this array are parse rules.  See {@link SS_HTTPRequest::match()} for a description of the rules available.
     48      *
     49      * The values of the array are the method to be called if the rule matches.  If this value starts with a '$', then the
     50      * named parameter of the parsed URL wil be used to determine the method name.
     51      */
     52     static $url_handlers = array(
     53         '$Action' => '$Action',
     54     );
     55
     56
     57     /**
     58      * Define a list of action handling methods that are allowed to be called directly by URLs.
     59      * The variable should be an array of action names. This sample shows the different values that it can contain:
     60      *
     61      * <code>
     62      * array(
     63      *      'someaction', // someaction can be accessed by anyone, any time
     64      *      'otheraction' => true, // So can otheraction
     65      *      'restrictedaction' => 'ADMIN', // restrictedaction can only be people with ADMIN privilege
     66      *      'complexaction' '->canComplexAction' // complexaction can only be accessed if $this->canComplexAction() returns true
     67      *  );
     68      * </code>
     69      */
     70     static $allowed_actions = null;
     71
     72     public function __construct() {
     73         $this->brokenOnConstruct = false;
     74         parent::__construct();
     75     }
     76
     77     /**
     78      * Handles URL requests.
     79      *
     80      *  - ViewableData::handleRequest() iterates through each rule in {@link self::$url_handlers}.
     81      *  - If the rule matches, the named method will be called.
     82      *  - If there is still more URL to be processed, then handleRequest()
     83      *    is called on the object that that method returns.
     84      *
     85      * Once all of the URL has been processed, the final result is returned.
     86      * However, if the final result is an array, this
     87      * array is interpreted as being additional template data to customise the
     88      * 2nd to last result with, rather than an object
     89      * in its own right.  This is most frequently used when a Controller's
     90      * action will return an array of data with which to
     91      * customise the controller.
     92      *
     93      * @param $request The {@link SS_HTTPRequest} object that is reponsible for distributing URL parsing
     94      * @uses SS_HTTPRequest
     95      * @uses SS_HTTPRequest->match()
     96      * @return SS_HTTPResponse|RequestHandler|string|array
     97      */
     98     function handleRequest(SS_HTTPRequest $request) {
     99         // $handlerClass is used to step up the class hierarchy to implement url_handlers inheritance
    100         $handlerClass = ($this->class) ? $this->class : get_class($this);
    101
    102         if($this->brokenOnConstruct) {
    103             user_error("parent::__construct() needs to be called on {$handlerClass}::__construct()", E_USER_WARNING);
    104         }
    105
    106         $this->request = $request;
    107
    108         // We stop after RequestHandler; in other words, at ViewableData
    109         while($handlerClass && $handlerClass != 'ViewableData') {
    110             $urlHandlers = Object::get_static($handlerClass, 'url_handlers');
    111
    112             if($urlHandlers) foreach($urlHandlers as $rule => $action) {
    113                 if(isset($_REQUEST['debug_request'])) Debug::message("Testing '$rule' with '" . $request->remaining() . "' on $this->class");
    114                 if($params = $request->match($rule, true)) {
    115                     // FIXME: This unnecessary coupling was added to fix a bug in Image_Uploader.
    116                     if($this instanceof Controller) $this->urlParams = $request->allParams();
    117
    118                     if(isset($_REQUEST['debug_request'])) {
    119                         Debug::message("Rule '$rule' matched to action '$action' on $this->class.  Latest request params: " . var_export($request->latestParams(), true));
    120                     }
    121
    122                     // Actions can reference URL parameters, eg, '$Action/$ID/$OtherID' => '$Action',
    123                     if($action[0] == '$') $action = $params[substr($action,1)];
    124
    125                     if($this->checkAccessAction($action)) {
    126                         if(!$action) {
    127                             if(isset($_REQUEST['debug_request'])) Debug::message("Action not set; using default action method name 'index'");
    128                             $action = "index";
    129                         } else if(!is_string($action)) {
    130                             user_error("Non-string method name: " . var_export($action, true), E_USER_ERROR);
    131                         }
    132
    133                         try {
    134                             $result = $this->$action($request);
    135                         } catch(SS_HTTPResponse_Exception $responseException) {
    136                             $result = $responseException->getResponse();
    137                         }
    138                     } else {
    139                         return $this->httpError(403, "Action '$action' isn't allowed on class $this->class");
    140                     }
    141
    142                     if($result instanceof SS_HTTPResponse && $result->isError()) {
    143                         if(isset($_REQUEST['debug_request'])) Debug::message("Rule resulted in HTTP error; breaking");
    144                         return $result;
    145                     }
    146
    147                     // If we return a RequestHandler, call handleRequest() on that, even if there is no more URL to parse.
    148                     // It might have its own handler. However, we only do this if we haven't just parsed an empty rule ourselves,
    149                     // to prevent infinite loops. Also prevent further handling of controller actions which return themselves
    150                     // to avoid infinite loops.
    151                     if($this !== $result && !$request->isEmptyPattern($rule) && is_object($result) && $result instanceof RequestHandler) {
    152                         $returnValue = $result->handleRequest($request);
    153
    154                         // Array results can be used to handle
    155                         if(is_array($returnValue)) $returnValue = $this->customise($returnValue);
    156
    157                         return $returnValue;
    158
    159                     // If we return some other data, and all the URL is parsed, then return that
    160                     } else if($request->allParsed()) {
    161                         return $result;
    162
    163                     // But if we have more content on the URL and we don't know what to do with it, return an error.
    164                     } else {
    165                         return $this->httpError(404, "I can't handle sub-URLs of a $this->class object.");
    166                     }
    167
    168                     return $this;
    169                 }
    170             }
    171
    172             $handlerClass = get_parent_class($handlerClass);
    173         }
    174
    175         // If nothing matches, return this object
    176         return $this;
    177     }
    178
    179     /**
    180      * Get a unified array of allowed actions on this controller (if such data is available) from both the controller
    181      * ancestry and any extensions.
    182      *
    183      * @return array|null
    184      */
    185     public function allowedActions() {
    186         $actions = Object::combined_static(get_class($this), 'allowed_actions', 'RequestHandler');
    187
    188         foreach($this->extension_instances as $extension) {
    189             if($extensionActions = Object::get_static(get_class($extension), 'allowed_actions')) {
    190                 $actions = array_merge($actions, $extensionActions);
    191             }
    192         }
    193
    194         if($actions) {
    195             // convert all keys and values to lowercase to
    196             // allow for easier comparison, unless it is a permission code
    197             $actions = array_change_key_case($actions, CASE_LOWER);
    198
    199             foreach($actions as $key => $value) {
    200                 if(is_numeric($key)) $actions[$key] = strtolower($value);
    201             }
    202
    203             return $actions;
    204         }
    205     }
    206
    207     /**
    208      * Checks if this request handler has a specific action (even if the current user cannot access it).
    209      *
    210      * @param string $action
    211      * @return bool
    212      */
    213     public function hasAction($action) {
    214         if($action == 'index') return true;
    215
    216         $action  = strtolower($action);
    217         $actions = $this->allowedActions();
    218
    219         // Check if the action is defined in the allowed actions as either a
    220         // key or value. Note that if the action is numeric, then keys are not
    221         // searched for actions to prevent actual array keys being recognised
    222         // as actions.
    223         if(is_array($actions)) {
    224             $isKey   = !is_numeric($action) && array_key_exists($action, $actions);
    225             $isValue = in_array($action, $actions);
    226
    227             if($isKey || $isValue) return true;
    228         }
    229
    230         if(!is_array($actions) || !$this->uninherited('allowed_actions')) {
    231             if($action != 'init' && $action != 'run' && method_exists($this, $action)) return true;
    232         }
    233
    234         return false;
    235     }
    236
    237     /**
    238      * Check that the given action is allowed to be called from a URL.
    239      * It will interrogate {@link self::$allowed_actions} to determine this.
    240      */
    241     function checkAccessAction($action) {
    242         $actionOrigCasing = $action;
    243         $action            = strtolower($action);
    244         $allowedActions    = $this->allowedActions();
    245
    246         if($allowedActions)  {
    247             // check for specific action rules first, and fall back to global rules defined by asterisk
    248             foreach(array($action,'*') as $actionOrAll) {
    249                 // check if specific action is set
    250                 if(isset($allowedActions[$actionOrAll])) {
    251                     $test = $allowedActions[$actionOrAll];
    252                     if($test === true || $test === 1 || $test === '1') {
    253                         // Case 1: TRUE should always allow access
    254                         return true;
    255                     } elseif(substr($test, 0, 2) == '->') {
    256                         // Case 2: Determined by custom method with "->" prefix
    257                         return $this->{substr($test, 2)}();
    258                     } else {
    259                         // Case 3: Value is a permission code to check the current member against
    260                         return Permission::check($test);
    261                     }
    262
    263                 } elseif((($key = array_search($actionOrAll, $allowedActions)) !== false) && is_numeric($key)) {
    264                     // Case 4: Allow numeric array notation (search for array value as action instead of key)
    265                     return true;
    266                 }
    267             }
    268         }
    269
    270         // If we get here an the action is 'index', then it hasn't been specified, which means that
    271         // it should be allowed.
    272         if($action == 'index' || empty($action)) return true;
    273
    274         if($allowedActions === null || !$this->uninherited('allowed_actions')) {
    275             // If no allowed_actions are provided, then we should only let through actions that aren't handled by magic methods
    276             // we test this by calling the unmagic method_exists.
    277             if(method_exists($this, $action)) {
    278                 // Disallow any methods which aren't defined on RequestHandler or subclasses
    279                 // (e.g. ViewableData->getSecurityID())
    280                 $r = new ReflectionClass(get_class($this));
    281                 if($r->hasMethod($actionOrigCasing)) {
    282                     $m = $r->getMethod($actionOrigCasing);
    283                     return ($m && is_subclass_of($m->getDeclaringClass()->getName(), 'RequestHandler'));
    284                 } else {
    285                     throw new Exception("method_exists() true but ReflectionClass can't find method - PHP is b0kred");
    286                 }
    287             } else if(!$this->hasMethod($action)){
    288                 // Return true so that a template can handle this action
    289                 return true;
    290             }
    291         }
    292
    293         return false;
    294     }
    295
    296     /**
    297      * Throws a HTTP error response encased in a {@link SS_HTTPResponse_Exception}, which is later caught in
    298      * {@link RequestHandler::handleAction()} and returned to the user.
    299      *
    300      * @param int $errorCode
    301      * @param string $errorMessage
    302      * @uses SS_HTTPResponse_Exception
    303      */
    304     public function httpError($errorCode, $errorMessage = null) {
    305         throw new SS_HTTPResponse_Exception($errorMessage, $errorCode);
    306     }
    307
    308     /**
    309      * Returns the SS_HTTPRequest object that this controller is using.
    310      *
    311      * @return SS_HTTPRequest
    312      */
    313     function getRequest() {
    314         return $this->request;
    315     }
    316 }
    317

[Raise a SilverStripe Framework issue/bug](https://github.com/silverstripe/silverstripe-framework/issues/new)
- [Raise a SilverStripe CMS issue/bug](https://github.com/silverstripe/silverstripe-cms/issues/new)
- Please use the Silverstripe Forums to ask development related questions. -