Commit 887b4dbb authored by Emma's avatar Emma 😻

rate limit submissions for untrusted users

parent 45a9a0d8
Pipeline #10559065 passed with stage
in 6 minutes and 14 seconds
......@@ -6,11 +6,14 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use Raddit\AppBundle\Validator\Constraints\RateLimit;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass="Raddit\AppBundle\Repository\SubmissionRepository")
* @ORM\Table(name="submissions")
*
* @RateLimit(period="1 hour", max="3", groups={"untrusted_user_create"})
*/
class Submission extends Votable {
/**
......
......@@ -14,6 +14,7 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
......@@ -97,6 +98,26 @@ final class SubmissionType extends AbstractType {
'forum' => null,
'label_format' => 'submission_form.%name%',
'honeypot' => true,
'validation_groups' => function (FormInterface $form) {
$groups = ['Default'];
$trusted = $this->authorizationChecker->isGranted('ROLE_TRUSTED_USER');
if ($form->getData() && $form->getData()->getId()) {
$groups[] = 'edit';
if (!$trusted) {
$groups[] = 'untrusted_user_edit';
}
} else {
$groups[] = 'create';
if (!$trusted) {
$groups[] = 'untrusted_user_create';
}
}
return $groups;
},
]);
$resolver->setAllowedTypes('forum', [Forum::class, 'null']);
......
......@@ -12,3 +12,4 @@
'expression() syntax is not allowed in strings on line {{ line }}': 'expression() syntax is not allowed in strings on line {{ line }}'
'Embedded data is not allowed on line {{ line }}': 'Embedded data is not allowed on line {{ line }}'
'@import syntax is not allowed on line {{ line }}': '@import syntax is not allowed on line {{ line }}'
'You cannot post more. Wait a while before trying again.': 'You cannot post more. Wait a while before trying again.'
......@@ -4,6 +4,8 @@
{% block body %}
{{ form_start(form) }}
{{ form_errors(form) }}
{{ form_row(form.url, {attr: {class: 'fetch-title'}}) }}
{{ form_row(form.title, {attr: {rows: 3, class: 'receive-title'}}) }}
......
<?php
namespace Raddit\AppBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
/**
* Rate limit by user.
*
* @Annotation
* @Target("CLASS")
*/
class RateLimit extends Constraint {
const RATE_LIMITED_ERROR = 'bf95a6b8-f86d-4c9c-80ba-db0f8630fb27';
protected static $errorNames = [
self::RATE_LIMITED_ERROR => 'RATE_LIMITED_ERROR',
];
public $entityClass;
public $message = 'You cannot post more. Wait a while before trying again.';
public $max;
public $timestampField = 'timestamp';
public $userField = 'user';
/**
* {@link strtotime()} compatible date string.
*
* @var string
*/
public $period;
/**
* {@inheritdoc}
*/
public function __construct($options = null) {
parent::__construct($options);
$time = new \DateTime();
$altered = @(clone $time)->modify($options['period']);
if ($altered === false) {
throw new ConstraintDefinitionException(
'"period" must be a date string accepted by \DateTime::modify()'
);
}
if ($time == $altered) {
throw new ConstraintDefinitionException(
'The period specifies does not alter a \DateTime object'
);
}
}
/**
* {@inheritdoc}
*/
public function getRequiredOptions() {
return ['max', 'period'];
}
public function getTargets() {
return Constraint::CLASS_CONSTRAINT;
}
}
<?php
namespace Raddit\AppBundle\Validator\Constraints;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManagerInterface;
use Raddit\AppBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class RateLimitValidator extends ConstraintValidator {
/**
* @var EntityManagerInterface
*/
private $manager;
/**
* @var TokenStorageInterface
*/
private $tokenStorage;
public function __construct(
EntityManagerInterface $manager,
TokenStorageInterface $tokenStorage
) {
$this->manager = $manager;
$this->tokenStorage = $tokenStorage;
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
if ($value === null) {
return;
}
$token = $this->tokenStorage->getToken();
if (!$token || !$token->getUser() instanceof User) {
return;
}
if (!$constraint instanceof RateLimit) {
throw new UnexpectedTypeException($constraint, RateLimit::class);
}
if (!$constraint->entityClass && !is_object($value)) {
throw new UnexpectedTypeException($value, 'object');
}
$class = $constraint->entityClass ?: get_class($value);
$time = new \DateTime('@'.time());
$diff = $time->diff((clone $time)->modify($constraint->period));
$time->sub($diff);
$count = $this->manager->createQueryBuilder()
->select('COUNT(e)')
->from($class, 'e')
->where(sprintf('e.%s = ?1', $constraint->userField))
->andWhere(sprintf('e.%s >= ?2', $constraint->timestampField))
->setParameter(1, $token->getUser())
->setParameter(2, $time, Type::DATETIMETZ)
->getQuery()
->getSingleScalarResult();
if ($count >= $constraint->max) {
$this->context->buildViolation($constraint->message)
->setCode(RateLimit::RATE_LIMITED_ERROR)
->addViolation();
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment