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
	 * @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
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
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
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
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
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
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
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
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