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

code/model/Comment.php (14 issues)

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
0 ignored issues
show
Should the return type not be string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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
0 ignored issues
show
Should the return type not be boolean|string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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
0 ignored issues
show
Should the return type not be boolean|string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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
0 ignored issues
show
Should the return type not be string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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
0 ignored issues
show
Should the return type not be false|string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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
0 ignored issues
show
Should the return type not be false|string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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
0 ignored issues
show
Should the return type not be false|string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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
0 ignored issues
show
Should the return type not be false|string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
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