mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-12 04:48:22 +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.
136 lines
5.0 KiB
PHP
136 lines
5.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tvdt\Repository;
|
|
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
|
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
|
use Tvdt\Entity\BankQuestion;
|
|
use Tvdt\Entity\Elimination;
|
|
use Tvdt\Entity\GivenAnswer;
|
|
use Tvdt\Entity\QuizCandidate;
|
|
use Tvdt\Entity\Season;
|
|
use Tvdt\Entity\User;
|
|
|
|
/** @extends ServiceEntityRepository<User> */
|
|
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, User::class);
|
|
}
|
|
|
|
/** Used to upgrade (rehash) the user's password automatically over time.
|
|
* @param User $user
|
|
* */
|
|
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
|
{
|
|
$user->password = $newHashedPassword;
|
|
$this->getEntityManager()->persist($user);
|
|
$this->getEntityManager()->flush();
|
|
}
|
|
|
|
/** Deletes all outstanding reset-password tokens for the user (e.g. after a password or email change). */
|
|
public function invalidateResetPasswordRequests(User $user): void
|
|
{
|
|
$this->getEntityManager()
|
|
->createQuery('delete from Tvdt\Entity\ResetPasswordRequest r where r.user = :user')
|
|
->setParameter('user', $user)
|
|
->execute();
|
|
}
|
|
|
|
/** Deletes the user, all seasons the user is the sole owner of, and the user's ownership of shared seasons. */
|
|
public function deleteUser(User $user): void
|
|
{
|
|
$em = $this->getEntityManager();
|
|
$em->wrapInTransaction(function () use ($em, $user): void {
|
|
$this->invalidateResetPasswordRequests($user);
|
|
|
|
$bankQuestionIds = [];
|
|
foreach ($user->seasons->toArray() as $season) {
|
|
if (1 === $season->owners->count()) {
|
|
$this->purgeSoftDeletableData($em, $season);
|
|
array_push($bankQuestionIds, ...$this->bankQuestionIds($season));
|
|
$em->remove($season);
|
|
|
|
continue;
|
|
}
|
|
|
|
$season->removeOwner($user);
|
|
}
|
|
|
|
$em->remove($user);
|
|
$em->flush();
|
|
|
|
// Gedmo\Loggable writes its own "removed" log entry as part of the flush above, so the
|
|
// audit-log purge must happen after — purging first would just leave that final row behind.
|
|
$this->purgeBankQuestionAuditLog($em, $bankQuestionIds);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their
|
|
* removal through the season/quiz/candidate relations only sets deletedAt — it never removes
|
|
* the row. That leaves personal data behind indefinitely and, since Candidate/Answer are hard
|
|
* deleted via orphanRemoval, it also breaks their foreign keys and rolls back the whole
|
|
* deletion. Bulk DQL deletes bypass the Gedmo listener and physically remove these rows first.
|
|
*/
|
|
private function purgeSoftDeletableData(EntityManagerInterface $em, Season $season): void
|
|
{
|
|
foreach ([QuizCandidate::class, GivenAnswer::class, Elimination::class] as $class) {
|
|
$em->createQuery(<<<DQL
|
|
delete from {$class} e
|
|
where e.quiz in (select q from Tvdt\Entity\Quiz q where q.season = :season)
|
|
DQL)
|
|
->setParameter('season', $season)
|
|
->execute();
|
|
}
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function bankQuestionIds(Season $season): array
|
|
{
|
|
return array_values(array_map(
|
|
static fn (BankQuestion $bankQuestion): string => $bankQuestion->id->toString(),
|
|
$season->bankQuestions->toArray(),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Gedmo\Loggable audit rows (ext_log_entries) aren't foreign-keyed to the entity they log —
|
|
* object_id is a plain string — so removing a BankQuestion never cleans up its history, and
|
|
* the editor's username/email would otherwise remain in those rows forever.
|
|
*
|
|
* @param list<string> $bankQuestionIds
|
|
*/
|
|
private function purgeBankQuestionAuditLog(EntityManagerInterface $em, array $bankQuestionIds): void
|
|
{
|
|
if ([] === $bankQuestionIds) {
|
|
return;
|
|
}
|
|
|
|
$em->createQuery(<<<'DQL'
|
|
delete from Tvdt\Entity\LogEntry l
|
|
where l.objectClass = :class and l.objectId in (:ids)
|
|
DQL)
|
|
->setParameter('class', BankQuestion::class)
|
|
->setParameter('ids', $bankQuestionIds)
|
|
->execute();
|
|
}
|
|
|
|
public function makeAdmin(string $email): void
|
|
{
|
|
$user = $this->findOneBy(['email' => $email]);
|
|
if (!$user instanceof User) {
|
|
throw new \InvalidArgumentException('User not found');
|
|
}
|
|
|
|
$user->roles = ['ROLE_ADMIN'];
|
|
$this->getEntityManager()->flush();
|
|
}
|
|
}
|