source: subversion/trunk/plugins/managesieve/lib/rcube_sieve_script.php @ 5215

Last change on this file since 5215 was 5215, checked in by alec, 21 months ago
  • Fixed handling of enabled magic_quotes_gpc setting
  • Fixed PHP warning on connection error when submitting filter form
  • Fixed bug where new action row with flags wasn't handled properly
  • Property svn:keywords set to Date Author Id Revision
File size: 22.2 KB
Line 
1<?php
2
3/**
4  Class for operations on Sieve scripts
5
6  Author: Aleksander Machniak <alec@alec.pl>
7
8  $Id$
9
10*/
11
12class rcube_sieve_script
13{
14    public $content = array();      // script rules array
15
16    private $supported = array(     // extensions supported by class
17        'fileinto',                 // RFC3028
18        'reject',                   // RFC5429
19        'ereject',                  // RFC5429
20        'copy',                     // RFC3894
21        'vacation',                 // RFC5230
22        'relational',               // RFC3431
23        'regex',                    // draft-ietf-sieve-regex-01
24        'imapflags',                // draft-melnikov-sieve-imapflags-06
25        'imap4flags',               // RFC5232
26        // TODO: body, notify
27    );
28
29    private $capabilities;
30
31    /**
32     * Object constructor
33     *
34     * @param  string  Script's text content
35     * @param  array   List of disabled extensions
36     * @param  array   List of capabilities supported by server
37     */
38    public function __construct($script, $disabled=null, $capabilities=null)
39    {
40        if (!empty($disabled)) {
41            // we're working on lower-cased names
42            $disabled = array_map('strtolower', (array) $disabled);
43            foreach ($disabled as $ext) {
44                if (($idx = array_search($ext, $this->supported)) !== false) {
45                    unset($this->supported[$idx]);
46                }
47            }
48        }
49
50        $this->capabilities = $capabilities;
51        $this->content      = $this->_parse_text($script);
52    }
53
54    /**
55     * Adds script contents as text to the script array (at the end)
56     *
57     * @param    string    Text script contents
58     */
59    public function add_text($script)
60    {
61        $content = $this->_parse_text($script);
62        $result = false;
63
64        // check existsing script rules names
65        foreach ($this->content as $idx => $elem) {
66            $names[$elem['name']] = $idx;
67        }
68
69        foreach ($content as $elem) {
70            if (!isset($names[$elem['name']])) {
71                array_push($this->content, $elem);
72                $result = true;
73            }
74        }
75
76        return $result;
77    }
78
79    /**
80     * Adds rule to the script (at the end)
81     *
82     * @param string Rule name
83     * @param array  Rule content (as array)
84     */
85    public function add_rule($content)
86    {
87        // TODO: check this->supported
88        array_push($this->content, $content);
89        return sizeof($this->content)-1;
90    }
91
92    public function delete_rule($index)
93    {
94        if(isset($this->content[$index])) {
95            unset($this->content[$index]);
96            return true;
97        }
98        return false;
99    }
100
101    public function size()
102    {
103        return sizeof($this->content);
104    }
105
106    public function update_rule($index, $content)
107    {
108        // TODO: check this->supported
109        if ($this->content[$index]) {
110            $this->content[$index] = $content;
111            return $index;
112        }
113        return false;
114    }
115
116    /**
117     * Returns script as text
118     */
119    public function as_text()
120    {
121        $script = '';
122        $exts = array();
123        $idx = 0;
124
125        // rules
126        foreach ($this->content as $rule) {
127            $extension = '';
128            $tests = array();
129            $i = 0;
130
131            // header
132            $script .= '# rule:[' . $rule['name'] . "]\n";
133
134            // constraints expressions
135            foreach ($rule['tests'] as $test) {
136                $tests[$i] = '';
137                switch ($test['test']) {
138                case 'size':
139                    $tests[$i] .= ($test['not'] ? 'not ' : '');
140                    $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
141                    break;
142                case 'true':
143                    $tests[$i] .= ($test['not'] ? 'false' : 'true');
144                    break;
145                case 'exists':
146                    $tests[$i] .= ($test['not'] ? 'not ' : '');
147                    $tests[$i] .= 'exists ' . self::escape_string($test['arg']);
148                    break;
149                case 'header':
150                    $tests[$i] .= ($test['not'] ? 'not ' : '');
151
152                    // relational operator + comparator
153                                        if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
154                                                array_push($exts, 'relational');
155                                                array_push($exts, 'comparator-i;ascii-numeric');
156
157                        $tests[$i] .= 'header :' . $m[1] . ' "' . $m[2] . '" :comparator "i;ascii-numeric"';
158                    }
159                    else {
160                                            if ($test['type'] == 'regex') {
161                                                    array_push($exts, 'regex');
162                        }
163
164                        $tests[$i] .= 'header :' . $test['type'];
165                    }
166
167                    $tests[$i] .= ' ' . self::escape_string($test['arg1']);
168                    $tests[$i] .= ' ' . self::escape_string($test['arg2']);
169                    break;
170                }
171                $i++;
172            }
173
174            // disabled rule: if false #....
175            $script .= 'if ' . ($rule['disabled'] ? 'false # ' : '');
176
177            if (empty($tests)) {
178                $tests_str = 'true';
179            }
180            else if (count($tests) > 1) {
181                $tests_str = implode(', ', $tests);
182            }
183            else {
184                $tests_str = $tests[0];
185            }
186
187            if ($rule['join'] || count($tests) > 1) {
188                $script .= sprintf('%s (%s)', $rule['join'] ? 'allof' : 'anyof', $tests_str);
189            }
190            else {
191                $script .= $tests_str;
192            }
193            $script .= "\n{\n";
194
195            // action(s)
196            foreach ($rule['actions'] as $action) {
197                switch ($action['type']) {
198
199                case 'fileinto':
200                    array_push($exts, 'fileinto');
201                    $script .= "\tfileinto ";
202                    if ($action['copy']) {
203                        $script .= ':copy ';
204                        array_push($exts, 'copy');
205                    }
206                    $script .= self::escape_string($action['target']) . ";\n";
207                    break;
208
209                case 'redirect':
210                    $script .= "\tredirect ";
211                    if ($action['copy']) {
212                        $script .= ':copy ';
213                        array_push($exts, 'copy');
214                    }
215                    $script .= self::escape_string($action['target']) . ";\n";
216                    break;
217
218                case 'reject':
219                case 'ereject':
220                    array_push($exts, $action['type']);
221                    $script .= "\t".$action['type']." "
222                        . self::escape_string($action['target']) . ";\n";
223                    break;
224
225                case 'addflag':
226                case 'setflag':
227                case 'removeflag':
228                    if (is_array($this->capabilities) && in_array('imap4flags', $this->capabilities))
229                        array_push($exts, 'imap4flags');
230                    else
231                        array_push($exts, 'imapflags');
232
233                    $script .= "\t".$action['type']." "
234                        . self::escape_string($action['target']) . ";\n";
235                    break;
236
237                case 'keep':
238                case 'discard':
239                case 'stop':
240                    $script .= "\t" . $action['type'] .";\n";
241                    break;
242
243                case 'vacation':
244                    array_push($exts, 'vacation');
245                    $script .= "\tvacation";
246                    if (!empty($action['days']))
247                        $script .= " :days " . $action['days'];
248                    if (!empty($action['addresses']))
249                        $script .= " :addresses " . self::escape_string($action['addresses']);
250                    if (!empty($action['subject']))
251                        $script .= " :subject " . self::escape_string($action['subject']);
252                    if (!empty($action['handle']))
253                        $script .= " :handle " . self::escape_string($action['handle']);
254                    if (!empty($action['from']))
255                        $script .= " :from " . self::escape_string($action['from']);
256                    if (!empty($action['mime']))
257                        $script .= " :mime";
258                    $script .= " " . self::escape_string($action['reason']) . ";\n";
259                    break;
260                }
261            }
262
263            $script .= "}\n";
264            $idx++;
265        }
266
267        // requires
268        if (!empty($exts))
269            $script = 'require ["' . implode('","', array_unique($exts)) . "\"];\n" . $script;
270
271        return $script;
272    }
273
274    /**
275     * Returns script object
276     *
277     */
278    public function as_array()
279    {
280        return $this->content;
281    }
282
283    /**
284     * Returns array of supported extensions
285     *
286     */
287    public function get_extensions()
288    {
289        return array_values($this->supported);
290    }
291
292    /**
293     * Converts text script to rules array
294     *
295     * @param string Text script
296     */
297    private function _parse_text($script)
298    {
299        $i = 0;
300        $content = array();
301
302        // tokenize rules
303        if ($tokens = preg_split('/(# rule:\[.*\])\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
304            foreach($tokens as $token) {
305                if (preg_match('/^# rule:\[(.*)\]/', $token, $matches)) {
306                    $content[$i]['name'] = $matches[1];
307                }
308                else if (isset($content[$i]['name']) && sizeof($content[$i]) == 1) {
309                    if ($rule = $this->_tokenize_rule($token)) {
310                        $content[$i] = array_merge($content[$i], $rule);
311                        $i++;
312                    }
313                    else // unknown rule format
314                        unset($content[$i]);
315                }
316            }
317        }
318
319        return $content;
320    }
321
322    /**
323     * Convert text script fragment to rule object
324     *
325     * @param string Text rule
326     */
327    private function _tokenize_rule($content)
328    {
329        $cond = strtolower(self::tokenize($content, 1));
330
331        if ($cond != 'if' && $cond != 'elsif' && $cond != 'else') {
332            return null;
333        }
334
335        $disabled = false;
336        $join     = false;
337
338        // disabled rule (false + comment): if false # .....
339        if (preg_match('/^\s*false\s+#/i', $content)) {
340            $content = preg_replace('/^\s*false\s+#\s*/i', '', $content);
341            $disabled = true;
342        }
343
344        while (strlen($content)) {
345            $tokens = self::tokenize($content, true);
346            $separator = array_pop($tokens);
347
348            if (!empty($tokens)) {
349                $token = array_shift($tokens);
350            }
351            else {
352                $token = $separator;
353            }
354
355            $token = strtolower($token);
356
357            if ($token == 'not') {
358                $not = true;
359                $token = strtolower(array_shift($tokens));
360            }
361            else {
362                $not = false;
363            }
364
365            switch ($token) {
366            case 'allof':
367                $join = true;
368                break;
369            case 'anyof':
370                break;
371
372            case 'size':
373                $size = array('test' => 'size', 'not'  => $not);
374                for ($i=0, $len=count($tokens); $i<$len; $i++) {
375                    if (!is_array($tokens[$i])
376                        && preg_match('/^:(under|over)$/i', $tokens[$i])
377                    ) {
378                        $size['type'] = strtolower(substr($tokens[$i], 1));
379                    }
380                    else {
381                        $size['arg'] = $tokens[$i];
382                    }
383                }
384
385                $tests[] = $size;
386                break;
387
388            case 'header':
389                $header = array('test' => 'header', 'not' => $not, 'arg1' => '', 'arg2' => '');
390                for ($i=0, $len=count($tokens); $i<$len; $i++) {
391                    if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
392                        $i++;
393                    }
394                    else if (!is_array($tokens[$i]) && preg_match('/^:(count|value)$/i', $tokens[$i])) {
395                        $header['type'] = strtolower(substr($tokens[$i], 1)) . '-' . $tokens[++$i];
396                    }
397                    else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches|regex)$/i', $tokens[$i])) {
398                        $header['type'] = strtolower(substr($tokens[$i], 1));
399                    }
400                    else {
401                        $header['arg1'] = $header['arg2'];
402                        $header['arg2'] = $tokens[$i];
403                    }
404                }
405
406                $tests[] = $header;
407                break;
408
409            case 'exists':
410                $tests[] = array('test' => 'exists', 'not'  => $not,
411                    'arg'  => array_pop($tokens));
412                break;
413
414            case 'true':
415                $tests[] = array('test' => 'true', 'not'  => $not);
416                break;
417
418            case 'false':
419                $tests[] = array('test' => 'true', 'not'  => !$not);
420                break;
421            }
422
423            // goto actions...
424            if ($separator == '{') {
425                break;
426            }
427        }
428
429        // ...and actions block
430        if ($tests) {
431            $actions = $this->_parse_actions($content);
432        }
433
434        if ($tests && $actions) {
435            $result = array(
436                'type'     => $cond,
437                'tests'    => $tests,
438                'actions'  => $actions,
439                'join'     => $join,
440                'disabled' => $disabled,
441            );
442        }
443
444        return $result;
445    }
446
447    /**
448     * Parse body of actions section
449     *
450     * @param string Text body
451     * @return array Array of parsed action type/target pairs
452     */
453    private function _parse_actions($content)
454    {
455        $result = null;
456
457        while (strlen($content)) {
458            $tokens = self::tokenize($content, true);
459            $separator = array_pop($tokens);
460
461            if (!empty($tokens)) {
462                $token = array_shift($tokens);
463            }
464            else {
465                $token = $separator;
466            }
467
468            switch ($token) {
469            case 'discard':
470            case 'keep':
471            case 'stop':
472                $result[] = array('type' => $token);
473                break;
474
475            case 'fileinto':
476            case 'redirect':
477                $copy   = false;
478                $target = '';
479
480                for ($i=0, $len=count($tokens); $i<$len; $i++) {
481                    if (strtolower($tokens[$i]) == ':copy') {
482                        $copy = true;
483                    }
484                    else {
485                        $target = $tokens[$i];
486                    }
487                }
488
489                $result[] = array('type' => $token, 'copy' => $copy,
490                    'target' => $target);
491                break;
492
493            case 'reject':
494            case 'ereject':
495                $result[] = array('type' => $token, 'target' => array_pop($tokens));
496                break;
497
498            case 'vacation':
499                $vacation = array('type' => 'vacation', 'reason' => array_pop($tokens));
500
501                for ($i=0, $len=count($tokens); $i<$len; $i++) {
502                    $tok = strtolower($tokens[$i]);
503                    if ($tok == ':days') {
504                        $vacation['days'] = $tokens[++$i];
505                    }
506                    else if ($tok == ':subject') {
507                        $vacation['subject'] = $tokens[++$i];
508                    }
509                    else if ($tok == ':addresses') {
510                        $vacation['addresses'] = $tokens[++$i];
511                    }
512                    else if ($tok == ':handle') {
513                        $vacation['handle'] = $tokens[++$i];
514                    }
515                    else if ($tok == ':from') {
516                        $vacation['from'] = $tokens[++$i];
517                    }
518                    else if ($tok == ':mime') {
519                        $vacation['mime'] = true;
520                    }
521                }
522
523                $result[] = $vacation;
524                break;
525
526            case 'setflag':
527            case 'addflag':
528            case 'removeflag':
529                $result[] = array('type' => $token,
530                    // Flags list: last token (skip optional variable)
531                    'target' => $tokens[count($tokens)-1]
532                );
533                break;
534            }
535        }
536
537        return $result;
538    }
539
540    /**
541     * Escape special chars into quoted string value or multi-line string
542     * or list of strings
543     *
544     * @param string $str Text or array (list) of strings
545     *
546     * @return string Result text
547     */
548    static function escape_string($str)
549    {
550        if (is_array($str) && count($str) > 1) {
551            foreach($str as $idx => $val)
552                $str[$idx] = self::escape_string($val);
553
554            return '[' . implode(',', $str) . ']';
555        }
556        else if (is_array($str)) {
557            $str = array_pop($str);
558        }
559
560        // multi-line string
561        if (preg_match('/[\r\n\0]/', $str) || strlen($str) > 1024) {
562            return sprintf("text:\n%s\n.\n", self::escape_multiline_string($str));
563        }
564        // quoted-string
565        else {
566            return '"' . addcslashes($str, '\\"') . '"';
567        }
568    }
569
570    /**
571     * Escape special chars in multi-line string value
572     *
573     * @param string $str Text
574     *
575     * @return string Text
576     */
577    static function escape_multiline_string($str)
578    {
579        $str = preg_split('/(\r?\n)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
580
581        foreach ($str as $idx => $line) {
582            // dot-stuffing
583            if (isset($line[0]) && $line[0] == '.') {
584                $str[$idx] = '.' . $line;
585            }
586        }
587
588        return implode($str);
589    }
590
591    /**
592     * Splits script into string tokens
593     *
594     * @param string &$str    The script
595     * @param mixed  $num     Number of tokens to return, 0 for all
596     *                        or True for all tokens until separator is found.
597     *                        Separator will be returned as last token.
598     * @param int    $in_list Enable to called recursively inside a list
599     *
600     * @return mixed Tokens array or string if $num=1
601     */
602    static function tokenize(&$str, $num=0, $in_list=false)
603    {
604        $result = array();
605
606        // remove spaces from the beginning of the string
607        while (($str = ltrim($str)) !== ''
608            && (!$num || $num === true || count($result) < $num)
609        ) {
610            switch ($str[0]) {
611
612            // Quoted string
613            case '"':
614                $len = strlen($str);
615
616                for ($pos=1; $pos<$len; $pos++) {
617                    if ($str[$pos] == '"') {
618                        break;
619                    }
620                    if ($str[$pos] == "\\") {
621                        if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
622                            $pos++;
623                        }
624                    }
625                }
626                if ($str[$pos] != '"') {
627                    // error
628                }
629                // we need to strip slashes for a quoted string
630                $result[] = stripslashes(substr($str, 1, $pos - 1));
631                $str      = substr($str, $pos + 1);
632                break;
633
634            // Parenthesized list
635            case '[':
636                $str = substr($str, 1);
637                $result[] = self::tokenize($str, 0, true);
638                break;
639            case ']':
640                $str = substr($str, 1);
641                return $result;
642                break;
643
644            // list/test separator
645            case ',':
646            // command separator
647            case ';':
648            // block/tests-list
649            case '(':
650            case ')':
651            case '{':
652            case '}':
653                $sep = $str[0];
654                $str = substr($str, 1);
655                if ($num === true) {
656                    $result[] = $sep;
657                    break 2; 
658                }
659                break;
660
661            // bracket-comment
662            case '/':
663                if ($str[1] == '*') {
664                    if ($end_pos = strpos($str, '*/')) {
665                        $str = substr($str, $end_pos + 2);
666                    }
667                    else {
668                        // error
669                        $str = '';
670                    }
671                }
672                break;
673
674            // hash-comment
675            case '#':
676                if ($lf_pos = strpos($str, "\n")) {
677                    $str = substr($str, $lf_pos);
678                    break;
679                }
680                else {
681                    $str = '';
682                }
683
684            // String atom
685            default:
686                // empty or one character
687                if ($str === '' || $str === null) {
688                    break 2;
689                }
690                if (strlen($str) < 2) {
691                    $result[] = $str;
692                    $str = '';
693                    break;
694                }
695
696                // tag/identifier/number
697                if (preg_match('/^([a-z0-9:_]+)/i', $str, $m)) {
698                    $str = substr($str, strlen($m[1]));
699
700                    if ($m[1] != 'text:') {
701                        $result[] = $m[1];
702                    }
703                    // multiline string
704                    else {
705                        // possible hash-comment after "text:"
706                        if (preg_match('/^( |\t)*(#[^\n]+)?\n/', $str, $m)) {
707                            $str = substr($str, strlen($m[0]));
708                        }
709                        // get text until alone dot in a line
710                        if (preg_match('/^(.*)\r?\n\.\r?\n/sU', $str, $m)) {
711                            $text = $m[1];
712                            // remove dot-stuffing
713                            $text = str_replace("\n..", "\n.", $text);
714                            $str = substr($str, strlen($m[0]));
715                        }
716                        else {
717                            $text = '';
718                        }
719
720                        $result[] = $text;
721                    }
722                }
723
724                break;
725            }
726        }
727
728        return $num === 1 ? (isset($result[0]) ? $result[0] : null) : $result;
729    }
730
731}
Note: See TracBrowser for help on using the repository browser.