1<?php
2/**
3 * Bureaucracy Plugin: Allows flexible creation of forms
4 *
5 * This plugin allows definition of forms in wiki pages. The forms can be
6 * submitted via email or used to create new pages from templates.
7 *
8 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author     Andreas Gohr <andi@splitbrain.org>
10 * @author     Adrian Lang <dokuwiki@cosmocode.de>
11 */
12// must be run within Dokuwiki
13use dokuwiki\Utf8\PhpString;
14
15if(!defined('DOKU_INC')) die();
16
17/**
18 * All DokuWiki plugins to extend the parser/rendering mechanism
19 * need to inherit from this class
20 */
21class syntax_plugin_bureaucracy extends DokuWiki_Syntax_Plugin {
22
23    private $form_id = 0;
24    var $patterns = array();
25    var $values = array();
26    var $noreplace = null;
27    var $functions = array();
28
29    /**
30     * Prepare some replacements
31     */
32    public function __construct() {
33        $this->prepareDateTimereplacements();
34        $this->prepareNamespacetemplateReplacements();
35        $this->prepareFunctions();
36    }
37
38    /**
39     * What kind of syntax are we?
40     */
41    public function getType() {
42        return 'substition';
43    }
44
45    /**
46     * What about paragraphs?
47     */
48    public function getPType() {
49        return 'block';
50    }
51
52    /**
53     * Where to sort in?
54     */
55    public function getSort() {
56        return 155;
57    }
58
59    /**
60     * Connect pattern to lexer
61     *
62     * @param string $mode
63     */
64    public function connectTo($mode) {
65        $this->Lexer->addSpecialPattern('<form>.*?</form>', $mode, 'plugin_bureaucracy');
66    }
67
68    /**
69     * Handler to prepare matched data for the rendering process
70     *
71     * @param   string       $match   The text matched by the patterns
72     * @param   int          $state   The lexer state for the match
73     * @param   int          $pos     The character position of the matched text
74     * @param   Doku_Handler $handler The Doku_Handler object
75     * @return  bool|array Return an array with all data you want to use in render, false don't add an instruction
76     */
77    public function handle($match, $state, $pos, Doku_Handler $handler) {
78        $match = substr($match, 6, -7); // remove form wrap
79        $lines = explode("\n", $match);
80        $actions = $rawactions = array();
81        $thanks = '';
82        $labels = '';
83
84        // parse the lines into an command/argument array
85        $cmds = array();
86        while(count($lines) > 0) {
87            $line = trim(array_shift($lines));
88            if(!$line) continue;
89            $args = $this->_parse_line($line, $lines);
90            $args[0] = $this->_sanitizeClassName($args[0]);
91
92            if(in_array($args[0], array('action', 'thanks', 'labels'))) {
93                if(count($args) < 2) {
94                    msg(sprintf($this->getLang('e_missingargs'), hsc($args[0]), hsc($args[1])), -1);
95                    continue;
96                }
97
98                // is action element?
99                if($args[0] == 'action') {
100                    array_shift($args);
101                    $rawactions[] = array('type' => array_shift($args), 'argv' => $args);
102                    continue;
103                }
104
105                // is thank you text?
106                if($args[0] == 'thanks') {
107                    $thanks = $args[1];
108                    continue;
109                }
110
111                // is labels?
112                if($args[0] == 'labels') {
113                    $labels = $args[1];
114                    continue;
115                }
116            }
117
118            if(strpos($args[0], '_') === false) {
119                $name = 'bureaucracy_field' . $args[0];
120            } else {
121                //name convention: plugin_componentname
122                $name = $args[0];
123            }
124
125            /** @var helper_plugin_bureaucracy_field $field */
126            $field = $this->loadHelper($name, false);
127            if($field && is_a($field, 'helper_plugin_bureaucracy_field')) {
128                $field->initialize($args);
129                $cmds[] = $field;
130            } else {
131                $evdata = array('fields' => &$cmds, 'args' => $args);
132                $event = new Doku_Event('PLUGIN_BUREAUCRACY_FIELD_UNKNOWN', $evdata);
133                if($event->advise_before()) {
134                    msg(sprintf($this->getLang('e_unknowntype'), hsc($name)), -1);
135                }
136            }
137
138        }
139
140        // check if action is available
141        foreach($rawactions as $action) {
142            $action['type'] = $this->_sanitizeClassName($action['type']);
143
144            if(strpos($action['type'], '_') === false) {
145                $action['actionname'] = 'bureaucracy_action' . $action['type'];
146            } else {
147                //name convention for other plugins: plugin_componentname
148                $action['actionname'] = $action['type'];
149            }
150
151            list($plugin, $component) = explode('_', $action['actionname']);
152            $alternativename = $action['type'] . '_'. $action['type'];
153
154            // bureaucracy_action<name> or <plugin>_<componentname>
155            if(!plugin_isdisabled($action['actionname']) || @file_exists(DOKU_PLUGIN . $plugin . '/helper/'  . $component . '.php')) {
156                $actions[] = $action;
157
158            // shortcut for other plugins with component name <name>_<name>
159            } elseif(plugin_isdisabled($alternativename) || !@file_exists(DOKU_PLUGIN . $action['type'] . '/helper/'  . $action['type'] . '.php')) {
160                $action['actionname'] = $alternativename;
161                $actions[] = $action;
162
163            // not found
164            } else {
165                $evdata = array('actions' => &$actions, 'action' => $action);
166                $event = new Doku_Event('PLUGIN_BUREAUCRACY_ACTION_UNKNOWN', $evdata);
167                if($event->advise_before()) {
168                    msg(sprintf($this->getLang('e_unknownaction'), hsc($action['actionname'])), -1);
169                }
170            }
171        }
172
173        // action(s) found?
174        if(count($actions) < 1) {
175            msg($this->getLang('e_noaction'), -1);
176        }
177
178        // set thank you message
179        if(!$thanks) {
180            $thanks = "";
181            foreach($actions as $action) {
182                $thanks .= $this->getLang($action['type'] . '_thanks');
183            }
184        } else {
185            $thanks = hsc($thanks);
186        }
187        return array(
188            'fields'  => $cmds,
189            'actions' => $actions,
190            'thanks'  => $thanks,
191            'labels'  => $labels
192        );
193    }
194
195    /**
196     * Handles the actual output creation.
197     *
198     * @param string          $format   output format being rendered
199     * @param Doku_Renderer   $R        the current renderer object
200     * @param array           $data     data created by handler()
201     * @return  boolean                 rendered correctly? (however, returned value is not used at the moment)
202     */
203    public function render($format, Doku_Renderer $R, $data) {
204        if($format != 'xhtml') return false;
205        $R->info['cache'] = false; // don't cache
206
207        /**
208         * replace some time and name placeholders in the default values
209         * @var $field helper_plugin_bureaucracy_field
210         */
211        foreach($data['fields'] as &$field) {
212            if(isset($field->opt['value'])) {
213                $field->opt['value'] = $this->replace($field->opt['value']);
214            }
215        }
216
217        if($data['labels']) $this->loadlabels($data);
218
219        $this->form_id++;
220        if(isset($_POST['bureaucracy']) && checkSecurityToken() && $_POST['bureaucracy']['$$id'] == $this->form_id) {
221            $success = $this->_handlepost($data);
222            if($success !== false) {
223                $R->doc .= '<div class="bureaucracy__plugin" id="scroll__here">' . $success . '</div>';
224                return true;
225            }
226        }
227
228        $R->doc .= $this->_htmlform($data['fields']);
229
230        return true;
231    }
232
233    /**
234     * Initializes the labels, loaded from a defined labelpage
235     *
236     * @param array $data all data passed to render()
237     */
238    protected function loadlabels(&$data) {
239        global $INFO;
240        $labelpage = $data['labels'];
241        $exists = false;
242        resolve_pageid($INFO['namespace'], $labelpage, $exists);
243        if(!$exists) {
244            msg(sprintf($this->getLang('e_labelpage'), html_wikilink($labelpage)), -1);
245            return;
246        }
247
248        // parse simple list (first level cdata only)
249        $labels = array();
250        $instructions = p_cached_instructions(wikiFN($labelpage));
251        $inli = 0;
252        $item = '';
253        foreach($instructions as $instruction) {
254            if($instruction[0] == 'listitem_open') {
255                $inli++;
256                continue;
257            }
258            if($inli === 1 && $instruction[0] == 'cdata') {
259                $item .= $instruction[1][0];
260            }
261            if($instruction[0] == 'listitem_close') {
262                $inli--;
263                if($inli === 0) {
264                    list($k, $v) = explode('=', $item, 2);
265                    $k = trim($k);
266                    $v = trim($v);
267                    if($k && $v) $labels[$k] = $v;
268                    $item = '';
269                }
270            }
271        }
272
273        // apply labels to all fields
274        $len = count($data['fields']);
275        for($i = 0; $i < $len; $i++) {
276            if(isset($data['fields'][$i]->depends_on)) {
277                // translate dependency on fieldsets
278                $label = $data['fields'][$i]->depends_on[0];
279                if(isset($labels[$label])) {
280                    $data['fields'][$i]->depends_on[0] = $labels[$label];
281                }
282
283            } else if(isset($data['fields'][$i]->opt['label'])) {
284                // translate field labels
285                $label = $data['fields'][$i]->opt['label'];
286                if(isset($labels[$label])) {
287                    $data['fields'][$i]->opt['display'] = $labels[$label];
288                }
289            }
290        }
291
292        if(isset($data['thanks'])) {
293            if(isset($labels[$data['thanks']])) {
294                $data['thanks'] = $labels[$data['thanks']];
295            }
296        }
297
298    }
299
300    /**
301     * Validate posted data, perform action(s)
302     *
303     * @param array $data all data passed to render()
304     * @return bool|string
305     *      returns thanks message when fields validated and performed the action(s) succesfully;
306     *      otherwise returns false.
307     */
308    private function _handlepost($data) {
309        $success = true;
310        foreach($data['fields'] as $index => $field) {
311            /** @var $field helper_plugin_bureaucracy_field */
312
313            $isValid = true;
314            if($field->getFieldType() === 'file') {
315                $file = array();
316                foreach($_FILES['bureaucracy'] as $key => $value) {
317                    $file[$key] = $value[$index];
318                }
319                $isValid = $field->handle_post($file, $data['fields'], $index, $this->form_id);
320
321            } elseif($field->getFieldType() === 'fieldset' || !$field->hidden) {
322                $isValid = $field->handle_post($_POST['bureaucracy'][$index] ?? null, $data['fields'], $index, $this->form_id);
323            }
324
325            if(!$isValid) {
326                // Do not return instantly to allow validation of all fields.
327                $success = false;
328            }
329        }
330        if(!$success) {
331            return false;
332        }
333
334        $thanks_array = array();
335
336        foreach($data['actions'] as $actionData) {
337            /** @var helper_plugin_bureaucracy_action $action */
338            $action = $this->loadHelper($actionData['actionname'], false);
339
340            // action helper found?
341            if(!$action) {
342                msg(sprintf($this->getLang('e_unknownaction'), hsc($actionData['actionname'])), -1);
343                return false;
344            }
345
346            try {
347                $thanks_array[] = $action->run(
348                    $data['fields'],
349                    $data['thanks'],
350                    $actionData['argv']
351                );
352            } catch(Exception $e) {
353                msg($e->getMessage(), -1);
354                return false;
355            }
356        }
357
358        // Perform after_action hooks
359        foreach($data['fields'] as $field) {
360            $field->after_action();
361        }
362
363		// create thanks string
364		$thanks = implode('', array_unique($thanks_array));
365
366        return $thanks;
367    }
368
369    /**
370     * Create the form
371     *
372     * @param helper_plugin_bureaucracy_field[] $fields array with form fields
373     * @return string html of the form
374     */
375    private function _htmlform($fields) {
376        global $INFO;
377
378        $form = new Doku_Form(array('class'   => 'bureaucracy__plugin',
379                                    'id'      => 'bureaucracy__plugin' . $this->form_id,
380                                    'enctype' => 'multipart/form-data'));
381        $form->addHidden('id', $INFO['id']);
382        $form->addHidden('bureaucracy[$$id]', $this->form_id);
383
384        foreach($fields as $id => $field) {
385            $field->renderfield(array('name' => 'bureaucracy[' . $id . ']'), $form, $this->form_id);
386        }
387
388        return $form->getForm();
389    }
390
391    /**
392     * Parse a line into (quoted) arguments
393     * Splits line at spaces, except when quoted
394     *
395     * @author William Fletcher <wfletcher@applestone.co.za>
396     *
397     * @param string $line line to parse
398     * @param array  $lines all remaining lines
399     * @return array with all the arguments
400     */
401    private function _parse_line($line, &$lines) {
402        $args = array();
403        $inQuote = false;
404        $escapedQuote = false;
405        $arg = '';
406        do {
407            $len = strlen($line);
408            for($i = 0; $i < $len; $i++) {
409                if($line[$i] == '"') {
410                    if($inQuote) {
411                        if($escapedQuote) {
412                            $arg .= '"';
413                            $escapedQuote = false;
414                            continue;
415                        }
416                        if($i + 1 < $len && $line[$i + 1] == '"') {
417                            $escapedQuote = true;
418                            continue;
419                        }
420                        array_push($args, $arg);
421                        $inQuote = false;
422                        $arg = '';
423                        continue;
424                    } else {
425                        $inQuote = true;
426                        continue;
427                    }
428                } else if($line[$i] == ' ') {
429                    if($inQuote) {
430                        $arg .= ' ';
431                        continue;
432                    } else {
433                        if(strlen($arg) < 1) continue;
434                        array_push($args, $arg);
435                        $arg = '';
436                        continue;
437                    }
438                }
439                $arg .= $line[$i];
440            }
441            if(!$inQuote || count($lines) === 0) break;
442            $line = array_shift($lines);
443            $arg .= "\n";
444        } while(true);
445        if(strlen($arg) > 0) array_push($args, $arg);
446        return $args;
447    }
448
449    /**
450     * Clean class name
451     *
452     * @param string $classname
453     * @return string cleaned name
454     */
455    private function _sanitizeClassName($classname) {
456        return preg_replace('/[^\w\x7f-\xff]/', '', strtolower($classname));
457    }
458
459    /**
460     * Save content in <noreplace> tags into $this->noreplace
461     *
462     * @param string $input    The text to work on
463     */
464    protected function noreplace_save($input) {
465        $pattern = '/<noreplace>(.*?)<\/noreplace>/is';
466        //save content of <noreplace> tags
467        preg_match_all($pattern, $input, $matches);
468        $this->noreplace = $matches[1];
469    }
470
471    /**
472     * Apply replacement patterns and values as prepared earlier
473     * (disable $strftime to prevent double replacements with default strftime() replacements in nstemplate)
474     *
475     * @param string $input    The text to work on
476     * @param bool   $strftime Apply strftime() replacements
477     * @return string processed text
478     */
479    function replace($input, $strftime = true) {
480        //in helper_plugin_struct_field::setVal $input can be an array
481        //just return $input in that case
482        if (!is_string($input)) return $input;
483        if (is_null($this->noreplace)) $this->noreplace_save($input);
484
485        foreach ($this->values as $label => $value) {
486            $pattern = $this->patterns[$label];
487            if (is_callable($value)) {
488                $input = preg_replace_callback(
489                    $pattern,
490                    $value,
491                    $input
492                );
493            } else {
494                $input = preg_replace($pattern, $value, $input);
495            }
496
497        }
498
499        if($strftime) {
500            $input = preg_replace_callback(
501                '/%./',
502                function($m){return strftime($m[0]);},
503                $input
504            );
505        }
506        // user syntax: %%.(.*?)
507        // strftime() is already applied once, so syntax is at this point: %.(.*?)
508        $input = preg_replace_callback(
509            '/@DATE\((.*?)(?:,\s*(.*?))?\)@/',
510            array($this, 'replacedate'),
511            $input
512        );
513
514        //run functions
515        foreach ($this->functions as $name => $callback) {
516            $pattern = '/@' . preg_quote($name) . '\((.*?)\)@/';
517            if (is_callable($callback)) {
518                $input = preg_replace_callback($pattern, function ($matches) use ($callback) {
519                    return call_user_func($callback, $matches[1]);
520                }, $input);
521            }
522        }
523
524        //replace <noreplace> tags with their original content
525        $pattern = '/<noreplace>.*?<\/noreplace>/is';
526        if (is_array($this->noreplace)) foreach ($this->noreplace as $nr) {
527            $input = preg_replace($pattern, $nr, $input, 1);
528        }
529
530        return $input;
531    }
532
533    /**
534     * (callback) Replace date by request datestring
535     * e.g. '%m(30-11-1975)' is replaced by '11'
536     *
537     * @param array $match with [0]=>whole match, [1]=> first subpattern, [2] => second subpattern
538     * @return string
539     */
540    function replacedate($match) {
541        global $conf;
542
543        //no 2nd argument for default date format
544        $match[2] = $match[2] ?? $conf['dformat'];
545
546        return strftime($match[2], strtotime($match[1]));
547    }
548
549    /**
550     * Same replacements as applied at template namespaces
551     *
552     * @see parsePageTemplate()
553     */
554    function prepareNamespacetemplateReplacements() {
555        /* @var Input $INPUT */
556        global $INPUT;
557        global $INFO;
558        global $USERINFO;
559        global $conf;
560        global $ID;
561
562        $this->patterns['__formpage_id__'] = '/@FORMPAGE_ID@/';
563        $this->patterns['__formpage_ns__'] = '/@FORMPAGE_NS@/';
564        $this->patterns['__formpage_curns__'] = '/@FORMPAGE_CURNS@/';
565        $this->patterns['__formpage_file__'] = '/@FORMPAGE_FILE@/';
566        $this->patterns['__formpage_!file__'] = '/@FORMPAGE_!FILE@/';
567        $this->patterns['__formpage_!file!__'] = '/@FORMPAGE_!FILE!@/';
568        $this->patterns['__formpage_page__'] = '/@FORMPAGE_PAGE@/';
569        $this->patterns['__formpage_!page__'] = '/@FORMPAGE_!PAGE@/';
570        $this->patterns['__formpage_!!page__'] = '/@FORMPAGE_!!PAGE@/';
571        $this->patterns['__formpage_!page!__'] = '/@FORMPAGE_!PAGE!@/';
572        $this->patterns['__user__'] = '/@USER@/';
573        $this->patterns['__name__'] = '/@NAME@/';
574        $this->patterns['__mail__'] = '/@MAIL@/';
575        $this->patterns['__date__'] = '/@DATE@/';
576
577        // replace placeholders
578        $localid = isset($INFO['id']) ? $INFO['id'] : $ID;
579        $file = noNS($localid);
580        $page = strtr($file, $conf['sepchar'], ' ');
581        $this->values['__formpage_id__'] = $localid;
582        $this->values['__formpage_ns__'] = getNS($localid);
583        $this->values['__formpage_curns__'] = curNS($localid);
584        $this->values['__formpage_file__'] = $file;
585        $this->values['__formpage_!file__'] = PhpString::ucfirst($file);
586        $this->values['__formpage_!file!__'] = PhpString::strtoupper($file);
587        $this->values['__formpage_page__'] = $page;
588        $this->values['__formpage_!page__'] = PhpString::ucfirst($page);
589        $this->values['__formpage_!!page__'] = PhpString::ucwords($page);
590        $this->values['__formpage_!page!__'] = PhpString::strtoupper($page);
591        $this->values['__user__'] = $INPUT->server->str('REMOTE_USER');
592        $this->values['__name__'] = $USERINFO['name'] ?? '';
593        $this->values['__mail__'] = $USERINFO['mail'] ?? '';
594        $this->values['__date__'] = strftime($conf['dformat']);
595    }
596
597    /**
598     * Date time replacements
599     */
600    function prepareDateTimereplacements() {
601        $this->patterns['__year__'] = '/@YEAR@/';
602        $this->patterns['__month__'] = '/@MONTH@/';
603        $this->patterns['__monthname__'] = '/@MONTHNAME@/';
604        $this->patterns['__day__'] = '/@DAY@/';
605        $this->patterns['__time__'] = '/@TIME@/';
606        $this->patterns['__timesec__'] = '/@TIMESEC@/';
607        $this->values['__year__'] = date('Y');
608        $this->values['__month__'] = date('m');
609        $this->values['__monthname__'] = date('B');
610        $this->values['__day__'] = date('d');
611        $this->values['__time__'] = date('H:i');
612        $this->values['__timesec__'] = date('H:i:s');
613
614    }
615
616    /**
617     * Functions that can be used after replacements
618     */
619    function prepareFunctions() {
620        $this->functions['curNS'] = 'curNS';
621        $this->functions['getNS'] = 'getNS';
622        $this->functions['noNS'] = 'noNS';
623        $this->functions['p_get_first_heading'] = 'p_get_first_heading';
624    }
625}
626