1<?php
2
3namespace Sabre\CalDAV;
4
5use Sabre\VObject;
6use DateTime;
7
8/**
9 * CalendarQuery Validator
10 *
11 * This class is responsible for checking if an iCalendar object matches a set
12 * of filters. The main function to do this is 'validate'.
13 *
14 * This is used to determine which icalendar objects should be returned for a
15 * calendar-query REPORT request.
16 *
17 * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/).
18 * @author Evert Pot (http://evertpot.com/)
19 * @license http://sabre.io/license/ Modified BSD License
20 */
21class CalendarQueryValidator {
22
23    /**
24     * Verify if a list of filters applies to the calendar data object
25     *
26     * The list of filters must be formatted as parsed by \Sabre\CalDAV\CalendarQueryParser
27     *
28     * @param VObject\Component $vObject
29     * @param array $filters
30     * @return bool
31     */
32    function validate(VObject\Component\VCalendar $vObject, array $filters) {
33
34        // The top level object is always a component filter.
35        // We'll parse it manually, as it's pretty simple.
36        if ($vObject->name !== $filters['name']) {
37            return false;
38        }
39
40        return
41            $this->validateCompFilters($vObject, $filters['comp-filters']) &&
42            $this->validatePropFilters($vObject, $filters['prop-filters']);
43
44
45    }
46
47    /**
48     * This method checks the validity of comp-filters.
49     *
50     * A list of comp-filters needs to be specified. Also the parent of the
51     * component we're checking should be specified, not the component to check
52     * itself.
53     *
54     * @param VObject\Component $parent
55     * @param array $filters
56     * @return bool
57     */
58    protected function validateCompFilters(VObject\Component $parent, array $filters) {
59
60        foreach ($filters as $filter) {
61
62            $filterName = $filter['name'];
63
64            $isDefined = isset($parent->$filterName);
65
66            if ($filter['is-not-defined']) {
67
68                if ($isDefined) {
69                    return false;
70                } else {
71                    continue;
72                }
73
74            }
75            if (!$isDefined) {
76                return false;
77            }
78
79            if ($filter['time-range']) {
80                foreach ($parent->$filterName as $subComponent) {
81                    if ($this->validateTimeRange($subComponent, $filter['time-range']['start'], $filter['time-range']['end'])) {
82                        continue 2;
83                    }
84                }
85                return false;
86            }
87
88            if (!$filter['comp-filters'] && !$filter['prop-filters']) {
89                continue;
90            }
91
92            // If there are sub-filters, we need to find at least one component
93            // for which the subfilters hold true.
94            foreach ($parent->$filterName as $subComponent) {
95
96                if (
97                    $this->validateCompFilters($subComponent, $filter['comp-filters']) &&
98                    $this->validatePropFilters($subComponent, $filter['prop-filters'])) {
99                        // We had a match, so this comp-filter succeeds
100                        continue 2;
101                }
102
103            }
104
105            // If we got here it means there were sub-comp-filters or
106            // sub-prop-filters and there was no match. This means this filter
107            // needs to return false.
108            return false;
109
110        }
111
112        // If we got here it means we got through all comp-filters alive so the
113        // filters were all true.
114        return true;
115
116    }
117
118    /**
119     * This method checks the validity of prop-filters.
120     *
121     * A list of prop-filters needs to be specified. Also the parent of the
122     * property we're checking should be specified, not the property to check
123     * itself.
124     *
125     * @param VObject\Component $parent
126     * @param array $filters
127     * @return bool
128     */
129    protected function validatePropFilters(VObject\Component $parent, array $filters) {
130
131        foreach ($filters as $filter) {
132
133            $filterName = $filter['name'];
134
135            $isDefined = isset($parent->$filterName);
136
137            if ($filter['is-not-defined']) {
138
139                if ($isDefined) {
140                    return false;
141                } else {
142                    continue;
143                }
144
145            }
146            if (!$isDefined) {
147                return false;
148            }
149
150            if ($filter['time-range']) {
151                foreach ($parent->$filterName as $subComponent) {
152                    if ($this->validateTimeRange($subComponent, $filter['time-range']['start'], $filter['time-range']['end'])) {
153                        continue 2;
154                    }
155                }
156                return false;
157            }
158
159            if (!$filter['param-filters'] && !$filter['text-match']) {
160                continue;
161            }
162
163            // If there are sub-filters, we need to find at least one property
164            // for which the subfilters hold true.
165            foreach ($parent->$filterName as $subComponent) {
166
167                if (
168                    $this->validateParamFilters($subComponent, $filter['param-filters']) &&
169                    (!$filter['text-match'] || $this->validateTextMatch($subComponent, $filter['text-match']))
170                ) {
171                    // We had a match, so this prop-filter succeeds
172                    continue 2;
173                }
174
175            }
176
177            // If we got here it means there were sub-param-filters or
178            // text-match filters and there was no match. This means the
179            // filter needs to return false.
180            return false;
181
182        }
183
184        // If we got here it means we got through all prop-filters alive so the
185        // filters were all true.
186        return true;
187
188    }
189
190    /**
191     * This method checks the validity of param-filters.
192     *
193     * A list of param-filters needs to be specified. Also the parent of the
194     * parameter we're checking should be specified, not the parameter to check
195     * itself.
196     *
197     * @param VObject\Property $parent
198     * @param array $filters
199     * @return bool
200     */
201    protected function validateParamFilters(VObject\Property $parent, array $filters) {
202
203        foreach ($filters as $filter) {
204
205            $isDefined = isset($parent[$filter['name']]);
206
207            if ($filter['is-not-defined']) {
208
209                if ($isDefined) {
210                    return false;
211                } else {
212                    continue;
213                }
214
215            }
216            if (!$isDefined) {
217                return false;
218            }
219
220            if (!$filter['text-match']) {
221                continue;
222            }
223
224            // If there are sub-filters, we need to find at least one parameter
225            // for which the subfilters hold true.
226            foreach ($parent[$filter['name']]->getParts() as $paramPart) {
227
228                if ($this->validateTextMatch($paramPart, $filter['text-match'])) {
229                    // We had a match, so this param-filter succeeds
230                    continue 2;
231                }
232
233            }
234
235            // If we got here it means there was a text-match filter and there
236            // were no matches. This means the filter needs to return false.
237            return false;
238
239        }
240
241        // If we got here it means we got through all param-filters alive so the
242        // filters were all true.
243        return true;
244
245    }
246
247    /**
248     * This method checks the validity of a text-match.
249     *
250     * A single text-match should be specified as well as the specific property
251     * or parameter we need to validate.
252     *
253     * @param VObject\Node|string $check Value to check against.
254     * @param array $textMatch
255     * @return bool
256     */
257    protected function validateTextMatch($check, array $textMatch) {
258
259        if ($check instanceof VObject\Node) {
260            $check = $check->getValue();
261        }
262
263        $isMatching = \Sabre\DAV\StringUtil::textMatch($check, $textMatch['value'], $textMatch['collation']);
264
265        return ($textMatch['negate-condition'] xor $isMatching);
266
267    }
268
269    /**
270     * Validates if a component matches the given time range.
271     *
272     * This is all based on the rules specified in rfc4791, which are quite
273     * complex.
274     *
275     * @param VObject\Node $component
276     * @param DateTime $start
277     * @param DateTime $end
278     * @return bool
279     */
280    protected function validateTimeRange(VObject\Node $component, $start, $end) {
281
282        if (is_null($start)) {
283            $start = new DateTime('1900-01-01');
284        }
285        if (is_null($end)) {
286            $end = new DateTime('3000-01-01');
287        }
288
289        switch ($component->name) {
290
291            case 'VEVENT' :
292            case 'VTODO' :
293            case 'VJOURNAL' :
294
295                return $component->isInTimeRange($start, $end);
296
297            case 'VALARM' :
298
299                // If the valarm is wrapped in a recurring event, we need to
300                // expand the recursions, and validate each.
301                //
302                // Our datamodel doesn't easily allow us to do this straight
303                // in the VALARM component code, so this is a hack, and an
304                // expensive one too.
305                if ($component->parent->name === 'VEVENT' && $component->parent->RRULE) {
306
307                    // Fire up the iterator!
308                    $it = new VObject\Recur\EventIterator($component->parent->parent, (string)$component->parent->UID);
309                    while ($it->valid()) {
310                        $expandedEvent = $it->getEventObject();
311
312                        // We need to check from these expanded alarms, which
313                        // one is the first to trigger. Based on this, we can
314                        // determine if we can 'give up' expanding events.
315                        $firstAlarm = null;
316                        if ($expandedEvent->VALARM !== null) {
317                            foreach ($expandedEvent->VALARM as $expandedAlarm) {
318
319                                $effectiveTrigger = $expandedAlarm->getEffectiveTriggerTime();
320                                if ($expandedAlarm->isInTimeRange($start, $end)) {
321                                    return true;
322                                }
323
324                                if ((string)$expandedAlarm->TRIGGER['VALUE'] === 'DATE-TIME') {
325                                    // This is an alarm with a non-relative trigger
326                                    // time, likely created by a buggy client. The
327                                    // implication is that every alarm in this
328                                    // recurring event trigger at the exact same
329                                    // time. It doesn't make sense to traverse
330                                    // further.
331                                } else {
332                                    // We store the first alarm as a means to
333                                    // figure out when we can stop traversing.
334                                    if (!$firstAlarm || $effectiveTrigger < $firstAlarm) {
335                                        $firstAlarm = $effectiveTrigger;
336                                    }
337                                }
338                            }
339                        }
340                        if (is_null($firstAlarm)) {
341                            // No alarm was found.
342                            //
343                            // Or technically: No alarm that will change for
344                            // every instance of the recurrence was found,
345                            // which means we can assume there was no match.
346                            return false;
347                        }
348                        if ($firstAlarm > $end) {
349                            return false;
350                        }
351                        $it->next();
352                    }
353                    return false;
354                } else {
355                    return $component->isInTimeRange($start, $end);
356                }
357
358            case 'VFREEBUSY' :
359                throw new \Sabre\DAV\Exception\NotImplemented('time-range filters are currently not supported on ' . $component->name . ' components');
360
361            case 'COMPLETED' :
362            case 'CREATED' :
363            case 'DTEND' :
364            case 'DTSTAMP' :
365            case 'DTSTART' :
366            case 'DUE' :
367            case 'LAST-MODIFIED' :
368                return ($start <= $component->getDateTime() && $end >= $component->getDateTime());
369
370
371
372            default :
373                throw new \Sabre\DAV\Exception\BadRequest('You cannot create a time-range filter on a ' . $component->name . ' component');
374
375        }
376
377    }
378
379}
380