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
	 * @var array
27
	 */
28
	private static $db = array(
29
		'Name' => 'Varchar(200)',
30
		'Comment' => 'Text',
31
		'Email' => 'Varchar(200)',
32
		'URL' => 'Varchar(255)',
33
		'BaseClass' => 'Varchar(200)',
34
		'Moderated' => 'Boolean(0)',
35
		'IsSpam' => 'Boolean(0)',
36
		'ParentID' => 'Int',
37
		'AllowHtml' => 'Boolean',
38
		'SecretToken' => 'Varchar(255)',
39
		'Depth' => 'Int',
40
	);
41
42
	private static $has_one = array(
43
		"Author" => "Member",
44
		"ParentComment" => "Comment",
45
	);
46
47
	private static $has_many = array(
48
		"ChildComments"	=> "Comment"
49
	);
50
51
	private static $default_sort = '"Created" DESC';
52
53
	private static $defaults = array(
54
		'Moderated' => 0,
55
		'IsSpam' => 0,
56
	);
57
58
	private static $casting = array(
59
		'Title' => 'Varchar',
60
		'ParentTitle' => 'Varchar',
61
		'ParentClassName' => 'Varchar',
62
		'AuthorName' => 'Varchar',
63
		'RSSName' => 'Varchar',
64
		'DeleteLink' => 'Varchar',
65
		'SpamLink' => 'Varchar',
66
		'HamLink' => 'Varchar',
67
		'ApproveLink' => 'Varchar',
68
		'Permalink' => 'Varchar',
69
	);
70
71
	private static $searchable_fields = array(
72
		'Name',
73
		'Email',
74
		'Comment',
75
		'Created',
76
		'BaseClass',
77
	);
78
79
	private static $summary_fields = array(
80
		'Name' => 'Submitted By',
81
		'Email' => 'Email',
82
		'Comment.LimitWordCount' => 'Comment',
83
		'Created' => 'Date Posted',
84
		'ParentTitle' => 'Post',
85
		'IsSpam' => 'Is Spam',
86
	);
87
88
	private static $field_labels = array(
89
		'Author' => 'Author Member',
90
	);
91
92
	public function onBeforeWrite() {
93
		parent::onBeforeWrite();
94
95
		// Sanitize HTML, because its expected to be passed to the template unescaped later
96
		if($this->AllowHtml) {
97
			$this->Comment = $this->purifyHtml($this->Comment);
98
		}
99
100
		// Check comment depth
101
		$this->updateDepth();
102
	}
103
104
	public function onBeforeDelete() {
105
		parent::onBeforeDelete();
106
107
		// Delete all children
108
		foreach($this->ChildComments() as $comment) {
109
			$comment->delete();
110
		}
111
	}
112
113
	/**
114
	 * @return Comment_SecurityToken
115
	 */
116
	public function getSecurityToken() {
117
		return Injector::inst()->createWithArgs('Comment_SecurityToken', array($this));
118
	}
119
120
	/**
121
	 * Migrates the old {@link PageComment} objects to {@link Comment}
122
	 */
123
	public function requireDefaultRecords() {
124
		parent::requireDefaultRecords();
125
126
		if(DB::getConn()->hasTable('PageComment')) {
127
			$comments = DB::query('SELECT * FROM "PageComment"');
128
129
			if($comments) {
130
				while($pageComment = $comments->nextRecord()) {
131
					// create a new comment from the older page comment
132
					$comment = new Comment();
133
					$comment->update($pageComment);
134
135
					// set the variables which have changed
136
					$comment->BaseClass = 'SiteTree';
137
					$comment->URL = (isset($pageComment['CommenterURL'])) ? $pageComment['CommenterURL'] : '';
138
					if((int) $pageComment['NeedsModeration'] == 0) $comment->Moderated = true;
139
140
					$comment->write();
141
				}
142
			}
143
144
			DB::alteration_message('Migrated PageComment to Comment', 'changed');
145
			DB::getConn()->dontRequireTable('PageComment');
146
		}
147
	}
148
149
	/**
150
	 * Return a link to this comment
151
	 *
152
	 * @param string $action
153
	 *
154
	 * @return string link to this comment.
155
	 */
156
	public function Link($action = '') {
157
		if($parent = $this->getParent()) {
158
			return $parent->Link($action) . '#' . $this->Permalink();
159
		}
160
	}
161
162
	/**
163
	 * Returns the permalink for this {@link Comment}. Inserted into
164
	 * the ID tag of the comment
165
	 *
166
	 * @return string
167
	 */
168
	public function Permalink() {
169
		$prefix = $this->getOption('comment_permalink_prefix');
170
		return $prefix . $this->ID;
171
	}
172
173
	/**
174
	 * Translate the form field labels for the CMS administration
175
	 *
176
	 * @param boolean $includerelations
177
	 *
178
	 * @return array
179
	 */
180
	public function fieldLabels($includerelations = true) {
181
		$labels = parent::fieldLabels($includerelations);
182
183
		$labels['Name'] = _t('Comment.NAME', 'Author Name');
184
		$labels['Comment'] = _t('Comment.COMMENT', 'Comment');
185
		$labels['Email'] = _t('Comment.EMAIL', 'Email');
186
		$labels['URL'] = _t('Comment.URL', 'URL');
187
		$labels['IsSpam'] = _t('Comment.ISSPAM', 'Spam?');
188
		$labels['Moderated'] = _t('Comment.MODERATED', 'Moderated?');
189
		$labels['ParentTitle'] = _t('Comment.PARENTTITLE', 'Parent');
190
		$labels['Created'] = _t('Comment.CREATED', 'Date posted');
191
192
		return $labels;
193
	}
194
195
	/**
196
	 * Get the commenting option
197
	 *
198
	 * @param string $key
199
	 *
200
	 * @return mixed Result if the setting is available, or null otherwise
201
	 */
202
	public function getOption($key) {
203
		// If possible use the current record
204
		$record = $this->getParent();
205
206
		if(!$record && $this->BaseClass) {
207
			// Otherwise a singleton of that record
208
			$record = singleton($this->BaseClass);
209
		}
210
		else if(!$record) {
211
			// Otherwise just use the default options
212
			$record = singleton('CommentsExtension');
213
		}
214
215
		return ($record->hasMethod('getCommentsOption')) ? $record->getCommentsOption($key) : null;
216
	}
217
218
	/**
219
	 * Returns the parent {@link DataObject} this comment is attached too
220
	 *
221
	 * @return DataObject
222
	 */
223
	public function getParent() {
224
		return $this->BaseClass && $this->ParentID
225
			? DataObject::get_by_id($this->BaseClass, $this->ParentID, true)
226
			: null;
227
	}
228
229
230
	/**
231
	 * Returns a string to help identify the parent of the comment
232
	 *
233
	 * @return string
234
	 */
235
	public function getParentTitle() {
236
		if($parent = $this->getParent()) {
237
			return $parent->Title ?: ($parent->ClassName . ' #' . $parent->ID);
238
		}
239
	}
240
241
	/**
242
	 * Comment-parent classnames obviously vary, return the parent classname
243
	 *
244
	 * @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...
245
	 */
246
	public function getParentClassName() {
247
		return $this->BaseClass;
248
	}
249
250
	public function castingHelper($field) {
251
		// Safely escape the comment
252
		if($field === 'EscapedComment') {
253
			return $this->AllowHtml ? 'HTMLText' : 'Text';
254
		}
255
		return parent::castingHelper($field);
256
	}
257
258
	/**
259
	 * Content to be safely escaped on the frontend
260
	 *
261
	 * @return string
262
	 */
263
	public function getEscapedComment() {
264
		return $this->Comment;
265
	}
266
267
	/**
268
	 * Return whether this comment is a preview (has not been written to the db)
269
	 *
270
	 * @return boolean
271
	 */
272
	public function isPreview() {
273
		return !$this->exists();
274
	}
275
276
	/**
277
	 * @todo needs to compare to the new {@link Commenting} configuration API
278
	 *
279
	 * @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...
280
	 *
281
	 * @return bool
282
	 */
283
	public function canCreate($member = null) {
284
		return false;
285
	}
286
287
	/**
288
	 * Checks for association with a page, and {@link SiteTree->ProvidePermission}
289
	 * flag being set to true.
290
	 *
291
	 * @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...
292
	 *
293
	 * @return Boolean
294
	 */
295
	public function canView($member = null) {
296
		$member = $this->getMember($member);
297
298
		$extended = $this->extendedCan('canView', $member);
299
		if($extended !== null) {
300
			return $extended;
301
		}
302
303
		if(Permission::checkMember($member, 'CMS_ACCESS_CommentAdmin')) {
304
			return true;
305
		}
306
307
		if($parent = $this->getParent()) {
308
			return $parent->canView($member)
309
				&& $parent->has_extension('CommentsExtension')
310
				&& $parent->CommentsEnabled;
311
		}
312
313
		return false;
314
	}
315
316
	/**
317
	 * Checks if the comment can be edited.
318
	 *
319
	 * @param null|int|Member $member
320
	 *
321
	 * @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...
322
	 */
323
	public function canEdit($member = null) {
324
		$member = $this->getMember($member);
325
326
		if(!$member) {
327
			return false;
328
		}
329
330
		$extended = $this->extendedCan('canEdit', $member);
331
		if($extended !== null) {
332
			return $extended;
333
		}
334
335
		if(Permission::checkMember($member, 'CMS_ACCESS_CommentAdmin')) {
336
			return true;
337
		}
338
339
		if($parent = $this->getParent()) {
340
			return $parent->canEdit($member);
341
		}
342
343
		return false;
344
	}
345
346
	/**
347
	 * Checks if the comment can be deleted.
348
	 *
349
	 * @param null|int|Member $member
350
	 *
351
	 * @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...
352
	 */
353
	public function canDelete($member = null) {
354
		$member = $this->getMember($member);
355
356
		if(!$member) {
357
			return false;
358
		}
359
360
		$extended = $this->extendedCan('canDelete', $member);
361
		if($extended !== null) {
362
			return $extended;
363
		}
364
365
		return $this->canEdit($member);
366
	}
367
368
	/**
369
	 * Resolves Member object.
370
	 *
371
	 * @param Member|int|null $member
372
	 * @return Member|null
373
	 */
374
	protected function getMember($member = null) {
375
		if(!$member) {
376
			$member = Member::currentUser();
377
		}
378
379
		if(is_numeric($member)) {
380
			$member = DataObject::get_by_id('Member', $member, true);
381
		}
382
383
		return $member;
384
	}
385
386
	/**
387
	 * Return the authors name for the comment
388
	 *
389
	 * @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...
390
	 */
391
	public function getAuthorName() {
392
		if($this->Name) {
393
			return $this->Name;
394
		} else if($author = $this->Author()) {
395
			return $author->getName();
396
		}
397
	}
398
399
	/**
400
	 * Generate a secure admin-action link authorised for the specified member
401
	 *
402
	 * @param string $action An action on CommentingController to link to
403
	 * @param Member $member The member authorised to invoke this action
404
	 *
405
	 * @return string
406
	 */
407
	protected function actionLink($action, $member = null) {
408
		if(!$member) $member = Member::currentUser();
409
		if(!$member) return false;
410
411
		$url = Controller::join_links(
412
			Director::baseURL(),
413
			'CommentingController',
414
			$action,
415
			$this->ID
416
		);
417
418
		// Limit access for this user
419
		$token = $this->getSecurityToken();
420
		return $token->addToUrl($url, $member);
421
	}
422
423
	/**
424
	 * Link to delete this comment
425
	 *
426
	 * @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...
427
	 *
428
	 * @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...
429
	 */
430
	public function DeleteLink($member = null) {
431
		if($this->canDelete($member)) {
432
			return $this->actionLink('delete', $member);
433
		}
434
	}
435
436
	/**
437
	 * Link to mark as spam
438
	 *
439
	 * @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...
440
	 *
441
	 * @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...
442
	 */
443
	public function SpamLink($member = null) {
444
		if($this->canEdit($member) && !$this->IsSpam) {
445
			return $this->actionLink('spam', $member);
446
		}
447
	}
448
449
	/**
450
	 * Link to mark as not-spam (ham)
451
	 *
452
	 * @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...
453
	 *
454
	 * @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...
455
	 */
456
	public function HamLink($member = null) {
457
		if($this->canEdit($member) && $this->IsSpam) {
458
			return $this->actionLink('ham', $member);
459
		}
460
	}
461
462
	/**
463
	 * Link to approve this comment
464
	 *
465
	 * @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...
466
	 *
467
	 * @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...
468
	 */
469
	public function ApproveLink($member = null) {
470
		if($this->canEdit($member) && !$this->Moderated) {
471
			return $this->actionLink('approve', $member);
472
		}
473
	}
474
475
	/**
476
	 * Mark this comment as spam
477
	 */
478
	public function markSpam() {
479
		$this->IsSpam = true;
480
		$this->Moderated = true;
481
		$this->write();
482
		$this->extend('afterMarkSpam');
483
	}
484
485
	/**
486
	 * Mark this comment as approved
487
	 */
488
	public function markApproved() {
489
		$this->IsSpam = false;
490
		$this->Moderated = true;
491
		$this->write();
492
		$this->extend('afterMarkApproved');
493
	}
494
495
	/**
496
	 * Mark this comment as unapproved
497
	 */
498
	public function markUnapproved() {
499
		$this->Moderated = false;
500
		$this->write();
501
		$this->extend('afterMarkUnapproved');
502
	}
503
504
	/**
505
	 * @return string
506
	 */
507
	public function SpamClass() {
508
		if($this->IsSpam) {
509
			return 'spam';
510
		} else if(!$this->Moderated) {
511
			return 'unmoderated';
512
		} else {
513
			return 'notspam';
514
		}
515
	}
516
517
	/**
518
	 * @return string
519
	 */
520
	public function getTitle() {
521
		$title = sprintf(_t('Comment.COMMENTBY', 'Comment by %s', 'Name'), $this->getAuthorName());
522
523
		if($parent = $this->getParent()) {
524
			if($parent->Title) {
525
				$title .= sprintf(' %s %s', _t('Comment.ON', 'on'), $parent->Title);
526
			}
527
		}
528
529
		return $title;
530
	}
531
532
	/*
533
	 * Modify the default fields shown to the user
534
	 */
535
	public function getCMSFields() {
536
		$commentField = $this->AllowHtml ? 'HtmlEditorField' : 'TextareaField';
537
		$fields = new FieldList(
538
			$this
539
				->obj('Created')
540
				->scaffoldFormField($this->fieldLabel('Created'))
541
				->performReadonlyTransformation(),
542
			TextField::create('Name', $this->fieldLabel('AuthorName')),
543
			$commentField::create('Comment', $this->fieldLabel('Comment')),
544
			EmailField::create('Email', $this->fieldLabel('Email')),
545
			TextField::create('URL', $this->fieldLabel('URL')),
546
			FieldGroup::create(array(
547
				CheckboxField::create('Moderated', $this->fieldLabel('Moderated')),
548
				CheckboxField::create('IsSpam', $this->fieldLabel('IsSpam')),
549
			))
550
				->setTitle('Options')
551
				->setDescription(_t(
552
					'Comment.OPTION_DESCRIPTION',
553
					'Unmoderated and spam comments will not be displayed until approved'
554
				))
555
		);
556
557
		// Show member name if given
558
		if(($author = $this->Author()) && $author->exists()) {
559
			$fields->insertAfter(
560
				TextField::create('AuthorMember', $this->fieldLabel('Author'), $author->Title)
561
					->performReadonlyTransformation(),
562
				'Name'
563
			);
564
		}
565
566
		// Show parent comment if given
567
		if(($parent = $this->ParentComment()) && $parent->exists()) {
568
			$fields->push(new HeaderField(
569
				'ParentComment_Title',
570
				_t('Comment.ParentComment_Title', 'This comment is a reply to the below')
571
			));
572
			// Created date
573
            // FIXME - the method setName in DatetimeField is not chainable, hence
574
            // the lack of chaining here
575
            $createdField = $parent
576
                    ->obj('Created')
577
                    ->scaffoldFormField($parent->fieldLabel('Created'));
578
            $createdField->setName('ParentComment_Created');
579
            $createdField->setValue($parent->Created);
580
            $createdField->performReadonlyTransformation();
581
            $fields->push($createdField);
582
583
			// Name (could be member or string value)
584
			$fields->push(
585
				$parent
586
					->obj('AuthorName')
587
					->scaffoldFormField($parent->fieldLabel('AuthorName'))
588
					->setName('ParentComment_AuthorName')
589
					->setValue($parent->getAuthorName())
590
					->performReadonlyTransformation()
591
			);
592
593
			// Comment body
594
			$fields->push(
595
				$parent
596
					->obj('EscapedComment')
597
					->scaffoldFormField($parent->fieldLabel('Comment'))
598
					->setName('ParentComment_EscapedComment')
599
					->setValue($parent->Comment)
600
					->performReadonlyTransformation()
601
			);
602
		}
603
604
		$this->extend('updateCMSFields', $fields);
605
		return $fields;
606
	}
607
608
	/**
609
	 * @param  String $dirtyHtml
610
	 *
611
	 * @return String
612
	 */
613
	public function purifyHtml($dirtyHtml) {
614
		$purifier = $this->getHtmlPurifierService();
615
		return $purifier->purify($dirtyHtml);
616
	}
617
618
	/**
619
	 * @return HTMLPurifier (or anything with a "purify()" method)
620
	 */
621
	public function getHtmlPurifierService() {
622
		$config = HTMLPurifier_Config::createDefault();
623
        $allowedElements = $this->getOption('html_allowed_elements');
624
        $config->set('HTML.AllowedElements', $allowedElements);
625
626
        // This injector cannot be set unless the 'p' element is allowed
627
        if (in_array('p', $allowedElements)) {
628
            $config->set('AutoFormat.AutoParagraph', true);
629
        }
630
631
		$config->set('AutoFormat.Linkify', true);
632
		$config->set('URI.DisableExternalResources', true);
633
		$config->set('Cache.SerializerPath', getTempFolder());
634
		return new HTMLPurifier($config);
635
	}
636
637
	/**
638
	 * Calculate the Gravatar link from the email address
639
	 *
640
	 * @return string
641
	 */
642
	public function Gravatar() {
643
		$gravatar = '';
644
		$use_gravatar = $this->getOption('use_gravatar');
645
		if($use_gravatar) {
646
			$gravatar = 'http://www.gravatar.com/avatar/' . md5(strtolower(trim($this->Email)));
647
			$gravatarsize = $this->getOption('gravatar_size');
648
			$gravatardefault = $this->getOption('gravatar_default');
649
			$gravatarrating = $this->getOption('gravatar_rating');
650
			$gravatar .= '?s=' . $gravatarsize . '&d=' . $gravatardefault . '&r=' . $gravatarrating;
651
		}
652
653
		return $gravatar;
654
	}
655
656
	/**
657
	 * Determine if replies are enabled for this instance
658
	 *
659
	 * @return boolean
660
	 */
661
	public function getRepliesEnabled() {
662
		// Check reply option
663
		if(!$this->getOption('nested_comments')) {
664
			return false;
665
		}
666
667
		// Check if depth is limited
668
 		$maxLevel = $this->getOption('nested_depth');
669
 		$notSpam = ($this->SpamClass() == 'notspam');
670
		return $notSpam && (!$maxLevel || $this->Depth < $maxLevel);
671
	}
672
673
	/**
674
	 * Returns the list of all replies
675
	 *
676
	 * @return SS_List
677
	 */
678
	public function AllReplies() {
679
		// No replies if disabled
680
		if(!$this->getRepliesEnabled()) {
681
			return new ArrayList();
682
		}
683
684
		// Get all non-spam comments
685
		$order = $this->getOption('order_replies_by')
686
			?: $this->getOption('order_comments_by');
687
		$list = $this
688
			->ChildComments()
689
			->sort($order);
690
691
		$this->extend('updateAllReplies', $list);
692
		return $list;
693
	}
694
695
	/**
696
	 * Returns the list of replies, with spam and unmoderated items excluded, for use in the frontend
697
	 *
698
	 * @return SS_List
699
	 */
700
	public function Replies() {
701
		// No replies if disabled
702
		if(!$this->getRepliesEnabled()) {
703
			return new ArrayList();
704
		}
705
		$list = $this->AllReplies();
706
707
		// Filter spam comments for non-administrators if configured
708
		$parent = $this->getParent();
709
		$showSpam = $this->getOption('frontend_spam') && $parent && $parent->canModerateComments();
710
		if(!$showSpam) {
711
			$list = $list->filter('IsSpam', 0);
712
		}
713
714
		// Filter un-moderated comments for non-administrators if moderation is enabled
715
		$showUnmoderated = $parent && (
716
			($parent->ModerationRequired === 'None')
717
			|| ($this->getOption('frontend_moderation') && $parent->canModerateComments())
718
		);
719
		if (!$showUnmoderated) {
720
		    $list = $list->filter('Moderated', 1);
721
		}
722
723
		$this->extend('updateReplies', $list);
724
		return $list;
725
	}
726
727
	/**
728
	 * Returns the list of replies paged, with spam and unmoderated items excluded, for use in the frontend
729
	 *
730
	 * @return PaginatedList
731
	 */
732 View Code Duplication
	public function PagedReplies() {
733
		$list = $this->Replies();
734
735
		// Add pagination
736
		$list = new PaginatedList($list, Controller::curr()->getRequest());
737
		$list->setPaginationGetVar('repliesstart'.$this->ID);
738
		$list->setPageLength($this->getOption('comments_per_page'));
739
740
		$this->extend('updatePagedReplies', $list);
741
		return $list;
742
	}
743
744
	/**
745
	 * Generate a reply form for this comment
746
	 *
747
	 * @return Form
748
	 */
749
	public function ReplyForm() {
750
		// Ensure replies are enabled
751
		if(!$this->getRepliesEnabled()) {
752
			return null;
753
		}
754
755
		// Check parent is available
756
		$parent = $this->getParent();
757
		if(!$parent || !$parent->exists()) {
758
			return null;
759
		}
760
761
		// Build reply controller
762
		$controller = CommentingController::create();
763
		$controller->setOwnerRecord($parent);
764
		$controller->setBaseClass($parent->ClassName);
765
		$controller->setOwnerController(Controller::curr());
766
767
		return $controller->ReplyForm($this);
768
	}
769
770
	/**
771
	 * Refresh of this comment in the hierarchy
772
	 */
773
	public function updateDepth() {
774
		$parent = $this->ParentComment();
775
		if($parent && $parent->exists()) {
776
			$parent->updateDepth();
777
			$this->Depth = $parent->Depth + 1;
778
		} else {
779
			$this->Depth = 1;
780
		}
781
	}
782
}
783
784
785
/**
786
 * Provides the ability to generate cryptographically secure tokens for comment moderation
787
 */
788
class Comment_SecurityToken {
789
790
	private $secret = null;
791
792
	/**
793
	 * @param Comment $comment Comment to generate this token for
794
	 */
795
	public function __construct($comment) {
796
		if(!$comment->SecretToken) {
797
			$comment->SecretToken = $this->generate();
798
			$comment->write();
799
		}
800
		$this->secret = $comment->SecretToken;
801
	}
802
803
	/**
804
	 * Generate the token for the given salt and current secret
805
	 *
806
	 * @param string $salt
807
	 *
808
	 * @return string
809
	 */
810
	protected function getToken($salt) {
811
		return hash_pbkdf2('sha256', $this->secret, $salt, 1000, 30);
812
	}
813
814
	/**
815
	 * Get the member-specific salt.
816
	 *
817
	 * The reason for making the salt specific to a user is that it cannot be "passed in" via a
818
	 * querystring, requiring the same user to be present at both the link generation and the
819
	 * controller action.
820
	 *
821
	 * @param string $salt   Single use salt
822
	 * @param Member $member Member object
823
	 *
824
	 * @return string Generated salt specific to this member
825
	 */
826
	protected function memberSalt($salt, $member) {
827
		// Fallback to salting with ID in case the member has not one set
828
		return $salt . ($member->Salt ?: $member->ID);
829
	}
830
831
	/**
832
	 * @param string $url    Comment action URL
833
	 * @param Member $member Member to restrict access to this action to
834
	 *
835
	 * @return string
836
	 */
837
	public function addToUrl($url, $member) {
838
		$salt = $this->generate(15); // New random salt; Will be passed into url
839
		// Generate salt specific to this member
840
		$memberSalt = $this->memberSalt($salt, $member);
841
		$token = $this->getToken($memberSalt);
842
		return Controller::join_links(
843
			$url,
844
			sprintf(
845
				'?t=%s&s=%s',
846
				urlencode($token),
847
				urlencode($salt)
848
			)
849
		);
850
	}
851
852
	/**
853
	 * @param SS_HTTPRequest $request
854
	 *
855
	 * @return boolean
856
	 */
857
	public function checkRequest($request) {
858
		$member = Member::currentUser();
859
		if(!$member) return false;
860
861
		$salt = $request->getVar('s');
862
		$memberSalt = $this->memberSalt($salt, $member);
863
		$token = $this->getToken($memberSalt);
864
865
		// Ensure tokens match
866
		return $token === $request->getVar('t');
867
	}
868
869
870
	/**
871
	 * Generates new random key
872
	 *
873
	 * @param integer $length
874
	 *
875
	 * @return string
876
	 */
877
	protected function generate($length = null) {
878
		$generator = new RandomGenerator();
879
		$result = $generator->randomToken('sha256');
880
		if($length !== null) return substr($result, 0, $length);
881
		return $result;
882
	}
883
}
884