| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | namespace om\ICal\Validation; |
| 5: | |
| 6: | use om\ICal\Calendar; |
| 7: | use om\ICal\Component; |
| 8: | use om\ICal\Exception\InvalidRecurrenceRuleException; |
| 9: | use om\ICal\Exception\ValidationException; |
| 10: | use om\ICal\Property; |
| 11: | use om\ICal\Value\DateTimeValue; |
| 12: | use om\ICal\Value\ValueParser; |
| 13: | use om\RRule\Rule; |
| 14: | |
| 15: | |
| 16: | |
| 17: | |
| 18: | |
| 19: | |
| 20: | |
| 21: | final class Validator { |
| 22: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | public const array SINGLE = [ |
| 28: | 'VCALENDAR' => ['PRODID', 'VERSION', 'CALSCALE', 'METHOD'], |
| 29: | 'VEVENT' => ['DTSTAMP', 'UID', 'DTSTART', 'CLASS', 'CREATED', 'DESCRIPTION', 'GEO', 'LAST-MODIFIED', 'LOCATION', 'ORGANIZER', 'PRIORITY', 'SEQUENCE', 'STATUS', 'SUMMARY', 'TRANSP', 'URL', 'RECURRENCE-ID', 'DTEND', 'DURATION'], |
| 30: | 'VTODO' => ['DTSTAMP', 'UID', 'CLASS', 'COMPLETED', 'CREATED', 'DESCRIPTION', 'DTSTART', 'GEO', 'LAST-MODIFIED', 'LOCATION', 'ORGANIZER', 'PERCENT-COMPLETE', 'PRIORITY', 'RECURRENCE-ID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'URL', 'DUE', 'DURATION'], |
| 31: | 'VJOURNAL' => ['DTSTAMP', 'UID', 'CLASS', 'CREATED', 'DTSTART', 'LAST-MODIFIED', 'ORGANIZER', 'RECURRENCE-ID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'URL'], |
| 32: | 'VFREEBUSY' => ['DTSTAMP', 'UID', 'CONTACT', 'DTSTART', 'DTEND', 'ORGANIZER', 'URL'], |
| 33: | 'VTIMEZONE' => ['TZID', 'LAST-MODIFIED', 'TZURL'], |
| 34: | 'VALARM' => ['ACTION', 'TRIGGER', 'DURATION', 'REPEAT', 'DESCRIPTION', 'SUMMARY', 'UID', 'ACKNOWLEDGED'], |
| 35: | 'VLOCATION' => ['UID', 'DESCRIPTION', 'GEO', 'LOCATION-TYPE', 'NAME'], |
| 36: | ]; |
| 37: | |
| 38: | |
| 39: | private const array REQUIRED = [ |
| 40: | 'VCALENDAR' => ['PRODID', 'VERSION'], |
| 41: | 'VEVENT' => ['DTSTAMP', 'UID'], |
| 42: | 'VTODO' => ['DTSTAMP', 'UID'], |
| 43: | 'VJOURNAL' => ['DTSTAMP', 'UID'], |
| 44: | 'VFREEBUSY' => ['DTSTAMP', 'UID'], |
| 45: | 'VTIMEZONE' => ['TZID'], |
| 46: | 'STANDARD' => ['DTSTART', 'TZOFFSETTO', 'TZOFFSETFROM'], |
| 47: | 'DAYLIGHT' => ['DTSTART', 'TZOFFSETTO', 'TZOFFSETFROM'], |
| 48: | 'VALARM' => ['ACTION', 'TRIGGER'], |
| 49: | 'VLOCATION' => ['UID'], |
| 50: | ]; |
| 51: | |
| 52: | |
| 53: | private const array UTC = ['ACKNOWLEDGED' => true]; |
| 54: | |
| 55: | |
| 56: | private array $issues = []; |
| 57: | private ValueParser $values; |
| 58: | |
| 59: | private array $definedTimezones = []; |
| 60: | |
| 61: | |
| 62: | |
| 63: | |
| 64: | public function validate(Calendar $calendar): array { |
| 65: | $this->issues = []; |
| 66: | $this->values = new ValueParser($calendar->component, $calendar->timezoneResolver()); |
| 67: | $this->definedTimezones = []; |
| 68: | foreach ($calendar->timezones() as $timezone) { |
| 69: | $tzid = $timezone->tzid(); |
| 70: | if ($tzid !== null) { |
| 71: | $this->definedTimezones[$tzid] = true; |
| 72: | } |
| 73: | } |
| 74: | $this->component($calendar->component, null); |
| 75: | return $this->issues; |
| 76: | } |
| 77: | |
| 78: | |
| 79: | |
| 80: | |
| 81: | public function assertValid(Calendar $calendar): void { |
| 82: | foreach ($this->validate($calendar) as $issue) { |
| 83: | if ($issue->severity === Severity::Error) { |
| 84: | throw ValidationException::create($issue->code, $issue->message, $issue->line, $issue->property); |
| 85: | } |
| 86: | } |
| 87: | } |
| 88: | |
| 89: | private function component(Component $component, ?string $parent): void { |
| 90: | $uid = $component->property('UID')?->value; |
| 91: | foreach (self::REQUIRED[$component->name] ?? [] as $name) { |
| 92: | if (!$component->has($name)) { |
| 93: | $this->issue(Severity::Error, 'component.missing-property', "$name is required.", $component, $name, uid: $uid); |
| 94: | } |
| 95: | } |
| 96: | foreach (self::SINGLE[$component->name] ?? [] as $name) { |
| 97: | $properties = $component->properties($name); |
| 98: | if (count($properties) > 1) { |
| 99: | $this->issue(Severity::Error, 'component.duplicate-property', "$name must not occur more than once.", $component, $name, $properties[1]->line, $uid); |
| 100: | } |
| 101: | } |
| 102: | if (count($component->properties('RRULE')) > 1) { |
| 103: | $this->issue(Severity::Warning, 'recurrence.multiple-rrule', 'RRULE should not occur more than once.', $component, 'RRULE', $component->properties('RRULE')[1]->line, $uid); |
| 104: | } |
| 105: | |
| 106: | foreach ($component->properties as $property) { |
| 107: | $this->property($property, $component, $uid); |
| 108: | } |
| 109: | |
| 110: | match ($component->name) { |
| 111: | 'VEVENT' => $this->ends($component, 'DTEND', $uid), |
| 112: | 'VTODO' => $this->ends($component, 'DUE', $uid), |
| 113: | 'VTIMEZONE' => $component->components('STANDARD') === [] && $component->components('DAYLIGHT') === [] |
| 114: | ? $this->issue(Severity::Error, 'timezone.no-observance', 'VTIMEZONE needs a STANDARD or DAYLIGHT component.', $component) |
| 115: | : null, |
| 116: | default => null, |
| 117: | }; |
| 118: | if (in_array($component->name, ['VEVENT', 'VTODO', 'VJOURNAL'], true)) { |
| 119: | $this->recurrence($component, $uid); |
| 120: | } |
| 121: | if ($parent === 'VCALENDAR' && $component->name === 'VALARM') { |
| 122: | $this->issue(Severity::Error, 'alarm.outside-item', 'VALARM must be inside VEVENT or VTODO.', $component); |
| 123: | } |
| 124: | |
| 125: | foreach ($component->components as $child) { |
| 126: | $this->component($child, $component->name); |
| 127: | } |
| 128: | } |
| 129: | |
| 130: | private function property(Property $property, Component $component, ?string $uid): void { |
| 131: | if (isset(ValueParser::TYPES[$property->name]) || $property->parameter('VALUE') !== null) { |
| 132: | $value = $this->values->value($property); |
| 133: | if ($value === null || $value === []) { |
| 134: | $this->issue(Severity::Error, 'value.invalid', "Invalid value \"$property->value\".", $component, $property->name, $property->line, $uid); |
| 135: | } elseif (isset(self::UTC[$property->name]) && $value instanceof DateTimeValue && !$value->isUtc()) { |
| 136: | $this->issue(Severity::Warning, 'value.not-utc', "$property->name must be a UTC time.", $component, $property->name, $property->line, $uid); |
| 137: | } |
| 138: | } |
| 139: | $tzid = $property->parameter('TZID'); |
| 140: | if ($tzid !== null) { |
| 141: | if (!isset($this->definedTimezones[$tzid])) { |
| 142: | $this->issue(Severity::Warning, 'timezone.not-defined', "TZID \"$tzid\" has no VTIMEZONE definition.", $component, $property->name, $property->line, $uid); |
| 143: | } |
| 144: | if ($this->values->timezone($tzid) === null) { |
| 145: | $this->issue(Severity::Warning, 'timezone.unresolved', "TZID \"$tzid\" cannot be resolved, its times are floating.", $component, $property->name, $property->line, $uid); |
| 146: | } |
| 147: | } |
| 148: | } |
| 149: | |
| 150: | |
| 151: | |
| 152: | |
| 153: | private function ends(Component $component, string $endName, ?string $uid): void { |
| 154: | $end = $component->property($endName); |
| 155: | $duration = $component->property('DURATION'); |
| 156: | if ($end !== null && $duration !== null) { |
| 157: | $this->issue(Severity::Error, 'component.end-and-duration', "$endName and DURATION must not occur together.", $component, 'DURATION', $duration->line, $uid); |
| 158: | } |
| 159: | $start = $this->date($component->property('DTSTART')); |
| 160: | if ($duration !== null && $start === null) { |
| 161: | $this->issue(Severity::Error, 'component.duration-without-start', 'DURATION requires DTSTART.', $component, 'DURATION', $duration->line, $uid); |
| 162: | } |
| 163: | $endValue = $this->date($end); |
| 164: | if ($start === null || $endValue === null || $end === null) { |
| 165: | return; |
| 166: | } |
| 167: | if ($start->isDate() !== $endValue->isDate()) { |
| 168: | $this->issue(Severity::Error, 'component.end-type', "$endName must have the same value type as DTSTART.", $component, $endName, $end->line, $uid); |
| 169: | } elseif ($endValue->toDateTime(null, $start->timezone() ?? new \DateTimeZone('UTC')) < $start->toDateTime(null, $start->timezone() ?? new \DateTimeZone('UTC'))) { |
| 170: | $this->issue(Severity::Error, 'component.end-before-start', "$endName must not be earlier than DTSTART.", $component, $endName, $end->line, $uid); |
| 171: | } |
| 172: | } |
| 173: | |
| 174: | private function recurrence(Component $component, ?string $uid): void { |
| 175: | $start = $this->date($component->property('DTSTART')); |
| 176: | foreach ($component->properties('RRULE') as $property) { |
| 177: | $rule = $this->values->recur($property); |
| 178: | if ($rule === null) { |
| 179: | continue; |
| 180: | } |
| 181: | if ($rule->rscale === null && preg_match('/(?:^|;)SKIP=/i', trim($property->value))) { |
| 182: | |
| 183: | $this->issue(Severity::Error, 'recurrence.skip-without-rscale', 'SKIP must not be present without RSCALE (RFC 7529, section 4).', $component, 'RRULE', $property->line, $uid); |
| 184: | } |
| 185: | try { |
| 186: | $rule->assertGregorian(); |
| 187: | } catch (InvalidRecurrenceRuleException $e) { |
| 188: | $this->issue(Severity::Warning, $e->errorCode(), $e->getMessage(), $component, 'RRULE', $property->line, $uid); |
| 189: | } |
| 190: | if ($rule->count !== null && $rule->until !== null) { |
| 191: | $this->issue(Severity::Error, 'recurrence.count-and-until', 'COUNT and UNTIL must not occur together.', $component, 'RRULE', $property->line, $uid); |
| 192: | } |
| 193: | if ($start !== null && $rule->until !== null) { |
| 194: | $this->until($rule, $start, $component, $property, $uid); |
| 195: | } |
| 196: | } |
| 197: | if ($start === null && ($component->has('RRULE') || $component->has('RDATE'))) { |
| 198: | $this->issue(Severity::Error, 'recurrence.without-start', 'A recurring component requires DTSTART.', $component, 'RRULE', uid: $uid); |
| 199: | } |
| 200: | } |
| 201: | |
| 202: | |
| 203: | |
| 204: | |
| 205: | private function until(Rule $rule, DateTimeValue $start, Component $component, Property $property, ?string $uid): void { |
| 206: | $until = strtoupper($property->value); |
| 207: | preg_match('/UNTIL=([0-9TZ]+)/', $until, $match); |
| 208: | $value = $match[1] ?? ''; |
| 209: | $expected = match (true) { |
| 210: | $start->isDate() => strlen($value) === 8, |
| 211: | $start->isFloating() => strlen($value) === 15, |
| 212: | default => str_ends_with($value, 'Z'), |
| 213: | }; |
| 214: | if (!$expected) { |
| 215: | $this->issue(Severity::Error, 'recurrence.until-type', 'UNTIL must have the value type of DTSTART (UTC for zoned times).', $component, 'RRULE', $property->line, $uid); |
| 216: | } |
| 217: | } |
| 218: | |
| 219: | private function date(?Property $property): ?DateTimeValue { |
| 220: | return $property === null ? null : $this->values->dateTime($property); |
| 221: | } |
| 222: | |
| 223: | private function issue(Severity $severity, string $code, string $message, Component $component, ?string $property = null, ?int $line = null, ?string $uid = null): void { |
| 224: | $this->issues[] = new Issue($severity, $code, $message, $component->name, $property, $line, $uid); |
| 225: | } |
| 226: | } |
| 227: | |