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