mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-10 09:30:14 +02:00
0ee15e3cbb
* feat: add GDPR data export (download data) button Wires up the previously disabled "Download data" button on the settings page. Downloads a zip with a profile.xlsx (account + owned seasons), and per owned season a folder with each quiz's xlsx (questions, results, eliminations tabs) and a candidates.xlsx (candidates + season info tabs). Soft-deleted rows are included and flagged so the export reflects everything the app still holds about the user. * feat: include question bank in GDPR data export Adds a question-bank.xlsx per season folder with Questions (bank questions, answers, reusable/complete flags, labels, and which quizzes they've been used in) and Labels tabs, since BankQuestion/BankAnswer content was previously missing from the export. * fix: hard-delete quiz/audit-log data when deleting an account QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their removal through Season -> Quiz/Candidate only set deletedAt instead of physically deleting the row. Since Candidate and Answer are hard-deleted via orphanRemoval, this broke their foreign keys and rolled back the entire account deletion whenever a candidate had actually participated in a quiz. Bulk DQL deletes now purge these rows before the cascade runs. Also purge BankQuestion audit-log rows (ext_log_entries), which store the editor's username/email but aren't foreign-keyed to the entity they log, so they were never cleaned up and would otherwise keep a deleted user's email around indefinitely. * style: make the download data button primary (blue) * feat: add raw answers crosstab to quiz export Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one column per question, with the given answer text in each cell — the raw data behind the aggregated Results tab. * i18n: translate new settings page string to Dutch * refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by symfony/string's AsciiSlugger) and uses it everywhere user-controlled text (season/quiz names, account email) ends up in a zip entry path or a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9 survive; everything else, including unicode and path-traversal sequences, is folded or stripped) rather than a denylist of "unsafe" characters, and it's an officially maintained Symfony component already present as a transitive dependency. Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint that built its Content-Disposition filename directly from an unsanitized quiz name — the same class of risk the data export already guarded against. Naming note: sanitized names are now slugs (spaces become dashes, unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend". * fix: check ZipArchive open/close results and clean up temp files on failure Addresses CodeRabbit findings on the GDPR export: - ZipArchive::open() and close() can both return false without throwing; neither was checked, so a failure silently produced an empty or corrupt zip, and the temp zip path was never cleaned up on a mid-build exception. - writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save() threw before the caller could track it for cleanup. * fix: drop public link identifier from the candidates export sheet The nameHash is a public quiz-access token, not something a data export should hand out — remove it from candidates.xlsx. * feat: add Quiz info tab covering dropouts, finalization, and disabled questions An entity-by-entity audit of the export vs. delete flows found the delete flow fully covered, but three Quiz/Question fields missing from the export: dropouts, finalizedAt, and Question.enabled. Adds a new "Quiz info" tab (first sheet) to each quiz's xlsx with this data, without touching the shared fillQuestionsSheet() used by the existing single-quiz template export/import feature. Deliberately left out per user decision: the BankQuestion audit log (would leak other owners' emails, consistent with hiding co-owner identities elsewhere in this export) and a few low-value timestamp fields already covered by existing Started/time-taken columns. * feat: require a confirmed email before exporting data Antispam measure: both the full data export (SettingsController::downloadData) and the single-quiz export (BackofficeController::exportQuiz) now redirect with a flash warning instead of exporting when the account's email isn't verified yet. Adds a matching hint on the settings page next to the download button.
215 lines
9.0 KiB
PHP
215 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tvdt\Controller\Backoffice;
|
|
|
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Safe\DateTimeImmutable;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
use Symfony\Component\Form\FormError;
|
|
use Symfony\Component\Form\FormInterface;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
use Tvdt\Controller\AbstractController;
|
|
use Tvdt\Entity\User;
|
|
use Tvdt\Enum\FlashType;
|
|
use Tvdt\Form\ChangeEmailFormType;
|
|
use Tvdt\Form\ChangeUserPasswordFormType;
|
|
use Tvdt\Helpers\FilenameSanitizer;
|
|
use Tvdt\Repository\UserRepository;
|
|
use Tvdt\Security\EmailVerifier;
|
|
use Tvdt\Service\DataExportService;
|
|
|
|
final class SettingsController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
|
private readonly UserRepository $userRepository,
|
|
private readonly EmailVerifier $emailVerifier,
|
|
private readonly Security $security,
|
|
private readonly TranslatorInterface $translator,
|
|
private readonly DataExportService $dataExportService,
|
|
) {}
|
|
|
|
#[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])]
|
|
public function index(): Response
|
|
{
|
|
return $this->renderSettings();
|
|
}
|
|
|
|
#[IsCsrfTokenValid('settings_language')]
|
|
#[Route('/backoffice/settings/language', name: 'tvdt_backoffice_settings_language', methods: ['POST'])]
|
|
public function saveLanguage(): RedirectResponse
|
|
{
|
|
// Only Dutch is available for now, so saving is a noop.
|
|
$this->addFlash(FlashType::Success, $this->translator->trans('Language saved'));
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
}
|
|
|
|
#[Route('/backoffice/settings/password', name: 'tvdt_backoffice_settings_password', methods: ['POST'])]
|
|
public function changePassword(Request $request): Response
|
|
{
|
|
$user = $this->authenticatedUser;
|
|
$form = $this->createForm(ChangeUserPasswordFormType::class);
|
|
$form->handleRequest($request);
|
|
|
|
if ($form->isSubmitted() && $form->isValid()) {
|
|
/** @var string $plainPassword */
|
|
$plainPassword = $form->get('plainPassword')->getData();
|
|
|
|
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
|
|
$this->entityManager->flush();
|
|
$this->userRepository->invalidateResetPasswordRequests($user);
|
|
|
|
$this->security->login($user, 'form_login', 'main');
|
|
$this->addFlash(FlashType::Success, $this->translator->trans('Your password has been changed.'));
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
}
|
|
|
|
return $this->renderSettings(passwordForm: $form);
|
|
}
|
|
|
|
#[Route('/backoffice/settings/email', name: 'tvdt_backoffice_settings_email', methods: ['POST'])]
|
|
public function changeEmail(Request $request): Response
|
|
{
|
|
$user = $this->authenticatedUser;
|
|
$form = $this->createForm(ChangeEmailFormType::class);
|
|
$form->handleRequest($request);
|
|
|
|
if ($form->isSubmitted() && $form->isValid()) {
|
|
/** @var string $email */
|
|
$email = $form->get('email')->getData();
|
|
|
|
$found = $this->userRepository->findOneBy(['email' => $email]);
|
|
if ($found instanceof User && $found !== $user) {
|
|
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
|
|
|
|
return $this->renderSettings(emailForm: $form);
|
|
}
|
|
|
|
$originalEmail = $user->email;
|
|
$originalIsVerified = $user->isVerified;
|
|
|
|
$user->email = $email;
|
|
$user->isVerified = false;
|
|
|
|
try {
|
|
$this->entityManager->flush();
|
|
} catch (UniqueConstraintViolationException) {
|
|
// A concurrent request can claim the email between the uniqueness check above and the flush
|
|
$user->email = $originalEmail;
|
|
$user->isVerified = $originalIsVerified;
|
|
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
|
|
|
|
return $this->renderSettings(emailForm: $form);
|
|
}
|
|
|
|
$this->userRepository->invalidateResetPasswordRequests($user);
|
|
|
|
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
|
|
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed. Please check your inbox to confirm it.'));
|
|
} else {
|
|
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed.'));
|
|
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please use the resend button to try again.'));
|
|
}
|
|
|
|
$this->security->login($user, 'form_login', 'main');
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
}
|
|
|
|
return $this->renderSettings(emailForm: $form);
|
|
}
|
|
|
|
#[IsCsrfTokenValid('resend_confirmation')]
|
|
#[Route('/backoffice/settings/resend-confirmation', name: 'tvdt_backoffice_settings_resend_confirmation', methods: ['POST'])]
|
|
public function resendConfirmationEmail(): RedirectResponse
|
|
{
|
|
$user = $this->authenticatedUser;
|
|
|
|
if ($user->isVerified) {
|
|
$this->addFlash(FlashType::Info, $this->translator->trans('Your email address is already confirmed.'));
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
}
|
|
|
|
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
|
|
$this->addFlash(FlashType::Success, $this->translator->trans('A new confirmation email has been sent. Please check your inbox.'));
|
|
} else {
|
|
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please try again later.'));
|
|
}
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
}
|
|
|
|
#[Route('/backoffice/settings/download-data', name: 'tvdt_backoffice_settings_download_data', methods: ['GET'])]
|
|
public function downloadData(): Response
|
|
{
|
|
if (!$this->authenticatedUser->isVerified) {
|
|
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before downloading your data.'));
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
}
|
|
|
|
$zipPath = $this->dataExportService->exportForUser($this->authenticatedUser);
|
|
|
|
$filename = \sprintf(
|
|
'tijd-voor-de-test-data-%s-%s.zip',
|
|
FilenameSanitizer::sanitize($this->authenticatedUser->email),
|
|
new DateTimeImmutable()->format('Y-m-d_H-i-s'),
|
|
);
|
|
|
|
$response = new BinaryFileResponse($zipPath);
|
|
$response->deleteFileAfterSend(true);
|
|
$response->headers->set('Content-Type', 'application/zip');
|
|
$response->headers->set(
|
|
'Content-Disposition',
|
|
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename),
|
|
);
|
|
|
|
return $response;
|
|
}
|
|
|
|
#[IsCsrfTokenValid('delete_account')]
|
|
#[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])]
|
|
public function deleteAccount(Request $request): Response
|
|
{
|
|
$user = $this->authenticatedUser;
|
|
$password = (string) $request->request->get('password', '');
|
|
|
|
if (!$this->passwordHasher->isPasswordValid($user, $password)) {
|
|
$this->addFlash(FlashType::Danger, $this->translator->trans('Wrong password, your account has not been deleted.'));
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
}
|
|
|
|
$this->userRepository->deleteUser($user);
|
|
|
|
return $this->security->logout(false) ?? $this->redirectToRoute('tvdt_login_login');
|
|
}
|
|
|
|
/**
|
|
* @param FormInterface<array{currentPassword: string, plainPassword: string}|null>|null $passwordForm
|
|
* @param FormInterface<array{email: string}|null>|null $emailForm
|
|
*/
|
|
private function renderSettings(?FormInterface $passwordForm = null, ?FormInterface $emailForm = null): Response
|
|
{
|
|
return $this->render('backoffice/settings/index.html.twig', [
|
|
'passwordForm' => $passwordForm ?? $this->createForm(ChangeUserPasswordFormType::class),
|
|
'emailForm' => $emailForm ?? $this->createForm(ChangeEmailFormType::class),
|
|
]);
|
|
}
|
|
}
|