From 0ee15e3cbb7bae6386cb9306b56db6a3b162a63e Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Thu, 9 Jul 2026 22:28:10 +0200 Subject: [PATCH] feat: add GDPR data export (download data) button (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- composer.json | 1 + composer.lock | 2 +- .../Backoffice/BackofficeController.php | 14 +- .../Backoffice/SettingsController.php | 34 ++ src/Helpers/FilenameSanitizer.php | 18 + src/Repository/UserRepository.php | 63 +++ src/Service/DataExportService.php | 444 ++++++++++++++++++ src/Service/QuizSpreadsheetService.php | 12 +- templates/backoffice/settings/index.html.twig | 13 +- .../Backoffice/BackofficeControllerTest.php | 57 +++ .../Backoffice/SettingsControllerTest.php | 43 ++ tests/Helpers/FilenameSanitizerTest.php | 44 ++ tests/Repository/UserRepositoryTest.php | 91 ++++ tests/Service/DataExportServiceTest.php | 286 +++++++++++ translations/messages+intl-icu.nl.xliff | 24 + 15 files changed, 1132 insertions(+), 14 deletions(-) create mode 100644 src/Helpers/FilenameSanitizer.php create mode 100644 src/Service/DataExportService.php create mode 100644 tests/Controller/Backoffice/BackofficeControllerTest.php create mode 100644 tests/Helpers/FilenameSanitizerTest.php create mode 100644 tests/Service/DataExportServiceTest.php diff --git a/composer.json b/composer.json index c634daf..322857e 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "symfony/security-bundle": "8.1.*", "symfony/security-csrf": "8.1.*", "symfony/serializer": "8.1.*", + "symfony/string": "8.1.*", "symfony/translation": "8.1.*", "symfony/twig-bundle": "8.1.*", "symfony/uid": "8.1.*", diff --git a/composer.lock b/composer.lock index 8fa7717..a5b403c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "010a4456ebc1a8ebaf73c6db051d3d09", + "content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5", "packages": [ { "name": "composer/pcre", diff --git a/src/Controller/Backoffice/BackofficeController.php b/src/Controller/Backoffice/BackofficeController.php index 97e1bb6..9ea0b02 100644 --- a/src/Controller/Backoffice/BackofficeController.php +++ b/src/Controller/Backoffice/BackofficeController.php @@ -14,10 +14,13 @@ use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Contracts\Translation\TranslatorInterface; use Tvdt\Controller\AbstractController; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; +use Tvdt\Enum\FlashType; use Tvdt\Form\CreateSeasonFormType; +use Tvdt\Helpers\FilenameSanitizer; use Tvdt\Repository\SeasonRepository; use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Service\QuizSpreadsheetService; @@ -31,6 +34,7 @@ final class BackofficeController extends AbstractController private readonly Security $security, private readonly QuizSpreadsheetService $excel, private readonly EntityManagerInterface $em, + private readonly TranslatorInterface $translator, ) {} #[Route('/backoffice/', name: 'tvdt_backoffice_index')] @@ -83,11 +87,17 @@ final class BackofficeController extends AbstractController requirements: ['quiz' => Requirement::UUID], methods: ['GET'], )] - public function exportQuiz(Quiz $quiz): StreamedResponse + public function exportQuiz(Quiz $quiz): Response { + if (!$this->authenticatedUser->isVerified) { + $this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before exporting a quiz.')); + + return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]); + } + $response = new StreamedResponse($this->excel->quizToXlsx($quiz)); $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); - $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx')); + $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, FilenameSanitizer::sanitize($quiz->name).'.xlsx')); return $response; } diff --git a/src/Controller/Backoffice/SettingsController.php b/src/Controller/Backoffice/SettingsController.php index c8bf816..ebcfcd7 100644 --- a/src/Controller/Backoffice/SettingsController.php +++ b/src/Controller/Backoffice/SettingsController.php @@ -6,9 +6,12 @@ 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; @@ -21,8 +24,10 @@ 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 { @@ -33,6 +38,7 @@ final class SettingsController extends AbstractController 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'])] @@ -148,6 +154,34 @@ final class SettingsController extends AbstractController 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 diff --git a/src/Helpers/FilenameSanitizer.php b/src/Helpers/FilenameSanitizer.php new file mode 100644 index 0000000..286191c --- /dev/null +++ b/src/Helpers/FilenameSanitizer.php @@ -0,0 +1,18 @@ +slug($value)->toString(); + + return '' === $slug ? 'unnamed' : $slug; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 0a3433f..7eb8f4c 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -5,9 +5,15 @@ 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 */ @@ -44,8 +50,11 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader $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; @@ -56,9 +65,63 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader $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(<<setParameter('season', $season) + ->execute(); + } + } + + /** @return list */ + 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 $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]); diff --git a/src/Service/DataExportService.php b/src/Service/DataExportService.php new file mode 100644 index 0000000..396084a --- /dev/null +++ b/src/Service/DataExportService.php @@ -0,0 +1,444 @@ +entityManager->getFilters(); + $filter->disable('softdeleteable'); + + try { + return $this->buildZip($user); + } finally { + $filter->enable('softdeleteable'); + } + } + + private function buildZip(User $user): string + { + $zipPath = tempnam(sys_get_temp_dir(), 'tvdt_export_'); + $tempXlsxFiles = []; + + $zip = new \ZipArchive(); + if (true !== $zip->open($zipPath, \ZipArchive::OVERWRITE)) { + unlink($zipPath); + + throw new \RuntimeException('Could not create the export zip archive.'); + } + + try { + $profilePath = $this->writeToTempFile($this->buildProfileWorkbook($user)); + $tempXlsxFiles[] = $profilePath; + $zip->addFile($profilePath, 'profile.xlsx'); + + foreach ($user->seasons as $season) { + $folder = FilenameSanitizer::sanitize($season->seasonCode.'-'.$season->name).'/'; + + foreach ($season->quizzes as $quiz) { + $quizPath = $this->writeToTempFile($this->buildQuizWorkbook($quiz)); + $tempXlsxFiles[] = $quizPath; + $zip->addFile($quizPath, $folder.FilenameSanitizer::sanitize($quiz->name).'.xlsx'); + } + + $candidatesPath = $this->writeToTempFile($this->buildCandidatesWorkbook($season)); + $tempXlsxFiles[] = $candidatesPath; + $zip->addFile($candidatesPath, $folder.'candidates.xlsx'); + + $questionBankPath = $this->writeToTempFile($this->buildQuestionBankWorkbook($season)); + $tempXlsxFiles[] = $questionBankPath; + $zip->addFile($questionBankPath, $folder.'question-bank.xlsx'); + } + + if (!$zip->close()) { + throw new \RuntimeException('Could not finalize the export zip archive.'); + } + } catch (\Throwable $throwable) { + unlink($zipPath); + + throw $throwable; + } finally { + foreach ($tempXlsxFiles as $tempXlsxFile) { + unlink($tempXlsxFile); + } + } + + return $zipPath; + } + + private function buildProfileWorkbook(User $user): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + + $account = $spreadsheet->getActiveSheet(); + $account->setTitle('Account'); + $account->getStyle('A:A')->getFont()->setBold(true); + $account->fromArray([ + ['Email', $user->email], + ['Roles', implode(', ', $user->getRoles())], + ['Email verified', $user->isVerified ? 'Yes' : 'No'], + ['Account ID', $user->id->toString()], + ], null, 'A1'); + $account->getColumnDimension('A')->setAutoSize(true); + $account->getColumnDimension('B')->setAutoSize(true); + + $seasons = $spreadsheet->createSheet(); + $seasons->setTitle('Seasons'); + $seasons->fromArray(['Season', 'Season code', 'Quizzes', 'Candidates', 'Shared with other owners'], null, 'A1'); + $seasons->getStyle('1:1')->getFont()->setBold(true); + + $row = 2; + foreach ($user->seasons as $season) { + $seasons->fromArray([ + $season->name, + $season->seasonCode, + $season->quizzes->count(), + $season->candidates->count(), + $season->owners->count() > 1 ? 'Yes' : 'No', + ], null, 'A'.$row); + ++$row; + } + + foreach (['A', 'B', 'C', 'D', 'E'] as $column) { + $seasons->getColumnDimension($column)->setAutoSize(true); + } + + $spreadsheet->setActiveSheetIndex(0); + + return $spreadsheet; + } + + private function buildQuizWorkbook(Quiz $quiz): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + + $info = $spreadsheet->getActiveSheet(); + $info->setTitle('Quiz info'); + $this->fillQuizInfoSheet($info, $quiz); + + $questions = $spreadsheet->createSheet(); + $questions->setTitle('Questions'); + + $this->quizSpreadsheetService->fillQuestionsSheet($questions, $quiz); + + $rawAnswers = $spreadsheet->createSheet(); + $rawAnswers->setTitle('Raw answers'); + $this->fillRawAnswersSheet($rawAnswers, $quiz); + + $results = $spreadsheet->createSheet(); + $results->setTitle('Results'); + $this->fillResultsSheet($results, $quiz); + + $eliminations = $spreadsheet->createSheet(); + $eliminations->setTitle('Eliminations'); + $this->fillEliminationsSheet($eliminations, $quiz); + + $spreadsheet->setActiveSheetIndex(0); + + return $spreadsheet; + } + + private function fillQuizInfoSheet(Worksheet $sheet, Quiz $quiz): void + { + $disabledQuestions = $quiz->questions + ->filter(static fn (Question $question): bool => !$question->enabled) + ->map(static fn (Question $question): string => $question->question) + ->toArray(); + + $sheet->getStyle('A:A')->getFont()->setBold(true); + $sheet->fromArray([ + ['Quiz name', $quiz->name], + ['Number of dropouts', $quiz->dropouts], + ['Finalized', $quiz->isFinalized ? 'Yes' : 'No'], + ['Finalized at', $quiz->finalizedAt?->format(\DateTimeInterface::ATOM) ?? ''], + ['Disabled questions', implode(', ', $disabledQuestions)], + ], null, 'A1'); + $sheet->getColumnDimension('A')->setAutoSize(true); + $sheet->getColumnDimension('B')->setAutoSize(true); + } + + private function fillResultsSheet(Worksheet $sheet, Quiz $quiz): void + { + $sheet->fromArray(['Candidate', 'Correct answers', 'Corrections', 'Penalty (s)', 'Score', 'Time', 'Started', 'Active', 'Deleted'], null, 'A1'); + $sheet->getStyle('1:1')->getFont()->setBold(true); + + /** @var array $scoresByCandidateId */ + $scoresByCandidateId = []; + foreach ($this->quizRepository->getScores($quiz) as $result) { + $scoresByCandidateId[$result->id->toString()] = $result; + } + + $row = 2; + foreach ($quiz->candidateData as $quizCandidate) { + $candidate = $quizCandidate->candidate; + $result = $scoresByCandidateId[$candidate->id->toString()] ?? null; + + $sheet->fromArray([ + $candidate->name, + $result?->correct, + $result?->corrections, + $result?->penaltySeconds, + $result?->score, + $result instanceof Result ? $result->time->format('%i:%S') : null, + $quizCandidate->started?->format(\DateTimeInterface::ATOM), + $quizCandidate->active ? 'Yes' : 'No', + $quizCandidate->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '', + ], null, 'A'.$row); + ++$row; + } + + foreach (range('A', 'I') as $column) { + $sheet->getColumnDimension($column)->setAutoSize(true); + } + } + + /** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */ + private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void + { + /** @var list $questions */ + $questions = $quiz->questions->toArray(); + + $header = ['Candidate']; + foreach ($questions as $question) { + $header[] = $question->question; + } + + $sheet->fromArray($header, null, 'A1'); + $sheet->getStyle('1:1')->getFont()->setBold(true); + $sheet->getStyle('1:1')->getAlignment()->setWrapText(true); + + /** @var array> $answersByCandidateAndQuestion */ + $answersByCandidateAndQuestion = []; + foreach ($questions as $question) { + foreach ($question->answers as $answer) { + foreach ($answer->givenAnswers as $givenAnswer) { + $candidateId = $givenAnswer->candidate->id->toString(); + $answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text; + } + } + } + + $row = 2; + foreach ($quiz->candidateData as $quizCandidate) { + $candidate = $quizCandidate->candidate; + + $line = [$candidate->name]; + foreach ($questions as $question) { + $line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? ''; + } + + $sheet->fromArray($line, null, 'A'.$row); + ++$row; + } + + $lastColumnIndex = 1 + \count($questions); + foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) { + $sheet->getColumnDimension($column)->setWidth(30); + $sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true); + } + } + + private function fillEliminationsSheet(Worksheet $sheet, Quiz $quiz): void + { + /** @var list $candidates */ + $candidates = $quiz->season->candidates->toArray(); + + $header = ['Prepared at', 'Deleted']; + foreach ($candidates as $candidate) { + $header[] = $candidate->name; + } + + $sheet->fromArray($header, null, 'A1'); + $sheet->getStyle('1:1')->getFont()->setBold(true); + + $row = 2; + foreach ($quiz->eliminations as $elimination) { + $line = [ + $elimination->getCreatedAt()?->format(\DateTimeInterface::ATOM) ?? '', + $elimination->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '', + ]; + + foreach ($candidates as $candidate) { + $line[] = $elimination->getScreenColour($candidate->name) ?? ''; + } + + $sheet->fromArray($line, null, 'A'.$row); + ++$row; + } + + foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) { + $sheet->getColumnDimension($column)->setAutoSize(true); + } + } + + private function buildCandidatesWorkbook(Season $season): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + + $candidatesSheet = $spreadsheet->getActiveSheet(); + $candidatesSheet->setTitle('Candidates'); + $candidatesSheet->fromArray(['Name'], null, 'A1'); + $candidatesSheet->getStyle('1:1')->getFont()->setBold(true); + + $row = 2; + foreach ($season->candidates as $candidate) { + $candidatesSheet->fromArray([$candidate->name], null, 'A'.$row); + ++$row; + } + + $candidatesSheet->getColumnDimension('A')->setAutoSize(true); + + $infoSheet = $spreadsheet->createSheet(); + $infoSheet->setTitle('Season info'); + $infoSheet->getStyle('A:A')->getFont()->setBold(true); + $infoSheet->fromArray([ + ['Season name', $season->name], + ['Season code', $season->seasonCode], + ['Number of quizzes', $season->quizzes->count()], + ['Number of candidates', $season->candidates->count()], + ['Active quiz', $season->activeQuiz instanceof Quiz ? $season->activeQuiz->name : ''], + ['Show numbers', $season->settings?->showNumbers ? 'Yes' : 'No'], + ['Confirm answers', $season->settings?->confirmAnswers ? 'Yes' : 'No'], + ['Shared with other owners', $season->owners->count() > 1 ? 'Yes' : 'No'], + ], null, 'A1'); + $infoSheet->getColumnDimension('A')->setAutoSize(true); + $infoSheet->getColumnDimension('B')->setAutoSize(true); + + $spreadsheet->setActiveSheetIndex(0); + + return $spreadsheet; + } + + private function buildQuestionBankWorkbook(Season $season): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + + $questions = $spreadsheet->getActiveSheet(); + $questions->setTitle('Questions'); + $this->fillBankQuestionsSheet($questions, $season); + + $labels = $spreadsheet->createSheet(); + $labels->setTitle('Labels'); + $this->fillQuestionLabelsSheet($labels, $season); + + $spreadsheet->setActiveSheetIndex(0); + + return $spreadsheet; + } + + private function fillBankQuestionsSheet(Worksheet $sheet, Season $season): void + { + $metaColumns = ['Question', 'Reusable', 'Complete for quiz', 'Labels', 'Used in quizzes']; + $sheet->fromArray($metaColumns, null, 'A1'); + $sheet->getStyle('1:1')->getFont()->setBold(true); + + $answerStartColumnIndex = \count($metaColumns); + $maxAnswers = 0; + $row = 2; + + foreach ($season->bankQuestions as $bankQuestion) { + $labels = implode(', ', array_map( + static fn (QuestionLabel $label): string => $label->name, + $bankQuestion->labels->toArray(), + )); + $usedInQuizzes = implode(', ', array_map( + static fn (BankQuestionUsage $usage): string => $usage->quiz->name, + $bankQuestion->usages->toArray(), + )); + + $sheet->fromArray([ + $bankQuestion->question, + $bankQuestion->reusable ? 'Yes' : 'No', + $bankQuestion->isCompleteForQuiz ? 'Yes' : 'No', + $labels, + $usedInQuizzes, + ], null, 'A'.$row); + + $col = 0; + foreach ($bankQuestion->answers as $answer) { + $sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $col).$row, $answer->text); + $sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $col).$row, $answer->isRightAnswer); + ++$col; + } + + $maxAnswers = max($maxAnswers, $col); + ++$row; + } + + for ($i = 0; $i < $maxAnswers; ++$i) { + $answerCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $i); + $correctCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $i); + + $sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1)); + $sheet->setCellValue($correctCol.'1', 'Correct'); + } + + $lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers); + foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) { + $sheet->getColumnDimension($column)->setAutoSize(true); + } + } + + private function fillQuestionLabelsSheet(Worksheet $sheet, Season $season): void + { + $sheet->fromArray(['Name', 'Colour', 'Slug'], null, 'A1'); + $sheet->getStyle('1:1')->getFont()->setBold(true); + + $row = 2; + foreach ($season->questionLabels as $label) { + $sheet->fromArray([$label->name, $label->colour->name, $label->slug], null, 'A'.$row); + ++$row; + } + + foreach (['A', 'B', 'C'] as $column) { + $sheet->getColumnDimension($column)->setAutoSize(true); + } + } + + /** @throws FilesystemException */ + private function writeToTempFile(Spreadsheet $spreadsheet): string + { + $path = tempnam(sys_get_temp_dir(), 'tvdt_export_sheet_'); + + try { + new Writer\Xlsx($spreadsheet)->save($path); + } catch (\Throwable $throwable) { + unlink($path); + + throw $throwable; + } + + return $path; + } +} diff --git a/src/Service/QuizSpreadsheetService.php b/src/Service/QuizSpreadsheetService.php index 152995c..f186223 100644 --- a/src/Service/QuizSpreadsheetService.php +++ b/src/Service/QuizSpreadsheetService.php @@ -7,6 +7,7 @@ namespace Tvdt\Service; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Writer; use Symfony\Component\HttpFoundation\File\File; use Tvdt\Entity\Answer; @@ -117,8 +118,13 @@ class QuizSpreadsheetService public function quizToXlsx(Quiz $quiz): \Closure { $spreadsheet = new Spreadsheet(); - $sheet = $spreadsheet->getActiveSheet(); + $this->fillQuestionsSheet($spreadsheet->getActiveSheet(), $quiz); + return $this->toXlsx($spreadsheet); + } + + public function fillQuestionsSheet(Worksheet $sheet, Quiz $quiz): void + { // Write data rows first so we know the maximum answer count. $maxAnswers = 0; $row = 2; @@ -153,11 +159,9 @@ class QuizSpreadsheetService $sheet->setCellValue($correctCol.'1', 'Correct'); $sheet->getColumnDimension($correctCol)->setAutoSize(true); } - - return $this->toXlsx($spreadsheet); } - private function toXlsx(Spreadsheet $spreadsheet): \Closure + public function toXlsx(Spreadsheet $spreadsheet): \Closure { $writer = new Writer\Xlsx($spreadsheet); diff --git a/templates/backoffice/settings/index.html.twig b/templates/backoffice/settings/index.html.twig index e87f321..860b62b 100644 --- a/templates/backoffice/settings/index.html.twig +++ b/templates/backoffice/settings/index.html.twig @@ -55,14 +55,13 @@ {{ form(emailForm, {action: path('tvdt_backoffice_settings_email')}) }} -
+

{{ 'Your data'|trans }}

- - - +

{{ 'Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.'|trans }}

+ {% if not app.user.isVerified %} +

{{ 'Confirm your email address to enable this feature.'|trans }}

+ {% endif %} + {{ 'Download data'|trans }}
diff --git a/tests/Controller/Backoffice/BackofficeControllerTest.php b/tests/Controller/Backoffice/BackofficeControllerTest.php new file mode 100644 index 0000000..0072558 --- /dev/null +++ b/tests/Controller/Backoffice/BackofficeControllerTest.php @@ -0,0 +1,57 @@ +get(EntityManagerInterface::class); + + $user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']); + $this->assertInstanceOf(User::class, $user); + $user->isVerified = true; + $entityManager->flush(); + $client->loginUser($user); + + $quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']); + $this->assertInstanceOf(Quiz::class, $quiz); + + $client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id)); + + self::assertResponseIsSuccessful(); + $disposition = (string) $client->getResponse()->headers->get('Content-Disposition'); + $this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition); + $this->assertStringNotContainsString('Quiz 1.xlsx', $disposition); + } + + public function testExportQuizRequiresVerifiedEmail(): void + { + $client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']); + $this->assertInstanceOf(User::class, $user); + $this->assertFalse($user->isVerified); + $client->loginUser($user); + + $quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']); + $this->assertInstanceOf(Quiz::class, $quiz); + + $client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id)); + + self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode)); + } +} diff --git a/tests/Controller/Backoffice/SettingsControllerTest.php b/tests/Controller/Backoffice/SettingsControllerTest.php index cb4aead..8261692 100644 --- a/tests/Controller/Backoffice/SettingsControllerTest.php +++ b/tests/Controller/Backoffice/SettingsControllerTest.php @@ -350,4 +350,47 @@ final class SettingsControllerTest extends WebTestCase $this->assertNotEmpty($ownerEmails); } } + + public function testDownloadDataRequiresAuthentication(): void + { + $this->client->restart(); + $this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data'); + + self::assertResponseRedirects(); + } + + public function testDownloadDataReturnsAZipWithATimestampedAccountFilename(): void + { + $this->markUserVerified('test@example.org'); + + $this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/zip'); + + $disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition'); + $this->assertMatchesRegularExpression( + '/filename=tijd-voor-de-test-data-test-example-org-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip/', + $disposition, + ); + } + + public function testDownloadDataRequiresVerifiedEmail(): void + { + $user = $this->getUserByEmail('test@example.org'); + $this->assertInstanceOf(User::class, $user); + $this->assertFalse($user->isVerified); + + $this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data'); + + self::assertResponseRedirects('/backoffice/settings'); + } + + private function markUserVerified(string $email): void + { + $user = $this->getUserByEmail($email); + $this->assertInstanceOf(User::class, $user); + $user->isVerified = true; + $this->entityManager->flush(); + } } diff --git a/tests/Helpers/FilenameSanitizerTest.php b/tests/Helpers/FilenameSanitizerTest.php new file mode 100644 index 0000000..c1553c9 --- /dev/null +++ b/tests/Helpers/FilenameSanitizerTest.php @@ -0,0 +1,44 @@ +assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend')); + } + + public function testStripsPathSeparatorsAndTraversal(): void + { + $this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd')); + $this->assertSame('a-b', FilenameSanitizer::sanitize('a/b')); + $this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b')); + } + + public function testStripsControlCharactersAndSpecialSymbols(): void + { + $this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1