mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-10 09:30:14 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7028c972 |
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with:
|
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) —
|
||||||
|
a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode,
|
||||||
|
participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is
|
||||||
|
eliminated. This app replicates that quiz format with:
|
||||||
|
|
||||||
- Test creation with variable question counts
|
- Test creation with variable question counts
|
||||||
- Season management with active test controls
|
- Season management with active test controls
|
||||||
- Candidate answer tracking with automatic timing
|
- Candidate answer tracking with automatic timing
|
||||||
@@ -12,6 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- Backoffice management for quiz administration and statistics
|
- Backoffice management for quiz administration and statistics
|
||||||
|
|
||||||
Tech Stack:
|
Tech Stack:
|
||||||
|
|
||||||
- **Framework**: Symfony 8.1
|
- **Framework**: Symfony 8.1
|
||||||
- **PHP**: 8.5+
|
- **PHP**: 8.5+
|
||||||
- **Database**: PostgreSQL 16
|
- **Database**: PostgreSQL 16
|
||||||
@@ -76,7 +81,7 @@ All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
Controller/ # HTTP request handlers (attribute-routed)
|
Controller/ # HTTP request handlers (attribute-routed)
|
||||||
Backoffice/ # Admin panel controllers
|
Backoffice/ # Admin panel controllers
|
||||||
Entity/ # Doctrine ORM entities
|
Entity/ # Doctrine ORM entities
|
||||||
Repository/ # Database queries
|
Repository/ # Database queries
|
||||||
@@ -122,59 +127,151 @@ tests/
|
|||||||
- **Elimination**: Records red/green screens and forced results with joker adjustments.
|
- **Elimination**: Records red/green screens and forced results with joker adjustments.
|
||||||
- **User**: Administrative accounts for managing the system.
|
- **User**: Administrative accounts for managing the system.
|
||||||
|
|
||||||
|
## Domain Context: "De Test" (Wie is de Mol)
|
||||||
|
|
||||||
|
**Wie is de Mol?** (WIDM) is a Dutch reality competition: a group of contestants ("kandidaten") travels together while
|
||||||
|
one of them, "de Mol", secretly sabotages assignments. Each episode ends with the fixed line: *"Tijd voor de test.
|
||||||
|
Twintig vragen over de identiteit en het doen en laten van de Mol. Degene die het minst weet, ligt uit het spel. Behalve
|
||||||
|
de Mol. Die hoeft nooit naar huis."* ("Time for the test. Twenty questions about the identity and the actions of the
|
||||||
|
Mol. Whoever knows the least is out of the game. Except the Mol — they never have to go home.") The contestant with the
|
||||||
|
worst score is eliminated ("afvallen"); the Mol is immune regardless of score, since they already know the answers. This
|
||||||
|
app is a generic engine for running that quiz format for private/fan seasons, not just modeling the TV show
|
||||||
|
incidentally — the entity model below exists specifically to reproduce WIDM's test mechanics.
|
||||||
|
|
||||||
|
### What a test's 20 questions actually are
|
||||||
|
|
||||||
|
Per the intro line, questions fall into two factual categories — never opinion ("who would you vote off") — plus a
|
||||||
|
third recurring format used on the show:
|
||||||
|
|
||||||
|
1. **Identity of the Mol**: guessing which contestant is the Mol.
|
||||||
|
2. **The Mol's actions**: what the Mol did or where the Mol was during a specific assignment/moment.
|
||||||
|
3. **Candidate self-answered questions**: earlier, every contestant privately answered a question about themselves
|
||||||
|
(an interview-style question); the test then asks other contestants to guess what a *specific* candidate answered
|
||||||
|
about themselves. This tests how well contestants know each other, not just Mol-tracking.
|
||||||
|
|
||||||
|
### Why answers can be bound to candidates
|
||||||
|
|
||||||
|
All three categories above can have contestants themselves as the answer options rather than free text: "who is the
|
||||||
|
Mol" and "who did X" both need contestant names as options, and "what did candidate Y answer" needs Y's own submitted
|
||||||
|
answer among the options. In the domain model this is `Answer::$candidates` (a `ManyToMany` to `Candidate`, on both
|
||||||
|
sides): an answer option can *be* another contestant, not just text.
|
||||||
|
|
||||||
|
Because the relationship is many-to-many on the answer side too, a single answer option can cover **more than one
|
||||||
|
candidate at once** — e.g. "Anna en Bram" as one option for "who missed the assignment together", or an option
|
||||||
|
representing everyone who gave a particular self-answer in category 3 above. So a candidate-bound answer isn't always
|
||||||
|
one candidate, it can be a group; treat `Answer::$candidates` as "the set of contestants this option represents", not
|
||||||
|
as a single foreign key.
|
||||||
|
|
||||||
|
Combined with `GivenAnswer::$candidate` (who answered), every given answer on a candidate-bound question is a directed
|
||||||
|
relationship from the answering candidate to *every* candidate covered by the chosen option — a one-to-many edge when
|
||||||
|
the option is a group, not just candidate A pointed at candidate B. This is the mechanic behind any "who's suspected of
|
||||||
|
what" or sociogram-style statistic — it only applies to candidate-bound questions, plain trivia questions have no such
|
||||||
|
relationship. `Quiz::getQuestionErrors()` already relies on this distinction to validate that every active candidate is
|
||||||
|
covered exactly once per candidate-bound question (a candidate appearing across multiple group-options on the same
|
||||||
|
question counts as covered more than once).
|
||||||
|
|
||||||
|
### Elimination mechanics
|
||||||
|
|
||||||
|
- **Red/green screens**: at the end of a test, contestants are shown red or green screens one at a time to build tension
|
||||||
|
before the elimination is revealed. `Elimination::$data` stores the colour shown per candidate (
|
||||||
|
`SCREEN_RED/SCREEN_GREEN` via `getScreenColour()`), independent of the actual quiz score.
|
||||||
|
- **Jokers / corrections**: contestants can hold a "joker" (an advantage, e.g. an extra correct answer) that adjusts
|
||||||
|
their effective score without changing what they actually answered. This is `QuizCandidate::$corrections` — a float
|
||||||
|
added to the raw score, kept separate from `GivenAnswer` so the audit trail of what was actually answered stays
|
||||||
|
untouched.
|
||||||
|
- **Dropouts**: `Quiz::$dropouts` controls how many contestants can be eliminated in a single test (normally 1, but some
|
||||||
|
episodes eliminate more).
|
||||||
|
- **Finalization/locking**: `Quiz::$isFinalized` and `$isLocked` gate when a quiz's questions/answers can still be
|
||||||
|
edited — a quiz becomes immutable once a candidate has started it or an admin explicitly finalizes it. Treat this as
|
||||||
|
the natural point where computed results (scores, statistics) can be cached indefinitely, since nothing that feeds
|
||||||
|
them can change afterward.
|
||||||
|
|
||||||
|
### Terminology map (Dutch UI ↔ domain code)
|
||||||
|
|
||||||
|
| UI/domain term (Dutch) | Code |
|
||||||
|
|------------------------------|-------------------------------|
|
||||||
|
| Test | `Quiz` |
|
||||||
|
| Vraag | `Question` |
|
||||||
|
| Antwoord | `Answer` |
|
||||||
|
| Kandidaat | `Candidate` |
|
||||||
|
| Ingevuld antwoord | `GivenAnswer` |
|
||||||
|
| Afvallen / rood-groen scherm | `Elimination` |
|
||||||
|
| Joker / correctie | `QuizCandidate::$corrections` |
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
|
|
||||||
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
|
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
|
||||||
- Configured in `config/routes/attributes.yaml` for automatic discovery
|
- Configured in `config/routes/attributes.yaml` for automatic discovery
|
||||||
- Main entry point: `config/routes.yaml`
|
- Main entry point: `config/routes.yaml`
|
||||||
|
|
||||||
### Service Container & Dependency Injection
|
### Service Container & Dependency Injection
|
||||||
|
|
||||||
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
|
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
|
||||||
- Exclusions: Entity, DependencyInjection, Kernel classes
|
- Exclusions: Entity, DependencyInjection, Kernel classes
|
||||||
- Autowiring and autoconfiguration enabled by default
|
- Autowiring and autoconfiguration enabled by default
|
||||||
- Service definitions in `config/services.yaml`
|
- Service definitions in `config/services.yaml`
|
||||||
|
|
||||||
### Database & Migrations
|
### Database & Migrations
|
||||||
|
|
||||||
- PostgreSQL-based with Doctrine ORM
|
- PostgreSQL-based with Doctrine ORM
|
||||||
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
|
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate
|
||||||
|
with `bin/console make:migration`
|
||||||
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
|
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
|
||||||
- Test database configured separately via `.env.test`
|
- Test database configured separately via `.env.test`
|
||||||
|
|
||||||
### Testing Infrastructure
|
### Testing Infrastructure
|
||||||
|
|
||||||
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
|
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
|
||||||
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector)
|
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test
|
||||||
|
kernel/container (used by Rector)
|
||||||
- Symfony test utilities (BrowserKit, CSS selectors) available
|
- Symfony test utilities (BrowserKit, CSS selectors) available
|
||||||
- Coverage excluded from: `src/DataFixtures/`
|
- Coverage excluded from: `src/DataFixtures/`
|
||||||
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
|
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
|
||||||
|
|
||||||
### Testing Conventions (TDD)
|
### Testing Conventions (TDD)
|
||||||
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes.
|
|
||||||
|
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure
|
||||||
|
before touching the production code. Fix the code until the test passes.
|
||||||
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
|
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
|
||||||
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists in a template). Tests cover behavior: routing, forms, persistence, authorization.
|
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists
|
||||||
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state.
|
in a template). Tests cover behavior: routing, forms, persistence, authorization.
|
||||||
|
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
|
||||||
|
POST form data, assert redirect, clear entity manager, assert DB state.
|
||||||
|
- **Prefer `TestCase` over `WebTestCase`/`KernelTestCase`.** Reach for the full kernel/DB boot only when the test
|
||||||
|
genuinely needs routing, persistence, or the container — pure logic (services, listeners, helpers) should be tested
|
||||||
|
with plain PHPUnit `TestCase` and mocked dependencies; it's faster and more isolated.
|
||||||
|
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same
|
||||||
|
commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later —
|
||||||
|
but don't let this balloon into an unrelated refactor.
|
||||||
|
|
||||||
### Code Style & Standards
|
### Code Style & Standards
|
||||||
|
|
||||||
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
|
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
|
||||||
- Strict types declaration required
|
- Strict types declaration required
|
||||||
- Trailing commas in multiline structures
|
- Trailing commas in multiline structures
|
||||||
- No else-only blocks
|
- No else-only blocks
|
||||||
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
|
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine,
|
||||||
|
Symfony, PHPUnit)
|
||||||
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
|
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
|
||||||
- **Twig-CS-Fixer**: Template style enforcement
|
- **Twig-CS-Fixer**: Template style enforcement
|
||||||
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
|
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
|
||||||
|
they throw exceptions instead
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
- `.env` - Local development defaults (uncommitted in .env.local)
|
- `.env` - Local development defaults (uncommitted in .env.local)
|
||||||
- `.env.dev` - Development overrides
|
- `.env.dev` - Development overrides
|
||||||
- `.env.test` - Test environment configuration
|
- `.env.test` - Test environment configuration
|
||||||
- Production uses `composer dump-env prod` for compiled configuration
|
- Production uses `composer dump-env prod` for compiled configuration
|
||||||
- Key variables:
|
- Key variables:
|
||||||
- `APP_ENV` - Environment (dev/test/prod)
|
- `APP_ENV` - Environment (dev/test/prod)
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
- `MAILER_SENDER` - From address for emails
|
- `MAILER_SENDER` - From address for emails
|
||||||
|
|
||||||
### Frontend Build
|
### Frontend Build
|
||||||
|
|
||||||
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
|
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
|
||||||
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
|
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
|
||||||
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
|
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
|
||||||
@@ -187,27 +284,28 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
|
|||||||
|
|
||||||
1. **Linting**: Dockerfile (hadolint), Twig templates
|
1. **Linting**: Dockerfile (hadolint), Twig templates
|
||||||
2. **Code Quality**:
|
2. **Code Quality**:
|
||||||
- PHP-CS-Fixer style check
|
- PHP-CS-Fixer style check
|
||||||
- Twig-CS-Fixer style check
|
- Twig-CS-Fixer style check
|
||||||
- PHPStan static analysis
|
- PHPStan static analysis
|
||||||
- Rector dry-run
|
- Rector dry-run
|
||||||
3. **Integration Tests**:
|
3. **Integration Tests**:
|
||||||
- Docker image build and start services
|
- Docker image build and start services
|
||||||
- Database creation and migration
|
- Database creation and migration
|
||||||
- Fixture loading
|
- Fixture loading
|
||||||
- Full PHPUnit test suite with JUnit XML output
|
- Full PHPUnit test suite with JUnit XML output
|
||||||
- Doctrine schema validation
|
- Doctrine schema validation
|
||||||
4. **Build & Deploy** (on tags or main, disabled currently):
|
4. **Build & Deploy** (on tags or main, disabled currently):
|
||||||
- Docker image push to GitHub Container Registry
|
- Docker image push to GitHub Container Registry
|
||||||
- Sentry release creation
|
- Sentry release creation
|
||||||
- Portainer webhook trigger for production deployment
|
- Portainer webhook trigger for production deployment
|
||||||
|
|
||||||
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
|
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
|
||||||
|
|
||||||
## Important Files & Conventions
|
## Important Files & Conventions
|
||||||
|
|
||||||
- **Kernel**: `src/Kernel.php` - Symfony kernel class
|
- **Kernel**: `src/Kernel.php` - Symfony kernel class
|
||||||
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
|
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`,
|
||||||
|
`CANDIDATE_HASH_REGEX`) and flash helpers
|
||||||
- **Flash Messages**: Use `FlashType` enum instead of string literals
|
- **Flash Messages**: Use `FlashType` enum instead of string literals
|
||||||
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
|
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
|
||||||
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
|
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
|
||||||
@@ -226,6 +324,7 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
|
|||||||
## Composer Scripts
|
## Composer Scripts
|
||||||
|
|
||||||
Auto-executed scripts on install/update:
|
Auto-executed scripts on install/update:
|
||||||
|
|
||||||
- `cache:clear` - Symfony cache clear
|
- `cache:clear` - Symfony cache clear
|
||||||
- `assets:install` - Copy public assets
|
- `assets:install` - Copy public assets
|
||||||
- `importmap:install` - JS import map setup
|
- `importmap:install` - JS import map setup
|
||||||
@@ -244,4 +343,5 @@ When writing Dutch help content in `templates/backoffice/help/nl/`:
|
|||||||
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
||||||
- Quiz timing logic starts on candidate start click and stops on final answer selection
|
- Quiz timing logic starts on candidate start click and stops on final answer selection
|
||||||
- Background music feature noted but not yet implemented (requirements only)
|
- Background music feature noted but not yet implemented (requirements only)
|
||||||
- Statistics functionality is marked TBD in README
|
- Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199 —
|
||||||
|
see "Domain Context" above for why candidate-bound answers matter to it
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'tvdt-fullscreen';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
this.onFullscreenChange = this.onFullscreenChange.bind(this);
|
||||||
|
document.addEventListener('fullscreenchange', this.onFullscreenChange);
|
||||||
|
this.syncState();
|
||||||
|
|
||||||
|
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request() {
|
||||||
|
document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFullscreenChange() {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
|
||||||
|
this.syncState();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncState() {
|
||||||
|
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,32 @@ input.btn-check:checked + label.answer-btn {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fullscreen-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 1040;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.is-fullscreen .fullscreen-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.elimination-screen {
|
.elimination-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Event\LogoutEvent;
|
||||||
|
|
||||||
|
final readonly class LogoutRedirectListener implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
private const array BLOCKED_TARGET_PREFIXES = ['/backoffice', '/elimination'];
|
||||||
|
|
||||||
|
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
|
||||||
|
|
||||||
|
public function onLogout(LogoutEvent $event): void
|
||||||
|
{
|
||||||
|
$target = $event->getRequest()->query->get('target');
|
||||||
|
|
||||||
|
if (\is_string($target) && $this->isAllowedTarget($target)) {
|
||||||
|
$event->setResponse(new RedirectResponse($target));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('tvdt_quiz_select_season')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
// Must run before Symfony's DefaultLogoutListener (priority 64), which only
|
||||||
|
// sets a response if none is set yet.
|
||||||
|
return [
|
||||||
|
LogoutEvent::class => ['onLogout', 128],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAllowedTarget(string $target): bool
|
||||||
|
{
|
||||||
|
if (!str_starts_with($target, '/') || str_starts_with($target, '//')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !array_any(self::BLOCKED_TARGET_PREFIXES, static fn (string $prefix): bool => str_starts_with($target, $prefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
{% block importmap %}{{ importmap('quiz') }}{% endblock %}
|
{% block importmap %}{{ importmap('quiz') }}{% endblock %}
|
||||||
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
|
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
|
||||||
|
{% block main %}
|
||||||
|
<div data-controller="fullscreen">
|
||||||
|
<button type="button"
|
||||||
|
class="fullscreen-btn"
|
||||||
|
data-action="fullscreen#toggle"
|
||||||
|
aria-label="{{ 'Fullscreen'|trans }}"
|
||||||
|
title="{{ 'Fullscreen'|trans }}">⛶</button>
|
||||||
|
{{ parent() }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
|
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
{{ 'Backoffice'|trans }}
|
{{ 'Backoffice'|trans }}
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
|
<a href="{{ path('tvdt_login_logout', {target: app.request.pathInfo}) }}" class="btn btn-outline-secondary btn-sm">
|
||||||
{{ 'Logout'|trans }}
|
{{ 'Logout'|trans }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Security;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Event\LogoutEvent;
|
||||||
|
use Tvdt\Security\LogoutRedirectListener;
|
||||||
|
|
||||||
|
#[CoversClass(LogoutRedirectListener::class)]
|
||||||
|
final class LogoutRedirectListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testLogoutRedirectsBackToTheGivenTarget(): void
|
||||||
|
{
|
||||||
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->expects($this->never())->method('generate');
|
||||||
|
$listener = new LogoutRedirectListener($urlGenerator);
|
||||||
|
|
||||||
|
$event = new LogoutEvent(Request::create('/logout?target=/krtek'), null);
|
||||||
|
$listener->onLogout($event);
|
||||||
|
|
||||||
|
$this->assertSame('/krtek', $event->getResponse()?->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutWithoutTargetRedirectsToSeasonSelect(): void
|
||||||
|
{
|
||||||
|
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
|
||||||
|
|
||||||
|
$event = new LogoutEvent(Request::create('/logout'), null);
|
||||||
|
$listener->onLogout($event);
|
||||||
|
|
||||||
|
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('blockedTargetProvider')]
|
||||||
|
public function testLogoutIgnoresBlockedOrUnsafeTargets(string $target): void
|
||||||
|
{
|
||||||
|
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
|
||||||
|
|
||||||
|
$event = new LogoutEvent(Request::create('/logout?target='.urlencode($target)), null);
|
||||||
|
$listener->onLogout($event);
|
||||||
|
|
||||||
|
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return iterable<string, array{string}> */
|
||||||
|
public static function blockedTargetProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'backoffice page' => ['/backoffice/season/krtek'];
|
||||||
|
yield 'elimination page' => ['/elimination/00000000-0000-0000-0000-000000000000'];
|
||||||
|
yield 'protocol-relative url' => ['//evil.example.org'];
|
||||||
|
yield 'absolute url' => ['https://evil.example.org'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seasonSelectUrlGenerator(): UrlGeneratorInterface&MockObject
|
||||||
|
{
|
||||||
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->expects($this->once())
|
||||||
|
->method('generate')
|
||||||
|
->with('tvdt_quiz_select_season')
|
||||||
|
->willReturn('/');
|
||||||
|
|
||||||
|
return $urlGenerator;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -369,6 +369,10 @@
|
|||||||
<source>Forgot your password?</source>
|
<source>Forgot your password?</source>
|
||||||
<target>Wachtwoord vergeten?</target>
|
<target>Wachtwoord vergeten?</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="3cWWP_q" resname="Fullscreen">
|
||||||
|
<source>Fullscreen</source>
|
||||||
|
<target>Volledig scherm</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="MebBrmp" resname="Gray">
|
<trans-unit id="MebBrmp" resname="Gray">
|
||||||
<source>Gray</source>
|
<source>Gray</source>
|
||||||
<target>Grijs</target>
|
<target>Grijs</target>
|
||||||
|
|||||||
Reference in New Issue
Block a user