Skip to content

Commit

Permalink
feat: add rate limiting for comment by IP
Browse files Browse the repository at this point in the history
  • Loading branch information
Grafikart committed Dec 29, 2024
1 parent f76f2b4 commit 7847cf9
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 9 deletions.
3 changes: 2 additions & 1 deletion fixtures/templates.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,12 @@ App\Domain\Auth\User:
App\Domain\Comment\Entity\Comment:
comment (template):
username: john<numberBetween(0, 1000)>
createdAt: <dateTimeImmutableThisYear()>
createdAt: <dateTimeImmutableThisYear('-1 hour')>
content: <sentence(100)>
parent: '20%? @comment*'
author: '@user*'
target: '@course*'
ip: '127.0.0.1'

App\Domain\Forum\Entity\Tag:
tag (template):
Expand Down
14 changes: 14 additions & 0 deletions src/Domain/Comment/CommentRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,18 @@ public function queryByIp(string $ip): QueryBuilder
->where('row.ip LIKE :ip')
->setParameter('ip', $ip);
}

public function hasIpCommentedRecently(string $ip, string $time = '-5 minutes'): bool
{
$date = new \DateTimeImmutable($time);

return $this->createQueryBuilder('c')
->select('COUNT(c.id)')
->where('c.ip = :ip')
->andWhere('c.createdAt > :date')
->setParameter('ip', $ip)
->setParameter('date', $date)
->getQuery()
->getSingleScalarResult() === 0;
}
}
18 changes: 10 additions & 8 deletions src/Http/Admin/Controller/CourseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,27 +153,29 @@ public function upload(
}

/**
* Trouve tous les cours qui ont des sources manquantes
* Trouve tous les cours qui ont des sources manquantes.
*/
#[Route(path: '/missing', methods:['GET'], name: 'missing')]
#[Route(path: '/missing', methods: ['GET'], name: 'missing')]
public function missing(
CourseRepository $courseRepository,
StorageInterface $storage,
)
{
): Response {
$rows = $this->paginator->paginate($courseRepository
->queryAll()
->where('c.source IS NOT NULL')
->setMaxResults(2500)
->getQuery()
);

$filteredRows = array_filter($rows->getItems(), fn(Course $c) => !file_exists($storage->resolvePath($c, 'sourceFile')));
$filteredRows = array_filter(
[...$rows->getItems()],
fn (Course $c) => !file_exists($storage->resolvePath($c, 'sourceFile') ?? '')
);

return $this->render("admin/{$this->templatePath}/missing.html.twig", [
"rows" => $rows,
"filtered_rows" => $filteredRows,
"storage" => $storage,
'rows' => $rows,
'filtered_rows' => $filteredRows,
'storage' => $storage,
'prefix' => $this->routePrefix,
]);
}
Expand Down
1 change: 1 addition & 0 deletions src/Http/Api/Controller/CommentsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function index(
}

#[Route('/comments', methods: ['POST'])]
#[IsGranted(CommentVoter::CREATE)]
public function create(
Request $request,
SerializerInterface $serializer,
Expand Down
36 changes: 36 additions & 0 deletions src/Http/Security/CommentVoter.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,32 @@
namespace App\Http\Security;

use App\Domain\Auth\User;
use App\Domain\Comment\CommentRepository;
use App\Domain\Comment\Entity\Comment;
use App\Http\Api\Resource\CommentResource;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;

class CommentVoter extends Voter
{
final public const DELETE = 'delete';
final public const UPDATE = 'update';
final public const CREATE = 'CREATE_COMMENT';

public function __construct(
private readonly RequestStack $requestStack,
private readonly CommentRepository $commentRepository,
) {
}

protected function supports(string $attribute, $subject): bool
{
if ($attribute === self::CREATE) {
return true;
}

return in_array($attribute, [
self::DELETE,
self::UPDATE,
Expand All @@ -27,6 +41,10 @@ protected function supports(string $attribute, $subject): bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
if ($attribute === self::CREATE) {
return $this->canCreate($token->getUser());
}

$user = $token->getUser();

if (!$user instanceof User) {
Expand All @@ -43,4 +61,22 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token):

return null !== $subject->getAuthor() && $subject->getAuthor()->getId() === $user->getId();
}

private function canCreate(?UserInterface $user): bool
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return false;
}

$ip = $request->getClientIp();
if (!$ip) {
return false;
}

return $this->commentRepository->hasIpCommentedRecently(
$ip,
$user instanceof User ? '-1 minutes' : '-5 minutes'
);
}
}
17 changes: 17 additions & 0 deletions tests/Http/Api/CommentApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ public function testCreateWithGoodData()
$this->assertResponseIsSuccessful();
}

public function testLimitSuccessiveComment()
{
$fixtures = $this->loadFixtures(['comments']);
$this->jsonRequest('POST', '/api/comments', [
'content' => 'Hello world !',
'username' => 'John Doe',
'target' => $fixtures['post1']->getId(),
]);
$this->assertResponseIsSuccessful();
$this->jsonRequest('POST', '/api/comments', [
'content' => 'Hello world !',
'username' => 'John Doe',
'target' => $fixtures['post1']->getId(),
]);
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}

public function testCreateWithUsedUsername()
{
$fixtures = $this->loadFixtures(['comments', 'users']);
Expand Down

0 comments on commit 7847cf9

Please sign in to comment.