Skip to content

Commit 51edc14

Browse files
Re-introduce check for comment similarity to annoy spammers. Do not allow comments with email addresses.
1 parent 0d7ac8c commit 51edc14

File tree

4 files changed

+181
-25
lines changed

4 files changed

+181
-25
lines changed

src/Controller/CommentController.php

+28-21
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ public function addComment(
152152
$memberPreference = $loggedInMember->getMemberPreference($preference);
153153
$showCommentGuideline = ('0' === $memberPreference->getValue());
154154

155-
/** @var Member $loggedInMember */
156155
$form = $this->createForm(CommentType::class, null, [
157156
'to_member' => $member,
158157
'show_comment_guideline' => $showCommentGuideline,
@@ -162,30 +161,38 @@ public function addComment(
162161
if ($form->isSubmitted() && $form->isValid()) {
163162
/** @var Comment $comment */
164163
$comment = $form->getData();
165-
$comment->setToMember($member);
166-
$comment->setFromMember($loggedInMember);
167-
if (CommentQualityType::NEGATIVE === $comment->getQuality()) {
168-
$comment->setAdminAction(CommentAdminActionType::ADMIN_CHECK);
169-
$comment->setEditingAllowed(false);
170-
}
171-
$entityManager->persist($comment);
164+
if (
165+
$commentModel->checkCommentSpam($loggedInMember, $comment)
166+
|| $commentModel->checkForEmailAddress($comment)
167+
) {
168+
$form->addError(new FormError($this->translator->trans('commentsomethingwentwrong')));
169+
} else {
170+
$comment->setToMember($member);
171+
$comment->setFromMember($loggedInMember);
172172

173-
// Mark comment guidelines as read and hide the checkbox for the future
174-
$memberPreference->setValue('1');
175-
$entityManager->persist($memberPreference);
176-
$entityManager->flush();
173+
if (CommentQualityType::NEGATIVE === $comment->getQuality()) {
174+
$comment->setAdminAction(CommentAdminActionType::ADMIN_CHECK);
175+
$comment->setEditingAllowed(false);
176+
}
177+
$entityManager->persist($comment);
177178

178-
$mailer->sendNewCommentNotification($comment);
179+
// Mark comment guidelines as read and hide the checkbox for the future
180+
$memberPreference->setValue('1');
181+
$entityManager->persist($memberPreference);
182+
$entityManager->flush();
179183

180-
$this->addTranslatedFlash(
181-
'notice',
182-
'flash.comment.added',
183-
[
184-
'username' => $member->getUsername(),
185-
]
186-
);
184+
$mailer->sendNewCommentNotification($comment);
187185

188-
return $this->redirectToRoute('profile_comments', ['username' => $member->getUsername()]);
186+
$this->addTranslatedFlash(
187+
'notice',
188+
'flash.comment.added',
189+
[
190+
'username' => $member->getUsername(),
191+
]
192+
);
193+
194+
return $this->redirectToRoute('profile_comments', ['username' => $member->getUsername()]);
195+
}
189196
}
190197

191198
return $this->render('/profile/comment.add.html.twig', [

src/Model/CommentModel.php

+103-4
Original file line numberDiff line numberDiff line change
@@ -60,28 +60,127 @@ public function checkIfNewExperience(Comment $original, Comment $updated): bool
6060
$newExperience = false;
6161
try {
6262
$maxlen = max(strlen($updatedText), strlen($originalText));
63-
$calculator = new LevenshteinDistance(false, 0, 1000**2);
63+
$calculator = new LevenshteinDistance(false, 0, 1000 ** 2);
6464
$iteration = 0;
6565
$maxIteration = $maxlen / 1000;
6666
while ($iteration < $maxIteration && !$newExperience) {
67-
6867
$currentUpdatedText = substr($updatedText, $iteration * 1000, 1000);
6968
$currentOriginalText = substr($originalText, $iteration * 1000, 1000);
7069
$levenshteinDistance = ($calculator->calculate(
7170
$currentUpdatedText,
72-
$currentOriginalText)
71+
$currentOriginalText
72+
)
7373
)['distance'];
7474

7575
if ($levenshteinDistance >= max(strlen($currentUpdatedText), strlen($currentOriginalText)) / 7) {
7676
$newExperience = true;
7777
}
7878
$iteration++;
7979
}
80-
} catch(Throwable $e) {
80+
} catch (Throwable $e) {
8181
// ignore exception and just return false (likely consumed too much memory)
8282
return $newExperience;
8383
}
8484

8585
return $newExperience;
8686
}
87+
88+
public function checkCommentSpam(Member $loggedInMember, Comment $comment): bool
89+
{
90+
$spamCheckParams = [
91+
['duration' => '00:02:00', 'count' => 1],
92+
['duration' => '00:20:00', 'count' => 5],
93+
['duration' => '06:00:00', 'count' => 25],
94+
];
95+
96+
$check1 = $this->checkCommentsDuration($loggedInMember, $comment, $spamCheckParams[0]);
97+
$check2 = $this->checkCommentsDuration($loggedInMember, $comment, $spamCheckParams[1]);
98+
$check3 = $this->checkCommentsDuration($loggedInMember, $comment, $spamCheckParams[2]);
99+
100+
return $check1 || $check2 || $check3;
101+
}
102+
103+
private function checkCommentsDuration(Member $member, Comment $comment, array $params): bool
104+
{
105+
$duration = $params['duration'];
106+
$count = $params['count'];
107+
108+
$result = false;
109+
$commentCount = $this->entityManager
110+
->getConnection()
111+
->executeQuery(
112+
"
113+
SELECT
114+
COUNT(*) as cnt
115+
FROM
116+
comments c
117+
WHERE
118+
c.IdFromMember = :memberId
119+
AND TIMEDIFF(NOW(), created) < :duration
120+
",
121+
[ ':memberId' => $member->getId(), ':duration' => $duration]
122+
)
123+
->fetchOne()
124+
;
125+
126+
if ($commentCount >= $count) {
127+
// Okay limit was hit, check for comment quality
128+
// Get all comments written during the given duration
129+
$comments = $this->entityManager
130+
->getConnection()
131+
->executeQuery(
132+
"
133+
SELECT
134+
c.TextFree
135+
FROM
136+
comments c
137+
WHERE
138+
c.IdFromMember = :memberId
139+
AND TIMEDIFF(NOW(), created) < :duration
140+
",
141+
[ ':memberId' => $member->getId(), ':duration' => $duration]
142+
)
143+
->fetchAllAssociative()
144+
;
145+
$result = $this->checkCommentSimilarity($comments, $comment);
146+
}
147+
148+
return $result;
149+
}
150+
151+
private function checkCommentSimilarity(array $comments, Comment $comment): bool
152+
{
153+
$similar = 0;
154+
$comments[count($comments)] = ['TextFree' => $comment->getTextfree()];
155+
$count = count($comments);
156+
for ($i = 0; $i < $count - 1; $i++) {
157+
for ($j = $i + 1; $j < $count; $j++) {
158+
similar_text(
159+
$comments[$i]['TextFree'],
160+
$comments[$j]['TextFree'],
161+
$percent
162+
);
163+
if ($percent > 95) {
164+
$similar++;
165+
}
166+
}
167+
}
168+
return $similar != $count * ($count - 1);
169+
}
170+
171+
public function checkForEmailAddress(Comment $comment): bool
172+
{
173+
$commentText = $comment->getTextfree();
174+
$atPos = strpos($commentText, '@');
175+
$whiteSpaceBefore = strrpos(substr($commentText, 0, $atPos), ' ');
176+
$whiteSpaceAfter = strpos($commentText, ' ', $atPos);
177+
if (false === $whiteSpaceAfter) {
178+
$whiteSpaceAfter = strlen($commentText);
179+
}
180+
$potentialEmailAddress =
181+
substr($commentText, $whiteSpaceBefore + 1, $whiteSpaceAfter - $whiteSpaceBefore - 1);
182+
$emailAddressFound = filter_var($potentialEmailAddress, FILTER_VALIDATE_EMAIL) !== false;
183+
184+
return $emailAddressFound;
185+
}
87186
}

templates/profile/comment.form.html.twig

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<h2>{{ 'commentheading'|trans|format(member.username) }}</h2>
3232
<div class="alert alert-info">{{ 'followcommentguidelines'|trans|raw }}</div>
3333
{{ form_start(form, { 'attr': { 'novalidate': 'novalidate' } }) }}
34+
{{ form_errors(form) }}
3435
{{ form_row(form.quality) }}
3536
{{ form_errors(form.quality) }}
3637
{{ form_row(form.relations) }}

tests/Model/CommentModelTest.php

+49
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,55 @@ public function testLongTextWithLowNumberUpdatesIsNotANewExperience()
269269

270270
$this->assertFalse($this->commentModel->checkIfNewExperience($original, $updated));
271271
}
272+
273+
public function testCommentWithEmailAddressInTextIsRecognized()
274+
{
275+
$comment = new Comment();
276+
$comment->setTextfree('This is [email protected] in the middle of a text.');
277+
278+
$emailAddressFound = $this->commentModel->checkForEmailAddress($comment);
279+
280+
$this->assertTrue($emailAddressFound);
281+
}
282+
public function testCommentWithEmailAddressAtStartOfTextIsRecognized()
283+
{
284+
$comment = new Comment();
285+
$comment->setTextfree('[email protected] at the start of a text.');
286+
287+
$emailAddressFound = $this->commentModel->checkForEmailAddress($comment);
288+
289+
$this->assertTrue($emailAddressFound);
290+
}
291+
public function testCommentWithEmailAddressAtTheEndOfTextIsRecognized()
292+
{
293+
$comment = new Comment();
294+
$comment->setTextfree('At the end of this text, there is [email protected]');
295+
296+
$emailAddressFound = $this->commentModel->checkForEmailAddress($comment);
297+
298+
$this->assertTrue($emailAddressFound);
299+
}
300+
301+
public function testCommentWithTwoEmailAddressesInTextIsRecognized()
302+
{
303+
$comment = new Comment();
304+
$comment->setTextfree('This is [email protected] in the middle of a text. And another one at the end. [email protected]');
305+
306+
$emailAddressFound = $this->commentModel->checkForEmailAddress($comment);
307+
308+
$this->assertTrue($emailAddressFound);
309+
}
310+
311+
public function testCommentWithoutEmailAddressInTextIsRecognized()
312+
{
313+
$comment = new Comment();
314+
$comment->setTextfree('This is an @instagram username.');
315+
316+
$emailAddressFound = $this->commentModel->checkForEmailAddress($comment);
317+
318+
$this->assertFalse($emailAddressFound);
319+
}
320+
272321
private function buildRelations(array $relations): string
273322
{
274323
return implode(',', $relations);

0 commit comments

Comments
 (0)