1<?php
2
3/**
4 *  BENNU - PHP iCalendar library
5 *  (c) 2005-2006 Ioannis Papaioannou (pj@moodle.org). All rights reserved.
6 *
7 *  Released under the LGPL.
8 *
9 *  See http://bennu.sourceforge.net/ for more information and downloads.
10 *
11 * @author Ioannis Papaioannou
12 * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
13 */
14
15/*
16
17   All names of properties, property parameters, enumerated property
18   values and property parameter values are case-insensitive. However,
19   all other property values are case-sensitive, unless otherwise
20   stated.
21
22*/
23
24define('RFC2445_CRLF',               "\r\n");
25define('RFC2445_WSP',                "\t ");
26define('RFC2445_WEEKDAYS',           'MO,TU,WE,TH,FR,SA,SU');
27define('RFC2445_FOLDED_LINE_LENGTH', 75);
28
29define('RFC2445_PARAMETER_SEPARATOR',	';');
30define('RFC2445_VALUE_SEPARATOR',    	':');
31
32define('RFC2445_REQUIRED', 0x01);
33define('RFC2445_OPTIONAL', 0x02);
34define('RFC2445_ONCE',     0x04);
35
36define('RFC2445_PROP_FLAGS',       0);
37define('RFC2445_PROP_TYPE',        1);
38define('RFC2445_PROP_DEFAULT',     2);
39
40define('RFC2445_XNAME', 'X-');
41
42define('RFC2445_TYPE_BINARY',       0);
43define('RFC2445_TYPE_BOOLEAN',      1);
44define('RFC2445_TYPE_CAL_ADDRESS',  2);
45define('RFC2445_TYPE_DATE',         3);
46define('RFC2445_TYPE_DATE_TIME',    4);
47define('RFC2445_TYPE_DURATION',     5);
48define('RFC2445_TYPE_FLOAT',        6);
49define('RFC2445_TYPE_INTEGER',      7);
50define('RFC2445_TYPE_PERIOD',       8);
51define('RFC2445_TYPE_RECUR',        9);
52define('RFC2445_TYPE_TEXT',        10);
53define('RFC2445_TYPE_TIME',        11);
54define('RFC2445_TYPE_URI',         12); // CAL_ADDRESS === URI
55define('RFC2445_TYPE_UTC_OFFSET',  13);
56
57
58function rfc2445_fold($string) {
59    if(core_text::strlen($string, 'utf-8') <= RFC2445_FOLDED_LINE_LENGTH) {
60        return $string;
61    }
62
63    $retval = '';
64
65    $i=0;
66    $len_count=0;
67
68    //multi-byte string, get the correct length
69    $section_len = core_text::strlen($string, 'utf-8');
70
71    while($len_count<$section_len) {
72
73        //get the current portion of the line
74        $section = core_text::substr($string, ($i * RFC2445_FOLDED_LINE_LENGTH), (RFC2445_FOLDED_LINE_LENGTH), 'utf-8');
75
76        //increment the length we've processed by the length of the new portion
77        $len_count += core_text::strlen($section, 'utf-8');
78
79        /* Add the portion to the return value, terminating with CRLF.HTAB
80           As per RFC 2445, CRLF.HTAB will be replaced by the processor of the
81           data */
82        $retval .= $section . RFC2445_CRLF . substr(RFC2445_WSP, 0, 1);
83
84        $i++;
85    }
86
87    return $retval;
88
89}
90
91function rfc2445_unfold($string) {
92    for($i = 0; $i < strlen(RFC2445_WSP); ++$i) {
93        $string = str_replace(RFC2445_CRLF.substr(RFC2445_WSP, $i, 1), '', $string);
94    }
95
96    return $string;
97}
98
99function rfc2445_is_xname($name) {
100
101    // If it's less than 3 chars, it cannot be legal
102    if(strlen($name) < 3) {
103        return false;
104    }
105
106    // If it contains an illegal char anywhere, reject it
107    if(strspn($name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') != strlen($name)) {
108        return false;
109    }
110
111    // To be legal, it must still start with "X-"
112    return substr($name, 0, 2) === 'X-';
113}
114
115function rfc2445_is_valid_value($value, $type) {
116
117    // This branch should only be taken with xname values
118    if($type === NULL) {
119        return true;
120    }
121
122    switch($type) {
123        case RFC2445_TYPE_CAL_ADDRESS:
124        case RFC2445_TYPE_URI:
125            if(!is_string($value)) {
126                return false;
127            }
128
129            $valid_schemes = array('ftp', 'http', 'ldap', 'gopher', 'mailto', 'news', 'nntp', 'telnet', 'wais', 'file', 'prospero');
130
131            $pos = strpos($value, ':');
132            if(!$pos) {
133                return false;
134            }
135
136            $scheme = strtolower(substr($value, 0, $pos));
137            $remain = substr($value, $pos + 1);
138
139            if(!in_array($scheme, $valid_schemes)) {
140                return false;
141            }
142
143            if($scheme === 'mailto') {
144                $regexp = '#^[a-zA-Z0-9]+[_a-zA-Z0-9\-]*(\.[_a-z0-9\-]+)*@(([0-9a-zA-Z\-]+\.)+[a-zA-Z][0-9a-zA-Z\-]+|([0-9]{1,3}\.){3}[0-9]{1,3})$#';
145            }
146            else {
147                $regexp = '#^//(.+(:.*)?@)?(([0-9a-zA-Z\-]+\.)+[a-zA-Z][0-9a-zA-Z\-]+|([0-9]{1,3}\.){3}[0-9]{1,3})(:[0-9]{1,5})?(/.*)?$#';
148            }
149
150            return preg_match($regexp, $remain);
151        break;
152
153        case RFC2445_TYPE_BINARY:
154            if(!is_string($value)) {
155                return false;
156            }
157
158            $len = strlen($value);
159
160            if($len % 4 != 0) {
161                return false;
162            }
163
164            for($i = 0; $i < $len; ++$i) {
165                $ch = $value[$i];
166                if(!($ch >= 'a' && $ch <= 'z' || $ch >= 'A' && $ch <= 'Z' || $ch >= '0' && $ch <= '9' || $ch == '-' || $ch == '+')) {
167                    if($ch == '=' && $len - $i <= 2) {
168                        continue;
169                    }
170                    return false;
171                }
172            }
173            return true;
174        break;
175
176        case RFC2445_TYPE_BOOLEAN:
177            if(is_bool($value)) {
178                return true;
179            }
180            if(is_string($value)) {
181                $value = strtoupper($value);
182                return ($value == 'TRUE' || $value == 'FALSE');
183            }
184            return false;
185        break;
186
187        case RFC2445_TYPE_DATE:
188            if(is_int($value)) {
189                if($value < 0) {
190                    return false;
191                }
192                $value = "$value";
193            }
194            else if(!is_string($value)) {
195                return false;
196            }
197
198            if(strlen($value) != 8) {
199                return false;
200            }
201
202            $y = intval(substr($value, 0, 4));
203            $m = intval(substr($value, 4, 2));
204            $d = intval(substr($value, 6, 2));
205
206            return checkdate($m, $d, $y);
207        break;
208
209        case RFC2445_TYPE_DATE_TIME:
210            if(!is_string($value) || strlen($value) < 15) {
211                return false;
212            }
213
214            return($value[8] == 'T' &&
215                   rfc2445_is_valid_value(substr($value, 0, 8), RFC2445_TYPE_DATE) &&
216                   rfc2445_is_valid_value(substr($value, 9), RFC2445_TYPE_TIME));
217        break;
218
219        case RFC2445_TYPE_DURATION:
220            if(!is_string($value)) {
221                return false;
222            }
223
224            $len = strlen($value);
225
226            if($len < 3) {
227                // Minimum conformant length: "P1W"
228                return false;
229            }
230
231            if($value[0] == '+' || $value[0] == '-') {
232                $value = substr($value, 1);
233                --$len; // Don't forget to update this!
234            }
235
236            if($value[0] != 'P') {
237                return false;
238            }
239
240            // OK, now break it up
241            $num = '';
242            $allowed = 'WDT';
243
244            for($i = 1; $i < $len; ++$i) {
245                $ch = $value[$i];
246                if($ch >= '0' && $ch <= '9') {
247                    $num .= $ch;
248                    continue;
249                }
250                if(strpos($allowed, $ch) === false) {
251                    // Non-numeric character which shouldn't be here
252                    return false;
253                }
254                if($num === '' && $ch != 'T') {
255                    // Allowed non-numeric character, but no digits came before it
256                    return false;
257                }
258
259                // OK, $ch now holds a character which tells us what $num is
260                switch($ch) {
261                    case 'W':
262                        // If duration in weeks is specified, this must end the string
263                        return ($i == $len - 1);
264                    break;
265
266                    case 'D':
267                        // Days specified, now if anything comes after it must be a 'T'
268                        $allowed = 'T';
269                    break;
270
271                    case 'T':
272                        // Starting to specify time, H M S are now valid delimiters
273                        $allowed = 'HMS';
274                    break;
275
276                    case 'H':
277                        $allowed = 'M';
278                    break;
279
280                    case 'M':
281                        $allowed = 'S';
282                    break;
283
284                    case 'S':
285                        return ($i == $len - 1);
286                    break;
287                }
288
289                // If we 're going to continue, reset $num
290                $num = '';
291
292            }
293
294            // $num is kept for this reason: if we 're here, we ran out of chars
295            // therefore $num must be empty for the period to be legal
296            return ($num === '' && $ch != 'T');
297
298        break;
299
300        case RFC2445_TYPE_FLOAT:
301            if(is_float($value)) {
302                return true;
303            }
304            if(!is_string($value) || $value === '') {
305                return false;
306            }
307
308            $dot = false;
309            $int = false;
310            $len = strlen($value);
311            for($i = 0; $i < $len; ++$i) {
312                switch($value[$i]) {
313                    case '-': case '+':
314                        // A sign can only be seen at position 0 and cannot be the only char
315                        if($i != 0 || $len == 1) {
316                            return false;
317                        }
318                    break;
319                    case '.':
320                        // A second dot is an error
321                        // Make sure we had at least one int before the dot
322                        if($dot || !$int) {
323                            return false;
324                        }
325                        $dot = true;
326                        // Make also sure that the float doesn't end with a dot
327                        if($i == $len - 1) {
328                            return false;
329                        }
330                    break;
331                    case '0': case '1': case '2': case '3': case '4':
332                    case '5': case '6': case '7': case '8': case '9':
333                        $int = true;
334                    break;
335                    default:
336                        // Any other char is a no-no
337                        return false;
338                    break;
339                }
340            }
341            return true;
342        break;
343
344        case RFC2445_TYPE_INTEGER:
345            if(is_int($value)) {
346                return true;
347            }
348            if(!is_string($value) || $value === '') {
349                return false;
350            }
351
352            if($value[0] == '+' || $value[0] == '-') {
353                if(strlen($value) == 1) {
354                    return false;
355                }
356                $value = substr($value, 1);
357            }
358
359            if(strspn($value, '0123456789') != strlen($value)) {
360                return false;
361            }
362
363            return ($value >= -2147483648 && $value <= 2147483647);
364        break;
365
366        case RFC2445_TYPE_PERIOD:
367            if(!is_string($value) || empty($value)) {
368                return false;
369            }
370
371            $parts = explode('/', $value);
372            if(count($parts) != 2) {
373                return false;
374            }
375
376            if(!rfc2445_is_valid_value($parts[0], RFC2445_TYPE_DATE_TIME)) {
377                return false;
378            }
379
380            // Two legal cases for the second part:
381            if(rfc2445_is_valid_value($parts[1], RFC2445_TYPE_DATE_TIME)) {
382                // It has to be after the start time, so
383                return ($parts[1] > $parts[0]);
384            }
385            else if(rfc2445_is_valid_value($parts[1], RFC2445_TYPE_DURATION)) {
386                // The period MUST NOT be negative
387                return ($parts[1][0] != '-');
388            }
389
390            // It seems to be illegal
391            return false;
392        break;
393
394        case RFC2445_TYPE_RECUR:
395            if(!is_string($value)) {
396                return false;
397            }
398
399            $parts = explode(';', strtoupper($value));
400
401            // We need at least one part for a valid rule, for example: "FREQ=DAILY".
402            if(empty($parts)) {
403                return false;
404            }
405
406            // Let's get that into a more easily comprehensible format
407            $vars = array();
408            foreach($parts as $part) {
409
410                $pieces = explode('=', $part);
411                // There must be exactly 2 pieces, e.g. FREQ=WEEKLY
412                if(count($pieces) != 2) {
413                    return false;
414                }
415
416                // It's illegal for a variable to appear twice
417                if(isset($vars[$pieces[0]])) {
418                    return false;
419                }
420
421                // Sounds good
422                $vars[$pieces[0]] = $pieces[1];
423            }
424
425            // OK... now to test everything else
426
427            // FREQ must be the first thing appearing
428            reset($vars);
429            if(key($vars) != 'FREQ') {
430                return false;
431            }
432
433            // It's illegal to have both UNTIL and COUNT appear
434            if(isset($vars['UNTIL']) && isset($vars['COUNT'])) {
435                return false;
436            }
437
438            // Special case: BYWEEKNO is only valid for FREQ=YEARLY
439            if(isset($vars['BYWEEKNO']) && $vars['FREQ'] != 'YEARLY') {
440                return false;
441            }
442
443            // Special case: BYSETPOS is only valid if another BY option is specified
444            if(isset($vars['BYSETPOS'])) {
445                $options = array('BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYDAY', 'BYMONTHDAY', 'BYYEARDAY', 'BYWEEKNO', 'BYMONTH');
446                $defined = array_keys($vars);
447                $common  = array_intersect($options, $defined);
448                if(empty($common)) {
449                    return false;
450                }
451            }
452
453            // OK, now simply check if each element has a valid value,
454            // unsetting them on the way. If at the end the array still
455            // has some elements, they are illegal.
456
457            if($vars['FREQ'] != 'SECONDLY' && $vars['FREQ'] != 'MINUTELY' && $vars['FREQ'] != 'HOURLY' &&
458               $vars['FREQ'] != 'DAILY'    && $vars['FREQ'] != 'WEEKLY' &&
459               $vars['FREQ'] != 'MONTHLY'  && $vars['FREQ'] != 'YEARLY') {
460                return false;
461            }
462            unset($vars['FREQ']);
463
464            // Set this, we may need it later
465            $weekdays = explode(',', RFC2445_WEEKDAYS);
466
467            if(isset($vars['UNTIL'])) {
468                if(rfc2445_is_valid_value($vars['UNTIL'], RFC2445_TYPE_DATE_TIME)) {
469                    // The time MUST be in UTC format
470                    if(!(substr($vars['UNTIL'], -1) == 'Z')) {
471                        return false;
472                    }
473                }
474                else if(!rfc2445_is_valid_value($vars['UNTIL'], RFC2445_TYPE_DATE_TIME)) {
475                    return false;
476                }
477            }
478            unset($vars['UNTIL']);
479
480
481            if(isset($vars['COUNT'])) {
482                if(empty($vars['COUNT'])) {
483                    // This also catches the string '0', which makes no sense
484                    return false;
485                }
486                if(strspn($vars['COUNT'], '0123456789') != strlen($vars['COUNT'])) {
487                    return false;
488                }
489            }
490            unset($vars['COUNT']);
491
492
493            if(isset($vars['INTERVAL'])) {
494                if(empty($vars['INTERVAL'])) {
495                    // This also catches the string '0', which makes no sense
496                    return false;
497                }
498                if(strspn($vars['INTERVAL'], '0123456789') != strlen($vars['INTERVAL'])) {
499                    return false;
500                }
501            }
502            unset($vars['INTERVAL']);
503
504
505            if(isset($vars['BYSECOND'])) {
506                if($vars['BYSECOND'] == '') {
507                    return false;
508                }
509                // Comma also allowed
510                if(strspn($vars['BYSECOND'], '0123456789,') != strlen($vars['BYSECOND'])) {
511                    return false;
512                }
513                $secs = explode(',', $vars['BYSECOND']);
514                foreach($secs as $sec) {
515                    if($sec == '' || $sec < 0 || $sec > 59) {
516                        return false;
517                    }
518                }
519            }
520            unset($vars['BYSECOND']);
521
522
523            if(isset($vars['BYMINUTE'])) {
524                if($vars['BYMINUTE'] == '') {
525                    return false;
526                }
527                // Comma also allowed
528                if(strspn($vars['BYMINUTE'], '0123456789,') != strlen($vars['BYMINUTE'])) {
529                    return false;
530                }
531                $mins = explode(',', $vars['BYMINUTE']);
532                foreach($mins as $min) {
533                    if($min == '' || $min < 0 || $min > 59) {
534                        return false;
535                    }
536                }
537            }
538            unset($vars['BYMINUTE']);
539
540
541            if(isset($vars['BYHOUR'])) {
542                if($vars['BYHOUR'] == '') {
543                    return false;
544                }
545                // Comma also allowed
546                if(strspn($vars['BYHOUR'], '0123456789,') != strlen($vars['BYHOUR'])) {
547                    return false;
548                }
549                $hours = explode(',', $vars['BYHOUR']);
550                foreach($hours as $hour) {
551                    if($hour == '' || $hour < 0 || $hour > 23) {
552                        return false;
553                    }
554                }
555            }
556            unset($vars['BYHOUR']);
557
558
559            if(isset($vars['BYDAY'])) {
560                if(empty($vars['BYDAY'])) {
561                    return false;
562                }
563
564                // First off, split up all values we may have
565                $days = explode(',', $vars['BYDAY']);
566
567                foreach($days as $day) {
568                    $daypart = substr($day, -2);
569                    if(!in_array($daypart, $weekdays)) {
570                        return false;
571                    }
572
573                    if(strlen($day) > 2) {
574                        $intpart = substr($day, 0, strlen($day) - 2);
575                        if(!rfc2445_is_valid_value($intpart, RFC2445_TYPE_INTEGER)) {
576                            return false;
577                        }
578                        if(intval($intpart) == 0) {
579                            return false;
580                        }
581                    }
582                }
583            }
584            unset($vars['BYDAY']);
585
586
587            if(isset($vars['BYMONTHDAY'])) {
588                if(empty($vars['BYMONTHDAY'])) {
589                    return false;
590                }
591                $mdays = explode(',', $vars['BYMONTHDAY']);
592                foreach($mdays as $mday) {
593                    if(!rfc2445_is_valid_value($mday, RFC2445_TYPE_INTEGER)) {
594                        return false;
595                    }
596                    $mday = abs(intval($mday));
597                    if($mday == 0 || $mday > 31) {
598                        return false;
599                    }
600                }
601            }
602            unset($vars['BYMONTHDAY']);
603
604
605            if(isset($vars['BYYEARDAY'])) {
606                if(empty($vars['BYYEARDAY'])) {
607                    return false;
608                }
609                $ydays = explode(',', $vars['BYYEARDAY']);
610                foreach($ydays as $yday) {
611                    if(!rfc2445_is_valid_value($yday, RFC2445_TYPE_INTEGER)) {
612                        return false;
613                    }
614                    $yday = abs(intval($yday));
615                    if($yday == 0 || $yday > 366) {
616                        return false;
617                    }
618                }
619            }
620            unset($vars['BYYEARDAY']);
621
622
623            if(isset($vars['BYWEEKNO'])) {
624                if(empty($vars['BYWEEKNO'])) {
625                    return false;
626                }
627                $weeknos = explode(',', $vars['BYWEEKNO']);
628                foreach($weeknos as $weekno) {
629                    if(!rfc2445_is_valid_value($weekno, RFC2445_TYPE_INTEGER)) {
630                        return false;
631                    }
632                    $weekno = abs(intval($weekno));
633                    if($weekno == 0 || $weekno > 53) {
634                        return false;
635                    }
636                }
637            }
638            unset($vars['BYWEEKNO']);
639
640
641            if(isset($vars['BYMONTH'])) {
642                if(empty($vars['BYMONTH'])) {
643                    return false;
644                }
645                // Comma also allowed
646                if(strspn($vars['BYMONTH'], '0123456789,') != strlen($vars['BYMONTH'])) {
647                    return false;
648                }
649                $months = explode(',', $vars['BYMONTH']);
650                foreach($months as $month) {
651                    if($month == '' || $month < 1 || $month > 12) {
652                        return false;
653                    }
654                }
655            }
656            unset($vars['BYMONTH']);
657
658
659            if(isset($vars['BYSETPOS'])) {
660                if(empty($vars['BYSETPOS'])) {
661                    return false;
662                }
663                $sets = explode(',', $vars['BYSETPOS']);
664                foreach($sets as $set) {
665                    if(!rfc2445_is_valid_value($set, RFC2445_TYPE_INTEGER)) {
666                        return false;
667                    }
668                    $set = abs(intval($set));
669                    if($set == 0 || $set > 366) {
670                        return false;
671                    }
672                }
673            }
674            unset($vars['BYSETPOS']);
675
676
677            if(isset($vars['WKST'])) {
678                if(!in_array($vars['WKST'], $weekdays)) {
679                    return false;
680                }
681            }
682            unset($vars['WKST']);
683
684
685            // Any remaining vars must be x-names
686            if(empty($vars)) {
687                return true;
688            }
689
690            foreach($vars as $name => $var) {
691                if(!rfc2445_is_xname($name)) {
692                    return false;
693                }
694            }
695
696            // At last, all is OK!
697            return true;
698
699        break;
700
701        case RFC2445_TYPE_TEXT:
702            return true;
703        break;
704
705        case RFC2445_TYPE_TIME:
706            if(is_int($value)) {
707                if($value < 0) {
708                    return false;
709                }
710                $value = "$value";
711            }
712            else if(!is_string($value)) {
713                return false;
714            }
715
716            if(strlen($value) == 7) {
717                if(strtoupper(substr($value, -1)) != 'Z') {
718                    return false;
719                }
720                $value = substr($value, 0, 6);
721            }
722            if(strlen($value) != 6) {
723                return false;
724            }
725
726            $h = intval(substr($value, 0, 2));
727            $m = intval(substr($value, 2, 2));
728            $s = intval(substr($value, 4, 2));
729
730            return ($h <= 23 && $m <= 59 && $s <= 60);
731        break;
732
733        case RFC2445_TYPE_UTC_OFFSET:
734            if(is_int($value)) {
735                if($value >= 0) {
736                    $value = "+$value";
737                }
738                else {
739                    $value = "$value";
740                }
741            }
742            else if(!is_string($value)) {
743                return false;
744            }
745
746            $s = 0;
747            if(strlen($value) == 7) {
748                $s = intval(substr($value, 5, 2));
749                $value = substr($value, 0, 5);
750            }
751            if(strlen($value) != 5 || $value == "-0000") {
752                return false;
753            }
754
755            if($value[0] != '+' && $value[0] != '-') {
756                return false;
757            }
758
759            $h = intval(substr($value, 1, 2));
760            $m = intval(substr($value, 3, 2));
761
762            return ($h <= 23 && $m <= 59 && $s <= 59);
763        break;
764    }
765
766    // TODO: remove this assertion
767    trigger_error('bad code path', E_USER_WARNING);
768    var_dump($type);
769    return false;
770}
771
772function rfc2445_do_value_formatting($value, $type) {
773    // Note: this does not only do formatting; it also does conversion to string!
774    switch($type) {
775        case RFC2445_TYPE_CAL_ADDRESS:
776        case RFC2445_TYPE_URI:
777            // Enclose in double quotes
778            $value = '"'.$value.'"';
779        break;
780        case RFC2445_TYPE_TEXT:
781            // Escape entities
782            $value = strtr($value, array("\r\n" => '\\n', "\n" => '\\n', '\\' => '\\\\', ',' => '\\,', ';' => '\\;'));
783        break;
784    }
785    return $value;
786}
787
788function rfc2445_undo_value_formatting($value, $type) {
789    switch($type) {
790        case RFC2445_TYPE_CAL_ADDRESS:
791        case RFC2445_TYPE_URI:
792            // Trim beginning and end double quote
793            $value = substr($value, 1, strlen($value) - 2);
794        break;
795        case RFC2445_TYPE_TEXT:
796            // Unescape entities
797            $value = strtr($value, array('\\n' => "\n", '\\N' => "\n", '\\\\' => '\\', '\\,' => ',', '\\;' => ';'));
798        break;
799    }
800    return $value;
801}
802