1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace ICanBoogie;
13
14 use ICanBoogie\HTTP;
15 use ICanBoogie\HTTP\HTTPError;
16 use ICanBoogie\HTTP\NotFound;
17 use ICanBoogie\HTTP\Request;
18 use ICanBoogie\Operation\Failure;
19 use ICanBoogie\Operation\FailureEvent;
20 use ICanBoogie\Operation\GetFormEvent;
21 use ICanBoogie\Operation\FormHasExpired;
22 use ICanBoogie\Operation\BeforeControlEvent;
23 use ICanBoogie\Operation\ControlEvent;
24 use ICanBoogie\Operation\BeforeValidateEvent;
25 use ICanBoogie\Operation\ValidateEvent;
26 use ICanBoogie\Operation\BeforeProcessEvent;
27 use ICanBoogie\Operation\ProcessEvent;
28
29 /**
30 * An operation.
31 *
32 * @property ActiveRecord $record The target active record object of the operation.
33 * @property-read Request $request The request.
34 * @property-read bool $is_forwarded `true` if the operation is forwarded.
35 * See {@link get_is_forwarded()}.
36 */
37 abstract class Operation extends Object
38 {
39 /**
40 * Defines the destination of a forwarded operation.
41 *
42 * @var string
43 */
44 const DESTINATION = '_operation_destination';
45
46 /**
47 * Defines the operation name of a forwarded operation.
48 *
49 * @var string
50 */
51 const NAME = '_operation_name';
52
53 /**
54 * Defines the key of the resource targeted by the operation.
55 *
56 * @var string
57 */
58 const KEY = '_operation_key';
59
60 /**
61 * Defines the session token to be matched.
62 *
63 * @var string
64 */
65 const SESSION_TOKEN = '_session_token';
66
67 const RESTFUL_BASE = '/api/';
68 const RESTFUL_BASE_LENGTH = 5;
69
70 /**
71 * Creates a {@link Operation} instance from the specified parameters.
72 *
73 * @return Operation
74 */
75 static public function from($properties=null, array $construct_args=array(), $class_name=null)
76 {
77 if ($properties instanceof Request)
78 {
79 return static::from_request($properties);
80 }
81
82 return parent::from($properties, $construct_args, $class_name);
83 }
84
85 /**
86 * Creates an operation instance from a request.
87 *
88 * An operation can be defined as a route, in which case the path of the request starts with
89 * "/api/". An operation can also be defined using the request parameters, in which case
90 * the {@link DESTINATION}, {@link NAME} and optionaly {@link KEY} parameters are defined
91 * within the request parameters.
92 *
93 * When the operation is defined as a route, the method searches for a matching route.
94 *
95 * If a matching route is found, the captured parameters of the matching route are merged
96 * with the request parameters and the method tries to create an Operation instance using the
97 * route.
98 *
99 * If no matching route could be found, the method tries to extract the {@link DESTINATION},
100 * {@link NAME} and optional {@link KEY} parameters from the route using the
101 * `/api/:destination(/:key)/:name` pattern. If the route matches this pattern, captured
102 * parameters are merged with the request parameters and the operation decoding continues as
103 * if the operation was defined using parameters instead of the REST API.
104 *
105 * Finally, the method searches for the {@link DESTINATION}, {@link NAME} and optional
106 * {@link KEY} aparameters within the request parameters to create the Operation instance.
107 *
108 * If no operation was found in the request, the method returns null.
109 *
110 *
111 * Instancing using the matching route
112 * -----------------------------------
113 *
114 * The matching route must define either the class of the operation instance (by defining the
115 * `class` key) or a callback that would create the operation instance (by defining the
116 * `callback` key).
117 *
118 * If the route defines the instance class, it is used to create the instance. Otherwise, the
119 * callback is used to create the instance.
120 *
121 *
122 * Instancing using the request parameters
123 * ---------------------------------------
124 *
125 * The operation destination (specified by the {@link DESTINATION} parameter) is the id of the
126 * destination module. The class and the operation name (specified by the {@link NAME}
127 * parameter) are used to search for the corresponding operation class to create the instance:
128 *
129 * ICanBoogie\<normalized_module_id>\<normalized_operation_name>Operation
130 *
131 * The inheritance of the module class is used the find a suitable class. For example,
132 * these are the classes tried for the "articles" module and the "save" operation:
133 *
134 * ICanBoogie\Modules\Articles\SaveOperation
135 * ICanBoogie\Modules\Contents\SaveOperation
136 * ICanBoogie\Modules\Nodes\SaveOperation
137 *
138 * An instance of the found class is created with the request arguments and returned. If the
139 * class could not be found to create the operation instance, an exception is raised.
140 *
141 * @param string $uri The request URI.
142 * @param array $params The request parameters.
143 *
144 * @throws \BadMethodCallException when the destination module or the operation name is
145 * not defined for a module operation.
146 *
147 * @return Operation|null The decoded operation or null if no operation was found.
148 */
149 static protected function from_request(HTTP\Request $request)
150 {
151 global $core;
152
153 $path = \ICanBoogie\Routing\decontextualize($request->path);
154 $extension = $request->extension;
155
156 if ($extension == 'json')
157 {
158 $path = substr($path, 0, -5);
159 $request->headers['Accept'] = 'application/json';
160 $request->headers['X-Requested-With'] = 'XMLHttpRequest'; // FIXME-20110925: that's not very nice
161 }
162 else if ($extension == 'xml')
163 {
164 $path = substr($path, 0, -4);
165 $request->headers['Accept'] = 'application/xml';
166 $request->headers['X-Requested-With'] = 'XMLHttpRequest'; // FIXME-20110925: that's not very nice
167 }
168
169 $path = rtrim($path, '/');
170
171 if (substr($path, 0, self::RESTFUL_BASE_LENGTH) == self::RESTFUL_BASE)
172 {
173 $operation = static::from_route($request, $path);
174
175 if ($operation)
176 {
177 return $operation;
178 }
179
180 if ($request->is_patch)
181 {
182 preg_match('#^([^/]+)/(\d+)$#', substr($path, self::RESTFUL_BASE_LENGTH), $matches);
183
184 if (!$matches)
185 {
186 throw new NotFound(format('Unknown operation %operation.', array('operation' => $path)));
187 }
188
189 list(, $module_id, $operation_key) = $matches;
190
191 $operation_name = 'patch';
192 }
193 else
194 {
195 #
196 # We could not find a matching route, we try to extract the DESTINATION, NAME and
197 # optional KEY from the URI.
198 #
199
200 preg_match('#^([a-z\.\-]+)/(([^/]+)/)?([a-zA-Z0-9_\-]+)$#', substr($path, self::RESTFUL_BASE_LENGTH), $matches);
201
202 if (!$matches)
203 {
204 throw new NotFound(format('Unknown operation %operation.', array('operation' => $path)));
205 }
206
207 list(, $module_id, , $operation_key, $operation_name) = $matches;
208 }
209
210 if (empty($core->modules->descriptors[$module_id]))
211 {
212 throw new NotFound(format('Unknown operation %operation.', array('operation' => $path)));
213 }
214
215 if ($operation_key)
216 {
217 $request[self::KEY] = $operation_key;
218 }
219
220 return static::from_module_request($request, $module_id, $operation_name);
221 }
222
223 $module_id = $request[self::DESTINATION];
224 $operation_name = $request[self::NAME];
225 $operation_key = $request[self::KEY];
226
227 if (!$module_id && !$operation_name)
228 {
229 return;
230 }
231 else if (!$module_id)
232 {
233 throw new \BadMethodCallException("The operation's destination is required.");
234 }
235 else if (!$operation_name)
236 {
237 throw new \BadMethodCallException("The operation's name is required.");
238 }
239
240 return static::from_module_request($request, $module_id, $operation_name);
241 }
242
243 /**
244 * Tries to create an {@link Operation} instance from a route.
245 *
246 * @param HTTP\Request $request
247 * @param string $path An API path.
248 *
249 * @throws \Exception If the route controller fails to produce an {@link Operation} instance.
250 * @throws \InvalidArgumentException If the route's controller cannot be determined from the
251 * route definition.
252 *
253 * @return Operation|null
254 */
255 static protected function from_route(Request $request, $path)
256 {
257 global $core;
258
259 $route = $core->routes->find($path, $captured, $request->method, 'api');
260
261 if (!$route)
262 {
263 return;
264 }
265
266 #
267 # We found a matching route. The arguments captured from the route are merged with
268 # the request parameters. The route must define either a class for the operation
269 # instance (defined using the `class` key) or a callback to create that instance
270 # (defined using the `callback` key).
271 #
272
273 if ($captured)
274 {
275 if (isset($route->param_translation_list))
276 {
277 foreach ($route->param_translation_list as $from => $to)
278 {
279 $captured[$to] = $captured[$from];
280 }
281 }
282
283 $request->path_params = $captured;
284 $request->params = $captured + $request->params;
285
286 if (isset($request->path_params[self::DESTINATION]))
287 {
288 $route->module = $request->path_params[self::DESTINATION];
289 }
290 }
291
292 if ($route->controller)
293 {
294 $controller = $route->controller;
295
296 if (is_callable($controller))
297 {
298 $operation = call_user_func($controller, $request);
299 }
300 else if (!class_exists($controller, true))
301 {
302 throw new \Exception("Unable to instantiate operation, class not found: $controller.");
303 }
304 else
305 {
306 $operation = new $controller($request);
307 }
308
309 if (!($operation instanceof self))
310 {
311 throw new \Exception(format
312 (
313 'The controller for the route %route failed to produce an operation object, %rc returned.', array
314 (
315 'route' => $path,
316 'rc' => $operation
317 )
318 ));
319 }
320
321 if (isset($route->module))
322 {
323 $operation->module = $core->modules[$route->module];
324 }
325
326 if (isset($request->path_params[self::KEY]))
327 {
328 $operation->key = $request->path_params[self::KEY];
329 }
330 }
331 else
332 {
333 if ($route->callback)
334 {
335 throw new \InvalidArgumentException("'callback' is no longer supported, use 'controller'.");
336 }
337 else if ($route->class)
338 {
339 throw new \InvalidArgumentException("'class' is no longer supported, use 'controller'.");
340 }
341
342 throw new \InvalidArgumentException("'controller' is required.");
343 }
344
345 return $operation;
346 }
347
348 /**
349 * Creates an {@link Operation} instance from a module request.
350 *
351 * @param Request $request
352 * @param unknown $module_id
353 * @param unknown $operation_name
354 *
355 * @throws HTTPError if the operation is not supported by the module.
356 *
357 * @return Operation
358 */
359 static protected function from_module_request(Request $request, $module_id, $operation_name)
360 {
361 global $core;
362
363 $module = $core->modules[$module_id];
364 $class = self::resolve_operation_class($operation_name, $module);
365
366 if (!$class)
367 {
368 throw new HTTPError(format
369 (
370 'The operation %operation is not supported by the module %module.', array
371 (
372 '%module' => (string) $module,
373 '%operation' => $operation_name
374 )
375 ), 404);
376 }
377
378 return new $class($module);
379 }
380
381 /**
382 * Encodes a RESTful operation.
383 *
384 * @param string $pattern
385 * @param array $params
386 *
387 * @return string The operation encoded as a RESTful relative URL.
388 */
389 static public function encode($pattern, array $params=array())
390 {
391 $destination = null;
392 $name = null;
393 $key = null;
394
395 if (isset($params[self::DESTINATION]))
396 {
397 $destination = $params[self::DESTINATION];
398
399 unset($params[self::DESTINATION]);
400 }
401
402 if (isset($params[self::NAME]))
403 {
404 $name = $params[self::NAME];
405
406 unset($params[self::NAME]);
407 }
408
409 if (isset($params[self::KEY]))
410 {
411 $key = $params[self::KEY];
412
413 unset($params[self::KEY]);
414 }
415
416 $qs = http_build_query($params, '', '&');
417
418 $rc = self::RESTFUL_BASE . strtr
419 (
420 $pattern, array
421 (
422 '{destination}' => $destination,
423 '{name}' => $name,
424 '{key}' => $key
425 )
426 )
427
428 . ($qs ? '?' . $qs : '');
429
430 return \ICanBoogie\Routing\contextualize($rc);
431 }
432
433 /**
434 * Resolve operation class.
435 *
436 * The operation class name is resolved using the inherited classes for the target and the
437 * operation name.
438 *
439 * @param string $name Name of the operation.
440 * @param Module $target Target module.
441 *
442 * @return string|null The resolve class name, or null if none was found.
443 */
444 static private function resolve_operation_class($name, Module $target)
445 {
446 $module = $target;
447
448 while ($module)
449 {
450 $class = self::format_class_name($module->descriptor[Module::T_NAMESPACE], $name);
451
452 if (class_exists($class, true))
453 {
454 return $class;
455 }
456
457 $module = $module->parent;
458 }
459 }
460
461 /**
462 * Formats the specified namespace and operation name into an operation class.
463 *
464 * @param string $namespace
465 * @param string $operation_name
466 *
467 * @return string
468 */
469 static public function format_class_name($namespace, $operation_name)
470 {
471 return $namespace . '\\' . camelize(strtr($operation_name, '-', '_')) . 'Operation';
472 }
473
474 public $key;
475 public $destination;
476
477 /**
478 * @var \ICanBoogie\HTTP\Request The request triggering the operation.
479 */
480 protected $request;
481
482 protected function get_request()
483 {
484 return $this->request;
485 }
486
487 public $response;
488 public $method;
489
490 const CONTROL_METHOD = 101;
491 const CONTROL_SESSION_TOKEN = 102;
492 const CONTROL_AUTHENTICATION = 103;
493 const CONTROL_PERMISSION = 104;
494 const CONTROL_RECORD = 105;
495 const CONTROL_OWNERSHIP = 106;
496 const CONTROL_FORM = 107;
497
498 /**
499 * Returns the controls to pass.
500 *
501 * @return array All the controls set to false.
502 */
503 protected function get_controls()
504 {
505 return array
506 (
507 self::CONTROL_METHOD => false,
508 self::CONTROL_SESSION_TOKEN => false,
509 self::CONTROL_AUTHENTICATION => false,
510 self::CONTROL_PERMISSION => false,
511 self::CONTROL_RECORD => false,
512 self::CONTROL_OWNERSHIP => false,
513 self::CONTROL_FORM => false
514 );
515 }
516
517 /**
518 * Getter for the {@link $record} property.
519 *
520 * @return ActiveRecord
521 */
522 protected function lazy_get_record()
523 {
524 return $this->module->model[$this->key];
525 }
526
527 /**
528 * Returns the operation response.
529 *
530 * @return Operation\Response
531 */
532 protected function get_response()
533 {
534 return $this->response;
535 }
536
537 /**
538 * The form object of the operation.
539 *
540 * @var object
541 */
542 protected $form;
543
544 /**
545 * Getter for the {@link $form} property.
546 *
547 * The operation object fires a {@link GetFormEvent} event to retrieve the form. One can listen
548 * to the event to provide the form associated with the operation.
549 *
550 * One can override this method to provide the form using another method. Or simply define the
551 * {@link $form} property to circumvent the getter.
552 *
553 * @return object|null
554 */
555 protected function lazy_get_form()
556 {
557 new GetFormEvent($this, $this->request, $form);
558
559 return $form;
560 }
561
562 /**
563 * @var array The properties for the operation.
564 */
565 protected $properties;
566
567 /**
568 * Getter for the {@link $properties} property.
569 *
570 * The getter should only be called during the {@link process()} method.
571 *
572 * @return array
573 */
574 protected function lazy_get_properties()
575 {
576 return array();
577 }
578
579 /**
580 * Output format of the operation response.
581 *
582 * @var string
583 */
584 protected $format;
585
586 /**
587 * Target module for the operation.
588 *
589 * The property is set by the constructor.
590 *
591 * @var Module
592 */
593 protected $module;
594
595 protected function get_module()
596 {
597 return $this->module;
598 }
599
600 /**
601 * Returns `true` if the operation is forwarded.
602 *
603 * An operation is considered forwarded if the destination module and the operation name are
604 * defined in the request parameters. This is usually the case for forms which are posted
605 * on their URI but forwarded to a specified destination module.
606 *
607 * @return boolean
608 */
609 protected function get_is_forwarded()
610 {
611 return !empty($this->request->request_params[Operation::NAME])
612 && !empty($this->request->request_params[Operation::DESTINATION]);
613 }
614
615 /**
616 * Constructor.
617 *
618 * The {@link $controls} property is unset in order for its getters to be called on the next
619 * access, while keeping its scope.
620 *
621 * @param Request $request @todo: should be a Request, but is sometimes a module.
622 */
623 public function __construct($request=null)
624 {
625 global $core;
626
627 unset($this->controls);
628
629 if ($request instanceof Request)
630 {
631 if ($request[self::DESTINATION])
632 {
633 $this->module = $core->modules[$request[self::DESTINATION]];
634 }
635 }
636 else if ($request instanceof Module)
637 {
638 $this->module = $request;
639 }
640 }
641
642 /**
643 * Handles the operation and prints or returns its result.
644 *
645 * The {@link $record}, {@link $form} and {@link $properties} properties are unset in order
646 * for their getters to be called on the next access, while keeping their scope.
647 *
648 * The response object
649 * -------------------
650 *
651 * The operation result is saved in a _response_ object, which may contain meta data describing
652 * or accompanying the result. For example, the {@link Operation} class returns success and
653 * error messages in the {@link $message} and {@link $errors} properties.
654 *
655 * Depending on the `Accept` header of the request, the response object can be formatted as
656 * JSON or XML. If the `Accept` header is "application/json" the response is formatted as JSON.
657 * If the `Accept` header is "application/xml" the response is formatted as XML. If the
658 * `Accept` header is not of a supported type, only the result is printed, as a string.
659 *
660 * For API requests, the output format can also be defined by appending the corresponding
661 * extension to the request path:
662 *
663 * /api/system.nodes/12/online.json
664 *
665 *
666 * The response location
667 * ---------------------
668 *
669 * The `Location` header is used to ask the browser to load a different web page. This is often
670 * used to redirect the user when an operation has been performed e.g. creating/deleting a
671 * resource. The `location` property of the response is used to set that header. This is not
672 * a desirable behavior for XHR because although we might want to redirect the user, we still
673 * need to get the result of our request first. That is why when the `location` property is
674 * set, and the request is an XHR, the location is set to the `redirect_to` field and the
675 * `location` property is set to `null` to disable browser redirection.
676 *
677 *
678 *
679 * Control, validation and processing
680 * ----------------------------------
681 *
682 * Before the operation is actually processed with the {@link process()} method, it is
683 * controlled and validated using the {@link control()} and {@link validate()} methods. If the
684 * control or validation fail the operation is not processed.
685 *
686 * The controls passed to the {@link control()} method are obtained through the
687 * {@link $controls} property or the {@link get_controls()} getter if the property is not
688 * accessible.
689 *
690 *
691 * Events
692 * ------
693 *
694 * The `failure` event is fired when the control or validation of the operation failed. The
695 * `type` property of the event is "control" or "validation" depending on which method failed.
696 * Note that the event won't be fired if an exception is thrown.
697 *
698 * The `process:before` event is fired with the operation as sender before the operation is
699 * processed using the {@link process()} method.
700 *
701 * The `process` event is fired with the operation as sender after the operation has been
702 * processed if its result is not `null`.
703 *
704 *
705 * Failed operation
706 * ----------------
707 *
708 * If the result of the operation is `null`, the operation is considered as failed, in which
709 * case the status code of the response is changed to 404 and the {@link ProcessEvent} is not
710 * fired.
711 *
712 * Note that exceptions are not caught by the method.
713 *
714 * @param HTTP\Request $request The request triggering the operation.
715 *
716 * @return Operation\Response The response of the operation.
717 *
718 * @throws Failure when the response has a client or server error, or the
719 * {@link FormHasExpired} exception was raised.
720 */
721 public function __invoke(HTTP\Request $request)
722 {
723 $this->request = $request;
724 $this->reset();
725
726 $rc = null;
727 $response = $this->response;
728
729 try
730 {
731 $controls = $this->controls;
732 $control_success = true;
733 $control_payload = array('success' => &$control_success, 'controls' => &$controls, 'request' => $request);
734
735 new BeforeControlEvent($this, $control_payload);
736
737 if ($control_success)
738 {
739 $control_success = $this->control($controls);
740 }
741
742 new ControlEvent($this, $control_payload);
743
744 if (!$control_success)
745 {
746 new FailureEvent($this, FailureEvent::TYPE_CONTROL, $request);
747
748 if (!$response->errors->count())
749 {
750 $response->errors[] = 'Operation control failed.';
751 }
752 }
753 else
754 {
755 $validate_success = true;
756 $validate_payload = array('success' => &$validate_success, 'errors' => &$response->errors, 'request' => $request);
757
758 new BeforeValidateEvent($this, $validate_payload);
759
760 if ($validate_success)
761 {
762 $validate_success = $this->validate($response->errors);
763 }
764
765 new ValidateEvent($this, $validate_payload);
766
767 if (!$validate_success || $response->errors->count())
768 {
769 new FailureEvent($this, FailureEvent::TYPE_VALIDATE, $request);
770
771 if (!$response->errors->count())
772 {
773 $response->errors[] = 'Operation validation failed.';
774 }
775 }
776 else
777 {
778 new BeforeProcessEvent($this, array('request' => $request, 'response' => $response, 'errors' => $response->errors));
779
780 if (!$response->errors->count())
781 {
782 $rc = $this->process();
783
784 if ($rc === null && !$response->errors->count())
785 {
786 $response->errors[] = 'Operation failed (result was null).';
787 }
788 }
789 }
790 }
791 }
792 catch (Operation\FormHasExpired $e)
793 {
794 log_error($e->getMessage());
795
796 throw new Failure($this, $e);
797 }
798 catch (\Exception $e)
799 {
800 throw $e;
801 }
802
803 $response->rc = $rc;
804
805 #
806 # errors
807 #
808
809 if ($response->errors->count() && !$request->is_xhr && !isset($this->form))
810 {
811 foreach ($response->errors as $error_message)
812 {
813 log_error($error_message);
814 }
815 }
816
817 #
818 # If the operation succeed (its result is not null), the ProcessEvent event is fired.
819 # Listeners might use the event for further processing. For example, a _comment_ module
820 # might delete the comments related to an _article_ module from which an article was
821 # deleted.
822 #
823
824 if ($rc === null)
825 {
826 $response->status = array(400, 'Operation failed');
827 }
828 else
829 {
830 new ProcessEvent($this, array('rc' => &$response->rc, 'response' => $response, 'request' => $request));
831 }
832
833 #
834 # We log the `message` if the request is the main request and is not an XHR.
835 #
836
837 if ($response->message && !$request->previous && !$request->is_xhr)
838 {
839 log_success($response->message);
840 }
841
842 #
843 # Operation\Request rewrites the response body if the body is null, but we only want that
844 # for XHR request, so we need to set the response body to some value, which should be
845 # the operation result, or an empty string of the request is redirected.
846 #
847
848 if ($request->is_xhr)
849 {
850 $response->content_type = $request->headers['Accept'];
851
852 if ($response->location)
853 {
854 $response['redirect_to'] = $response->location;
855 }
856
857 $response->location = null;
858 }
859 else if ($response->location)
860 {
861 $response->body = '';
862 $response->headers['Referer'] = $request->uri;
863 }
864 else if ($response->status == 304)
865 {
866 $response->body = '';
867 }
868
869 #
870 # If the operation failed, we throw a Failure exception.
871 #
872
873 if ($response->is_client_error || $response->is_server_error)
874 {
875 throw new Failure($this);
876 }
877
878 return $response;
879 }
880
881 /**
882 * Resets the operation state.
883 *
884 * A same operation object can be used multiple time to perform an operation with different
885 * parameters, this method is invoked to reset the operation state before it is controled,
886 * validated and processed.
887 */
888 protected function reset()
889 {
890 $this->response = new Operation\Response;
891
892 unset($this->form);
893 unset($this->record);
894 unset($this->properties);
895
896 $key = $this->request[self::KEY];
897
898 if ($key)
899 {
900 $this->key = $key;
901 }
902 }
903
904 /**
905 * Controls the operation.
906 *
907 * A number of controls may be passed before an operation is validated and processed. Controls
908 * are defined as an array where the key is the control identifier, and the value defines
909 * whether the control is enabled. Controls are enabled by setting their value to true:
910 *
911 * array
912 * (
913 * self::CONTROL_AUTHENTICATION => true,
914 * self::CONTROL_RECORD => true,
915 * self::CONTROL_FORM => false
916 * );
917 *
918 * Instead of a boolean, the "permission" control is enabled by a permission string or a
919 * permission level.
920 *
921 * array
922 * (
923 * self::CONTROL_PERMISSION => Module::PERMISSION_MAINTAIN
924 * );
925 *
926 * The {@link $controls} property is used to get the controls or its magic getter
927 * {@link get_controls()} if the property is not accessible.
928 *
929 * Controls are passed in the following order:
930 *
931 * 1. CONTROL_SESSION_TOKEN
932 *
933 * Controls that '_session_token' is defined in $_POST and matches the current session's
934 * token. The {@link control_session_token()} method is invoked for this control. An exception
935 * with code 401 is thrown when the control fails.
936 *
937 * 2. CONTROL_AUTHENTICATION
938 *
939 * Controls the authentication of the user. The {@link control_authentication()} method is
940 * invoked for this control. An exception with the code 401 is thrown when the control fails.
941 *
942 * 3. CONTROL_PERMISSION
943 *
944 * Controls the permission of the guest or user. The {@link control_permission()} method is
945 * invoked for this control. An exception with code 401 is thrown when the control fails.
946 *
947 * 4. CONTROL_RECORD
948 *
949 * Controls the existence of the record specified by the operation's key. The
950 * {@link control_record()} method is invoked for this control. The value returned by the
951 * method is set in the operation object under the {@link record} property. The callback method
952 * must throw an exception if the record could not be loaded or the control of this record
953 * failed.
954 *
955 * The {@link record} property, or the {@link lazy_get_record()} getter, is used to get the
956 * record.
957 *
958 * 5. CONTROL_OWNERSHIP
959 *
960 * Controls the ownership of the user over the record loaded during the CONTROL_RECORD step.
961 * The {@link control_ownership()} method is invoked for the control. An exception with code
962 * 401 is thrown if the control fails.
963 *
964 * 6. CONTROL_FORM
965 *
966 * Controls the form associated with the operation by checking its existence and validity. The
967 * {@link control_form()} method is invoked for this control. Failing the control does not
968 * throw an exception, but a message is logged to the debug log.
969 *
970 * @param array $controls The controls to pass for the operation to be processed.
971 *
972 * @return boolean true if all the controls pass, false otherwise.
973 *
974 * @throws HTTPError Depends on the control.
975 */
976 protected function control(array $controls)
977 {
978 $controls += $this->controls;
979
980 $method = $controls[self::CONTROL_METHOD];
981
982 if ($method && !$this->control_method($method))
983 {
984 throw new HTTPError
985 (
986 format("The %operation operation requires the %method method.", array
987 (
988 'operation' => get_class($this),
989 'method' => $method
990 ))
991 );
992 }
993
994 if ($controls[self::CONTROL_SESSION_TOKEN] && !$this->control_session_token())
995 {
996 throw new HTTPError("Session token doesn't match", 401);
997 }
998
999 if ($controls[self::CONTROL_AUTHENTICATION] && !$this->control_authentication())
1000 {
1001 throw new HTTPError
1002 (
1003 format('The %operation operation requires authentication.', array
1004 (
1005 '%operation' => get_class($this)
1006 )),
1007
1008 401
1009 );
1010 }
1011
1012 if ($controls[self::CONTROL_PERMISSION] && !$this->control_permission($controls[self::CONTROL_PERMISSION]))
1013 {
1014 throw new HTTPError
1015 (
1016 format("You don't have permission to perform the %operation operation.", array
1017 (
1018 '%operation' => get_class($this)
1019 )),
1020
1021 401
1022 );
1023 }
1024
1025 if ($controls[self::CONTROL_RECORD] && !$this->control_record())
1026 {
1027 throw new HTTPError
1028 (
1029 format('Unable to retrieve record required for the %operation operation.', array
1030 (
1031 '%operation' => get_class($this)
1032 ))
1033 );
1034 }
1035
1036 if ($controls[self::CONTROL_OWNERSHIP] && !$this->control_ownership())
1037 {
1038 throw new HTTPError("You don't have ownership of the record.", 401);
1039 }
1040
1041 if ($controls[self::CONTROL_FORM] && !$this->control_form())
1042 {
1043 log('Control %control failed for operation %operation.', array('%control' => 'form', '%operation' => get_class($this)));
1044
1045 return false;
1046 }
1047
1048 return true;
1049 }
1050
1051 /**
1052 * Controls the request method.
1053 *
1054 * If the method is {@link Request::METHOD_ANY} it always matches.
1055 *
1056 * @param string $method
1057 *
1058 * @return boolean `true` if the method matches, `false` otherwise.
1059 */
1060 protected function control_method($method)
1061 {
1062 return ($method === Request::METHOD_ANY) ? true : $method === $this->request->method;
1063 }
1064
1065 /**
1066 * Controls the session token.
1067 *
1068 * @return boolean true if the token is defined and correspond to the session token, false
1069 * otherwise.
1070 */
1071 protected function control_session_token()
1072 {
1073 global $core;
1074
1075 $request = $this->request;
1076
1077 return isset($request->request_params['_session_token']) && $request->request_params['_session_token'] == $core->session->token;
1078 }
1079
1080 /**
1081 * Controls the authentication of the user.
1082 */
1083 protected function control_authentication()
1084 {
1085 global $core;
1086
1087 return ($core->user_id != 0);
1088 }
1089
1090 /**
1091 * Controls the permission of the user for the operation.
1092 *
1093 * @param mixed $permission The required permission.
1094 *
1095 * @return bool true if the user has the specified permission, false otherwise.
1096 */
1097 protected function control_permission($permission)
1098 {
1099 global $core;
1100
1101 return $core->user->has_permission($permission, $this->module);
1102 }
1103
1104 /**
1105 * Controls the ownership of the user over the operation target record.
1106 *
1107 * @return bool true if the user as ownership of the record or there is no record, false
1108 * otherwise.
1109 */
1110 protected function control_ownership()
1111 {
1112 global $core;
1113
1114 $record = $this->record;
1115
1116 return (!$record || $core->user->has_ownership($this->module, $record));
1117 }
1118
1119 /**
1120 * Checks if the operation target record exists.
1121 *
1122 * The method simply returns the {@link $record} property, which calls the
1123 * {@link lazy_get_record()} getter if the property is not accessible.
1124 *
1125 * @return ActiveRecord|null
1126 */
1127 protected function control_record()
1128 {
1129 return $this->record;
1130 }
1131
1132 /**
1133 * Control the operation's form.
1134 *
1135 * The form is retrieved from the {@link $form} property, which invokes the
1136 * {@link lazy_get_form()} getter if the property is not accessible.
1137 *
1138 * @return bool true if the form exists and validates, false otherwise.
1139 */
1140 protected function control_form()
1141 {
1142 $form = $this->form;
1143
1144 return ($form && $form->validate($this->request->params, $this->response->errors));
1145 }
1146
1147 /**
1148 * Validates the operation before processing.
1149 *
1150 * The method is abstract and therefore must be implemented by subclasses.
1151 *
1152 * @throws \Exception If something horribly wrong happens.
1153 *
1154 * @return bool true if the operation is valid, false otherwise.
1155 */
1156 abstract protected function validate(Errors $errors);
1157
1158 /**
1159 * Processes the operation.
1160 *
1161 * The method is abstract and therefore must be implemented by subclasses.
1162 *
1163 * @return mixed Depends on the implementation.
1164 */
1165 abstract protected function process();
1166 }
1167
1168 /*
1169 * Operation events
1170 */
1171
1172 namespace ICanBoogie\Operation;
1173
1174 use ICanBoogie\HTTP\Request;
1175
1176 abstract class ControlEventBase extends \ICanBoogie\Event
1177 {
1178 /**
1179 * Reference to the success result of the control.
1180 *
1181 * @var bool
1182 */
1183 public $success;
1184
1185 /**
1186 * Reference to operation controls.
1187 *
1188 * @var array
1189 */
1190 public $controls;
1191
1192 /**
1193 * The request that triggered the operation.
1194 *
1195 * @ var \ICanBoogie\HTTP\Request
1196 */
1197 public $request;
1198 }
1199
1200 /**
1201 * Event class for the `ICanBoogie\Operation::control:before` event.
1202 *
1203 * Third parties may use this event to alter the controls to run or clear them altogether.
1204 */
1205 class BeforeControlEvent extends ControlEventBase
1206 {
1207 /**
1208 * The event is constructed with the type `control:before`.
1209 *
1210 * @param \ICanBoogie\Operation $target
1211 * @param array $payload
1212 */
1213 public function __construct(\ICanBoogie\Operation $target, array $payload)
1214 {
1215 parent::__construct($target, 'control:before', $payload);
1216 }
1217 }
1218
1219 /**
1220 * Event class for the `ICanBoogie\Operation::control` event.
1221 *
1222 * Third parties may use this event to alter the outcome of the control.
1223 */
1224 class ControlEvent extends ControlEventBase
1225 {
1226 /**
1227 * The event is constructed with the type `control`.
1228 *
1229 * @param \ICanBoogie\Operation $target
1230 * @param array $payload
1231 */
1232 public function __construct(\ICanBoogie\Operation $target, array $payload)
1233 {
1234 parent::__construct($target, 'control', $payload);
1235 }
1236 }
1237
1238 abstract class ValidateEventBase extends \ICanBoogie\Event
1239 {
1240 /**
1241 * Reference the success of the validation.
1242 *
1243 * @var bool
1244 */
1245 public $success;
1246
1247 /**
1248 * Reference to the validation errors.
1249 *
1250 * @var \ICanBoogie\Errors
1251 */
1252 public $errors;
1253
1254 /**
1255 * Request that triggered the operation.
1256 *
1257 * @var \ICanBoogie\HTTP\Request
1258 */
1259 public $request;
1260 }
1261
1262 /**
1263 * Event class for the `ICanBoogie\Operation::validate:before` event.
1264 */
1265 class BeforeValidateEvent extends ValidateEventBase
1266 {
1267 /**
1268 * The event is constructed with the type `validate:before`.
1269 *
1270 * @param \ICanBoogie\Operation $target
1271 * @param array $payload
1272 */
1273 public function __construct(\ICanBoogie\Operation $target, array $payload)
1274 {
1275 parent::__construct($target, 'validate:before', $payload);
1276 }
1277 }
1278
1279 /**
1280 * Event class for the `ICanBoogie\Operation::validate` event.
1281 */
1282 class ValidateEvent extends ValidateEventBase
1283 {
1284 /**
1285 * The event is constructed with the type `validate`.
1286 *
1287 * @param \ICanBoogie\Operation $target
1288 * @param array $payload
1289 */
1290 public function __construct(\ICanBoogie\Operation $target, array $payload)
1291 {
1292 parent::__construct($target, 'validate', $payload);
1293 }
1294 }
1295
1296 /**
1297 * Event class for the `ICanBoogie\Operation::failure` event.
1298 */
1299 class FailureEvent extends \ICanBoogie\Event
1300 {
1301 /**
1302 * The failure occured during {@link \ICanBoogie\Operation::control()}.
1303 *
1304 * @var string
1305 */
1306 const TYPE_CONTROL = 'control';
1307
1308 /**
1309 * The failure occured during {@link \ICanBoogie\Operation::validate()}.
1310 *
1311 * @var string
1312 */
1313 const TYPE_VALIDATE = 'validate';
1314
1315 /**
1316 * Type of failure, either {@link TYPE_CONTROL} or {@link TYPE_VALIDATION}.
1317 *
1318 * @var string
1319 */
1320 public $type;
1321
1322 /**
1323 * The request that triggered the operation.
1324 *
1325 * @var \ICanBoogie\HTTP\Request
1326 */
1327 public $request;
1328
1329 /**
1330 * The event is constructed with the type `failure`.
1331 *
1332 * @param \ICanBoogie\Operation $target
1333 * @param array $payload
1334 */
1335 public function __construct(\ICanBoogie\Operation $target, $type, Request $request)
1336 {
1337 $this->type = $type;
1338 $this->request = $request;
1339
1340 parent::__construct($target, 'failure');
1341 }
1342
1343 public function __get($property)
1344 {
1345 switch ($property)
1346 {
1347 case 'is_control': return $this->type == self::TYPE_CONTROL;
1348 case 'is_validate': return $this->type == self::TYPE_VALIDATE;
1349 }
1350
1351 return parent::__get($property);
1352 }
1353 }
1354
1355 /**
1356 * Event class for the `ICanBoogie\Operation::process:before` event.
1357 *
1358 * Third parties may use this event to alter the request, response or errors.
1359 */
1360 class BeforeProcessEvent extends \ICanBoogie\Event
1361 {
1362 /**
1363 * The request that triggered the operation.
1364 *
1365 * @var \ICanBoogie\HTTP\Request
1366 */
1367 public $request;
1368
1369 /**
1370 * The response of the operation.
1371 *
1372 * @var \ICanBoogie\HTTP\Response
1373 */
1374 public $response;
1375
1376 /**
1377 * The errors collector.
1378 *
1379 * @var \ICanBoogie\Errors
1380 */
1381 public $errors;
1382
1383 /**
1384 * The event is constructed with the type `process:before`.
1385 *
1386 * @param \ICanBoogie\Operation $target
1387 * @param array $payload
1388 */
1389 public function __construct(\ICanBoogie\Operation $target, array $payload)
1390 {
1391 parent::__construct($target, 'process:before', $payload);
1392 }
1393 }
1394
1395 /**
1396 * Event class for the `ICanBoogie\Operation::process` event.
1397 */
1398 class ProcessEvent extends \ICanBoogie\Event
1399 {
1400 /**
1401 * Reference to the response result property.
1402 *
1403 * @var mixed
1404 */
1405 public $rc;
1406
1407 /**
1408 * The response object of the operation.
1409 *
1410 * @var \ICanBoogie\HTTP\Response
1411 */
1412 public $response;
1413
1414 /**
1415 * The request that triggered the operation.
1416 *
1417 * @var \ICanBoogie\HTTP\Request
1418 */
1419 public $request;
1420
1421 /**
1422 * The event is constructed with the type `process`.
1423 *
1424 * @param \ICanBoogie\Operation $target
1425 * @param array $payload
1426 */
1427 public function __construct(\ICanBoogie\Operation $target, array $payload)
1428 {
1429 parent::__construct($target, 'process', $payload);
1430 }
1431 }
1432
1433 /**
1434 * Event class for the `ICanBoogie\Operation::get_form` event.
1435 */
1436 class GetFormEvent extends \ICanBoogie\Event
1437 {
1438 /**
1439 * Reference to the result variable.
1440 *
1441 * @var mixed
1442 */
1443 public $form;
1444
1445 /**
1446 * The request that triggered the operation.
1447 *
1448 * @var \ICanBoogie\HTTP\Request
1449 */
1450 public $request;
1451
1452 /**
1453 * The event is constructed with the type `get_form`.
1454 *
1455 * @param \ICanBoogie\Operation $target
1456 * @param array $payload
1457 */
1458 public function __construct(\ICanBoogie\Operation $target, Request $request, &$form)
1459 {
1460 $this->request = $request;
1461 $this->form = &$form;
1462
1463 parent::__construct($target, 'get_form');
1464 }
1465 }
1466
1467 /**
1468 * Exception thrown when the form associated with an operation has expired.
1469 *
1470 * The exception is considered recoverable, if the request is not XHR.
1471 */
1472 class FormHasExpired extends \Exception
1473 {
1474 public function __construct($message="The form associated with the request has expired.", $code=500, \Exception $previous=null)
1475 {
1476 parent::__construct($message, $code, $previous);
1477 }
1478 }