xref: /plugin/discussion/action.php (revision 64829c37106252c090bb45ac0286a7d85bfc0e99)
1<?php
2/**
3 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
4 * @author     Esther Brunner <wikidesign@gmail.com>
5 */
6
7use dokuwiki\Extension\Event;
8use dokuwiki\Subscriptions\SubscriberManager;
9use dokuwiki\Utf8\PhpString;
10
11/**
12 * Class action_plugin_discussion
13 *
14 * Data format of file metadir/<id>.comments:
15 * array = [
16 *  'status' => int whether comments are 0=disabled/1=open/2=closed,
17 *  'number' => int number of visible comments,
18 *  'title' => string alternative title for discussion section
19 *  'comments' => [
20 *      '<cid>'=> [
21 *          'cid' => string comment id - long random string
22 *          'raw' => string comment text,
23 *          'xhtml' => string rendered html,
24 *          'parent' => null|string null or empty string at highest level, otherwise comment id of parent
25 *          'replies' => string[] array with comment ids
26 *          'user' => [
27 *              'id' => string,
28 *              'name' => string,
29 *              'mail' => string,
30 *              'address' => string,
31 *              'url' => string
32 *          ],
33 *          'date' => [
34 *              'created' => int timestamp,
35 *              'modified' => int (not defined if not modified)
36 *          ],
37 *          'show' => bool, whether shown (still be moderated, or hidden by moderator or user self)
38 *      ],
39 *      ...
40 *   ]
41 *   'subscribers' => [
42 *      '<mail>' => [
43 *          'hash' => string unique token,
44 *          'active' => bool, true if confirmed
45 *          'confirmsent' => bool, true if confirmation mail is sent
46 *      ],
47 *      ...
48 *   ]
49 */
50class action_plugin_discussion extends DokuWiki_Action_Plugin
51{
52
53    /** @var helper_plugin_avatar */
54    protected $avatar = null;
55    /** @var null|string */
56    protected $style = null;
57    /** @var null|bool */
58    protected $useAvatar = null;
59    /** @var helper_plugin_discussion */
60    protected $helper = null;
61
62    /**
63     * load helper
64     */
65    public function __construct()
66    {
67        $this->helper = plugin_load('helper', 'discussion');
68    }
69
70    /**
71     * Register the handlers
72     *
73     * @param Doku_Event_Handler $controller DokuWiki's event controller object.
74     */
75    public function register(Doku_Event_Handler $controller)
76    {
77        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleCommentActions');
78        $controller->register_hook('TPL_ACT_RENDER', 'AFTER', $this, 'renderCommentsSection');
79        $controller->register_hook('INDEXER_PAGE_ADD', 'AFTER', $this, 'addCommentsToIndex', ['id' => 'page', 'text' => 'body']);
80        $controller->register_hook('FULLTEXT_SNIPPET_CREATE', 'BEFORE', $this, 'addCommentsToIndex', ['id' => 'id', 'text' => 'text']);
81        $controller->register_hook('INDEXER_VERSION_GET', 'BEFORE', $this, 'addIndexVersion', []);
82        $controller->register_hook('FULLTEXT_PHRASE_MATCH', 'AFTER', $this, 'fulltextPhraseMatchInComments', []);
83        $controller->register_hook('PARSER_METADATA_RENDER', 'AFTER', $this, 'update_comment_status', []);
84        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addToolbarToCommentfield', []);
85        $controller->register_hook('TOOLBAR_DEFINE', 'AFTER', $this, 'modifyToolbar', []);
86        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'ajaxPreviewComments', []);
87        $controller->register_hook('TPL_TOC_RENDER', 'BEFORE', $this, 'addDiscussionToTOC', []);
88    }
89
90    /**
91     * Preview Comments
92     *
93     * @param Doku_Event $event
94     * @param $params
95     * @author Michael Klier <chi@chimeric.de>
96     *
97     */
98    public function ajaxPreviewComments(Doku_Event $event)
99    {
100        global $INPUT;
101        if ($event->data != 'discussion_preview') return;
102
103        $event->preventDefault();
104        $event->stopPropagation();
105        print p_locale_xhtml('preview');
106        print '<div class="comment_preview">';
107        if (!$INPUT->server->str('REMOTE_USER') && !$this->getConf('allowguests')) {
108            print p_locale_xhtml('denied');
109        } else {
110            print $this->renderComment($INPUT->post->str('comment'));
111        }
112        print '</div>';
113    }
114
115    /**
116     * Adds a TOC item if a discussion exists
117     *
118     * @param Doku_Event $event
119     * @param $params
120     * @author Michael Klier <chi@chimeric.de>
121     *
122     */
123    public function addDiscussionToTOC(Doku_Event $event)
124    {
125        global $ACT;
126        if ($this->hasDiscussion($title) && $event->data && $ACT != 'admin') {
127            $tocitem = ['hid' => 'discussion__section',
128                'title' => $this->getLang('discussion'),
129                'type' => 'ul',
130                'level' => 1];
131
132            $event->data[] = $tocitem;
133        }
134    }
135
136    /**
137     * Modify Toolbar for use with discussion plugin
138     *
139     * @param Doku_Event $event
140     * @param $param
141     * @author Michael Klier <chi@chimeric.de>
142     *
143     */
144    public function modifyToolbar(Doku_Event $event)
145    {
146        global $ACT;
147        if ($ACT != 'show') return;
148
149        if ($this->hasDiscussion($title) && $this->getConf('wikisyntaxok')) {
150            $toolbar = [];
151            foreach ($event->data as $btn) {
152                if ($btn['type'] == 'mediapopup') continue;
153                if ($btn['type'] == 'signature') continue;
154                if ($btn['type'] == 'linkwiz') continue;
155                if ($btn['type'] == 'NewTable') continue; //skip button for Edittable Plugin
156                if (isset($btn['open']) && preg_match("/=+?/", $btn['open'])) continue;
157
158                $toolbar[] = $btn;
159            }
160            $event->data = $toolbar;
161        }
162    }
163
164    /**
165     * Dirty workaround to add a toolbar to the discussion plugin
166     *
167     * @param Doku_Event $event
168     * @param $param
169     * @author Michael Klier <chi@chimeric.de>
170     *
171     */
172    public function addToolbarToCommentfield(Doku_Event $event)
173    {
174        global $ACT;
175        global $ID;
176        if ($ACT != 'show') return;
177
178        if ($this->hasDiscussion($title) && $this->getConf('wikisyntaxok')) {
179            // FIXME ugly workaround, replace this once DW the toolbar code is more flexible
180            @require_once(DOKU_INC . 'inc/toolbar.php');
181            ob_start();
182            print 'NS = "' . getNS($ID) . '";'; // we have to define NS, otherwise we get get JS errors
183            toolbar_JSdefines('toolbar');
184            $script = ob_get_clean();
185            $event->data['script'][] = ['type' => 'text/javascript', 'charset' => "utf-8", '_data' => $script];
186        }
187    }
188
189    /**
190     * Handles comment actions, dispatches data processing routines
191     *
192     * @param Doku_Event $event
193     * @param $param
194     * @return void
195     */
196    public function handleCommentActions(Doku_Event $event)
197    {
198        global $ID, $INFO, $lang, $INPUT;
199
200        // handle newthread ACTs
201        if ($event->data == 'newthread') {
202            // we can handle it -> prevent others
203            $event->data = $this->newThread();
204        }
205
206        // enable captchas
207        if (in_array($INPUT->str('comment'), ['add', 'save'])) {
208            $this->captchaCheck();
209            $this->recaptchaCheck();
210        }
211
212        // if we are not in show mode or someone wants to unsubscribe, that was all for now
213        if ($event->data != 'show'
214            && $event->data != 'discussion_unsubscribe'
215            && $event->data != 'discussion_confirmsubscribe') {
216            return;
217        }
218
219        if ($event->data == 'discussion_unsubscribe' or $event->data == 'discussion_confirmsubscribe') {
220            if ($INPUT->has('hash')) {
221                $file = metaFN($ID, '.comments');
222                $data = unserialize(io_readFile($file));
223                $matchedMail = '';
224                foreach ($data['subscribers'] as $mail => $info) {
225                    // convert old style subscribers just in case
226                    if (!is_array($info)) {
227                        $hash = $data['subscribers'][$mail];
228                        $data['subscribers'][$mail]['hash'] = $hash;
229                        $data['subscribers'][$mail]['active'] = true;
230                        $data['subscribers'][$mail]['confirmsent'] = true;
231                    }
232
233                    if ($data['subscribers'][$mail]['hash'] == $INPUT->str('hash')) {
234                        $matchedMail = $mail;
235                    }
236                }
237
238                if ($matchedMail != '') {
239                    if ($event->data == 'discussion_unsubscribe') {
240                        unset($data['subscribers'][$matchedMail]);
241                        msg(sprintf($lang['subscr_unsubscribe_success'], $matchedMail, $ID), 1);
242                    } else { //$event->data == 'discussion_confirmsubscribe'
243                        $data['subscribers'][$matchedMail]['active'] = true;
244                        msg(sprintf($lang['subscr_subscribe_success'], $matchedMail, $ID), 1);
245                    }
246                    io_saveFile($file, serialize($data));
247                    $event->data = 'show';
248                }
249
250            }
251            return;
252        } else {
253            // do the data processing for comments
254            $cid = $INPUT->str('cid');
255            switch ($INPUT->str('comment')) {
256                case 'add':
257                    if (empty($INPUT->str('text'))) return; // don't add empty comments
258
259                    if ($INPUT->server->has('REMOTE_USER') && !$this->getConf('adminimport')) {
260                        $comment['user']['id'] = $INPUT->server->str('REMOTE_USER');
261                        $comment['user']['name'] = $INFO['userinfo']['name'];
262                        $comment['user']['mail'] = $INFO['userinfo']['mail'];
263                    } elseif (($INPUT->server->has('REMOTE_USER') && $this->getConf('adminimport') && $this->helper->isDiscussionMod())
264                        || !$INPUT->server->has('REMOTE_USER')) {
265                        // don't add anonymous comments
266                        if (empty($INPUT->str('name')) or empty($INPUT->str('mail'))) {
267                            return;
268                        }
269
270                        if (!mail_isvalid($INPUT->str('mail'))) {
271                            msg($lang['regbadmail'], -1);
272                            return;
273                        } else {
274                            $comment['user']['id'] = 'test' . hsc($INPUT->str('user'));
275                            $comment['user']['name'] = hsc($INPUT->str('name'));
276                            $comment['user']['mail'] = hsc($INPUT->str('mail'));
277                        }
278                    }
279                    $comment['user']['address'] = ($this->getConf('addressfield')) ? hsc($INPUT->str('address')) : '';
280                    $comment['user']['url'] = ($this->getConf('urlfield')) ? $this->checkURL($INPUT->str('url')) : '';
281                    $comment['subscribe'] = ($this->getConf('subscribe')) ? $INPUT->has('subscribe') : '';
282                    $comment['date'] = ['created' => $INPUT->str('date')];
283                    $comment['raw'] = cleanText($INPUT->str('text'));
284                    $reply = $INPUT->str('reply');
285                    if ($this->getConf('moderate') && !$this->helper->isDiscussionMod()) {
286                        $comment['show'] = false;
287                    } else {
288                        $comment['show'] = true;
289                    }
290                    $this->add($comment, $reply);
291                    break;
292
293                case 'save':
294                    $raw = cleanText($INPUT->str('text'));
295                    $this->save([$cid], $raw);
296                    break;
297
298                case 'delete':
299                    $this->save([$cid], '');
300                    break;
301
302                case 'toogle':
303                    $this->save([$cid], '', 'toogle');
304                    break;
305            }
306        }
307    }
308
309    /**
310     * Main function; dispatches the visual comment actions
311     */
312    public function renderCommentsSection(Doku_Event $event)
313    {
314        global $INPUT;
315        if ($event->data != 'show') return; // nothing to do for us
316
317        $cid = $INPUT->str('cid');
318
319        if (!$cid) {
320            $cid = $INPUT->str('reply');
321        }
322
323        switch ($INPUT->str('comment')) {
324            case 'edit':
325                $this->showDiscussionSection(null, $cid);
326                break;
327            default: //'reply' or no action specified
328                $this->showDiscussionSection($cid);
329                break;
330        }
331    }
332
333    /**
334     * Redirects browser to given comment anchor
335     */
336    protected function redirect($cid)
337    {
338        global $ID;
339        global $ACT;
340
341        if ($ACT !== 'show') return;
342
343        if ($this->getConf('moderate') && !$this->helper->isDiscussionMod()) {
344            msg($this->getLang('moderation'), 1);
345            @session_start();
346            global $MSG;
347            $_SESSION[DOKU_COOKIE]['msg'] = $MSG;
348            session_write_close();
349            $url = wl($ID);
350        } else {
351            $url = wl($ID) . '#comment_' . $cid;
352        }
353
354        if (function_exists('send_redirect')) {
355            send_redirect($url);
356        } else {
357            header('Location: ' . $url);
358        }
359        exit();
360    }
361
362    /**
363     * Checks config settings to enable/disable discussions
364     *
365     * @return bool
366     */
367    public function isDiscussionEnabled()
368    {
369        global $INFO;
370
371        if ($this->getConf('excluded_ns') == '') {
372            $isNamespaceExcluded = false;
373        } else {
374            global $ID;
375            $ns = getNS($ID); // $INFO['namespace'] is not yet available, if used in update_comment_status()
376            $isNamespaceExcluded = preg_match($this->getConf('excluded_ns'), $INFO['namespace']);
377        }
378
379        if ($this->getConf('automatic')) {
380            if ($isNamespaceExcluded) {
381                return false;
382            } else {
383                return true;
384            }
385        } else {
386            if ($isNamespaceExcluded) {
387                return true;
388            } else {
389                return false;
390            }
391        }
392    }
393
394    /**
395     * Shows all comments of the current page, if no reply or edit requested, then comment form is shown on the end
396     *
397     * @param null|string $reply comment id on which the user requested a reply
398     * @param null|string $edit comment id which the user requested for editing
399     */
400    protected function showDiscussionSection($reply = null, $edit = null)
401    {
402        global $ID, $INFO, $INPUT;
403
404        // get .comments meta file name
405        $file = metaFN($ID, '.comments');
406
407        if (!$INFO['exists']) return;
408        if (!@file_exists($file) && !$this->isDiscussionEnabled()) return;
409        if (!$INPUT->server->has('REMOTE_USER') && !$this->getConf('showguests')) return;
410
411        // load data
412        $data = [];
413        if (@file_exists($file)) {
414            $data = unserialize(io_readFile($file, false));
415            // comments are turned off
416            if (!$data['status']) {
417                return;
418            }
419        } elseif (!@file_exists($file) && $this->isDiscussionEnabled() && $INFO['exists']) {
420            // set status to show the comment form
421            $data['status'] = 1;
422            $data['number'] = 0;
423        }
424
425        // show discussion wrapper only on certain circumstances
426        if (empty($data['comments']) || !is_array($data['comments'])) {
427            $cnt = 0;
428            $keys = [];
429        } else {
430            $cnt = count($data['comments']);
431            $keys = array_keys($data['comments']);
432        }
433
434        $show = false;
435        if ($cnt > 1 || ($cnt == 1 && $data['comments'][$keys[0]]['show'] == 1)
436            || $this->getConf('allowguests') || $INPUT->server->has('REMOTE_USER')) {
437            $show = true;
438            // section title
439            $title = ($data['title'] ? hsc($data['title']) : $this->getLang('discussion'));
440            ptln('<div class="comment_wrapper" id="comment_wrapper">'); // the id value is used for visibility toggling the section
441            ptln('<h2><a name="discussion__section" id="discussion__section">', 2);
442            ptln($title, 4);
443            ptln('</a></h2>', 2);
444            ptln('<div class="level2 hfeed">', 2);
445        }
446
447        // now display the comments
448        if (isset($data['comments'])) {
449            if (!$this->getConf('usethreading')) {
450                $data['comments'] = $this->flattenThreads($data['comments']);
451                uasort($data['comments'], [$this, 'sortThreadsOnCreation']);
452            }
453            if ($this->getConf('newestfirst')) {
454                $data['comments'] = array_reverse($data['comments']);
455            }
456            foreach ($data['comments'] as $cid => $value) {
457                if ($cid == $edit) { // edit form
458                    $this->showCommentForm($value['raw'], 'save', $edit);
459                } else {
460                    $this->showCommentWithReplies($cid, $data, '', $reply);
461                }
462            }
463        }
464
465        // comment form shown on the end, if no comment form of $reply or $edit is requested before
466        if ($data['status'] == 1 && (!$reply || !$this->getConf('usethreading')) && !$edit) {
467            $this->showCommentForm('');
468        }
469
470        if ($show) {
471            ptln('</div>', 2); // level2 hfeed
472            ptln('</div>'); // comment_wrapper
473        }
474
475        // check for toggle print configuration
476        if ($this->getConf('visibilityButton')) {
477            // print the hide/show discussion section button
478            $this->showDiscussionToggleButton();
479        }
480    }
481
482    /**
483     * Remove the parent-child relation, such that the comment structure becomes flat
484     *
485     * @param array $comments array with all comments
486     * @param null|array $cids comment ids of replies, which should be flatten
487     * @return array returned array with flattened comment structure
488     */
489    protected function flattenThreads($comments, $cids = null)
490    {
491        if (is_null($cids)) {
492            $cids = array_keys($comments);
493        }
494
495        foreach ($cids as $cid) {
496            if (!empty($comments[$cid]['replies'])) {
497                $rids = $comments[$cid]['replies'];
498                $comments = $this->flattenThreads($comments, $rids);
499                $comments[$cid]['replies'] = [];
500            }
501            $comments[$cid]['parent'] = '';
502        }
503        return $comments;
504    }
505
506    /**
507     * Adds a new comment and then displays all comments
508     *
509     * @param array $comment with
510     *  'raw' => string comment text,
511     *  'user' => [
512     *      'id' => string,
513     *      'name' => string,
514     *      'mail' => string
515     *  ],
516     *  'date' => [
517     *      'created' => int timestamp
518     *  ]
519     *  'show' => bool
520     *  'subscribe' => bool
521     * @param string $parent comment id of parent
522     * @return bool
523     */
524    protected function add($comment, $parent)
525    {
526        global $ID, $TEXT, $INPUT;
527
528        $originalTxt = $TEXT; // set $TEXT to comment text for wordblock check
529        $TEXT = $comment['raw'];
530
531        // spamcheck against the DokuWiki blacklist
532        if (checkwordblock()) {
533            msg($this->getLang('wordblock'), -1);
534            return false;
535        }
536
537        if (!$this->getConf('allowguests')
538            && $comment['user']['id'] != $INPUT->server->str('REMOTE_USER')
539        ) {
540            return false; // guest comments not allowed
541        }
542
543        $TEXT = $originalTxt; // restore global $TEXT
544
545        // get discussion meta file name
546        $file = metaFN($ID, '.comments');
547
548        // create comments file if it doesn't exist yet
549        if (!@file_exists($file)) {
550            $data = ['status' => 1, 'number' => 0];
551            io_saveFile($file, serialize($data));
552        } else {
553            $data = unserialize(io_readFile($file, false));
554            // comments off or closed
555            if ($data['status'] != 1) {
556                return false;
557            }
558        }
559
560        if ($comment['date']['created']) {
561            $date = strtotime($comment['date']['created']);
562        } else {
563            $date = time();
564        }
565
566        if ($date == -1) {
567            $date = time();
568        }
569
570        $cid = md5($comment['user']['id'] . $date); // create a unique id
571
572        if (!is_array($data['comments'][$parent])) {
573            $parent = null; // invalid parent comment
574        }
575
576        // render the comment
577        $xhtml = $this->renderComment($comment['raw']);
578
579        // fill in the new comment
580        $data['comments'][$cid] = [
581            'user' => $comment['user'],
582            'date' => ['created' => $date],
583            'raw' => $comment['raw'],
584            'xhtml' => $xhtml,
585            'parent' => $parent,
586            'replies' => [],
587            'show' => $comment['show']
588        ];
589
590        if ($comment['subscribe']) {
591            $mail = $comment['user']['mail'];
592            if ($data['subscribers']) {
593                if (!$data['subscribers'][$mail]) {
594                    $data['subscribers'][$mail]['hash'] = md5($mail . mt_rand());
595                    $data['subscribers'][$mail]['active'] = false;
596                    $data['subscribers'][$mail]['confirmsent'] = false;
597                } else {
598                    // convert old style subscribers and set them active
599                    if (!is_array($data['subscribers'][$mail])) {
600                        $hash = $data['subscribers'][$mail];
601                        $data['subscribers'][$mail]['hash'] = $hash;
602                        $data['subscribers'][$mail]['active'] = true;
603                        $data['subscribers'][$mail]['confirmsent'] = true;
604                    }
605                }
606            } else {
607                $data['subscribers'][$mail]['hash'] = md5($mail . mt_rand());
608                $data['subscribers'][$mail]['active'] = false;
609                $data['subscribers'][$mail]['confirmsent'] = false;
610            }
611        }
612
613        // update parent comment
614        if ($parent) {
615            $data['comments'][$parent]['replies'][] = $cid;
616        }
617
618        // update the number of comments
619        $data['number']++;
620
621        // notify subscribers of the page
622        $data['comments'][$cid]['cid'] = $cid;
623        $this->notify($data['comments'][$cid], $data['subscribers']);
624
625        // save the comment metadata file
626        io_saveFile($file, serialize($data));
627        $this->addLogEntry($date, $ID, 'cc', '', $cid);
628
629        $this->redirect($cid);
630        return true;
631    }
632
633    /**
634     * Saves the comment with the given ID and then displays all comments
635     *
636     * @param array|string $cids array with comment ids to save, or a single string comment id
637     * @param string $raw if empty comment is deleted, otherwise edited text is stored (note: storing is per one cid!)
638     * @param string|null $act 'toogle', 'show', 'hide', null. If null, it depends on $raw
639     * @return bool succeed?
640     */
641    public function save($cids, $raw, $act = null)
642    {
643        global $ID, $INPUT;
644
645        if (empty($cids)) return false; // do nothing if we get no comment id
646
647        if ($raw) {
648            global $TEXT;
649
650            $otxt = $TEXT; // set $TEXT to comment text for wordblock check
651            $TEXT = $raw;
652
653            // spamcheck against the DokuWiki blacklist
654            if (checkwordblock()) {
655                msg($this->getLang('wordblock'), -1);
656                return false;
657            }
658
659            $TEXT = $otxt; // restore global $TEXT
660        }
661
662        // get discussion meta file name
663        $file = metaFN($ID, '.comments');
664        $data = unserialize(io_readFile($file, false));
665
666        if (!is_array($cids)) {
667            $cids = [$cids];
668        }
669        foreach ($cids as $cid) {
670
671            if (is_array($data['comments'][$cid]['user'])) {
672                $user = $data['comments'][$cid]['user']['id'];
673                $convert = false;
674            } else {
675                $user = $data['comments'][$cid]['user'];
676                $convert = true;
677            }
678
679            // someone else was trying to edit our comment -> abort
680            if ($user != $INPUT->server->str('REMOTE_USER') && !$this->helper->isDiscussionMod()) {
681                return false;
682            }
683
684            $date = time();
685
686            // need to convert to new format?
687            if ($convert) {
688                $data['comments'][$cid]['user'] = [
689                    'id' => $user,
690                    'name' => $data['comments'][$cid]['name'],
691                    'mail' => $data['comments'][$cid]['mail'],
692                    'url' => $data['comments'][$cid]['url'],
693                    'address' => $data['comments'][$cid]['address'],
694                ];
695                $data['comments'][$cid]['date'] = [
696                    'created' => $data['comments'][$cid]['date']
697                ];
698            }
699
700            if ($act == 'toogle') {     // toogle visibility
701                $now = $data['comments'][$cid]['show'];
702                $data['comments'][$cid]['show'] = !$now;
703                $data['number'] = $this->countVisibleComments($data);
704
705                $type = ($data['comments'][$cid]['show'] ? 'sc' : 'hc');
706
707            } elseif ($act == 'show') { // show comment
708                $data['comments'][$cid]['show'] = true;
709                $data['number'] = $this->countVisibleComments($data);
710
711                $type = 'sc'; // show comment
712
713            } elseif ($act == 'hide') { // hide comment
714                $data['comments'][$cid]['show'] = false;
715                $data['number'] = $this->countVisibleComments($data);
716
717                $type = 'hc'; // hide comment
718
719            } elseif (!$raw) {          // remove the comment
720                $data['comments'] = $this->removeComment($cid, $data['comments']);
721                $data['number'] = $this->countVisibleComments($data);
722
723                $type = 'dc'; // delete comment
724
725            } else {                   // save changed comment
726                $xhtml = $this->renderComment($raw);
727
728                // now change the comment's content
729                $data['comments'][$cid]['date']['modified'] = $date;
730                $data['comments'][$cid]['raw'] = $raw;
731                $data['comments'][$cid]['xhtml'] = $xhtml;
732
733                $type = 'ec'; // edit comment
734            }
735        }
736
737        // save the comment metadata file
738        io_saveFile($file, serialize($data));
739        $this->addLogEntry($date, $ID, $type, '', $cid);
740
741        $this->redirect($cid);
742        return true;
743    }
744
745    /**
746     * Recursive function to remove a comment from the data array
747     *
748     * @param string $cid comment id to be removed
749     * @param array $comments array with all comments
750     * @return array returns modified array with all remaining comments
751     */
752    protected function removeComment($cid, $comments)
753    {
754        if (is_array($comments[$cid]['replies'])) {
755            foreach ($comments[$cid]['replies'] as $rid) {
756                $comments = $this->removeComment($rid, $comments);
757            }
758        }
759        unset($comments[$cid]);
760        return $comments;
761    }
762
763    /**
764     * Prints an individual comment
765     *
766     * @param string $cid comment id
767     * @param array $data array with all comments by reference
768     * @param string $parent comment id of parent
769     * @param string $reply comment id on which the user requested a reply
770     * @param bool $isVisible is marked as visible
771     */
772    protected function showCommentWithReplies($cid, &$data, $parent = '', $reply = '', $isVisible = true)
773    {
774        // comment was removed
775        if (!isset($data['comments'][$cid])) {
776            return;
777        }
778        $comment = $data['comments'][$cid];
779
780        // corrupt datatype
781        if (!is_array($comment)) {
782            return;
783        }
784
785        // handle only replies to given parent comment
786        if ($comment['parent'] != $parent) {
787            return;
788        }
789
790        // comment hidden
791        if (!$comment['show']) {
792            if ($this->helper->isDiscussionMod()) {
793                $hidden = ' comment_hidden';
794            } else {
795                return;
796            }
797        } else {
798            $hidden = '';
799        }
800
801        // print the actual comment
802        $this->showComment($cid, $data, $parent, $reply, $isVisible, $hidden);
803        // replies to this comment entry?
804        $this->showReplies($cid, $data, $reply, $isVisible);
805        // reply form
806        $this->showReplyForm($cid, $reply);
807    }
808
809    /**
810     * Print the comment
811     *
812     * @param string $cid comment id
813     * @param array $data array with all comments by reference
814     * @param string $parent comment id of parent
815     * @param string $reply comment id on which the user requested a reply
816     * @param bool $isVisible is marked as visible
817     * @param string $hidden extra class, for the admin only hidden view
818     */
819    protected function showComment($cid, &$data, $parent, $reply, $isVisible, $hidden)
820    {
821        global $conf, $lang, $HIGH, $INPUT;
822        $comment = $data['comments'][$cid];
823
824        // comment head with date and user data
825        ptln('<div class="hentry' . $hidden . '">', 4);
826        ptln('<div class="comment_head">', 6);
827        ptln('<a name="comment_' . $cid . '" id="comment_' . $cid . '"></a>', 8);
828        $head = '<span class="vcard author">';
829
830        // prepare variables
831        if (is_array($comment['user'])) { // new format
832            $user = $comment['user']['id'];
833            $name = $comment['user']['name'];
834            $mail = $comment['user']['mail'];
835            $url = $comment['user']['url'];
836            $address = $comment['user']['address'];
837        } else {                         // old format
838            $user = $comment['user'];
839            $name = $comment['name'];
840            $mail = $comment['mail'];
841            $url = $comment['url'];
842            $address = $comment['address'];
843        }
844        if (is_array($comment['date'])) { // new format
845            $created = $comment['date']['created'];
846            $modified = $comment['date']['modified'] ?? null;
847        } else {                         // old format
848            $created = $comment['date'];
849            $modified = $comment['edited'];
850        }
851
852        // show username or real name?
853        if (!$this->getConf('userealname') && $user) {
854            $showname = $user;
855        } else {
856            $showname = $name;
857        }
858
859        // show avatar image?
860        if ($this->useAvatar()) {
861            $user_data['name'] = $name;
862            $user_data['user'] = $user;
863            $user_data['mail'] = $mail;
864            $avatar = $this->avatar->getXHTML($user_data, $name, 'left');
865            if ($avatar) {
866                $head .= $avatar;
867            }
868        }
869
870        if ($this->getConf('linkemail') && $mail) {
871            $head .= $this->email($mail, $showname, 'email fn');
872        } elseif ($url) {
873            $head .= $this->external_link($this->checkURL($url), $showname, 'urlextern url fn');
874        } else {
875            $head .= '<span class="fn">' . $showname . '</span>';
876        }
877
878        if ($address) {
879            $head .= ', <span class="adr">' . $address . '</span>';
880        }
881        $head .= '</span>, ' .
882            '<abbr class="published" title="' . strftime('%Y-%m-%dT%H:%M:%SZ', $created) . '">' .
883            dformat($created, $conf['dformat']) . '</abbr>';
884        if ($modified) {
885            $head .= ', <abbr class="updated" title="' .
886                strftime('%Y-%m-%dT%H:%M:%SZ', $modified) . '">' . dformat($modified, $conf['dformat']) .
887                '</abbr>';
888        }
889        ptln($head, 8);
890        ptln('</div>', 6); // class="comment_head"
891
892        // main comment content
893        ptln('<div class="comment_body entry-content"' .
894            ($this->useAvatar() ? $this->getWidthStyle() : '') . '>', 6);
895        echo ($HIGH ? html_hilight($comment['xhtml'], $HIGH) : $comment['xhtml']) . DOKU_LF;
896        ptln('</div>', 6); // class="comment_body"
897
898        if ($isVisible) {
899            ptln('<div class="comment_buttons">', 6);
900
901            // show reply button?
902            if ($data['status'] == 1 && !$reply && $comment['show']
903                && ($this->getConf('allowguests') || $INPUT->server->has('REMOTE_USER'))
904                && $this->getConf('usethreading')
905            ) {
906                $this->showButton($cid, $this->getLang('btn_reply'), 'reply', true);
907            }
908
909            // show edit, show/hide and delete button?
910            if (($user == $INPUT->server->str('REMOTE_USER') && $user != '') || $this->helper->isDiscussionMod()) {
911                $this->showButton($cid, $lang['btn_secedit'], 'edit', true);
912                $label = ($comment['show'] ? $this->getLang('btn_hide') : $this->getLang('btn_show'));
913                $this->showButton($cid, $label, 'toogle');
914                $this->showButton($cid, $lang['btn_delete'], 'delete');
915            }
916            ptln('</div>', 6); // class="comment_buttons"
917        }
918        ptln('</div>', 4); // class="hentry"
919    }
920
921    /**
922     * If requested by user, show comment form to write a reply
923     *
924     * @param string $cid current comment id
925     * @param string $reply comment id on which the user requested a reply
926     */
927    protected function showReplyForm($cid, $reply)
928    {
929        if ($this->getConf('usethreading') && $reply == $cid) {
930            ptln('<div class="comment_replies">', 4);
931            $this->showCommentForm('', 'add', $cid);
932            ptln('</div>', 4); // class="comment_replies"
933        }
934    }
935
936    /**
937     *
938     *
939     * @param string $cid comment id
940     * @param array $data array with all comments by reference
941     * @param string $reply
942     * @param bool $isVisible
943     */
944    protected function showReplies($cid, &$data, $reply, &$isVisible)
945    {
946        $comment = $data['comments'][$cid];
947        if (!count($comment['replies'])) {
948            return;
949        }
950        ptln('<div class="comment_replies"' . $this->getWidthStyle() . '>', 4);
951        $isVisible = ($comment['show'] && $isVisible);
952        foreach ($comment['replies'] as $rid) {
953            $this->showCommentWithReplies($rid, $data, $cid, $reply, $isVisible);
954        }
955        ptln('</div>', 4);
956    }
957
958    /**
959     * Is an avatar displayed?
960     *
961     * @return bool
962     */
963    protected function useAvatar()
964    {
965        if (is_null($this->useAvatar)) {
966            $this->useAvatar = $this->getConf('useavatar')
967                && ($this->avatar = $this->loadHelper('avatar', false));
968        }
969        return $this->useAvatar;
970    }
971
972    /**
973     * Calculate width of indent
974     *
975     * @return string
976     */
977    protected function getWidthStyle()
978    {
979        if (is_null($this->style)) {
980            if ($this->useAvatar()) {
981                $this->style = ' style="margin-left: ' . ($this->avatar->getConf('size') + 14) . 'px;"';
982            } else {
983                $this->style = ' style="margin-left: 20px;"';
984            }
985        }
986        return $this->style;
987    }
988
989    /**
990     * Show the button which toggles between show/hide of the entire discussion section
991     */
992    protected function showDiscussionToggleButton()
993    {
994        ptln('<div id="toggle_button" class="toggle_button" style="text-align: right;">');
995        ptln('<input type="submit" id="discussion__btn_toggle_visibility" title="Toggle Visibiliy" class="button"'
996            . 'value="' . $this->getLang('toggle_display') . '">');
997        ptln('</div>');
998    }
999
1000    /**
1001     * Outputs the comment form
1002     */
1003    protected function showCommentForm($raw = '', $act = 'add', $cid = null)
1004    {
1005        global $lang, $conf, $ID, $INPUT;
1006
1007        // not for unregistered users when guest comments aren't allowed
1008        if (!$INPUT->server->has('REMOTE_USER') && !$this->getConf('allowguests')) {
1009            ?>
1010            <div class="comment_form">
1011                <?php echo $this->getLang('noguests'); ?>
1012            </div>
1013            <?php
1014            return;
1015        }
1016
1017        // fill $raw with $INPUT->str('text') if it's empty (for failed CAPTCHA check)
1018        if (!$raw && $INPUT->str('comment') == 'show') {
1019            $raw = $INPUT->str('text');
1020        }
1021        ?>
1022
1023        <div class="comment_form">
1024            <form id="discussion__comment_form" method="post" action="<?php echo script() ?>"
1025                  accept-charset="<?php echo $lang['encoding'] ?>">
1026                <div class="no">
1027                    <input type="hidden" name="id" value="<?php echo $ID ?>"/>
1028                    <input type="hidden" name="do" value="show"/>
1029                    <input type="hidden" name="comment" value="<?php echo $act ?>"/>
1030                    <?php
1031                    // for adding a comment
1032                    if ($act == 'add') {
1033                        ?>
1034                        <input type="hidden" name="reply" value="<?php echo $cid ?>"/>
1035                        <?php
1036                        // for guest/adminimport: show name, e-mail and subscribe to comments fields
1037                        if (!$INPUT->server->has('REMOTE_USER') or ($this->getConf('adminimport') && $this->helper->isDiscussionMod())) {
1038                            ?>
1039                            <input type="hidden" name="user" value="<?php echo clientIP() ?>"/>
1040                            <div class="comment_name">
1041                                <label class="block" for="discussion__comment_name">
1042                                    <span><?php echo $lang['fullname'] ?>:</span>
1043                                    <input type="text"
1044                                           class="edit<?php if ($INPUT->str('comment') == 'add' && empty($INPUT->str('name'))) echo ' error' ?>"
1045                                           name="name" id="discussion__comment_name" size="50" tabindex="1"
1046                                           value="<?php echo hsc($INPUT->str('name')) ?>"/>
1047                                </label>
1048                            </div>
1049                            <div class="comment_mail">
1050                                <label class="block" for="discussion__comment_mail">
1051                                    <span><?php echo $lang['email'] ?>:</span>
1052                                    <input type="text"
1053                                           class="edit<?php if ($INPUT->str('comment') == 'add' && empty($INPUT->str('mail'))) echo ' error' ?>"
1054                                           name="mail" id="discussion__comment_mail" size="50" tabindex="2"
1055                                           value="<?php echo hsc($INPUT->str('mail')) ?>"/>
1056                                </label>
1057                            </div>
1058                            <?php
1059                        }
1060
1061                        // allow entering an URL
1062                        if ($this->getConf('urlfield')) {
1063                            ?>
1064                            <div class="comment_url">
1065                                <label class="block" for="discussion__comment_url">
1066                                    <span><?php echo $this->getLang('url') ?>:</span>
1067                                    <input type="text" class="edit" name="url" id="discussion__comment_url" size="50"
1068                                           tabindex="3" value="<?php echo hsc($INPUT->str('url')) ?>"/>
1069                                </label>
1070                            </div>
1071                            <?php
1072                        }
1073
1074                        // allow entering an address
1075                        if ($this->getConf('addressfield')) {
1076                            ?>
1077                            <div class="comment_address">
1078                                <label class="block" for="discussion__comment_address">
1079                                    <span><?php echo $this->getLang('address') ?>:</span>
1080                                    <input type="text" class="edit" name="address" id="discussion__comment_address"
1081                                           size="50" tabindex="4" value="<?php echo hsc($INPUT->str('address')) ?>"/>
1082                                </label>
1083                            </div>
1084                            <?php
1085                        }
1086
1087                        // allow setting the comment date
1088                        if ($this->getConf('adminimport') && ($this->helper->isDiscussionMod())) {
1089                            ?>
1090                            <div class="comment_date">
1091                                <label class="block" for="discussion__comment_date">
1092                                    <span><?php echo $this->getLang('date') ?>:</span>
1093                                    <input type="text" class="edit" name="date" id="discussion__comment_date"
1094                                           size="50"/>
1095                                </label>
1096                            </div>
1097                            <?php
1098                        }
1099
1100                        // for saving a comment
1101                    } else {
1102                        ?>
1103                        <input type="hidden" name="cid" value="<?php echo $cid ?>"/>
1104                        <?php
1105                    }
1106                    ?>
1107                    <div class="comment_text">
1108                        <?php echo $this->getLang('entercomment');
1109                        echo($this->getConf('wikisyntaxok') ? "" : ":");
1110                        if ($this->getConf('wikisyntaxok')) echo '. ' . $this->getLang('wikisyntax') . ':'; ?>
1111
1112                        <!-- Fix for disable the toolbar when wikisyntaxok is set to false. See discussion's script.jss -->
1113                        <?php if ($this->getConf('wikisyntaxok')) { ?>
1114                        <div id="discussion__comment_toolbar" class="toolbar group">
1115                            <?php } else { ?>
1116                            <div id="discussion__comment_toolbar_disabled">
1117                                <?php } ?>
1118                            </div>
1119                            <textarea
1120                                class="edit<?php if ($INPUT->str('comment') == 'add' && empty($INPUT->str('text'))) echo ' error' ?>"
1121                                name="text" cols="80" rows="10" id="discussion__comment_text" tabindex="5"><?php
1122                                if ($raw) {
1123                                    echo formText($raw);
1124                                } else {
1125                                    echo hsc($INPUT->str('text'));
1126                                }
1127                                ?></textarea>
1128                        </div>
1129
1130                        <?php
1131                        /** @var helper_plugin_captcha $captcha */
1132                        $captcha = $this->loadHelper('captcha', false);
1133                        if ($captcha && $captcha->isEnabled()) {
1134                            echo $captcha->getHTML();
1135                        }
1136
1137                        /** @var helper_plugin_recaptcha $recaptcha */
1138                        $recaptcha = $this->loadHelper('recaptcha', false);
1139                        if ($recaptcha && $recaptcha->isEnabled()) {
1140                            echo $recaptcha->getHTML();
1141                        }
1142                        ?>
1143
1144                        <input class="button comment_submit" id="discussion__btn_submit" type="submit" name="submit"
1145                               accesskey="s" value="<?php echo $lang['btn_save'] ?>"
1146                               title="<?php echo $lang['btn_save'] ?> [S]" tabindex="7"/>
1147                        <input class="button comment_preview_button" id="discussion__btn_preview" type="button"
1148                               name="preview" accesskey="p" value="<?php echo $lang['btn_preview'] ?>"
1149                               title="<?php echo $lang['btn_preview'] ?> [P]"/>
1150
1151                        <?php if ((!$INPUT->server->has('REMOTE_USER')
1152                                || $INPUT->server->has('REMOTE_USER') && !$conf['subscribers'])
1153                                && $this->getConf('subscribe')) { ?>
1154                            <div class="comment_subscribe">
1155                                <input type="checkbox" id="discussion__comment_subscribe" name="subscribe"
1156                                       tabindex="6"/>
1157                                <label class="block" for="discussion__comment_subscribe">
1158                                    <span><?php echo $this->getLang('subscribe') ?></span>
1159                                </label>
1160                            </div>
1161                        <?php } ?>
1162
1163                        <div class="clearer"></div>
1164                        <div id="discussion__comment_preview">&nbsp;</div>
1165                    </div>
1166            </form>
1167        </div>
1168        <?php
1169    }
1170
1171    /**
1172     * Action button below a comment
1173     *
1174     * @param string $cid comment id
1175     * @param string $label translated label
1176     * @param string $act action
1177     * @param bool $jump whether to scroll to the commentform
1178     */
1179    protected function showButton($cid, $label, $act, $jump = false)
1180    {
1181        global $ID;
1182
1183        $anchor = ($jump ? '#discussion__comment_form' : '');
1184
1185        ?>
1186        <form class="button discussion__<?php echo $act ?>" method="get" action="<?php echo script() . $anchor ?>">
1187            <div class="no">
1188                <input type="hidden" name="id" value="<?php echo $ID ?>"/>
1189                <input type="hidden" name="do" value="show"/>
1190                <input type="hidden" name="comment" value="<?php echo $act ?>"/>
1191                <input type="hidden" name="cid" value="<?php echo $cid ?>"/>
1192                <input type="submit" value="<?php echo $label ?>" class="button" title="<?php echo $label ?>"/>
1193            </div>
1194        </form>
1195        <?php
1196    }
1197
1198    /**
1199     * Adds an entry to the comments changelog
1200     *
1201     * @param int $date
1202     * @param string $id page id
1203     * @param string $type create/edit/delete/show/hide comment 'cc', 'ec', 'dc', 'sc', 'hc'
1204     * @param string $summary
1205     * @param string $extra
1206     * @author Ben Coburn <btcoburn@silicodon.net>
1207     *
1208     * @author Esther Brunner <wikidesign@gmail.com>
1209     */
1210    protected function addLogEntry($date, $id, $type = 'cc', $summary = '', $extra = '')
1211    {
1212        global $conf, $INPUT;
1213
1214        $changelog = $conf['metadir'] . '/_comments.changes';
1215
1216        //use current time if none supplied
1217        if (!$date) {
1218            $date = time();
1219        }
1220        $remote = $INPUT->server->str('REMOTE_ADDR');
1221        $user = $INPUT->server->str('REMOTE_USER');
1222
1223        $strip = ["\t", "\n"];
1224        $logline = [
1225            'date' => $date,
1226            'ip' => $remote,
1227            'type' => str_replace($strip, '', $type),
1228            'id' => $id,
1229            'user' => $user,
1230            'sum' => str_replace($strip, '', $summary),
1231            'extra' => str_replace($strip, '', $extra)
1232        ];
1233
1234        // add changelog line
1235        $logline = implode("\t", $logline) . "\n";
1236        io_saveFile($changelog, $logline, true); //global changelog cache
1237        $this->trimRecentCommentsLog($changelog);
1238
1239        // tell the indexer to re-index the page
1240        @unlink(metaFN($id, '.indexed'));
1241    }
1242
1243    /**
1244     * Trims the recent comments cache to the last $conf['changes_days'] recent
1245     * changes or $conf['recent'] items, which ever is larger.
1246     * The trimming is only done once a day.
1247     *
1248     * @param string $changelog file path
1249     * @return bool
1250     * @author Ben Coburn <btcoburn@silicodon.net>
1251     *
1252     */
1253    protected function trimRecentCommentsLog($changelog)
1254    {
1255        global $conf;
1256
1257        if (@file_exists($changelog)
1258            && (filectime($changelog) + 86400) < time()
1259            && !@file_exists($changelog . '_tmp')
1260        ) {
1261
1262            io_lock($changelog);
1263            $lines = file($changelog);
1264            if (count($lines) < $conf['recent']) {
1265                // nothing to trim
1266                io_unlock($changelog);
1267                return true;
1268            }
1269
1270            // presave tmp as 2nd lock
1271            io_saveFile($changelog . '_tmp', '');
1272            $trim_time = time() - $conf['recent_days'] * 86400;
1273            $out_lines = [];
1274
1275            $num = count($lines);
1276            for ($i = 0; $i < $num; $i++) {
1277                $log = parseChangelogLine($lines[$i]);
1278                if ($log === false) continue;                      // discard junk
1279                if ($log['date'] < $trim_time) {
1280                    $old_lines[$log['date'] . ".$i"] = $lines[$i]; // keep old lines for now (append .$i to prevent key collisions)
1281                } else {
1282                    $out_lines[$log['date'] . ".$i"] = $lines[$i]; // definitely keep these lines
1283                }
1284            }
1285
1286            // sort the final result, it shouldn't be necessary,
1287            // however the extra robustness in making the changelog cache self-correcting is worth it
1288            ksort($out_lines);
1289            $extra = $conf['recent'] - count($out_lines);        // do we need extra lines do bring us up to minimum
1290            if ($extra > 0) {
1291                ksort($old_lines);
1292                $out_lines = array_merge(array_slice($old_lines, -$extra), $out_lines);
1293            }
1294
1295            // save trimmed changelog
1296            io_saveFile($changelog . '_tmp', implode('', $out_lines));
1297            @unlink($changelog);
1298            if (!rename($changelog . '_tmp', $changelog)) {
1299                // rename failed so try another way...
1300                io_unlock($changelog);
1301                io_saveFile($changelog, implode('', $out_lines));
1302                @unlink($changelog . '_tmp');
1303            } else {
1304                io_unlock($changelog);
1305            }
1306            return true;
1307        }
1308        return true;
1309    }
1310
1311    /**
1312     * Sends a notify mail on new comment
1313     *
1314     * @param array $comment data array of the new comment
1315     * @param array $subscribers data of the subscribers by reference
1316     *
1317     * @author Andreas Gohr <andi@splitbrain.org>
1318     * @author Esther Brunner <wikidesign@gmail.com>
1319     */
1320    protected function notify($comment, &$subscribers)
1321    {
1322        global $conf, $ID, $INPUT, $auth;
1323
1324        $notify_text = io_readfile($this->localfn('subscribermail'));
1325        $confirm_text = io_readfile($this->localfn('confirmsubscribe'));
1326        $subject_notify = '[' . $conf['title'] . '] ' . $this->getLang('mail_newcomment');
1327        $subject_subscribe = '[' . $conf['title'] . '] ' . $this->getLang('subscribe');
1328
1329        $mailer = new Mailer();
1330        if (!$INPUT->server->has('REMOTE_USER')) {
1331            $mailer->from($conf['mailfromnobody']);
1332        }
1333
1334        $replace = [
1335            'PAGE' => $ID,
1336            'TITLE' => $conf['title'],
1337            'DATE' => dformat($comment['date']['created'], $conf['dformat']),
1338            'NAME' => $comment['user']['name'],
1339            'TEXT' => $comment['raw'],
1340            'COMMENTURL' => wl($ID, '', true) . '#comment_' . $comment['cid'],
1341            'UNSUBSCRIBE' => wl($ID, 'do=subscribe', true, '&'),
1342            'DOKUWIKIURL' => DOKU_URL
1343        ];
1344
1345        $confirm_replace = [
1346            'PAGE' => $ID,
1347            'TITLE' => $conf['title'],
1348            'DOKUWIKIURL' => DOKU_URL
1349        ];
1350
1351
1352        $mailer->subject($subject_notify);
1353        $mailer->setBody($notify_text, $replace);
1354
1355        // send mail to notify address
1356        if ($conf['notify']) {
1357            $mailer->bcc($conf['notify']);
1358            $mailer->send();
1359        }
1360
1361        // send email to moderators
1362        if ($this->getConf('moderatorsnotify')) {
1363            $moderatorgrpsString = trim($this->getConf('moderatorgroups'));
1364            if (!empty($moderatorgrpsString)) {
1365                // create a clean mods list
1366                $moderatorgroups = explode(',', $moderatorgrpsString);
1367                $moderatorgroups = array_map('trim', $moderatorgroups);
1368                $moderatorgroups = array_unique($moderatorgroups);
1369                $moderatorgroups = array_filter($moderatorgroups);
1370                // search for moderators users
1371                foreach ($moderatorgroups as $moderatorgroup) {
1372                    if (!$auth->isCaseSensitive()) {
1373                        $moderatorgroup = PhpString::strtolower($moderatorgroup);
1374                    }
1375                    // create a clean mailing list
1376                    $bccs = [];
1377                    if ($moderatorgroup[0] == '@') {
1378                        foreach ($auth->retrieveUsers(0, 0, ['grps' => $auth->cleanGroup(substr($moderatorgroup, 1))]) as $user) {
1379                            if (!empty($user['mail'])) {
1380                                $bccs[] = $user['mail'];
1381                            }
1382                        }
1383                    } else {
1384                        //it is an user
1385                        $userdata = $auth->getUserData($auth->cleanUser($moderatorgroup));
1386                        if (!empty($userdata['mail'])) {
1387                            $bccs[] = $userdata['mail'];
1388                        }
1389                    }
1390                    $bccs = array_unique($bccs);
1391                    // notify the users
1392                    $mailer->bcc(implode(',', $bccs));
1393                    $mailer->send();
1394                }
1395            }
1396        }
1397
1398        // notify page subscribers
1399        if (actionOK('subscribe')) {
1400            $data = ['id' => $ID, 'addresslist' => '', 'self' => false];
1401            //FIXME default callback, needed to mentioned it again?
1402            Event::createAndTrigger(
1403                'COMMON_NOTIFY_ADDRESSLIST', $data,
1404                [new SubscriberManager(), 'notifyAddresses']
1405            );
1406
1407            $to = $data['addresslist'];
1408            if (!empty($to)) {
1409                $mailer->bcc($to);
1410                $mailer->send();
1411            }
1412        }
1413
1414        // notify comment subscribers
1415        if (!empty($subscribers)) {
1416
1417            foreach ($subscribers as $mail => $data) {
1418                $mailer->bcc($mail);
1419                if ($data['active']) {
1420                    $replace['UNSUBSCRIBE'] = wl($ID, 'do=discussion_unsubscribe&hash=' . $data['hash'], true, '&');
1421
1422                    $mailer->subject($subject_notify);
1423                    $mailer->setBody($notify_text, $replace);
1424                    $mailer->send();
1425                } elseif (!$data['confirmsent']) {
1426                    $confirm_replace['SUBSCRIBE'] = wl($ID, 'do=discussion_confirmsubscribe&hash=' . $data['hash'], true, '&');
1427
1428                    $mailer->subject($subject_subscribe);
1429                    $mailer->setBody($confirm_text, $confirm_replace);
1430                    $mailer->send();
1431                    $subscribers[$mail]['confirmsent'] = true;
1432                }
1433            }
1434        }
1435    }
1436
1437    /**
1438     * Counts the number of visible comments
1439     *
1440     * @param array $data array with all comments
1441     * @return int
1442     */
1443    protected function countVisibleComments($data)
1444    {
1445        $number = 0;
1446        foreach ($data['comments'] as $comment) {
1447            if ($comment['parent']) continue;
1448            if (!$comment['show']) continue;
1449
1450            $number++;
1451            $rids = $comment['replies'];
1452            if (count($rids)) {
1453                $number = $number + $this->countVisibleReplies($data, $rids);
1454            }
1455        }
1456        return $number;
1457    }
1458
1459    /**
1460     * Count visible replies on the comments
1461     *
1462     * @param array $data
1463     * @param array $rids
1464     * @return int counted replies
1465     */
1466    protected function countVisibleReplies(&$data, $rids)
1467    {
1468        $number = 0;
1469        foreach ($rids as $rid) {
1470            if (!isset($data['comments'][$rid])) continue; // reply was removed
1471            if (!$data['comments'][$rid]['show']) continue;
1472
1473            $number++;
1474            $rids = $data['comments'][$rid]['replies'];
1475            if (count($rids)) {
1476                $number = $number + $this->countVisibleReplies($data, $rids);
1477            }
1478        }
1479        return $number;
1480    }
1481
1482    /**
1483     * Renders the raw comment (wiki)text to html
1484     *
1485     * @param string $raw comment text
1486     * @return null|string
1487     */
1488    protected function renderComment($raw)
1489    {
1490        if ($this->getConf('wikisyntaxok')) {
1491            // Note the warning for render_text:
1492            //   "very ineffecient for small pieces of data - try not to use"
1493            // in dokuwiki/inc/plugin.php
1494            $xhtml = $this->render_text($raw);
1495        } else { // wiki syntax not allowed -> just encode special chars
1496            $xhtml = hsc(trim($raw));
1497            $xhtml = str_replace("\n", '<br />', $xhtml);
1498        }
1499        return $xhtml;
1500    }
1501
1502    /**
1503     * Finds out whether there is a discussion section for the current page
1504     *
1505     * @param string $title
1506     * @return bool
1507     */
1508    protected function hasDiscussion(&$title)
1509    {
1510        global $ID;
1511
1512        $file = metaFN($ID, '.comments');
1513
1514        if (!@file_exists($file)) {
1515            if ($this->isDiscussionEnabled()) {
1516                return true;
1517            } else {
1518                return false;
1519            }
1520        }
1521
1522        $comments = unserialize(io_readFile($file, false));
1523
1524        if ($comments['title']) {
1525            $title = hsc($comments['title']);
1526        }
1527        $num = $comments['number'];
1528        if (!$comments['status'] || ($comments['status'] == 2 && $num == 0)) {
1529            //disabled, or closed and no comments
1530            return false;
1531        } else {
1532            return true;
1533        }
1534    }
1535
1536    /**
1537     * Creates a new thread page
1538     *
1539     * @return string
1540     */
1541    protected function newThread()
1542    {
1543        global $ID, $INFO, $INPUT;
1544
1545        $ns = cleanID($INPUT->str('ns'));
1546        $title = str_replace(':', '', $INPUT->str('title'));
1547        $back = $ID;
1548        $ID = ($ns ? $ns . ':' : '') . cleanID($title);
1549        $INFO = pageinfo();
1550
1551        // check if we are allowed to create this file
1552        if ($INFO['perm'] >= AUTH_CREATE) {
1553
1554            //check if locked by anyone - if not lock for my self
1555            if ($INFO['locked']) {
1556                return 'locked';
1557            } else {
1558                lock($ID);
1559            }
1560
1561            // prepare the new thread file with default stuff
1562            if (!@file_exists($INFO['filepath'])) {
1563                global $TEXT;
1564
1565                $TEXT = pageTemplate(($ns ? $ns . ':' : '') . $title);
1566                if (!$TEXT) {
1567                    $data = ['id' => $ID, 'ns' => $ns, 'title' => $title, 'back' => $back];
1568                    $TEXT = $this->pageTemplate($data);
1569                }
1570                return 'preview';
1571            } else {
1572                return 'edit';
1573            }
1574        } else {
1575            return 'show';
1576        }
1577    }
1578
1579    /**
1580     * Adapted version of pageTemplate() function
1581     *
1582     * @param array $data
1583     * @return string
1584     */
1585    protected function pageTemplate($data)
1586    {
1587        global $conf, $INFO, $INPUT;
1588
1589        $id = $data['id'];
1590        $user = $INPUT->server->str('REMOTE_USER');
1591        $tpl = io_readFile(DOKU_PLUGIN . 'discussion/_template.txt');
1592
1593        // standard replacements
1594        $replace = [
1595            '@NS@' => $data['ns'],
1596            '@PAGE@' => strtr(noNS($id), '_', ' '),
1597            '@USER@' => $user,
1598            '@NAME@' => $INFO['userinfo']['name'],
1599            '@MAIL@' => $INFO['userinfo']['mail'],
1600            '@DATE@' => dformat(time(), $conf['dformat']),
1601        ];
1602
1603        // additional replacements
1604        $replace['@BACK@'] = $data['back'];
1605        $replace['@TITLE@'] = $data['title'];
1606
1607        // avatar if useavatar and avatar plugin available
1608        if ($this->getConf('useavatar') && !plugin_isdisabled('avatar')) {
1609            $replace['@AVATAR@'] = '{{avatar>' . $user . ' }} ';
1610        } else {
1611            $replace['@AVATAR@'] = '';
1612        }
1613
1614        // tag if tag plugin is available
1615        if (!plugin_isdisabled('tag')) {
1616            $replace['@TAG@'] = "\n\n{{tag>}}";
1617        } else {
1618            $replace['@TAG@'] = '';
1619        }
1620
1621        // perform the replacements in tpl
1622        return str_replace(array_keys($replace), array_values($replace), $tpl);
1623    }
1624
1625    /**
1626     * Checks if the CAPTCHA string submitted is valid
1627     */
1628    protected function captchaCheck()
1629    {
1630        global $INPUT;
1631        /** @var helper_plugin_captcha $captcha */
1632        if (!$captcha = $this->loadHelper('captcha', false)) {
1633            // CAPTCHA is disabled or not available
1634            return;
1635        }
1636
1637        if ($captcha->isEnabled() && !$captcha->check()) {
1638            if ($INPUT->str('comment') == 'save') {
1639                $INPUT->set('comment', 'edit');
1640            } elseif ($INPUT->str('comment') == 'add') {
1641                $INPUT->set('comment', 'show');
1642            }
1643        }
1644    }
1645
1646    /**
1647     * checks if the submitted reCAPTCHA string is valid
1648     *
1649     * @author Adrian Schlegel <adrian@liip.ch>
1650     */
1651    protected function recaptchaCheck()
1652    {
1653        global $INPUT;
1654        /** @var helper_plugin_recaptcha $recaptcha */
1655        if (!$recaptcha = plugin_load('helper', 'recaptcha'))
1656            return; // reCAPTCHA is disabled or not available
1657
1658        // do nothing if logged in user and no reCAPTCHA required
1659        if (!$recaptcha->getConf('forusers') && $INPUT->server->has('REMOTE_USER')) return;
1660
1661        $response = $recaptcha->check();
1662        if (!$response->is_valid) {
1663            msg($recaptcha->getLang('testfailed'), -1);
1664            if ($INPUT->str('comment') == 'save') {
1665                $INPUT->str('comment', 'edit');
1666            } elseif ($INPUT->str('comment') == 'add') {
1667                $INPUT->str('comment', 'show');
1668            }
1669        }
1670    }
1671
1672    /**
1673     * Add discussion plugin version to the indexer version
1674     * This means that all pages will be indexed again in order to add the comments
1675     * to the index whenever there has been a change that concerns the index content.
1676     *
1677     * @param Doku_Event $event
1678     * @param $param
1679     */
1680    public function addIndexVersion(Doku_Event $event)
1681    {
1682        $event->data['discussion'] = '0.1';
1683    }
1684
1685    /**
1686     * Adds the comments to the index
1687     *
1688     * @param Doku_Event $event
1689     * @param array $param with
1690     *  'id' => string 'page'/'id' for respectively INDEXER_PAGE_ADD and FULLTEXT_SNIPPET_CREATE event
1691     *  'text' => string 'body'/'text'
1692     */
1693    public function addCommentsToIndex(Doku_Event $event, $param)
1694    {
1695        // get .comments meta file name
1696        $file = metaFN($event->data[$param['id']], '.comments');
1697
1698        if (!@file_exists($file)) return;
1699        $data = unserialize(io_readFile($file, false));
1700
1701        // comments are turned off or no comments available to index
1702        if (!$data['status'] || $data['number'] == 0) return;
1703
1704        // now add the comments
1705        if (isset($data['comments'])) {
1706            foreach ($data['comments'] as $key => $value) {
1707                $event->data[$param['text']] .= DOKU_LF . $this->addCommentWords($key, $data);
1708            }
1709        }
1710    }
1711
1712    /**
1713     * Checks if the phrase occurs in the comments and return event result true if matching
1714     *
1715     * @param Doku_Event $event
1716     * @param $param
1717     * @return void
1718     */
1719    public function fulltextPhraseMatchInComments(Doku_Event $event)
1720    {
1721        if ($event->result === true) return;
1722
1723        // get .comments meta file name
1724        $file = metaFN($event->data['id'], '.comments');
1725
1726        if (!@file_exists($file)) return;
1727        $data = unserialize(io_readFile($file, false));
1728
1729        // comments are turned off or no comments available to match
1730        if (!$data['status'] || $data['number'] == 0) return;
1731
1732        $matched = false;
1733
1734        // now add the comments
1735        if (isset($data['comments'])) {
1736            foreach ($data['comments'] as $cid => $value) {
1737                $matched = $this->phraseMatchInComment($event->data['phrase'], $cid, $data);
1738                if ($matched) break;
1739            }
1740        }
1741
1742        if ($matched) {
1743            $event->result = true;
1744        }
1745    }
1746
1747    /**
1748     * Match the phrase in the comment and its replies
1749     *
1750     * @param string $phrase phrase to search
1751     * @param string $cid comment id
1752     * @param array $data array with all comments by reference
1753     * @param string $parent cid of parent
1754     * @return bool if match true, otherwise false
1755     */
1756    protected function phraseMatchInComment($phrase, $cid, &$data, $parent = '')
1757    {
1758        if (!isset($data['comments'][$cid])) return false; // comment was removed
1759
1760        $comment = $data['comments'][$cid];
1761
1762        if (!is_array($comment)) return false;             // corrupt datatype
1763        if ($comment['parent'] != $parent) return false;   // reply to an other comment
1764        if (!$comment['show']) return false;               // hidden comment
1765
1766        $text = PhpString::strtolower($comment['raw']);
1767        if (strpos($text, $phrase) !== false) {
1768            return true;
1769        }
1770
1771        if (is_array($comment['replies'])) {               // and the replies
1772            foreach ($comment['replies'] as $rid) {
1773                if ($this->phraseMatchInComment($phrase, $rid, $data, $cid)) {
1774                    return true;
1775                }
1776            }
1777        }
1778        return false;
1779    }
1780
1781    /**
1782     * Saves the current comment status and title from metadata into the .comments file
1783     *
1784     * @param Doku_Event $event
1785     * @param $param
1786     */
1787    public function update_comment_status(Doku_Event $event)
1788    {
1789        global $ID;
1790
1791        $meta = $event->data['current'];
1792        $file = metaFN($ID, '.comments');
1793        $status = ($this->isDiscussionEnabled() ? 1 : 0);
1794        $title = null;
1795        if (isset($meta['plugin_discussion'])) {
1796            $status = $meta['plugin_discussion']['status']; // 0, 1 or 2
1797            $title = $meta['plugin_discussion']['title'];
1798        } elseif ($status == 1) {
1799            // Don't enable comments when automatic comments are on - this already happens automatically
1800            // and if comments are turned off in the admin this only updates the .comments file
1801            return;
1802        }
1803
1804        if ($status || @file_exists($file)) {
1805            $data = [];
1806            if (@file_exists($file)) {
1807                $data = unserialize(io_readFile($file, false));
1808            }
1809
1810            if (!array_key_exists('title', $data) || $data['title'] !== $title || !isset($data['status']) || $data['status'] !== $status) {
1811                $data['title'] = $title;
1812                $data['status'] = $status;
1813                if (!isset($data['number'])) {
1814                    $data['number'] = 0;
1815                }
1816                io_saveFile($file, serialize($data));
1817            }
1818        }
1819    }
1820
1821    /**
1822     * Return words of a given comment and its replies, suitable to be added to the index
1823     *
1824     * @param string $cid comment id
1825     * @param array $data array with all comments by reference
1826     * @param string $parent cid of parent
1827     * @return string
1828     */
1829    protected function addCommentWords($cid, &$data, $parent = '')
1830    {
1831
1832        if (!isset($data['comments'][$cid])) return ''; // comment was removed
1833
1834        $comment = $data['comments'][$cid];
1835
1836        if (!is_array($comment)) return '';             // corrupt datatype
1837        if ($comment['parent'] != $parent) return '';   // reply to an other comment
1838        if (!$comment['show']) return '';               // hidden comment
1839
1840        $text = $comment['raw'];                        // we only add the raw comment text
1841        if (is_array($comment['replies'])) {            // and the replies
1842            foreach ($comment['replies'] as $rid) {
1843                $text .= $this->addCommentWords($rid, $data, $cid);
1844            }
1845        }
1846        return ' ' . $text;
1847    }
1848
1849    /**
1850     * Only allow http(s) URLs and append http:// to URLs if needed
1851     *
1852     * @param string $url
1853     * @return string
1854     */
1855    protected function checkURL($url)
1856    {
1857        if (preg_match("#^http://|^https://#", $url)) {
1858            return hsc($url);
1859        } elseif (substr($url, 0, 4) == 'www.') {
1860            return hsc('https://' . $url);
1861        } else {
1862            return '';
1863        }
1864    }
1865
1866    /**
1867     * Sort threads
1868     *
1869     * @param array $a array with comment properties
1870     * @param array $b array with comment properties
1871     * @return int
1872     */
1873    function sortThreadsOnCreation($a, $b)
1874    {
1875        if (is_array($a['date'])) {
1876            // new format
1877            $createdA = $a['date']['created'];
1878        } else {
1879            // old format
1880            $createdA = $a['date'];
1881        }
1882
1883        if (is_array($b['date'])) {
1884            // new format
1885            $createdB = $b['date']['created'];
1886        } else {
1887            // old format
1888            $createdB = $b['date'];
1889        }
1890
1891        if ($createdA == $createdB) {
1892            return 0;
1893        } else {
1894            return ($createdA < $createdB) ? -1 : 1;
1895        }
1896    }
1897
1898}
1899
1900
1901