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