Added Gedmo stuff, fix translations (#117)

* Added Gedmo stuff, fix translations

* Add CSRF token validation across backoffice forms

- Added CSRF validations to candidate correction, penalty, answer saving, and elimination forms.
- Updated corresponding Twig templates to include CSRF token inputs.
- Adjusted column count in `tab_result` template to maintain layout consistency.

* Add unique index constraint for `quiz_candidate` with soft delete support

- Updated migration to include a unique index on `quiz_candidate` table that excludes soft-deleted records.
- Adjusted `QuizCandidate` entity to reflect the new unique constraint with `deleted_at` condition.

* Add CSRF token validation for quiz-related actions

- Added CSRF validation to `enableQuiz`, `clearQuiz`, `deleteQuiz`, `toggleCandidate`, and `prepareElimination` actions.
- Updated Twig templates to replace links with POST forms to include CSRF tokens.
- Set HTTP method restrictions for related endpoints to `POST`.

* Fix unique index condition for `quiz_candidate` with soft deletes

- Updated condition in unique index definition of `quiz_candidate` to add parentheses for clarity.
- Adjusted related migration to reflect the revised condition.

* Remove if for post an use methods in Route instead

* Refactor CSRF token validation in backoffice controllers

- Applied `#[IsCsrfTokenValid]` attribute for CSRF checks to simplify and standardize validation.
- Removed manual `isCsrfTokenValid` calls and associated exception throwing.
- Updated method signatures across affected endpoints to remove unnecessary `Request` dependency.
- Ensured consistency in route HTTP method restrictions where applicable.

* Add rector and phpstan

* Add validation for answering incorrect quiz question

- Added logic to prevent candidates from answering questions out of sequence in `QuizController`.
- Updated Dutch translations to include the new error message.

* Things
This commit is contained in:
2026-05-24 19:43:30 +02:00
committed by GitHub
parent c033965652
commit 281462fab8
30 changed files with 319 additions and 135 deletions
@@ -10,6 +10,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\Quiz;
@@ -20,10 +21,12 @@ final class PrepareEliminationController extends AbstractController
{
public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {}
#[IsCsrfTokenValid('prepare_elimination')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare',
name: 'tvdt_prepare_elimination',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function index(Season $season, Quiz $quiz): RedirectResponse
{
@@ -32,14 +35,16 @@ final class PrepareEliminationController extends AbstractController
return $this->redirectToRoute('tvdt_prepare_elimination_view', ['elimination' => $elimination->id]);
}
#[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])]
#[Route(
'/backoffice/elimination/{elimination}',
name: 'tvdt_prepare_elimination_view',
requirements: ['elimination' => Requirement::UUID],
methods: ['GET', 'POST'],
)]
public function viewElimination(Elimination $elimination, Request $request): Response
{
if ('POST' === $request->getMethod()) {
if ($request->isMethod('POST')) {
$elimination->updateFromInputBag($request->request);
$this->em->flush();
@@ -48,6 +53,8 @@ final class PrepareEliminationController extends AbstractController
}
$this->addFlash('success', 'Elimination updated');
return $this->redirectToRoute('tvdt_prepare_elimination_view', ['elimination' => $elimination->id]);
}
return $this->render('backoffice/prepare_elimination/index.html.twig', [
+14 -10
View File
@@ -10,9 +10,9 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
@@ -184,6 +184,7 @@ class QuizController extends AbstractController
]);
}
#[IsCsrfTokenValid('candidate_answer')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}',
@@ -241,11 +242,13 @@ class QuizController extends AbstractController
]);
}
#[IsCsrfTokenValid('enable_quiz')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/enable',
name: 'tvdt_backoffice_enable',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
methods: ['POST'],
)]
public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse
{
@@ -259,11 +262,13 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('clear_quiz')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/clear',
name: 'tvdt_backoffice_quiz_clear',
requirements: ['quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function clearQuiz(Quiz $quiz): RedirectResponse
{
@@ -277,11 +282,13 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsCsrfTokenValid('delete_quiz')]
#[IsGranted(SeasonVoter::DELETE, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/delete',
name: 'tvdt_backoffice_quiz_delete',
requirements: ['quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function deleteQuiz(Quiz $quiz): RedirectResponse
{
@@ -292,18 +299,16 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
}
#[IsCsrfTokenValid('candidate_correction')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/candidate/{candidate}/modify_correction',
name: 'tvdt_backoffice_modify_correction',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function modifyCorrection(Quiz $quiz, Candidate $candidate, Request $request): RedirectResponse
{
if (!$request->isMethod('POST')) {
throw new MethodNotAllowedHttpException(['POST']);
}
$corrections = (float) $request->request->get('corrections');
$this->quizCandidateRepository->setCorrectionsForCandidate($quiz, $candidate, $corrections);
@@ -311,18 +316,16 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsCsrfTokenValid('candidate_penalty')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/candidate/{candidate}/modify_penalty',
name: 'tvdt_backoffice_modify_penalty',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function modifyPenalty(Quiz $quiz, Candidate $candidate, Request $request): RedirectResponse
{
if (!$request->isMethod('POST')) {
throw new MethodNotAllowedHttpException(['POST']);
}
$penalty = (int) $request->request->get('penalty');
$this->quizCandidateRepository->setPenaltyForCandidate($quiz, $candidate, $penalty);
@@ -330,12 +333,13 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsCsrfTokenValid('toggle_candidate')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/candidate/{candidate}/toggle',
name: 'tvdt_backoffice_toggle_candidate',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['GET'],
methods: ['POST'],
)]
public function toggleCandidate(Quiz $quiz, Candidate $candidate): RedirectResponse
{
+2 -2
View File
@@ -55,7 +55,7 @@ final class EliminationController extends AbstractController
$candidate = $this->candidateRepository->getCandidateByHash($elimination->quiz->season, $candidateHash);
if (!$candidate instanceof Candidate) {
$this->addFlash(FlashType::Warning,
t('Cound not find candidate with name %name%', ['%name%' => Base64::base64UrlDecode($candidateHash)])->trans($this->translator),
t('Could not find candidate with name {name}', ['name' => Base64::base64UrlDecode($candidateHash)])->trans($this->translator),
);
return $this->redirectToRoute('tvdt_elimination', ['elimination' => $elimination->id]);
@@ -64,7 +64,7 @@ final class EliminationController extends AbstractController
$screenColour = $elimination->getScreenColour($candidate->name);
if (null === $screenColour) {
$this->addFlash(FlashType::Warning, $this->translator->trans('Cound not find candidate with name %name% in elimination.', ['%name%' => $candidate->name]));
$this->addFlash(FlashType::Warning, $this->translator->trans('Could not find candidate with name {name} in elimination.', ['name' => $candidate->name]));
return $this->redirectToRoute('tvdt_elimination', ['elimination' => $elimination->id]);
}
+1
View File
@@ -8,6 +8,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Enum\FlashType;
+10 -1
View File
@@ -9,6 +9,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
@@ -70,10 +71,12 @@ final class QuizController extends AbstractController
return $this->render('quiz/enter_name.twig', ['season' => $season, 'form' => $form]);
}
#[IsCsrfTokenValid('question', tokenKey: 'token', methods: ['POST'])]
#[Route(
path: '/{seasonCode:season}/{nameHash}',
name: 'tvdt_quiz_quiz_page',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'nameHash' => self::CANDIDATE_HASH_REGEX],
methods: ['GET', 'POST'],
)]
public function quizPage(
Season $season,
@@ -96,7 +99,7 @@ final class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]);
}
if ('POST' === $request->getMethod()) {
if ($request->isMethod('POST')) {
// TODO: Extract saving answer logic to a service
// Check if candidate is inactive for this quiz
$quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]);
@@ -114,6 +117,12 @@ final class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_quiz_quiz_page', ['seasonCode' => $season->seasonCode, 'nameHash' => $nameHash]);
}
if ($answer->question !== $this->questionRepository->findNextQuestionForCandidate($candidate)) {
$this->addFlash(FlashType::Danger, $this->translator->trans('You cannot answer this question'));
return $this->redirectToRoute('tvdt_quiz_quiz_page', ['seasonCode' => $season->seasonCode, 'nameHash' => $nameHash]);
}
$givenAnswer = new GivenAnswer($candidate, $answer->question->quiz, $answer);
$this->entityManager->persist($givenAnswer);
$this->entityManager->flush();