MDL-39846 protect event dispatching process
[moodle.git] / lib / classes / event / base.php
CommitLineData
d8a1f426
PS
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17namespace core\event;
18
19/**
20 * Base event class.
21 *
22 * @package core
23 * @copyright 2013 Petr Skoda {@link http://skodak.org}
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27
28/**
29 * All other event classes must extend this class.
30 *
31 * @package core
32 * @copyright 2013 Petr Skoda {@link http://skodak.org}
33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34 *
35 * @property-read string $eventname Name of the event (=== class name with leading \)
36 * @property-read string $component Full frankenstyle component name
37 * @property-read string $action what happened
38 * @property-read string $object what/who was object of the action (usually similar to database table name)
39 * @property-read int $objectid optional id of the object
40 * @property-read string $crud letter indicating event type
4b734e74 41 * @property-read int $level log level (number between 1 and 100)
d8a1f426
PS
42 * @property-read int $contextid
43 * @property-read int $contextlevel
44 * @property-read int $contextinstanceid
45 * @property-read int $userid who did this?
46 * @property-read int $courseid
47 * @property-read int $relateduserid
48 * @property-read mixed $extra array or scalar, can not contain objects
d8a1f426
PS
49 * @property-read int $timecreated
50 */
51abstract class base {
52 /** @var array event data */
53 protected $data;
54
4b734e74
PS
55 /** @var array the format is standardised by logging API */
56 protected $logdata;
57
d8a1f426
PS
58 /** @var \context of this event */
59 protected $context;
60
22626564
PS
61 /**
62 * @var bool indicates if event was already triggered,
63 * this prevents second attempt to trigger event.
64 */
d8a1f426
PS
65 private $triggered;
66
22626564
PS
67 /**
68 * @var bool indicates if event was already dispatched,
69 * this prevents direct calling of manager::dispatch($event).
70 */
71 private $dispatched;
72
73 /**
74 * @var bool indicates if event was restored from storage,
75 * this prevents triggering of restored events.
76 */
d8a1f426
PS
77 private $restored;
78
79 /** @var array list of event properties */
80 private static $fields = array(
81 'eventname', 'component', 'action', 'object', 'objectid', 'crud', 'level', 'contextid',
82 'contextlevel', 'contextinstanceid', 'userid', 'courseid', 'relateduserid', 'extra',
4b734e74 83 'timecreated');
d8a1f426
PS
84
85 /** @var array simple record cache */
86 protected $cachedrecords = array();
87
88 /**
89 * Private constructor, use create() or restore() methods instead.
90 */
91 private final function __construct() {
92 $this->data = array_fill_keys(self::$fields, null);
93 }
94
95 /**
96 * Create new event.
97 *
98 * The optional data keys as:
99 * 1/ objectid - the id of the object specified in class name
100 * 2/ context - the context of this event
101 * 3/ extra - the extra data describing the event, can not contain objects
102 * 4/ relateduserid - the id of user which is somehow related to this event
103 *
104 * @param array $data
105 * @return \core\event\base returns instance of new event
106 *
107 * @throws \coding_exception
108 */
109 public static final function create(array $data = null) {
110 global $PAGE, $USER;
111
112 $data = (array)$data;
113
114 /** @var \core\event\base $event */
115 $event = new static();
116 $event->triggered = false;
117 $event->restored = false;
22626564 118 $event->dispatched = false;
d8a1f426 119
4b734e74
PS
120 // Set automatic data.
121 $event->data['timecreated'] = time();
122
d8a1f426
PS
123 $classname = get_class($event);
124 $parts = explode('\\', $classname);
125 if (count($parts) !== 3 or $parts[1] !== 'event') {
126 throw new \coding_exception("Invalid event class name '$classname', it must be defined in component\\event\\ namespace");
127 }
128 $event->data['eventname'] = '\\'.$classname;
129 $event->data['component'] = $parts[0];
130
131 $pos = strrpos($parts[2], '_');
132 if ($pos === false) {
133 throw new \coding_exception("Invalid event class name '$classname', there must be at least one underscore separating object and action words");
134 }
135 $event->data['object'] = substr($parts[2], 0, $pos);
136 $event->data['action'] = substr($parts[2], $pos+1);
137
4b734e74 138 // Set optional data or use defaults.
d8a1f426
PS
139 $event->data['objectid'] = isset($data['objectid']) ? $data['objectid'] : null;
140 $event->data['courseid'] = isset($data['courseid']) ? $data['courseid'] : null;
141 $event->data['userid'] = isset($data['userid']) ? $data['userid'] : $USER->id;
142 $event->data['extra'] = isset($data['extra']) ? $data['extra'] : null;
143 $event->data['relateduserid'] = isset($data['relateduserid']) ? $data['relateduserid'] : null;
144
145 $event->context = null;
146 if (isset($data['context'])) {
147 $event->context = $data['context'];
148 } else if (isset($data['contextid'])) {
149 $event->context = \context::instance_by_id($data['contextid']);
150 } else if ($event->data['courseid']) {
151 $event->context = \context_course::instance($event->data['courseid']);
152 } else if (isset($PAGE)) {
153 $event->context = $PAGE->context;
154 }
155 if (!$event->context) {
156 $event->context = \context_system::instance();
157 }
d8a1f426
PS
158 $event->data['contextid'] = $event->context->id;
159 $event->data['contextlevel'] = $event->context->contextlevel;
160 $event->data['contextinstanceid'] = $event->context->instanceid;
161
162 if (!isset($event->data['courseid'])) {
163 if ($coursecontext = $event->context->get_course_context(false)) {
164 $event->data['courseid'] = $coursecontext->id;
165 } else {
166 $event->data['courseid'] = 0;
167 }
168 }
169
170 if (!array_key_exists('relateduserid', $data) and $event->context->contextlevel == CONTEXT_USER) {
171 $event->data['relateduserid'] = $event->context->instanceid;
172 }
173
4b734e74
PS
174 // Set static event data specific for child class, this should also validate extra data.
175 $event->init();
d8a1f426 176
4b734e74 177 // Warn developers if they do something wrong.
d8a1f426 178 if (debugging('', DEBUG_DEVELOPER)) {
4b734e74
PS
179 static $automatickeys = array('eventname', 'component', 'action', 'object', 'timecreated');
180 static $initkeys = array('crud', 'level');
181
182 foreach ($data as $key => $ignored) {
183 if ($key === 'context') {
184 continue;
185
186 } else if (in_array($key, $automatickeys)) {
187 debugging("Data key '$key' is not allowed in \\core\\event\\base::create() method, it is set automatically");
188
189 } else if (in_array($key, $initkeys)) {
190 debugging("Data key '$key' is not allowed in \\core\\event\\base::create() method, you need to set it in init() method");
191
192 } else if (!in_array($key, self::$fields)) {
193 debugging("Data key '$key' does not exist in \\core\\event\\base");
d8a1f426
PS
194 }
195 }
196 }
197
d8a1f426
PS
198 return $event;
199 }
200
201 /**
202 * Override in subclass.
203 *
204 * Set all required data properties:
4b734e74
PS
205 * 1/ crud - letter [crud]
206 * 2/ level - number 1...100
207 *
208 * Optionally validate $this->data['extra'].
d8a1f426
PS
209 *
210 * @return void
211 */
212 protected abstract function init();
213
4b734e74
PS
214 /**
215 * Returns localised general event name.
216 *
217 * Override in subclass, we can not make it static and abstract at the same time.
218 *
219 * @return string|\lang_string
220 */
221 public static function get_name() {
222 // Override in subclass with real lang string.
223 $parts = explode('\\', __CLASS__);
224 if (count($parts) !== 3) {
225 return 'unknown event';
226 }
227 return $parts[0].': '.str_replace('_', ' ', $parts[2]);
228 }
229
230 /**
231 * Returns localised description of what happened.
232 *
233 * @return string|\lang_string
234 */
235 public function get_description() {
236 return null;
237 }
238
239 /**
240 * Define whether a user can view the event or not.
241 *
242 * @param int|\stdClass $user_or_id ID of the user.
243 * @return bool True if the user can view the event, false otherwise.
244 */
245 public function can_view($user_or_id = null) {
246 return is_siteadmin($user_or_id);
247 }
248
d8a1f426
PS
249 /**
250 * Restore event from existing historic data.
251 *
252 * @param array $data
4b734e74 253 * @param array $logdata the format is standardised by logging API
d8a1f426
PS
254 * @return bool|\core\event\base
255 */
4b734e74 256 public static final function restore(array $data, array $logdata) {
d8a1f426
PS
257 $classname = $data['eventname'];
258 $component = $data['component'];
259 $action = $data['action'];
260 $object = $data['object'];
261
262 // Security: make 100% sure this really is an event class.
263 if ($classname !== "\\{$component}\\event\\{$object}_{$action}") {
264 return false;
265 }
266
267 if (!class_exists($classname)) {
268 return false;
269 }
270 $event = new $classname();
271 if (!($event instanceof \core\event\base)) {
272 return false;
273 }
274
d8a1f426 275 $event->restored = true;
22626564
PS
276 $event->triggered = true;
277 $event->dispatched = true;
4b734e74 278 $event->logdata = $logdata;
d8a1f426
PS
279
280 foreach (self::$fields as $key) {
4b734e74 281 if (!array_key_exists($key, $data)) {
d8a1f426 282 debugging("Event restore data must contain key $key");
4b734e74 283 $data[$key] = null;
d8a1f426
PS
284 }
285 }
4b734e74
PS
286 if (count($data) != count(self::$fields)) {
287 foreach ($data as $key => $value) {
288 if (!in_array($key, self::$fields)) {
289 debugging("Event restore data cannot contain key $key");
290 unset($data[$key]);
291 }
292 }
293 }
294 $event->data = $data;
d8a1f426
PS
295
296 return $event;
297 }
298
d8a1f426
PS
299 /**
300 * Returns event context.
301 * @return \context
302 */
303 public function get_context() {
304 if (isset($this->context)) {
305 return $this->context;
306 }
307 $this->context = \context::instance_by_id($this->data['contextid'], false);
308 return $this->context;
309 }
310
311 /**
312 * Returns relevant URL, override in subclasses.
4b734e74 313 * @return \moodle_url
d8a1f426
PS
314 */
315 public function get_url() {
316 return null;
317 }
318
319 /**
320 * Return standardised event data as array.
321 *
d8a1f426
PS
322 * @return array
323 */
324 public function get_data() {
325 return $this->data;
326 }
327
4b734e74
PS
328 /**
329 * Return auxiliary data that was stored in logs.
330 *
331 * @return array the format is standardised by logging API
332 */
333 public function get_logdata() {
334 return $this->logdata;
335 }
336
d8a1f426
PS
337 /**
338 * Does this event replace legacy event?
339 *
340 * @return null|string legacy event name
341 */
342 public function get_legacy_eventname() {
343 return null;
344 }
345
346 /**
347 * Legacy event data if get_legacy_eventname() is not empty.
348 *
349 * @return mixed
350 */
351 public function get_legacy_eventdata() {
352 return null;
353 }
354
355 /**
356 * Doest this event replace add_to_log() statement?
357 *
358 * @return null|array of parameters to be passed to legacy add_to_log() function.
359 */
360 public function get_legacy_logdata() {
361 return null;
362 }
363
364 /**
365 * Validate all properties right before triggering the event.
366 *
367 * This throws coding exceptions for fatal problems and debugging for minor problems.
368 *
369 * @throws \coding_exception
370 */
371 protected final function validate_before_trigger() {
372 if (empty($this->data['crud'])) {
373 throw new \coding_exception('crud must be specified in init() method of each method');
374 }
375 if (empty($this->data['level'])) {
376 throw new \coding_exception('level must be specified in init() method of each method');
377 }
378
379 if (debugging('', DEBUG_DEVELOPER)) {
d8a1f426
PS
380 // Ideally these should be coding exceptions, but we need to skip these for performance reasons
381 // on production servers.
4b734e74
PS
382
383 if (!in_array($this->data['crud'], array('c', 'r', 'u', 'd'), true)) {
384 debugging("Invalid event crud value specified.");
385 }
386 if (!is_number($this->data['level'])) {
387 debugging('Event property level must be a number');
388 }
d8a1f426 389 if (self::$fields !== array_keys($this->data)) {
4b734e74 390 debugging('Number of event data fields must not be changed in event classes');
d8a1f426 391 }
d8a1f426
PS
392 $encoded = json_encode($this->data['extra']);
393 if ($encoded === false or $this->data['extra'] !== json_decode($encoded, true)) {
4b734e74
PS
394 debugging('Extra event data must be compatible with json encoding');
395 }
396 if ($this->data['userid'] and !is_number($this->data['userid'])) {
397 debugging('Event property userid must be a number');
398 }
399 if ($this->data['courseid'] and !is_number($this->data['courseid'])) {
400 debugging('Event property courseid must be a number');
401 }
402 if ($this->data['objectid'] and !is_number($this->data['objectid'])) {
403 debugging('Event property objectid must be a number');
404 }
405 if ($this->data['relateduserid'] and !is_number($this->data['relateduserid'])) {
406 debugging('Event property relateduserid must be a number');
d8a1f426
PS
407 }
408 }
409 }
410
411 /**
412 * Trigger event.
413 */
414 public final function trigger() {
415 global $CFG;
416
417 if ($this->restored) {
418 throw new \coding_exception('Can not trigger restored event');
419 }
22626564 420 if ($this->triggered or $this->dispatched) {
d8a1f426
PS
421 throw new \coding_exception('Can not trigger event twice');
422 }
423
d8a1f426
PS
424 $this->validate_before_trigger();
425
22626564
PS
426 $this->triggered = true;
427
d8a1f426
PS
428 if (!empty($CFG->loglifetime)) {
429 if ($data = $this->get_legacy_logdata()) {
430 call_user_func_array('add_to_log', $data);
431 }
432 }
433
434 \core\event\manager::dispatch($this);
435
22626564
PS
436 $this->dispatched = true;
437
d8a1f426
PS
438 if ($legacyeventname = $this->get_legacy_eventname()) {
439 events_trigger($legacyeventname, $this->get_legacy_eventdata());
440 }
441 }
442
443 /**
22626564 444 * Was this event already triggered?
d8a1f426
PS
445 *
446 * @return bool
447 */
22626564 448 public final function is_triggered() {
d8a1f426
PS
449 return $this->triggered;
450 }
451
22626564
PS
452 /**
453 * Used from event manager to prevent direct access.
454 *
455 * @return bool
456 */
457 public final function is_dispatched() {
458 return $this->dispatched;
459 }
460
d8a1f426 461 /**
4b734e74 462 * Was this event restored?
d8a1f426
PS
463 *
464 * @return bool
465 */
22626564 466 public final function is_restored() {
d8a1f426
PS
467 return $this->restored;
468 }
469
470 /**
471 * Add cached data that will be most probably used in event observers.
472 *
473 * This is used to improve performance, but it is required for data
4b734e74 474 * that was just deleted.
d8a1f426
PS
475 *
476 * @param string $tablename
477 * @param \stdClass $record
478 */
479 public function add_cached_record($tablename, $record) {
480 global $DB;
481
482 // NOTE: this might use some kind of MUC cache,
483 // hopefully we will not run out of memory here...
484 if (debugging('', DEBUG_DEVELOPER)) {
485 if (!$DB->get_manager()->table_exists($tablename)) {
486 debugging("Invalid table name '$tablename' specified, database table does not exist.");
487 }
488 }
489 $this->cachedrecords[$tablename][$record->id] = $record;
490 }
491
492 /**
493 * Returns cached record or fetches data from database if not cached.
494 *
495 * @param string $tablename
496 * @param int $id
497 * @return \stdClass
498 */
499 public function get_cached_record($tablename, $id) {
500 global $DB;
501
502 if (isset($this->cachedrecords[$tablename][$id])) {
503 return $this->cachedrecords[$tablename][$id];
504 }
505
506 $record = $DB->get_record($tablename, array('id'=>$id));
507 $this->cachedrecords[$tablename][$id] = $record;
508
509 return $record;
510 }
511
512 /**
513 * Magic getter for read only access.
514 *
515 * Note: we must not allow modification of data from outside,
516 * after trigger() the data MUST NOT CHANGE!!!
517 *
518 * @param string $name
519 * @return mixed
d8a1f426
PS
520 */
521 public function __get($name) {
522 if (array_key_exists($name, $this->data)) {
523 return $this->data[$name];
524 }
525
4b734e74 526 debugging("Accessing non-existent event property '$name'");
d8a1f426
PS
527 }
528}