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