Completed
Push — master ( e42a4c...f187a0 )
by Daniel
03:17
created

code/model/Comment.php (6 issues)

Check that @param annotations have the correct type.

Documentation Informational

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
/**
4
 * Represents a single comment object.
5
 *
6
 * @property string  $Name
7
 * @property string  $Comment
8
 * @property string  $Email
9
 * @property string  $URL
10
 * @property string  $BaseClass
11
 * @property boolean $Moderated
12
 * @property boolean $IsSpam      True if the comment is known as spam
13
 * @property integer $ParentID    ID of the parent page / dataobject
14
 * @property boolean $AllowHtml   If true, treat $Comment as HTML instead of plain text
15
 * @property string  $SecretToken Secret admin token required to provide moderation links between sessions
16
 * @property integer $Depth       Depth of this comment in the nested chain
17
 *
18
 * @method HasManyList ChildComments() List of child comments
19
 * @method Member Author() Member object who created this comment
20
 * @method Comment ParentComment() Parent comment this is a reply to
21
 * @package comments
22
 */
23
class Comment extends DataObject
24
{
25
26
    /**
27
     * @var array
28
     */
29
    private static $db = array(
30
        'Name' => 'Varchar(200)',
31
        'Comment' => 'Text',
32
        'Email' => 'Varchar(200)',
33
        'URL' => 'Varchar(255)',
34
        'BaseClass' => 'Varchar(200)',
35
        'Moderated' => 'Boolean(0)',
36
        'IsSpam' => 'Boolean(0)',
37
        'ParentID' => 'Int',
38
        'AllowHtml' => 'Boolean',
39
        'SecretToken' => 'Varchar(255)',
40
        'Depth' => 'Int',
41
    );
42
43
    private static $has_one = array(
44
        "Author" => "Member",
45
        "ParentComment" => "Comment",
46
    );
47
48
    private static $has_many = array(
49
        "ChildComments"    => "Comment"
50
    );
51
52
    private static $default_sort = '"Created" DESC';
53
54
    private static $defaults = array(
55
        'Moderated' => 0,
56
        'IsSpam' => 0,
57
    );
58
59
    private static $casting = array(
60
        'Title' => 'Varchar',
61
        'ParentTitle' => 'Varchar',
62
        'ParentClassName' => 'Varchar',
63
        'AuthorName' => 'Varchar',
64
        'RSSName' => 'Varchar',
65
        'DeleteLink' => 'Varchar',
66
        'SpamLink' => 'Varchar',
67
        'HamLink' => 'Varchar',
68
        'ApproveLink' => 'Varchar',
69
        'Permalink' => 'Varchar',
70
    );
71
72
    private static $searchable_fields = array(
73
        'Name',
74
        'Email',
75
        'Comment',
76
        'Created',
77
        'BaseClass',
78
    );
79
80
    private static $summary_fields = array(
81
        'Name' => 'Submitted By',
82
        'Email' => 'Email',
83
        'Comment.LimitWordCount' => 'Comment',
84
        'Created' => 'Date Posted',
85
        'ParentTitle' => 'Post',
86
        'IsSpam' => 'Is Spam',
87
    );
88
89
    private static $field_labels = array(
90
        'Author' => 'Author Member',
91
    );
92
93
    public function onBeforeWrite()
94
    {
95
        parent::onBeforeWrite();
96
97
        // Sanitize HTML, because its expected to be passed to the template unescaped later
98
        if ($this->AllowHtml) {
99
            $this->Comment = $this->purifyHtml($this->Comment);
100
        }
101
102
        // Check comment depth
103
        $this->updateDepth();
104
    }
105
106
    public function onBeforeDelete()
107
    {
108
        parent::onBeforeDelete();
109
110
        // Delete all children
111
        foreach ($this->ChildComments() as $comment) {
112
            $comment->delete();
113
        }
114
    }
115
116
    /**
117
     * @return Comment_SecurityToken
118
     */
119
    public function getSecurityToken()
120
    {
121
        return Injector::inst()->createWithArgs('Comment_SecurityToken', array($this));
122
    }
123
124
    /**
125
     * Migrates the old {@link PageComment} objects to {@link Comment}
126
     */
127
    public function requireDefaultRecords()
128
    {
129
        parent::requireDefaultRecords();
130
131
        if (DB::getConn()->hasTable('PageComment')) {
132
            $comments = DB::query('SELECT * FROM "PageComment"');
133
134
            if ($comments) {
135
                while ($pageComment = $comments->nextRecord()) {
136
                    // create a new comment from the older page comment
137
                    $comment = new Comment();
138
                    $comment->update($pageComment);
139
140
                    // set the variables which have changed
141
                    $comment->BaseClass = 'SiteTree';
142
                    $comment->URL = (isset($pageComment['CommenterURL'])) ? $pageComment['CommenterURL'] : '';
143
                    if ((int) $pageComment['NeedsModeration'] == 0) {
144
                        $comment->Moderated = true;
145
                    }
146
147
                    $comment->write();
148
                }
149
            }
150
151
            DB::alteration_message('Migrated PageComment to Comment', 'changed');
152
            DB::getConn()->dontRequireTable('PageComment');
153
        }
154
    }
155
156
    /**
157
     * Return a link to this comment
158
     *
159
     * @param string $action
160
     *
161
     * @return string link to this comment.
162
     */
163
    public function Link($action = '')
164
    {
165
        if ($parent = $this->getParent()) {
166
            return $parent->Link($action) . '#' . $this->Permalink();
167
        }
168
    }
169
170
    /**
171
     * Returns the permalink for this {@link Comment}. Inserted into
172
     * the ID tag of the comment
173
     *
174
     * @return string
175
     */
176
    public function Permalink()
177
    {
178
        $prefix = $this->getOption('comment_permalink_prefix');
179
        return $prefix . $this->ID;
180
    }
181
182
    /**
183
     * Translate the form field labels for the CMS administration
184
     *
185
     * @param boolean $includerelations
186
     *
187
     * @return array
188
     */
189
    public function fieldLabels($includerelations = true)
190
    {
191
        $labels = parent::fieldLabels($includerelations);
192
193
        $labels['Name'] = _t('Comment.NAME', 'Author Name');
194
        $labels['Comment'] = _t('Comment.COMMENT', 'Comment');
195
        $labels['Email'] = _t('Comment.EMAIL', 'Email');
196
        $labels['URL'] = _t('Comment.URL', 'URL');
197
        $labels['IsSpam'] = _t('Comment.ISSPAM', 'Spam?');
198
        $labels['Moderated'] = _t('Comment.MODERATED', 'Moderated?');
199
        $labels['ParentTitle'] = _t('Comment.PARENTTITLE', 'Parent');
200
        $labels['Created'] = _t('Comment.CREATED', 'Date posted');
201
202
        return $labels;
203
    }
204
205
    /**
206
     * Get the commenting option
207
     *
208
     * @param string $key
209
     *
210
     * @return mixed Result if the setting is available, or null otherwise
211
     */
212
    public function getOption($key)
213
    {
214
        // If possible use the current record
215
        $record = $this->getParent();
216
217
        if (!$record && $this->BaseClass) {
218
            // Otherwise a singleton of that record
219
            $record = singleton($this->BaseClass);
220
        } elseif (!$record) {
221
            // Otherwise just use the default options
222
            $record = singleton('CommentsExtension');
223
        }
224
225
        return ($record->hasMethod('getCommentsOption')) ? $record->getCommentsOption($key) : null;
226
    }
227
228
    /**
229
     * Returns the parent {@link DataObject} this comment is attached too
230
     *
231
     * @return DataObject
232
     */
233
    public function getParent()
234
    {
235
        return $this->BaseClass && $this->ParentID
236
            ? DataObject::get_by_id($this->BaseClass, $this->ParentID, true)
237
            : null;
238
    }
239
240
241
    /**
242
     * Returns a string to help identify the parent of the comment
243
     *
244
     * @return string
245
     */
246
    public function getParentTitle()
247
    {
248
        if ($parent = $this->getParent()) {
249
            return $parent->Title ?: ($parent->ClassName . ' #' . $parent->ID);
250
        }
251
    }
252
253
    /**
254
     * Comment-parent classnames obviously vary, return the parent classname
255
     *
256
     * @return string
257
     */
258
    public function getParentClassName()
259
    {
260
        return $this->BaseClass;
261
    }
262
263
    public function castingHelper($field)
264
    {
265
        // Safely escape the comment
266
        if ($field === 'EscapedComment') {
267
            return $this->AllowHtml ? 'HTMLText' : 'Text';
268
        }
269
        return parent::castingHelper($field);
270
    }
271
272
    /**
273
     * Content to be safely escaped on the frontend
274
     *
275
     * @return string
276
     */
277
    public function getEscapedComment()
278
    {
279
        return $this->Comment;
280
    }
281
282
    /**
283
     * Return whether this comment is a preview (has not been written to the db)
284
     *
285
     * @return boolean
286
     */
287
    public function isPreview()
288
    {
289
        return !$this->exists();
290
    }
291
292
    /**
293
     * @todo needs to compare to the new {@link Commenting} configuration API
294
     *
295
     * @param Member $member
0 ignored issues
show
Should the type for parameter $member not be Member|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
296
     *
297
     * @return bool
298
     */
299
    public function canCreate($member = null)
300
    {
301
        return false;
302
    }
303
304
    /**
305
     * Checks for association with a page, and {@link SiteTree->ProvidePermission}
306
     * flag being set to true.
307
     *
308
     * @param Member $member
0 ignored issues
show
Should the type for parameter $member not be Member|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
309
     *
310
     * @return Boolean
311
     */
312
    public function canView($member = null)
313
    {
314
        $member = $this->getMember($member);
315
316
        $extended = $this->extendedCan('canView', $member);
317
        if ($extended !== null) {
318
            return $extended;
319
        }
320
321
        if (Permission::checkMember($member, 'CMS_ACCESS_CommentAdmin')) {
322
            return true;
323
        }
324
325
        if ($parent = $this->getParent()) {
326
            return $parent->canView($member)
327
                && $parent->has_extension('CommentsExtension')
328
                && $parent->CommentsEnabled;
329
        }
330
331
        return false;
332
    }
333
334
    /**
335
     * Checks if the comment can be edited.
336
     *
337
     * @param null|int|Member $member
338
     *
339
     * @return Boolean
340
     */
341
    public function canEdit($member = null)
342
    {
343
        $member = $this->getMember($member);
344
345
        if (!$member) {
346
            return false;
347
        }
348
349
        $extended = $this->extendedCan('canEdit', $member);
350
        if ($extended !== null) {
351
            return $extended;
352
        }
353
354
        if (Permission::checkMember($member, 'CMS_ACCESS_CommentAdmin')) {
355
            return true;
356
        }
357
358
        if ($parent = $this->getParent()) {
359
            return $parent->canEdit($member);
360
        }
361
362
        return false;
363
    }
364
365
    /**
366
     * Checks if the comment can be deleted.
367
     *
368
     * @param null|int|Member $member
369
     *
370
     * @return Boolean
371
     */
372
    public function canDelete($member = null)
373
    {
374
        $member = $this->getMember($member);
375
376
        if (!$member) {
377
            return false;
378
        }
379
380
        $extended = $this->extendedCan('canDelete', $member);
381
        if ($extended !== null) {
382
            return $extended;
383
        }
384
385
        return $this->canEdit($member);
386
    }
387
388
    /**
389
     * Resolves Member object.
390
     *
391
     * @param Member|int|null $member
392
     * @return Member|null
393
     */
394
    protected function getMember($member = null)
395
    {
396
        if (!$member) {
397
            $member = Member::currentUser();
398
        }
399
400
        if (is_numeric($member)) {
401
            $member = DataObject::get_by_id('Member', $member, true);
402
        }
403
404
        return $member;
405
    }
406
407
    /**
408
     * Return the authors name for the comment
409
     *
410
     * @return string
411
     */
412
    public function getAuthorName()
413
    {
414
        if ($this->Name) {
415
            return $this->Name;
416
        } elseif ($author = $this->Author()) {
417
            return $author->getName();
418
        }
419
    }
420
421
    /**
422
     * Generate a secure admin-action link authorised for the specified member
423
     *
424
     * @param string $action An action on CommentingController to link to
425
     * @param Member $member The member authorised to invoke this action
426
     *
427
     * @return string
428
     */
429
    protected function actionLink($action, $member = null)
430
    {
431
        if (!$member) {
432
            $member = Member::currentUser();
433
        }
434
        if (!$member) {
435
            return false;
436
        }
437
438
        $url = Controller::join_links(
439
            Director::baseURL(),
440
            'CommentingController',
441
            $action,
442
            $this->ID
443
        );
444
445
        // Limit access for this user
446
        $token = $this->getSecurityToken();
447
        return $token->addToUrl($url, $member);
448
    }
449
450
    /**
451
     * Link to delete this comment
452
     *
453
     * @param Member $member
0 ignored issues
show
Should the type for parameter $member not be Member|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
454
     *
455
     * @return string
456
     */
457
    public function DeleteLink($member = null)
458
    {
459
        if ($this->canDelete($member)) {
460
            return $this->actionLink('delete', $member);
461
        }
462
    }
463
464
    /**
465
     * Link to mark as spam
466
     *
467
     * @param Member $member
0 ignored issues
show
Should the type for parameter $member not be Member|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
468
     *
469
     * @return string
470
     */
471
    public function SpamLink($member = null)
472
    {
473
        if ($this->canEdit($member) && !$this->IsSpam) {
474
            return $this->actionLink('spam', $member);
475
        }
476
    }
477
478
    /**
479
     * Link to mark as not-spam (ham)
480
     *
481
     * @param Member $member
0 ignored issues
show
Should the type for parameter $member not be Member|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
482
     *
483
     * @return string
484
     */
485
    public function HamLink($member = null)
486
    {
487
        if ($this->canEdit($member) && $this->IsSpam) {
488
            return $this->actionLink('ham', $member);
489
        }
490
    }
491
492
    /**
493
     * Link to approve this comment
494
     *
495
     * @param Member $member
0 ignored issues
show
Should the type for parameter $member not be Member|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
496
     *
497
     * @return string
498
     */
499
    public function ApproveLink($member = null)
500
    {
501
        if ($this->canEdit($member) && !$this->Moderated) {
502
            return $this->actionLink('approve', $member);
503
        }
504
    }
505
506
    /**
507
     * Mark this comment as spam
508
     */
509
    public function markSpam()
510
    {
511
        $this->IsSpam = true;
512
        $this->Moderated = true;
513
        $this->write();
514
        $this->extend('afterMarkSpam');
515
    }
516
517
    /**
518
     * Mark this comment as approved
519
     */
520
    public function markApproved()
521
    {
522
        $this->IsSpam = false;
523
        $this->Moderated = true;
524
        $this->write();
525
        $this->extend('afterMarkApproved');
526
    }
527
528
    /**
529
     * Mark this comment as unapproved
530
     */
531
    public function markUnapproved()
532
    {
533
        $this->Moderated = false;
534
        $this->write();
535
        $this->extend('afterMarkUnapproved');
536
    }
537
538
    /**
539
     * @return string
540
     */
541
    public function SpamClass()
542
    {
543
        if ($this->IsSpam) {
544
            return 'spam';
545
        } elseif (!$this->Moderated) {
546
            return 'unmoderated';
547
        } else {
548
            return 'notspam';
549
        }
550
    }
551
552
    /**
553
     * @return string
554
     */
555
    public function getTitle()
556
    {
557
        $title = sprintf(_t('Comment.COMMENTBY', 'Comment by %s', 'Name'), $this->getAuthorName());
558
559
        if ($parent = $this->getParent()) {
560
            if ($parent->Title) {
561
                $title .= sprintf(' %s %s', _t('Comment.ON', 'on'), $parent->Title);
562
            }
563
        }
564
565
        return $title;
566
    }
567
568
    /*
569
     * Modify the default fields shown to the user
570
     */
571
    public function getCMSFields()
572
    {
573
        $commentField = $this->AllowHtml ? 'HtmlEditorField' : 'TextareaField';
574
        $fields = new FieldList(
575
            $this
576
                ->obj('Created')
577
                ->scaffoldFormField($this->fieldLabel('Created'))
578
                ->performReadonlyTransformation(),
579
            TextField::create('Name', $this->fieldLabel('AuthorName')),
580
            $commentField::create('Comment', $this->fieldLabel('Comment')),
581
            EmailField::create('Email', $this->fieldLabel('Email')),
582
            TextField::create('URL', $this->fieldLabel('URL')),
583
            FieldGroup::create(array(
584
                CheckboxField::create('Moderated', $this->fieldLabel('Moderated')),
585
                CheckboxField::create('IsSpam', $this->fieldLabel('IsSpam')),
586
            ))
587
                ->setTitle('Options')
588
                ->setDescription(_t(
589
                    'Comment.OPTION_DESCRIPTION',
590
                    'Unmoderated and spam comments will not be displayed until approved'
591
                ))
592
        );
593
594
        // Show member name if given
595
        if (($author = $this->Author()) && $author->exists()) {
596
            $fields->insertAfter(
597
                TextField::create('AuthorMember', $this->fieldLabel('Author'), $author->Title)
598
                    ->performReadonlyTransformation(),
599
                'Name'
600
            );
601
        }
602
603
        // Show parent comment if given
604
        if (($parent = $this->ParentComment()) && $parent->exists()) {
605
            $fields->push(new HeaderField(
606
                'ParentComment_Title',
607
                _t('Comment.ParentComment_Title', 'This comment is a reply to the below')
608
            ));
609
            // Created date
610
            // FIXME - the method setName in DatetimeField is not chainable, hence
611
            // the lack of chaining here
612
            $createdField = $parent
613
                    ->obj('Created')
614
                    ->scaffoldFormField($parent->fieldLabel('Created'));
615
            $createdField->setName('ParentComment_Created');
616
            $createdField->setValue($parent->Created);
617
            $createdField->performReadonlyTransformation();
618
            $fields->push($createdField);
619
620
            // Name (could be member or string value)
621
            $fields->push(
622
                $parent
623
                    ->obj('AuthorName')
624
                    ->scaffoldFormField($parent->fieldLabel('AuthorName'))
625
                    ->setName('ParentComment_AuthorName')
626
                    ->setValue($parent->getAuthorName())
627
                    ->performReadonlyTransformation()
628
            );
629
630
            // Comment body
631
            $fields->push(
632
                $parent
633
                    ->obj('EscapedComment')
634
                    ->scaffoldFormField($parent->fieldLabel('Comment'))
635
                    ->setName('ParentComment_EscapedComment')
636
                    ->setValue($parent->Comment)
637
                    ->performReadonlyTransformation()
638
            );
639
        }
640
641
        $this->extend('updateCMSFields', $fields);
642
        return $fields;
643
    }
644
645
    /**
646
     * @param  String $dirtyHtml
647
     *
648
     * @return String
649
     */
650
    public function purifyHtml($dirtyHtml)
651
    {
652
        $purifier = $this->getHtmlPurifierService();
653
        return $purifier->purify($dirtyHtml);
654
    }
655
656
    /**
657
     * @return HTMLPurifier (or anything with a "purify()" method)
658
     */
659
    public function getHtmlPurifierService()
660
    {
661
        $config = HTMLPurifier_Config::createDefault();
662
        $allowedElements = $this->getOption('html_allowed_elements');
663
        $config->set('HTML.AllowedElements', $allowedElements);
664
665
        // This injector cannot be set unless the 'p' element is allowed
666
        if (in_array('p', $allowedElements)) {
667
            $config->set('AutoFormat.AutoParagraph', true);
668
        }
669
670
        $config->set('AutoFormat.Linkify', true);
671
        $config->set('URI.DisableExternalResources', true);
672
        $config->set('Cache.SerializerPath', getTempFolder());
673
        return new HTMLPurifier($config);
674
    }
675
676
    /**
677
     * Calculate the Gravatar link from the email address
678
     *
679
     * @return string
680
     */
681
    public function Gravatar()
682
    {
683
        $gravatar = '';
684
        $use_gravatar = $this->getOption('use_gravatar');
685
        if ($use_gravatar) {
686
            $gravatar = 'http://www.gravatar.com/avatar/' . md5(strtolower(trim($this->Email)));
687
            $gravatarsize = $this->getOption('gravatar_size');
688
            $gravatardefault = $this->getOption('gravatar_default');
689
            $gravatarrating = $this->getOption('gravatar_rating');
690
            $gravatar .= '?s=' . $gravatarsize . '&d=' . $gravatardefault . '&r=' . $gravatarrating;
691
        }
692
693
        return $gravatar;
694
    }
695
696
    /**
697
     * Determine if replies are enabled for this instance
698
     *
699
     * @return boolean
700
     */
701
    public function getRepliesEnabled()
702
    {
703
        // Check reply option
704
        if (!$this->getOption('nested_comments')) {
705
            return false;
706
        }
707
708
        // Check if depth is limited
709
        $maxLevel = $this->getOption('nested_depth');
710
        $notSpam = ($this->SpamClass() == 'notspam');
711
        return $notSpam && (!$maxLevel || $this->Depth < $maxLevel);
712
    }
713
714
    /**
715
     * Returns the list of all replies
716
     *
717
     * @return SS_List
718
     */
719
    public function AllReplies()
720
    {
721
        // No replies if disabled
722
        if (!$this->getRepliesEnabled()) {
723
            return new ArrayList();
724
        }
725
726
        // Get all non-spam comments
727
        $order = $this->getOption('order_replies_by')
728
            ?: $this->getOption('order_comments_by');
729
        $list = $this
730
            ->ChildComments()
731
            ->sort($order);
732
733
        $this->extend('updateAllReplies', $list);
734
        return $list;
735
    }
736
737
    /**
738
     * Returns the list of replies, with spam and unmoderated items excluded, for use in the frontend
739
     *
740
     * @return SS_List
741
     */
742
    public function Replies()
743
    {
744
        // No replies if disabled
745
        if (!$this->getRepliesEnabled()) {
746
            return new ArrayList();
747
        }
748
        $list = $this->AllReplies();
749
750
        // Filter spam comments for non-administrators if configured
751
        $parent = $this->getParent();
752
        $showSpam = $this->getOption('frontend_spam') && $parent && $parent->canModerateComments();
753
        if (!$showSpam) {
754
            $list = $list->filter('IsSpam', 0);
755
        }
756
757
        // Filter un-moderated comments for non-administrators if moderation is enabled
758
        $showUnmoderated = $parent && (
759
            ($parent->ModerationRequired === 'None')
760
            || ($this->getOption('frontend_moderation') && $parent->canModerateComments())
761
        );
762
        if (!$showUnmoderated) {
763
            $list = $list->filter('Moderated', 1);
764
        }
765
766
        $this->extend('updateReplies', $list);
767
        return $list;
768
    }
769
770
    /**
771
     * Returns the list of replies paged, with spam and unmoderated items excluded, for use in the frontend
772
     *
773
     * @return PaginatedList
774
     */
775 View Code Duplication
    public function PagedReplies()
776
    {
777
        $list = $this->Replies();
778
779
        // Add pagination
780
        $list = new PaginatedList($list, Controller::curr()->getRequest());
781
        $list->setPaginationGetVar('repliesstart'.$this->ID);
782
        $list->setPageLength($this->getOption('comments_per_page'));
783
784
        $this->extend('updatePagedReplies', $list);
785
        return $list;
786
    }
787
788
    /**
789
     * Generate a reply form for this comment
790
     *
791
     * @return Form
792
     */
793
    public function ReplyForm()
794
    {
795
        // Ensure replies are enabled
796
        if (!$this->getRepliesEnabled()) {
797
            return null;
798
        }
799
800
        // Check parent is available
801
        $parent = $this->getParent();
802
        if (!$parent || !$parent->exists()) {
803
            return null;
804
        }
805
806
        // Build reply controller
807
        $controller = CommentingController::create();
808
        $controller->setOwnerRecord($parent);
809
        $controller->setBaseClass($parent->ClassName);
810
        $controller->setOwnerController(Controller::curr());
811
812
        return $controller->ReplyForm($this);
813
    }
814
815
    /**
816
     * Refresh of this comment in the hierarchy
817
     */
818
    public function updateDepth()
819
    {
820
        $parent = $this->ParentComment();
821
        if ($parent && $parent->exists()) {
822
            $parent->updateDepth();
823
            $this->Depth = $parent->Depth + 1;
824
        } else {
825
            $this->Depth = 1;
826
        }
827
    }
828
}
829
830
831
/**
832
 * Provides the ability to generate cryptographically secure tokens for comment moderation
833
 */
834
class Comment_SecurityToken
835
{
836
837
    private $secret = null;
838
839
    /**
840
     * @param Comment $comment Comment to generate this token for
841
     */
842
    public function __construct($comment)
843
    {
844
        if (!$comment->SecretToken) {
845
            $comment->SecretToken = $this->generate();
846
            $comment->write();
847
        }
848
        $this->secret = $comment->SecretToken;
849
    }
850
851
    /**
852
     * Generate the token for the given salt and current secret
853
     *
854
     * @param string $salt
855
     *
856
     * @return string
857
     */
858
    protected function getToken($salt)
859
    {
860
        return hash_pbkdf2('sha256', $this->secret, $salt, 1000, 30);
861
    }
862
863
    /**
864
     * Get the member-specific salt.
865
     *
866
     * The reason for making the salt specific to a user is that it cannot be "passed in" via a
867
     * querystring, requiring the same user to be present at both the link generation and the
868
     * controller action.
869
     *
870
     * @param string $salt   Single use salt
871
     * @param Member $member Member object
872
     *
873
     * @return string Generated salt specific to this member
874
     */
875
    protected function memberSalt($salt, $member)
876
    {
877
        // Fallback to salting with ID in case the member has not one set
878
        return $salt . ($member->Salt ?: $member->ID);
879
    }
880
881
    /**
882
     * @param string $url    Comment action URL
883
     * @param Member $member Member to restrict access to this action to
884
     *
885
     * @return string
886
     */
887
    public function addToUrl($url, $member)
888
    {
889
        $salt = $this->generate(15); // New random salt; Will be passed into url
890
        // Generate salt specific to this member
891
        $memberSalt = $this->memberSalt($salt, $member);
892
        $token = $this->getToken($memberSalt);
893
        return Controller::join_links(
894
            $url,
895
            sprintf(
896
                '?t=%s&s=%s',
897
                urlencode($token),
898
                urlencode($salt)
899
            )
900
        );
901
    }
902
903
    /**
904
     * @param SS_HTTPRequest $request
905
     *
906
     * @return boolean
907
     */
908
    public function checkRequest($request)
909
    {
910
        $member = Member::currentUser();
911
        if (!$member) {
912
            return false;
913
        }
914
915
        $salt = $request->getVar('s');
916
        $memberSalt = $this->memberSalt($salt, $member);
917
        $token = $this->getToken($memberSalt);
918
919
        // Ensure tokens match
920
        return $token === $request->getVar('t');
921
    }
922
923
924
    /**
925
     * Generates new random key
926
     *
927
     * @param integer $length
928
     *
929
     * @return string
930
     */
931
    protected function generate($length = null)
932
    {
933
        $generator = new RandomGenerator();
934
        $result = $generator->randomToken('sha256');
935
        if ($length !== null) {
936
            return substr($result, 0, $length);
937
        }
938
        return $result;
939
    }
940
}
941