Author SHA1 Message Date
Frank 8554f04735 Add "In Development" watermark badge over AI virus artwork
Overlay a rotated CSS badge on the homepage hero and briefing page
image instead of baking it into the PNG, so it's easy to remove once
the game ships.
2026-07-04 23:02:46 +02:00
Frank 839113c356 Add AI Virus Deflection story to homepage and new agent briefing page
Give the public homepage a themed breach-alert hero and add a /briefing
page with the full mission narrative, both linking into the existing
game dashboard flow.
2026-07-04 23:00:31 +02:00
FrankandClaude Sonnet 5 7dbb738da8 Show marketing opt-in status in admin users overview
Adds a Marketing column to the admin users table and a total opt-in
count in the header, so admins can see who signed up for updates on
future projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 20:48:48 +02:00
FrankandClaude Sonnet 5 ec34a1ddb9 Add Terms and Conditions and marketing opt-in checkboxes to registration
Registration now requires agreeing to the Terms and Conditions and lets
users opt in to hear about future projects. The opt-in is persisted on
the user via a new marketing_opt_in column (migration included).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 20:28:52 +02:00
FrankandClaude Sonnet 5 703d2af3d8 Add admin nav link and reuse site header/footer on admin pages
Admins now see an Admin link in the main navigation, and the admin
section inherits the branded header/footer from layout/site.html.twig
instead of the bare base layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 19:15:24 +02:00
Frank 5b03bd1d1c Complete layout overhaul 2026-07-04 19:08:31 +02:00
FrankandClaude Sonnet 5 05f4f2c32f Fix EveryoneVerified setting being scoped to a specific player
checkIfAllPlayersVerified() read and wrote the EveryoneVerified
setting scoped to whichever player happened to trigger the check
(getSetting(..., $player) / setPlayer($player)), but it's meant to be
a single session-wide flag. getFileContent() correctly reads it as
session-global (no player, filters player IS NULL), so the two never
matched: the "everyone verified" Mercure message still fired (that
code path only checks its own player-scoped copy), but the special
code injection into the Doyle/Vega/Lennox report files never ran
since getFileContent() never found the setting. Store and read it
consistently as session-global.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:19:14 +02:00
FrankandClaude Sonnet 5 ddd2726687 Fix cat command using relative path for physical file lookup
getFileContent() built the game1 filesystem path as a relative
string with no leading slash. That only resolves correctly when
PHP's cwd happens to be the project root (e.g. CLI), but under
PHP-FPM/nginx the cwd is nginx's document root (public/), so
file_exists() always failed for real requests even though the file
existed and the in-memory virtual file list (used by ls) said it
should. Use the already-injected $projectDir (%kernel.project_dir%),
matching how the class already builds the session log path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:12:55 +02:00
FrankandClaude Sonnet 5 3721abcc5d Fix missing leading slash in player's initial working directory
Every possible path/file in GameResponseService uses a leading slash
(e.g. /var/home/{username}), but a player's initial pwd was seeded as
'var/home/{username}' without one. getAllCurrentFilesInDirectory()
matches entries by comparing getPrevPath() (which always has the
leading slash) against pwd, so a fresh player's ls always came back
empty until their first cd command happened to normalize the format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 17:03:30 +02:00
FrankandClaude Sonnet 5 e76d0b0f07 Fix Mercure reload spam on waiting page in Chrome
When the last player clicked ready, toggleReady() published a
redundant 'player_ready' event on top of checkAllPlayersReady()'s
'all_ready', and the client reloaded on every message with no guard
and without closing the EventSource. Chrome kept the old page's
script (and its EventSource) alive across the overlapping reload
calls, causing repeated reconnects to the Mercure hub; Firefox
apparently tore the page down fast enough to mask it. Skip the
redundant publish server-side, and make the client reload idempotent
by tracking whether it already fired and closing the EventSource
before reloading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 16:26:47 +02:00
FrankandClaude Sonnet 5 b7693bd9d0 Remove quotes around MERCURE_CORS_ALLOWED_ORIGINS in docker/.env.dist
docker/.env feeds MERCURE_EXTRA_DIRECTIVES, a Caddyfile-style config
block, via Compose variable substitution. Quoting a space-separated
value there makes Caddy treat both origins as one single malformed
token rather than two arguments, which crash-loops the Mercure
container on startup. Compose's .env parsing for values with spaces
doesn't require quotes (unlike Symfony's Dotenv), so drop them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:43:03 +02:00
FrankandClaude Sonnet 5 b4f6a531c9 Fix Mercure CORS to allow both www and bare domain
Production's MERCURE_CORS_ALLOWED_ORIGINS only allowed
https://escapepage.com, but nginx has no www redirect
(server_name _;), so the site is also reachable at
https://www.escapepage.com. Visitors on the www host got a CORS
error on the Mercure EventSource connection since the Origin header
didn't match the allow-list. Dev's .env already allowed both; bring
docker/.env.dist in line, and fix its stale MERCURE_PUBLIC_URL
(bare domain instead of the mercure. subdomain actually used).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:26:17 +02:00
FrankandClaude Sonnet 5 c4e5139ec4 Increase email header logo size from 40px to 140px
40px made the logo too small in the header banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:55:15 +02:00
FrankandClaude Sonnet 5 238705495e Add shared HTML layout for transactional emails
confirmation_email.html.twig and reset_password/email.html.twig were
plain unstyled HTML with no shared structure. Extract a table-based
layout (templates/emails/layout.html.twig) with header/logo, content
block, and footer, so future transactional emails can extend it
instead of starting from scratch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:51:34 +02:00
FrankandClaude Sonnet 5 4a5d83ef53 Fix duplicate EmailLog rows for a single sent email
Mailer dispatches MessageEvent twice when routed through Messenger:
once when queuing (queued=true) and once on the actual send from the
worker (queued=false). EmailLoggerListener logged on both, creating
two rows per email actually sent. Skip the queued dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:44:03 +02:00
FrankandClaude Sonnet 5 0e12e7fa8f Switch docker/.env.dist mailer template from SendGrid to Mailgun
The project now sends mail via Mailgun (symfony/mailgun-mailer), not
SendGrid, so the tracked template should reflect the real transport
in use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:23:21 +02:00
Frank 3924b42ce0 Changes in install 2026-07-04 11:58:44 +00:00
Frank 58c9c60ad2 Mailer 2026-07-04 13:57:00 +02:00
Frank b11c50c237 composer files 2026-07-04 11:51:47 +00:00
Frank 2316266b80 Upgrades 2026-07-04 10:58:17 +00:00
Frank 2a45ebb953 server executable rights 2026-07-04 10:58:17 +00:00
FrankandClaude Sonnet 5 a1ff6df721 Add root .env.dist template
.env/.env.dev/.env.prod/.env.test are now gitignored, leaving no
tracked reference for what variables a fresh checkout needs. Add a
placeholder-only .env.dist (mirroring docker/.env.dist) to copy from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 12:49:51 +02:00
FrankandClaude Sonnet 5 487019d360 Stop tracking .env files and sanitize docker/.env.dist
.env, .env.dev, .env.prod, .env.test, and docker/.env contained real
production secrets and were tracked in git despite the Symfony
convention of keeping them local-only. Untrack them and ignore them
going forward; docker/.env.dist stays as a template but now uses
placeholder values instead of live credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 12:29:04 +02:00
Frank van den BergandClaude Sonnet 4.6 c045922c5b Fix Mercure JWT secret mismatch
setup.sh was forcing --env-file ../.env (root .env with placeholder secret)
instead of letting Docker Compose use docker/.env (real secret). Mercure
config now generates the publisher JWT from MERCURE_JWT_SECRET directly,
removing the need for a separate pre-generated token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:24:09 +02:00
Frank van den BergandClaude Sonnet 4.6 aa56617e50 hub location mercure adjustment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:09:35 +02:00
Frank van den Berg 831603e04e Migration game 1 2026-06-27 16:07:22 +02:00
Frank van den Berg ac57385a9d Admin panels 2026-06-27 15:53:43 +02:00
Frank van den Berg 0d8335dd2c Command voor aanmaken user 2026-06-27 15:34:36 +02:00
Frank van den Berg 6c40288fd8 Fix mailcatcher 2026-06-27 15:13:17 +02:00
Frank van den Berg 994bbafba2 Updates 2026-06-27 14:26:04 +02:00
Frank van den Berg a05adfd316 Claude aanpassingen 2026-06-27 13:23:08 +02:00
Frank van den Berg 9205db6611 changes for ssl 2026-06-27 12:07:06 +02:00
Frank 3b1d3a5aad Fixed network v4 2026-03-11 07:59:23 +01:00
Frank 8b3bf68954 Fixed network v3 2026-03-11 07:57:49 +01:00
Frank 74dadf102d Fixed network v2 2026-03-11 07:55:47 +01:00
Frank 74d5cd15b6 Fixed network 2026-03-11 07:52:11 +01:00
Frank 4e696af231 Fixed ip addresses 2026-03-10 22:13:22 +01:00
Frank 539a243353 Fix database container creation 2026-03-10 22:04:23 +01:00
Frank eb3d36c99f Unfixed ips 2026-03-10 22:00:08 +01:00
Frank 8564257761 Fixed ips 2026-03-10 21:55:21 +01:00
Frank be01de83ed network defined 2026-03-10 21:52:05 +01:00
Frank 700160e198 Mercure ssl 2026-01-17 22:56:34 +01:00
Frank 96c2e4ca61 Mercure cors error 3 2026-01-17 19:36:01 +01:00
Frank e5f959210a Mercure cors error 2 2026-01-17 19:27:01 +01:00
Frank e14cf8457b Mercure cors error 2026-01-17 19:03:25 +01:00
Frank 2c970fa9e7 nullable screen 2026-01-17 15:35:26 +01:00
Frank 714496fa77 Remote allowance 2026-01-17 15:12:08 +01:00
Frank 2b7e667bb3 captcha keys 2026-01-17 14:51:14 +01:00
Frank b8c1fecea2 executable 2026-01-17 14:24:54 +01:00
Frank 4f40c96ce5 restart via 1 script. 2026-01-17 14:22:08 +01:00
Frank 4545572b65 Verification mails solving try 1 2026-01-17 14:12:57 +01:00
Frank 1c27c093c7 Verification mails solving try 1 2026-01-17 13:47:56 +01:00
Frank 6d8d30e91a Added domain to verification mail 2026-01-16 21:01:01 +01:00
Frank 5c8a5f0bf5 Send variables to containers. 2026-01-14 14:00:11 +01:00
Frank 6b5c205a27 Request resend of verification mail 2026-01-14 13:09:32 +01:00
Frank 70f5aa785e captcha 2026-01-13 21:54:26 +01:00
Frank 6c96d45a04 Verifying mail addresses 2026-01-13 17:43:17 +01:00
Frank 8d4e08e4bc Mailer From 2026-01-11 23:10:30 +01:00
Frank 6fba1cbcf3 Try to fix 1 2026-01-11 15:46:46 +01:00
Frank 1512a3dde9 Revert compose files 2026-01-11 15:41:40 +01:00
Frank 5f4e2acd27 Niet weggooien van images 4 2026-01-11 15:38:33 +01:00
Frank 203dfaa13b Niet weggooien van images 3 2026-01-11 15:37:13 +01:00
Frank 799858d52c Niet weggooien van images 2 2026-01-11 15:35:10 +01:00
Frank ce79b77244 onbekende flag 5 2026-01-11 15:30:21 +01:00
Frank 5f17c9b89f onbekende flag 4 2026-01-11 15:28:40 +01:00
Frank 5335e62cfd onbekende flag 3 2026-01-11 15:26:34 +01:00
Frank 04b1ff6243 onbekende flag 2 2026-01-11 15:25:03 +01:00
Frank 84b7b1c64a onbekende flag 2026-01-11 15:21:19 +01:00
Frank b0af41c5d2 container en cache clear erin en image clear eruit 2026-01-10 17:21:59 +01:00
Frank a94b8cbef3 csrf error solve. try 5 2026-01-10 14:28:03 +01:00
Frank 3f33ec6fef csrf error solve. try 4 2026-01-10 14:19:57 +01:00
Frank ab80d4de83 csrf error solve. try 3 2026-01-10 14:06:29 +01:00
Frank b2817c5d8c csrf error solve. try 2 2026-01-10 13:37:14 +01:00
Frank c59d131b80 csrf error solve. try 1 2026-01-10 00:39:33 +01:00
Frank e8a6c9cc7a Validation fails 2026-01-10 00:25:57 +01:00
Frank e84f2c274e pass on token 2026-01-10 00:17:36 +01:00
Frank d9bc8c352f Restart containers 2026-01-09 23:40:25 +01:00
Frank ece3b05a9c Add error log 2026-01-09 23:36:50 +01:00
Frank 4cf614a98c Mercure en hostfile 2026-01-09 18:38:57 +01:00
Frank 24f51bf38d database volume 2026-01-09 16:53:47 +01:00
Frank 0acf60760f Fixed ips 2026-01-09 16:42:15 +01:00
Frank 342858fa0e rights to user 2026-01-09 16:10:44 +01:00
Frank 3c9bd13cc2 Meer updates. Next try 7 2026-01-09 15:47:44 +01:00
Frank eeda97cef8 Meer updates. Next try 6 2026-01-09 15:42:43 +01:00
Frank 5a6616484f Meer updates. Next try 5 2026-01-09 15:33:52 +01:00
Frank f0bcc9fe06 Meer updates. Next try 4 2026-01-09 15:25:41 +01:00
Frank 550cc1b2ed Meer updates. Next try 3 2026-01-09 15:18:25 +01:00
Frank b1910a63c3 Meer updates. Next try 2 2026-01-09 15:10:44 +01:00
Frank 60d52d7b5e Meer updates. Next try 2026-01-09 15:01:12 +01:00
Frank 0c7bbc2670 Meer updates. Hopelijk beter nu. 2026-01-09 14:51:31 +01:00
Frank 3e73cba942 Updated dockerfile om migrations uit te kunnen voeren 2026-01-09 14:41:59 +01:00
Frank 6027dcb56c Running containers 2026-01-09 14:30:05 +01:00
Frank 81b91e052c Merge pull request 'Settings from env files' (#13) from env-settings into main
Reviewed-on: #13
2026-01-09 13:21:02 +00:00
Frank a5a895b67f Settings from env files 2026-01-09 13:08:09 +01:00
Frank 28c663749c Merge pull request 'timer-af-laten-lopen' (#12) from timer-af-laten-lopen into main
Reviewed-on: #12
2026-01-09 11:23:39 +00:00
Frank b063e17863 Remove .env.* files from tracking and update .gitignore 2026-01-09 12:13:31 +01:00
Frank 2008a379a8 Lost page 2026-01-08 20:32:21 +01:00
Frank c0fc0b8e6e Merge pull request 'start-all-at-the-same-time' (#11) from start-all-at-the-same-time into main
Reviewed-on: #11
2026-01-08 19:12:17 +00:00
102 changed files with 4696 additions and 1625 deletions
+25
View File
@@ -0,0 +1,25 @@
# Git
.git
.gitignore
# Symfony
var/cache/*
var/log/*
var/sessions/*
!var/cache/.gitkeep
!var/log/.gitkeep
!var/sessions/.gitkeep
# Node
node_modules
npm-debug.log
# Other
.env.local
.env.local.php
.env.dev.local
.env.test.local
.env.prod.local
vendor
public/build
+88
View File
@@ -0,0 +1,88 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Copy this file to .env (and .env.dev / .env.prod / .env.test as needed) and
# fill in real values. Those files are gitignored and never committed.
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=CHANGEME_APP_SECRET
TRUSTED_PROXIES=127.0.0.1,172.20.0.1,172.20.0.0/16
TRUSTED_HOSTS=^.*$
###< symfony/framework-bundle ###
SITE_BASE_URL=https://escapepage.com
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DB_DRIVER=pdo_mysql
DB_SERVER_VERSION=8.0.32
DB_CHARSET=utf8mb4
DB_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD
DATABASE_URL="${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?serverVersion=${DB_SERVER_VERSION}&charset=${DB_CHARSET}"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> symfony/mailer ###
# Development: use Mailpit (docker compose override provides service `mailer` on port 1025)
MAILGUN_API_KEY=REPLACE_WITH_MAILGUN_API_KEY
MAILGUN_DOMAIN=REPLACE_WITH_MAILGUN_SENDING_DOMAIN
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu
MAILER_FROM=mailer@escapepage.nl
# Optional default sender (used by test command if --from not passed):
###< symfony/mailer ###
###> mercure ###
# Internal hub URL used by the PHP app (reachable from the php container)
MERCURE_URL=http://mercure/.well-known/mercure
# Public hub URL used by browsers
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
# Shared secret for signing JWTs (dev only). In prod, set via real env/secrets.
MERCURE_JWT_SECRET=!ChangeThisMercureJWTSignedBySymfonySecretKey!
# Pre-generated JWT tokens for convenience (signed with the dev secret above)
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
# CORS allowed origins (default)
MERCURE_CORS_ALLOWED_ORIGINS="https://www.escapepage.com https://escapepage.com"
# Base URL for Mercure topics.
MERCURE_TOPIC_BASE=https://escapepage.com
###< mercure ###
###> docker ###
USER_ID=1000
GROUP_ID=1000
###< docker ###
###> karser/karser-recaptcha3-bundle ###
# Get your API key and secret from https://g.co/recaptcha/v3
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
###< karser/karser-recaptcha3-bundle ###
+11
View File
@@ -1,11 +1,18 @@
###> symfony/framework-bundle ###
/.env
/.env.dev
/.env.prod
/.env.test
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
!/var/volumes/
/var/volumes/*
!/var/volumes/.gitignore
/vendor/
###< symfony/framework-bundle ###
@@ -27,3 +34,7 @@ yarn-error.log
###< symfony/webpack-encore-bundle ###
/.idea
###> docker env ###
/docker/.env
###< docker env ###
+1
View File
@@ -140,6 +140,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webpack-encore-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/verify-email-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/karser/karser-recaptcha3-bundle" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
Generated
+2 -1
View File
@@ -153,6 +153,7 @@
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
<path value="$PROJECT_DIR$/vendor/karser/karser-recaptcha3-bundle" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
@@ -177,4 +178,4 @@
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>
</project>
+13 -17
View File
@@ -16,7 +16,7 @@ This repository contains a Symfony 7.3 (PHP >= 8.5.1) application for a collabor
6. Run tests: `vendor/bin/phpunit`
- With Docker:
1. From `docker/`: `docker compose up -d`
1. `cd docker && docker compose up -d`
2. Install vendors inside the PHP container:
- `docker compose exec php bash`
- `composer install`
@@ -25,23 +25,25 @@ This repository contains a Symfony 7.3 (PHP >= 8.5.1) application for a collabor
- `php bin/console doctrine:migrations:migrate -n`
4. App is at http://localhost:8080
## Email (Mailpit in dev, SendGrid for prod)
## Email (Mailpit in dev, Mailgun for prod)
- Dev: a `mailer` service (Mailpit) runs in Docker.
- SMTP DSN in `.env`: `MAILER_DSN=smtp://mailer:1025`
- Mailpit UI: http://localhost:8025
- Mailpit UI: http://localhost:8025 (or mapped port 8025)
- Send a test mail: `php bin/console app:mail:test you@example.com`
- Staging/Prod: use SendGrid.
- Require package (already in composer): `symfony/sendgrid-mailer`.
- Staging/Prod: use Mailgun.
- Require package (already in composer): `symfony/mailgun-mailer`.
- Set environment variables (do NOT commit secrets):
- `MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%`
- `SENDGRID_API_KEY=YOUR_REAL_KEY`
- `MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu`
- `MAILGUN_API_KEY=YOUR_REAL_KEY`
- `MAILGUN_DOMAIN=YOUR_SENDING_DOMAIN` (e.g. `mg.escapepage.nl`)
- Optional: `MAILER_FROM=no-reply@your-domain.tld`
- Drop `region=eu` (or use `region=us`) depending on which region your Mailgun domain was created in.
- Alternatively via SMTP (no extra package):
- `MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"`
- `MAILER_DSN="mailgun+smtp://USERNAME:PASSWORD@default?region=eu"`
Troubleshooting:
- If emails dont appear in dev, open Mailpit at http://localhost:8025 and verify messages.
- In prod, check logs for HTTP 2xx responses from SendGrid and verify sender domain is verified in SendGrid.
- In prod, check logs for HTTP 2xx responses from Mailgun and verify sender domain is verified (SPF/DKIM) in Mailgun.
## Frontend assets with Webpack Encore
We use Webpack Encore to build and minify JS/CSS from the `assets/` directory into `public/build/`.
@@ -81,9 +83,9 @@ See doc/CONTRIBUTING.md for code style and more details.
We use a Mercure hub (Docker service) to push server updates to browsers via ServerSent Events (SSE).
Quick start (dev):
1. Start Docker stack from `docker/`:
1. Start Docker stack:
```
docker compose up -d
cd docker && docker compose up -d
```
This starts `mercure` at http://localhost:8090 and the app at http://localhost:8080.
2. Install PHP deps inside the PHP container if you haven't yet:
@@ -91,12 +93,6 @@ Quick start (dev):
docker compose exec php bash
composer install
```
3. Open the Game Hub page in your browser: http://localhost:8080/game
- The page subscribes to a demo topic and logs messages in the console.
4. Publish a test update (in the PHP container):
```
php bin/console app:mercure:publish
```
You should see a console log like `[Mercure] Update received: { ... }` on the Game Hub page.
Configuration:
+4 -3
View File
@@ -1,6 +1,7 @@
/*
* Welcome to your app's main JavaScript file!
*/
import './styles/app.css';
console.log('This log comes from assets/app.js built by Webpack Encore! 🎉');
import './styles/app.scss';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/alert';
import 'bootstrap/js/dist/dropdown';
+48
View File
@@ -131,6 +131,8 @@ document.addEventListener('DOMContentLoaded', async () => {
const screen = cfgEl.dataset.screen;
const apiPingUrl = cfgEl.dataset.apiPingUrl;
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
const lostUrl = cfgEl.dataset.lostUrl;
if (mercurePublicUrl && topic) {
subscribeToMercure(mercurePublicUrl, topic, screen);
@@ -138,6 +140,52 @@ document.addEventListener('DOMContentLoaded', async () => {
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
}
// Timer logic
const timerEl = document.getElementById('game-timer');
if (timerEl && timerEl.dataset.endTime) {
const endTime = parseInt(timerEl.dataset.endTime) * 1000;
const updateTimer = async () => {
const now = Date.now();
const diff = endTime - now;
if (diff <= 0) {
timerEl.textContent = '00:00:00';
// Timer reached zero, check with server
if (apiCheckFinishedUrl && lostUrl) {
try {
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
if (response && response.finished) {
window.location.href = lostUrl;
return; // Stop the timer loop
}
} catch (e) {
console.error('[API][game1] Failed to check finished status:', e);
}
}
// Even if check failed or not finished, stop the loop if diff <= 0
// (though technically if the server says not finished, we might want to keep checking,
// but 00:00:00 usually means it's over).
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
const hDisplay = hours.toString().padStart(2, '0');
const mDisplay = minutes.toString().padStart(2, '0');
const sDisplay = seconds.toString().padStart(2, '0');
timerEl.textContent = `${hDisplay}:${mDisplay}:${sDisplay}`;
setTimeout(updateTimer, 1000);
};
updateTimer();
}
// Demo API calls
try {
if (apiPingUrl) {
-3
View File
@@ -1,3 +0,0 @@
body {
background-color: skyblue;
}
+66
View File
@@ -0,0 +1,66 @@
// Brand palette derived from public/images/logo.png (teal key/globe on dark navy)
$brand-teal: #2f8fae;
$brand-teal-dark: #1c5a70;
$brand-teal-light:#6fb8d1;
$brand-navy: #0d1f26;
$brand-navy-soft: #16323f;
$brand-bg: #f4f8f9;
// Bootstrap variable overrides (must come before the bootstrap import)
$primary: $brand-teal;
$secondary: $brand-navy-soft;
$dark: $brand-navy;
$body-bg: $brand-bg;
$body-color: $brand-navy-soft;
$link-color: $brand-teal;
$link-hover-color: $brand-teal-dark;
$border-radius: .5rem;
@import "bootstrap/scss/bootstrap";
@import "bootstrap-icons/font/bootstrap-icons.css";
:root {
--brand-teal: #{$brand-teal};
--brand-teal-dark: #{$brand-teal-dark};
--brand-navy: #{$brand-navy};
--brand-navy-soft: #{$brand-navy-soft};
--brand-bg: #{$brand-bg};
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.site-main {
flex: 1 0 auto;
}
.site-header {
background: linear-gradient(90deg, $brand-navy, $brand-navy-soft);
}
.site-header .navbar-brand {
display: flex;
align-items: center;
gap: .5rem;
font-weight: 700;
letter-spacing: .02em;
}
.site-header .navbar-brand img {
height: 36px;
width: auto;
}
.site-footer {
flex-shrink: 0;
background: $brand-navy;
color: rgba(255, 255, 255, .65);
}
.auth-card {
max-width: 440px;
margin: 3rem auto;
}
-7
View File
@@ -1,7 +0,0 @@
services:
###> symfony/mercure-bundle ###
mercure:
ports:
- "80"
###< symfony/mercure-bundle ###
-31
View File
@@ -1,31 +0,0 @@
services:
###> symfony/mercure-bundle ###
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
# Uncomment the following line to disable HTTPS,
#SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
# Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://localhost:8080
# Comment the following line to disable the development mode
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
healthcheck:
test: ["CMD", "curl", "-f", "https://localhost/healthz"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- mercure_data:/data
- mercure_config:/config
###< symfony/mercure-bundle ###
volumes:
###> symfony/mercure-bundle ###
mercure_data:
mercure_config:
###< symfony/mercure-bundle ###
+50 -49
View File
@@ -7,48 +7,49 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.16",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.5",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3",
"symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.3.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/sendgrid-mailer": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.30",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-turbo": "^2.30",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.3.*",
"symfonycasts/reset-password-bundle": "^1.24",
"doctrine/dbal": "^3.10.5",
"doctrine/doctrine-bundle": "^2.18.3",
"doctrine/doctrine-migrations-bundle": "^3.7",
"doctrine/orm": "^3.6.7",
"karser/karser-recaptcha3-bundle": "^0.3.0",
"phpdocumentor/reflection-docblock": "^5.6.7",
"phpstan/phpdoc-parser": "^2.3.2",
"symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*",
"symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2.11",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mailgun-mailer": "7.4.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.11.2",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.36",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/ux-turbo": "^2.36",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.4.1",
"symfony/yaml": "7.4.*",
"symfonycasts/reset-password-bundle": "^1.25",
"symfonycasts/verify-email-bundle": "^1.18",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
"twig/extra-bundle": "^2.12|^3.24",
"twig/twig": "^2.12|^3.28.0"
},
"config": {
"allow-plugins": {
@@ -98,16 +99,16 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*"
"require": "7.4.*"
}
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
"phpunit/phpunit": "^11.5.55",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.67",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
}
}
Generated
+1352 -1111
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,4 +17,5 @@ return [
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Karser\Recaptcha3Bundle\KarserRecaptcha3Bundle::class => ['all' => true],
];
+8 -9
View File
@@ -1,11 +1,10 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout
# form:
# csrf_protection:
# token_id: submit
# csrf_protection:
# stateless_token_ids:
# - submit
# - authenticate
# - logout
+9 -1
View File
@@ -1,6 +1,14 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# url: '%env(resolve:DATABASE_URL)%'
driver: '%env(DB_DRIVER)%'
server_version: '%env(DB_SERVER_VERSION)%'
host: '%env(DB_HOST)%'
port: '%env(DB_PORT)%'
user: '%env(DB_USER)%'
password: '%env(DB_PASSWORD)%'
dbname: '%env(DB_NAME)%'
charset: '%env(DB_CHARSET)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
+15 -1
View File
@@ -8,7 +8,21 @@ framework:
fallbacks: ['en', 'nl']
# Note that the session will be started ONLY if you read or write from it.
session: true
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
when@prod:
framework:
session:
handler_id: null
cookie_secure: true
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
#esi: true
#fragments: true
+5
View File
@@ -0,0 +1,5 @@
karser_recaptcha3:
site_key: '%env(RECAPTCHA3_KEY)%'
secret_key: '%env(RECAPTCHA3_SECRET)%'
score_threshold: 0.5
enabled: true
+1 -1
View File
@@ -2,7 +2,7 @@ framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
default_uri: '%env(SITE_BASE_URL)%'
when@prod:
framework:
+1
View File
@@ -30,6 +30,7 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
@@ -0,0 +1,2 @@
karser_recaptcha3:
enabled: false
+1
View File
@@ -1,4 +1,5 @@
twig:
form_themes: ['bootstrap_5_layout.html.twig']
globals:
mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'
mercure_topic_base: '%env(MERCURE_TOPIC_BASE)%'
+1
View File
@@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
mailer_from: '%env(MAILER_FROM)%'
services:
# default configuration for services in *this* file
+1 -1
View File
@@ -4,7 +4,7 @@ Use this index to quickly locate files and directories during development and in
## Top-Level
- docker/compose.yaml / docker/compose.override.yaml — Docker services.
- docker/ — Docker build contexts and configs (php Dockerfile, nginx vhost, compose files).
- docker/ — Docker build contexts and configs (php Dockerfile, nginx vhost).
- composer.json / composer.lock — Dependencies and scripts.
- importmap.php — Importmap configuration for JS dependencies.
- phpunit.dist.xml — PHPUnit configuration.
+15 -9
View File
@@ -9,7 +9,7 @@ This app can run fully in Docker using docker compose with PHP-FPM, Nginx and My
- mailer (dev only via compose.override.yaml): Mailpit (SMTP/UI)
## Prerequisites
- Docker and Docker Compose (v2)
- Docker and Docker Compose (docker compose)
## Usage
@@ -21,36 +21,42 @@ App will be served at http://localhost:8080
Alternatively (manual):
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
cd docker
docker compose up -d --build
```
### 2) Install dependencies
The setup script already runs composer install. To run manually:
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php composer install
cd docker
docker compose exec php composer install
```
### 3) Prepare DB
The setup script already prepares the DB. To run manually:
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php php bin/console doctrine:database:create --if-not-exists
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php php bin/console doctrine:migrations:migrate -n
cd docker
docker compose exec php php bin/console doctrine:database:create --if-not-exists
docker compose exec php php bin/console doctrine:migrations:migrate -n
```
### 4) Run tests
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php vendor/bin/phpunit
cd docker
docker compose exec php vendor/bin/phpunit
```
### 5) Logs
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml logs -f nginx
docker compose -f docker/compose.yaml -f docker/compose.override.yaml logs -f php
cd docker
docker compose logs -f nginx
docker compose logs -f php
```
### 6) Stop
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml down
cd docker
docker compose down
```
## Notes
+14 -11
View File
@@ -1,9 +1,9 @@
# Email Delivery: Dev Mailcatcher & Production SendGrid
# Email Delivery: Dev Mailcatcher & Production Mailgun
This application uses Symfony Mailer. We separate development and production delivery:
- Development: Mailpit (mailcatcher) via SMTP in Docker.
- Production: SendGrid via API transport.
- Production: Mailgun via API transport.
## Development (Mailpit)
@@ -18,28 +18,31 @@ MAILER_DSN=smtp://mailer:1025
```
- Usage:
1. Start stack: `docker compose up -d`
1. Start stack: `docker-compose up -d`
2. Send an email from the app.
3. Open http://localhost:8025 to view captured emails.
## Production (SendGrid)
## Production (Mailgun)
Use the SendGrid API transport. Do not commit secrets.
Use the Mailgun API transport (`symfony/mailgun-mailer` bridge). Do not commit secrets.
- Example configuration is in `.env.prod`:
```
MAILER_DSN=sendgrid+api://%env(resolve:SENDGRID_API_KEY)%@default
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu
```
- Provide `SENDGRID_API_KEY` via:
- Real environment variable on the server/container, or
- Symfony secrets: `php bin/console secrets:set SENDGRID_API_KEY` (and dump for prod), or
- `region=eu` is only needed if the Mailgun account/domain was created in Mailgun's EU region (common for `.nl`/EU-based senders). Drop it (or use `region=us`) if the domain lives in the US region.
- Provide `MAILGUN_API_KEY` and `MAILGUN_DOMAIN` via:
- Real environment variables on the server/container, or
- Symfony secrets: `php bin/console secrets:set MAILGUN_API_KEY` (and dump for prod), or
- Orchestration secret stores (e.g., Docker/K8s).
- The sending domain must be added and DNS-verified (SPF/DKIM/tracking CNAME) in the Mailgun dashboard before production sending will work reliably; unverified domains are rate-limited/sandboxed.
### Notes
- No Mailpit container is defined in the base `compose.yaml`, only in `compose.override.yaml`. This ensures it is used in development only.
- To test email locally without Docker, you can:
- Run Mailpit on your host (ports 1025/8025) and set `MAILER_DSN=smtp://127.0.0.1:1025` in `.env.local`.
- If you need to use SendGrid SMTP instead of API, a DSN example:
`smtp://apikey:YOUR_SENDGRID_API_KEY@smtp.sendgrid.net:587`.
- If you need to use Mailgun SMTP instead of API, a DSN example:
`mailgun+smtp://USERNAME:PASSWORD@default?region=eu` (username/password come from the Mailgun domain's SMTP credentials).
- Use `php bin/console app:mail:test you@example.com` to send a quick test email against whatever `MAILER_DSN` is currently configured.
+31
View File
@@ -0,0 +1,31 @@
# User and Group IDs
USER_ID=1000
GROUP_ID=1000
# Application
APP_ENV=prod
SITE_BASE_URL=https://escapepage.com
# Mailer
MAILGUN_API_KEY=CHANGEME_MAILGUN_API_KEY
MAILGUN_DOMAIN=CHANGEME_MAILGUN_DOMAIN
MAILER_DSN=mailgun+api://CHANGEME_MAILGUN_API_KEY:CHANGEME_MAILGUN_DOMAIN@default?region=eu
MAILER_FROM=mailer@escapepage.nl
# Database
DATABASE_URL=mysql://escapepage:CHANGEME_DB_PASSWORD@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4
DB_NAME=escapepage
DB_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD
# Mercure
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=CHANGEME_MERCURE_JWT_SECRET
MERCURE_CORS_ALLOWED_ORIGINS=https://www.escapepage.com https://escapepage.com
MERCURE_TOPIC_BASE=https://escapepage.com
# Recaptcha
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
+20 -8
View File
@@ -1,20 +1,19 @@
services:
php:
environment:
XDEBUG_MODE: off
XDEBUG_MODE: "off"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- mailer
# networks:
# backend:
# ipv4_address: 172.23.0.10
###> doctrine/doctrine-bundle ###
database:
ports:
- "3306"
###< doctrine/doctrine-bundle ###
###> doctrine/doctrine-bundle ###
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
###> symfony/mailer ###
mailer:
image: axllent/mailpit
ports:
@@ -23,4 +22,17 @@ services:
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
# networks:
# backend:
# ipv4_address: 172.23.0.13
# networks:
# backend:
# name: escapepage_network
# driver: bridge
# ipam:
# config:
# - subnet: 172.23.0.0/16
# gateway: 172.23.0.1
# attachable: true
###< symfony/mailer ###
+77 -34
View File
@@ -1,4 +1,3 @@
version: '3.7'
services:
@@ -6,33 +5,65 @@ services:
build:
context: ..
dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment:
APP_ENV: dev
APP_ENV: ${APP_ENV}
SITE_BASE_URL: ${SITE_BASE_URL}
MAILER_DSN: ${MAILER_DSN}
MAILER_FROM: ${MAILER_FROM}
DATABASE_URL: ${DATABASE_URL}
MERCURE_URL: ${MERCURE_URL}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
depends_on:
- database
- mercure
networks:
- backend
# networks:
# backend:
# ipv4_address: 172.23.0.10
restart: unless-stopped
php-worker:
build:
context: ..
dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php-worker
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment:
APP_ENV: dev
APP_ENV: ${APP_ENV}
SITE_BASE_URL: ${SITE_BASE_URL}
MAILER_DSN: ${MAILER_DSN}
MAILER_FROM: ${MAILER_FROM}
DATABASE_URL: ${DATABASE_URL}
MERCURE_URL: ${MERCURE_URL}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
depends_on:
- database
- mercure
command: ["php", "bin/console", "messenger:consume", "async", "-vv"]
networks:
- backend
# networks:
# backend:
# ipv4_address: 172.23.0.11
restart: unless-stopped
nginx:
@@ -40,13 +71,17 @@ services:
container_name: escapepage-nginx
ports:
- "8080:80"
- "8443:443"
volumes:
- ../:/var/www/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- /etc/hosts:/etc/hosts:ro
depends_on:
- php
networks:
- backend
# networks:
# backend:
# ipv4_address: 172.23.0.12
restart: unless-stopped
mailer:
@@ -54,60 +89,68 @@ services:
container_name: escapepage-mailer
ports:
- "8025:8025"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
- backend
- default
- nginx_proxy
restart: unless-stopped
mercure:
image: dunglas/mercure:v0.21
container_name: escapepage-mercure
environment:
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
MERCURE_CORS_ALLOWED_ORIGINS: http://localhost:8080
MERCURE_PUBLISH_ALLOWED_ORIGINS: http://localhost:8080
SERVER_NAME: "http://:80"
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_PUBLISH_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://localhost:8080
# Allow anonymous subscribers in dev only
cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
anonymous
ports:
- "8090:80"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
- backend
- default
- nginx_proxy
restart: unless-stopped
###> doctrine/doctrine-bundle ###
###> doctrine/doctrine-bundle ###
database:
image: mysql:8.0
container_name: escapepage-db
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-app}
MYSQL_USER: ${MYSQL_USER:-app}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-!ChangeMe!}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}"]
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
command: ["--default-authentication-plugin=mysql_native_password", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
command: ["--default-authentication-plugin=mysql_native_password", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--lower-case-table-names=1", "--innodb-use-native-aio=0"]
volumes:
- database_data:/var/lib/mysql:rw
- ../var/volumes/db:/var/lib/mysql:rw
- ./mysql/init:/docker-entrypoint-initdb.d:ro
- /etc/hosts:/etc/hosts:ro
# Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
# ports:
# - "3306:3306"
networks:
- backend
ports:
- "3306:3306"
# networks:
# backend:
# ipv4_address: 172.23.0.15
restart: unless-stopped
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###
networks:
backend:
driver: bridge
nginx_proxy:
external: true
name: nginx_default
+5
View File
@@ -0,0 +1,5 @@
-- This script ensures the user has correct privileges.
-- The user is actually created by the official MySQL image using environment variables.
GRANT ALL PRIVILEGES ON *.* TO 'escapepage'@'%';
FLUSH PRIVILEGES;
+20
View File
@@ -1,6 +1,18 @@
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/html/public;
index index.php index.html;
@@ -18,6 +30,14 @@ server {
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass php:9000;
fastcgi_read_timeout 120;
# Ensure HTTPS is correctly detected by Symfony if Nginx is behind a TLS termination proxy
fastcgi_param HTTPS $https if_not_empty;
# Standard forwarded headers
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_PROTO $scheme;
fastcgi_param HTTP_X_FORWARDED_HOST $host;
fastcgi_param HTTP_X_FORWARDED_PORT $server_port;
}
location ~ /\.ht {
+22
View File
@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDrTCCApWgAwIBAgIURXHwywjcTFR43Q8+qtMAMuhHmW0wDQYJKoZIhvcNAQEL
BQAwZjELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5
MRUwEwYDVQQKDAxPcmdhbml6YXRpb24xDTALBgNVBAsMBFVuaXQxEjAQBgNVBAMM
CWxvY2FsaG9zdDAeFw0yNjAxMTAxMjMzNTNaFw0yNzAxMTAxMjMzNTNaMGYxCzAJ
BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
CgwMT3JnYW5pemF0aW9uMQ0wCwYDVQQLDARVbml0MRIwEAYDVQQDDAlsb2NhbGhv
c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0dQIpm6SeY/Qt1zTr
GDfQuRAqowde6vzlNDwwC5hNQUaA4MCsDcmqmxj/YPUA8qG4MWQzYsj3HEn8l863
a7BELIYy2kvHTO7mgZMsBiH6HzHilIOsZkMJEV3QLlFn7VRb7i6WSw48pbRJk77l
sOX/e3vzE2pemnx4ggSORzNorrQ7UwyBpK374yisKSFzs6KKPnkVDbfBNX2k+fUT
8Ncjq5WkllA93ztPzh1iHNcFThx+MiH5fcs9obdMbfNkcQy22J9Nbi0OT9Tf8R7k
OaBEVPxFkT+moj6bCwetLkdQDGaoGA6AXTR1lrN812eU1TJ6KA4TAOj4ZAuygWa0
kqi3AgMBAAGjUzBRMB0GA1UdDgQWBBSayyPInKCPbaliYycRx9GEK2tTFjAfBgNV
HSMEGDAWgBSayyPInKCPbaliYycRx9GEK2tTFjAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQCT3r5wZd8fN/ognHFopJRKxjw3ZBBYl54ELb32OSVS
NcKR63/2kZc7KQY5LjPbBMpDutLUPsVtJ97OSYY/JQDm/VVkJy0jIUtPD/bLnjEI
bhMoIGKwUDtnSaYF3oXhwMX3XchDCLmpsk+E17LTTq+tHUzkhXZu+sHoHrE70Wls
XfziM0O/zpApJQSeCLi8UDGffLVChFQd4uU//YW+4OMyk/mbu7dV4ckJXQVIvqTr
7UuC7SgRChcYkaQpkDUnaoX+miKbr9SHUmBSbCsXDyPDth5TOUSZWbP6ewDKVWW7
37OURA5UqT2RvnX75+FdLnBtqJrt/3X8wafOOLXILwmA
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0dQIpm6SeY/Qt
1zTrGDfQuRAqowde6vzlNDwwC5hNQUaA4MCsDcmqmxj/YPUA8qG4MWQzYsj3HEn8
l863a7BELIYy2kvHTO7mgZMsBiH6HzHilIOsZkMJEV3QLlFn7VRb7i6WSw48pbRJ
k77lsOX/e3vzE2pemnx4ggSORzNorrQ7UwyBpK374yisKSFzs6KKPnkVDbfBNX2k
+fUT8Ncjq5WkllA93ztPzh1iHNcFThx+MiH5fcs9obdMbfNkcQy22J9Nbi0OT9Tf
8R7kOaBEVPxFkT+moj6bCwetLkdQDGaoGA6AXTR1lrN812eU1TJ6KA4TAOj4ZAuy
gWa0kqi3AgMBAAECggEAfwOccgzK4XEY/OrspEx3fMHFTz1Qgs6DEhCiDG8c08OO
DEglVPSfbSWdgqKL0A73JN4e2Mw/By8yJEf1h8SUXGe6TTC5BZ5wyG2LWQE4CQTL
598AjuerZ0aB8XWodq3lIo+S2tYZPzainucPBjxsplYT+BNCWzQBSBC7hCk5VgPx
6BvzlzBEWJYizpnT55Ta7zDV1tofP2RUt5Q6GT27Qm5fMlAj3a3LsmgeDLIPHhQd
RCo0kEc56X4vZyojaNUrmTzh6+Ljoj7ahEsW9fr8kfQvIlvuR1qjkuuCEUDU7kS/
iblwVkY1Lfrfm9mI82EYI287m28LBTP99ULk9KRhAQKBgQDpEjK0/OmsHSQfjiG7
PHQXrmIdMzaz+BYttiGV9Fx5hsdVPvihdjzzwZck2MkSg5ODMtEthb7uBareS3Nl
CG7a7brY8a/x5ZdnUPNXGykfix/oz557EENembKaWpsV8qiHM8vuADOWEvmqBTVt
C0iXrwvyxgy/GuNz9A9Tfyya3wKBgQDGNb9Pr903/JzJKFkT+4dGpAgE0a3eQsDm
HEJimbhNoOw79AyOHWbpV2f74kz0GdG2MjU3988lZ/VJ7FM0eyDkuBvv3c2YdKCm
A/5tprB/8PefdNJD0HuVm4BE2XDLV74DbOCgoqsFMC1BdeUVBAhSqmRNrYFQYRqj
DvqtDQiFKQKBgB5p6YQEnNmA0/3qJiywrtWIQ/VbgX/ql7pPUgKnaInTNJ/DH96x
9zI3yOleAJ8R3GX6c6FlGo0k4C8x2VUNzKl07DTzFOqT8zXgMmDjgnJDTV6r+RpF
/QSTOeM6f5JVn/hEog/kptamkz3EgDxChK6GgSClB3TIpXW0G2vh5IgxAoGBAIIl
WHDicMcKP4h1zcepKLHhksJXS2rdOfveIljLxpByUassG/JUq/YbRlPFy/Gb4m9X
mEoflQxirlTTr+6NypNjsDRX1197dOCNTsqA4POhLXauJkIQ6pTZfee3PrDF9CYb
n4LaTKEjeRO6bajW9QASkbnPa1Fz8SGP/FkUbbvBAoGAKIuvVLwht1A8C0BXaFrb
znZu3u90SB9TEcm2V9pU1ptiU6Q/CGlxm8UYvx1ahmxNYL6Ip/QNIFyb+HCqvIUf
Id3C+4LlLeXVBP0uBCX828zREhuQutq3kju2iOQfsOkwc1McS4WXk6tExXoVwkzl
2WYMu+GpSZLcti71L58tOf4=
-----END PRIVATE KEY-----
+36 -3
View File
@@ -6,15 +6,32 @@ RUN apk add --no-cache \
git \
icu-dev \
libzip-dev \
libxml2-dev \
oniguruma-dev \
g++ \
make \
nodejs \
npm
npm \
shadow
# Install PHP extension installer
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
# Install PHP extensions
RUN docker-php-ext-configure intl \
&& docker-php-ext-install -j$(nproc) intl pdo pdo_mysql opcache zip
RUN install-php-extensions \
intl \
pdo_mysql \
opcache \
zip \
tokenizer \
ctype \
iconv \
mbstring \
dom \
xml \
simplexml \
xmlreader \
xmlwriter
# Install composer
ENV COMPOSER_ALLOW_SUPERUSER=1 \
@@ -24,7 +41,23 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Configure PHP
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
# Adjust www-data UID/GID to match host user (default 1000)
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN if [ ${USER_ID:-0} -ne 0 ] && [ ${GROUP_ID:-0} -ne 0 ]; then \
userdel -f www-data &&\
if getent group www-data ; then groupdel www-data; fi &&\
groupadd -g ${GROUP_ID} www-data &&\
useradd -l -u ${USER_ID} -g www-data www-data &&\
install -d -m 0755 -o www-data -g www-data /home/www-data \
;fi
WORKDIR /var/www/html
# Set permissions for Symfony directories
RUN mkdir -p var/cache var/log var/sessions && \
chown -R www-data:www-data var
# Default command
CMD ["php-fpm"]
+5
View File
@@ -7,3 +7,8 @@ opcache.enable=1
opcache.enable_cli=1
opcache.validate_timestamps=1
opcache.revalidate_freq=0
log_errors=On
error_log=/var/www/html/var/log/errorlog_php.log
session.gc_maxlifetime=1440
session.cookie_lifetime=0
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# Script to completely restart the project as requested
# Can be run from any directory
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
echo "Stopping and removing containers..."
(cd "$DOCKER_DIR" && docker compose -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
docker network rm escapepage_network || true
docker network rm $(docker network ls -q --filter name=escapepage) || true
docker network prune -f || true
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker || true
docker system prune -f || true
echo "Clearing Docker build cache..."
docker builder prune -af
echo "Setting permissions for var/volumes/db and var directories..."
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
sudo chown -R 1000:1000 "$ROOT_DIR/var" || true
sudo chmod -R 777 "$ROOT_DIR/var" || true
echo "Running setup script..."
"$DOCKER_DIR/setup.sh" --no-build
Regular → Executable
+15 -7
View File
@@ -17,18 +17,18 @@ set -euo pipefail
ROOT_DIR=$(cd "$(dirname "$0")"/.. && pwd)
DOCKER_DIR="$ROOT_DIR/docker"
# Determine the docker compose command (V2 'docker compose' or V1 'docker-compose')
# Determine the docker-compose command
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: Neither 'docker compose' nor 'docker-compose' was found. Please install Docker Compose." >&2
echo "Error: Neither 'docker-compose' nor 'docker compose' was found. Please install Docker Compose." >&2
exit 1
fi
# Helper to run docker compose from the docker/ directory
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -f compose.yaml "$@"); }
# Helper to run docker compose from the docker directory
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -f compose.yaml -f compose.override.yaml "$@"); }
REBUILD=1
RECREATE=0
@@ -61,7 +61,7 @@ if [ "$RECREATE" -eq 1 ]; then
fi
# Start stack
dc up "${BUILD_ARGS[@]}"
dc up -d "${BUILD_ARGS[@]}"
# Helper to run commands in php container
pexec() { dc exec -T php "$@"; }
@@ -104,9 +104,17 @@ fi
# Prepare DB
echo "Creating database if it doesn't exist..."
pexec php bin/console doctrine:database:create --if-not-exists
if ! pexec php bin/console doctrine:database:create --if-not-exists; then
echo "Error: Database creation failed. Check Docker logs for details." >&2
dc logs database
exit 1
fi
echo "Running migrations..."
pexec php bin/console doctrine:migrations:migrate -n
if ! pexec php bin/console doctrine:migrations:migrate -n; then
echo "Error: Migrations failed." >&2
exit 1
fi
# Import JS deps (Importmap/Asset Mapper)
if [ -f "$ROOT_DIR/importmap.php" ]; then
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260117143000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make player.screen nullable';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE player CHANGE screen screen INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE player CHANGE screen screen INT NOT NULL');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260627140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Seed initial game: AI Virus Deflection';
}
public function up(Schema $schema): void
{
$this->addSql("INSERT INTO game (name, number_of_players, status) VALUES ('AI Virus Deflection', 3, 'inDevelopment')");
$this->addSql("INSERT INTO game_setting (game_id, name, value) VALUES (LAST_INSERT_ID(), 'totalTime', '1800')");
}
public function down(Schema $schema): void
{
$this->addSql("DELETE gs FROM game_setting gs INNER JOIN game g ON gs.game_id = g.id WHERE g.name = 'AI Virus Deflection'");
$this->addSql("DELETE FROM game WHERE name = 'AI Virus Deflection'");
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260704160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add marketing_opt_in column to user table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE `user` ADD marketing_opt_in TINYINT(1) NOT NULL DEFAULT 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE `user` DROP marketing_opt_in');
}
}
+494
View File
@@ -8,6 +8,11 @@
"name": "escapepage",
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@babel/preset-env": "^7.25.0",
@@ -17,6 +22,8 @@
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.2",
"regenerator-runtime": "^0.14.1",
"sass": "^1.101.0",
"sass-loader": "^14.2.1",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-notifier": "^1.15.0"
@@ -1743,6 +1750,340 @@
"node": ">=4"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -2884,6 +3225,41 @@
"dev": true,
"license": "ISC"
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3718,6 +4094,17 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -4777,6 +5164,13 @@
"postcss": "^8.1.0"
}
},
"node_modules/immutable": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"dev": true,
"license": "MIT"
},
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -5554,6 +5948,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/node-forge": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
@@ -6861,6 +7263,98 @@
"dev": true,
"license": "MIT"
},
"node_modules/sass": {
"version": "1.101.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz",
"integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-loader": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-14.2.1.tgz",
"integrity": "sha512-G0VcnMYU18a4N7VoNDegg2OuMjYtxnqzQWARVWCIVSZwJeiL9kg8QMsuIZOplsJgTzZLF6jGxI3AClj8I9nRdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"neo-async": "^2.6.2"
},
"engines": {
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"@rspack/core": "0.x || 1.x",
"node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"sass": "^1.3.0",
"sass-embedded": "*",
"webpack": "^5.0.0"
},
"peerDependenciesMeta": {
"@rspack/core": {
"optional": true
},
"node-sass": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/sass/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
+7
View File
@@ -18,8 +18,15 @@
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.2",
"regenerator-runtime": "^0.14.1",
"sass": "^1.101.0",
"sass-loader": "^14.2.1",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-notifier": "^1.15.0"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+9
View File
@@ -1,9 +1,18 @@
<?php
use App\Kernel;
use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
if ($trustedProxies = $context['TRUSTED_PROXIES'] ?? $_ENV['TRUSTED_PROXIES'] ?? false) {
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PREFIX);
}
if ($trustedHosts = $context['TRUSTED_HOSTS'] ?? $_ENV['TRUSTED_HOSTS'] ?? false) {
Request::setTrustedHosts([$trustedHosts]);
}
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Command;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'app:create-user',
description: 'Create a new user',
)]
class CreateUserCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'Email address')
->addArgument('username', InputArgument::REQUIRED, 'Username')
->addArgument('password', InputArgument::REQUIRED, 'Plain-text password')
->addOption('admin', null, InputOption::VALUE_NONE, 'Grant ROLE_ADMIN');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$username = $input->getArgument('username');
$password = $input->getArgument('password');
$isAdmin = $input->getOption('admin');
$user = new User();
$user->setEmail($email);
$user->setUsername($username);
$user->setPassword($this->passwordHasher->hashPassword($user, $password));
$user->setIsVerified(true);
$user->setRoles($isAdmin ? ['ROLE_ADMIN'] : []);
$this->entityManager->persist($user);
$this->entityManager->flush();
$io->success(sprintf(
'User "%s" (%s) created%s.',
$username,
$email,
$isAdmin ? ' with ROLE_ADMIN' : '',
));
return Command::SUCCESS;
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ final class MercurePublishCommand extends Command
protected function configure(): void
{
$this
->addArgument('topic', InputArgument::OPTIONAL, 'Topic URL to publish to', $_ENV['MERCURE_TOPIC_BASE'] . '/game/hub')
->addArgument('topic', InputArgument::OPTIONAL, 'Topic URL to publish to', '/game/hub/test')
->addOption('type', null, InputOption::VALUE_REQUIRED, 'Update type (for clients to filter)', 'game.event')
->addOption('data', null, InputOption::VALUE_REQUIRED, 'JSON payload to send', '{"message":"Hello from Mercure!"}')
->addOption('private', null, InputOption::VALUE_NONE, 'Mark the update as private');
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Mime\Email;
#[AsCommand(
name: 'app:mail:test',
description: 'Sends a simple test email using the configured mail transport (SendGrid in prod).'
description: 'Sends a simple test email using the configured mail transport (Mailgun in prod).'
)]
final class TestEmailCommand extends Command
{
+19 -12
View File
@@ -1,9 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository;
use App\Game\Repository\SessionRepository;
use App\Tech\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -25,14 +28,23 @@ final class GameAdminController extends AbstractController
#[Route('', name: 'game_admin_dashboard', methods: ['GET'])]
public function index(
UserRepository $userRepository,
SessionRepository $sessionRepository
SessionRepository $sessionRepository,
GameRepository $gameRepository,
): Response {
$players = $userRepository->findByRole('ROLE_PLAYER');
$sessions = $sessionRepository->findAll();
$allUsers = $userRepository->findAll();
$allSessions = $sessionRepository->findAll();
$activeSessions = array_filter(
$allSessions,
fn(Session $s) => in_array($s->getStatus(), [SessionStatus::CREATED, SessionStatus::READY, SessionStatus::PLAYING])
);
return $this->render('game/admin/index.html.twig', [
'players' => $players,
'sessions' => $sessions,
'totalUsers' => count($allUsers),
'totalPlayers' => count($userRepository->findByRole('ROLE_PLAYER')),
'totalAdmins' => count($userRepository->findByRole('ROLE_ADMIN')),
'totalGames' => count($gameRepository->findAll()),
'totalSessions' => count($allSessions),
'activeSessions' => count($activeSessions),
]);
}
@@ -44,18 +56,13 @@ final class GameAdminController extends AbstractController
$username = $player->getUser()->getUsername();
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
$logs = '';
if (file_exists($logFile)) {
$logs = file_get_contents($logFile);
}
$playersLogs[] = [
'username' => $username,
'logs' => $logs,
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
];
}
return $this->render('game/admin/session.html.twig', [
return $this->render('game/admin/sessions/view.html.twig', [
'session' => $session,
'playersLogs' => $playersLogs,
]);
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Tech\Repository\EmailLogRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/email-log')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminEmailLogController extends AbstractController
{
#[Route('', name: 'game_admin_email_log', methods: ['GET'])]
public function index(EmailLogRepository $emailLogRepository): Response
{
return $this->render('game/admin/email_log/index.html.twig', [
'logs' => $emailLogRepository->findBy([], ['sentAt' => 'DESC']),
]);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\GameSetting;
use App\Game\Enum\GameSettingType;
use App\Game\Form\AdminGameType;
use App\Game\Entity\Game;
use App\Game\Repository\GameRepository;
use App\Game\Repository\GameSettingRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/games')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminGameController extends AbstractController
{
#[Route('', name: 'game_admin_games', methods: ['GET'])]
public function index(GameRepository $gameRepository): Response
{
return $this->render('game/admin/games/index.html.twig', [
'games' => $gameRepository->findBy([], ['id' => 'ASC']),
]);
}
#[Route('/{id}/edit', name: 'game_admin_game_edit', methods: ['GET', 'POST'])]
public function edit(
Game $game,
Request $request,
EntityManagerInterface $em,
GameSettingRepository $gameSettingRepository,
): Response {
$totalTimeSetting = $gameSettingRepository->getSetting($game, GameSettingType::TOTAL_TIME);
$form = $this->createForm(AdminGameType::class, $game, [
'total_time' => $totalTimeSetting?->getValue(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$totalTime = $form->get('totalTime')->getData();
if ($totalTime !== null) {
if (!$totalTimeSetting) {
$totalTimeSetting = new GameSetting();
$totalTimeSetting->setGame($game);
$totalTimeSetting->setName(GameSettingType::TOTAL_TIME);
}
$totalTimeSetting->setValue((string) $totalTime);
$em->persist($totalTimeSetting);
}
$em->flush();
$this->addFlash('success', sprintf('Game "%s" updated.', $game->getName()));
return $this->redirectToRoute('game_admin_games');
}
return $this->render('game/admin/games/edit.html.twig', [
'game' => $game,
'form' => $form,
]);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\SessionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/sessions')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminSessionController extends AbstractController
{
#[Route('', name: 'game_admin_sessions', methods: ['GET'])]
public function index(SessionRepository $sessionRepository): Response
{
return $this->render('game/admin/sessions/index.html.twig', [
'sessions' => $sessionRepository->findBy([], ['created' => 'DESC']),
]);
}
#[Route('/{id}/close', name: 'game_admin_session_close', methods: ['POST'])]
public function close(Session $session, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('close_session_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('game_admin_sessions');
}
$session->setStatus(SessionStatus::LOST);
$em->flush();
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
return $this->redirectToRoute('game_admin_sessions');
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Tech\Entity\User;
use App\Tech\Form\AdminUserType;
use App\Tech\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/users')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminUserController extends AbstractController
{
#[Route('', name: 'game_admin_users', methods: ['GET'])]
public function index(UserRepository $userRepository): Response
{
$users = $userRepository->findBy([], ['id' => 'ASC']);
return $this->render('game/admin/users/index.html.twig', [
'users' => $users,
'marketingOptInCount' => count(array_filter($users, static fn (User $user) => $user->isMarketingOptIn())),
]);
}
#[Route('/{id}/edit', name: 'game_admin_user_edit', methods: ['GET', 'POST'])]
public function edit(
User $user,
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher,
): Response {
$form = $this->createForm(AdminUserType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$plainPassword = $form->get('plainPassword')->getData();
if ($plainPassword) {
$user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
}
$em->flush();
$this->addFlash('success', sprintf('User "%s" updated.', $user->getUsername()));
return $this->redirectToRoute('game_admin_users');
}
return $this->render('game/admin/users/edit.html.twig', [
'user' => $user,
'form' => $form,
]);
}
#[Route('/{id}/delete', name: 'game_admin_user_delete', methods: ['POST'])]
public function delete(User $user, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_user_' . $user->getId(), $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('game_admin_users');
}
$username = $user->getUsername();
$em->remove($user);
$em->flush();
$this->addFlash('success', sprintf('User "%s" deleted.', $username));
return $this->redirectToRoute('game_admin_users');
}
}
+30 -1
View File
@@ -3,7 +3,10 @@ declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Service\GameResponseService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -15,7 +18,9 @@ final class GameApiController extends AbstractController
{
public function __construct(
protected GameResponseService $gameResponseService) {
protected GameResponseService $gameResponseService,
private EntityManagerInterface $entityManager
) {
}
@@ -29,6 +34,30 @@ final class GameApiController extends AbstractController
]);
}
#[Route('/check-finished/{session}', name: 'check_finished', methods: ['POST'])]
public function checkFinished(Session $session): JsonResponse
{
$now = (new \DateTime())->getTimestamp();
$isFinished = false;
if ($session->getStatus() === SessionStatus::PLAYING) {
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
$session->setStatus(SessionStatus::LOST);
$this->entityManager->persist($session);
$this->entityManager->flush();
$isFinished = true;
}
} elseif ($session->getStatus() === SessionStatus::LOST || $session->getStatus() === SessionStatus::WON) {
$isFinished = true;
}
return $this->json([
'ok' => true,
'finished' => $isFinished,
'status' => $session->getStatus()->value,
]);
}
#[Route('/message', name: 'message', methods: ['POST'])]
public function message(Request $request): JsonResponse
{
+70 -1
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Enum\SessionSettingType;
@@ -12,6 +13,7 @@ use App\Game\Repository\PlayerRepository;
use App\Game\Repository\SessionRepository;
use App\Game\Service\GameDashboardService;
use App\Tech\Entity\User;
use App\Game\Service\PlayerService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -25,7 +27,8 @@ final class GameController extends AbstractController
{
public function __construct(
#[Autowire('%env(MERCURE_PUBLIC_URL)%')]
private string $mercurePublicUrl
private string $mercurePublicUrl,
private \Doctrine\ORM\EntityManagerInterface $entityManager
) {
}
@@ -153,10 +156,76 @@ final class GameController extends AbstractController
}
$screen = $player ? $player->getScreen() : 0;
$session_id = $session->getId();
return $this->render('game/index.html.twig', [
'session' => $session,
'screen' => $screen,
'session_id' => $session_id,
]);
}
#[Route(path: '/lost/{session}', name: 'game_lost', methods: ['GET', 'POST'])]
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
#[IsGranted('SESSION_VIEW', subject: 'session')]
public function lost(
Session $session,
Request $request,
Security $security,
PlayerService $playerService,
GameDashboardService $dashboardService
): Response {
/** @var User $user */
$user = $security->getUser();
$player = $playerService->GetCurrentlyActiveAsPlayer($user);
if ($request->isMethod('POST')) {
$difficulty = $request->request->get('difficulty');
$entertaining = $request->request->get('entertaining');
$theme = $request->request->get('theme');
$feedback = $request->request->get('feedback');
// Save feedback
if ($player) {
$this->saveFeedback($session, $player, $difficulty, $entertaining, $theme, $feedback);
$this->addFlash('success', 'Thank you for your feedback!');
return $this->redirectToRoute('game_dashboard');
}
}
return $this->render('game/lost.html.twig', [
'session' => $session,
]);
}
private function saveFeedback(Session $session, Player $player, $difficulty, $entertaining, $theme, $feedback): void
{
$settings = [
SessionSettingType::FEEDBACK_DIFFICULTY,
SessionSettingType::FEEDBACK_ENTERTAINING,
SessionSettingType::FEEDBACK_THEME,
SessionSettingType::FEEDBACK_TEXT,
];
$values = [
$difficulty,
$entertaining,
$theme,
$feedback,
];
foreach ($settings as $index => $type) {
$value = $values[$index];
if ($value === null || $value === '') continue;
$setting = new SessionSetting();
$setting->setSession($session);
$setting->setPlayer($player);
$setting->setName($type);
$setting->setValue((string)$value);
$this->entityManager->persist($setting);
}
$this->entityManager->flush();
}
}
+4
View File
@@ -70,4 +70,8 @@ enum SessionSettingType: string
case READY_AT_FOR_PLAYER8 = 'ReadyAtForPlayer8';
case READY_AT_FOR_PLAYER9 = 'ReadyAtForPlayer9';
case READY_AT_FOR_PLAYER10 = 'ReadyAtForPlayer10';
case FEEDBACK_DIFFICULTY = 'FeedbackDifficulty';
case FEEDBACK_ENTERTAINING = 'FeedbackEntertaining';
case FEEDBACK_THEME = 'FeedbackTheme';
case FEEDBACK_TEXT = 'FeedbackText';
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Game\Form;
use App\Game\Entity\Game;
use App\Game\Enum\GameStatus;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Positive;
class AdminGameType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'constraints' => [new NotBlank()],
])
->add('numberOfPlayers', IntegerType::class, [
'label' => 'Number of players',
'constraints' => [new NotBlank(), new Positive()],
])
->add('status', ChoiceType::class, [
'choices' => [
'In development' => GameStatus::IN_DEVELOPMENT,
'Locked' => GameStatus::LOCKED,
'Open' => GameStatus::OPEN,
],
])
->add('totalTime', IntegerType::class, [
'mapped' => false,
'required' => false,
'label' => 'Total time (seconds)',
'data' => $options['total_time'],
'attr' => ['placeholder' => 'e.g. 3600 for 1 hour'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Game::class,
'total_time' => null,
]);
$resolver->setAllowedTypes('total_time', ['null', 'string', 'int']);
}
}
+23 -11
View File
@@ -17,7 +17,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class GameDashboardService
{
@@ -26,8 +25,6 @@ final class GameDashboardService
private readonly SessionRepository $sessionRepository,
private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private readonly string $mercureTopicBase,
) {
}
@@ -205,7 +202,7 @@ final class GameDashboardService
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($pwdSettingName);
$setting->setValue('var/home/' . $player->getUser()->getUsername());
$setting->setValue('/var/home/' . $player->getUser()->getUsername());
$this->entityManager->persist($setting);
}
@@ -329,11 +326,16 @@ final class GameDashboardService
$this->checkAllPlayersReady($session);
$this->entityManager->flush();
try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting])));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
// If this toggle just made everyone ready, checkAllPlayersReady() already
// transitioned the session out of READY and published 'all_ready' —
// don't also publish a redundant 'player_ready'.
if ($session->getStatus() === SessionStatus::READY) {
try {
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting])));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
}
}
return true;
@@ -382,7 +384,7 @@ final class GameDashboardService
if ($anyReset) {
$this->entityManager->flush();
try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready'])));
} catch (\Exception $e) {
// Mercure might be down
@@ -391,6 +393,16 @@ final class GameDashboardService
if ($readyPlayersCount === $numPlayers) {
$session->setStatus(SessionStatus::PLAYING);
// Set the end timer
$game = $session->getGame();
/** @var \App\Game\Repository\GameSettingRepository $gameSettingRepo */
$gameSettingRepo = $this->entityManager->getRepository(\App\Game\Entity\GameSetting::class);
$totalTimeSetting = $gameSettingRepo->getSetting($game, \App\Game\Enum\GameSettingType::TOTAL_TIME);
$totalTime = $totalTimeSetting ? (int)$totalTimeSetting->getValue() : 3600; // Default to 60 minutes if not set
$session->setTimer((new \DateTime())->getTimestamp() + $totalTime);
$this->entityManager->persist($session);
// Clean up ready settings
@@ -406,7 +418,7 @@ final class GameDashboardService
}
try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready'])));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
+5 -9
View File
@@ -12,7 +12,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class GameResponseService
{
@@ -23,8 +22,6 @@ class GameResponseService
private HubInterface $hub,
private EntityManagerInterface $entityManager,
private string $projectDir,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private string $mercureTopicBase,
) {
}
@@ -336,7 +333,7 @@ class GameResponseService
if(is_null($activeGame))
return false;
$topic = $this->mercureTopicBase . '/game/hub-' . $activeGame;
$topic = '/game/hub/' . $activeGame;
try {
$this->hub->publish(new Update($topic, json_encode([$sendTo, $message])));
} catch (\Exception $e) {
@@ -382,7 +379,7 @@ class GameResponseService
$this->entityManager->flush();
// Notify the player that their codes have changed
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$topic = '/game/hub/' . $session->getId();
$notification = "Security Alert: One of your verify codes was shared and has been regenerated.";
// We send it only to this player (screen)
try {
@@ -638,7 +635,7 @@ class GameResponseService
private function checkIfAllPlayersVerified(Player $player): void
{
$session = $player->getSession();
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::EVERYONE_VERIFIED, $player);
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::EVERYONE_VERIFIED);
if ($everyoneVerifiedSetting && $everyoneVerifiedSetting->getValue() === 'true') {
return;
@@ -666,14 +663,13 @@ class GameResponseService
if (!$everyoneVerifiedSetting) {
$everyoneVerifiedSetting = new SessionSetting();
$everyoneVerifiedSetting->setSession($session);
$everyoneVerifiedSetting->setPlayer($player);
$everyoneVerifiedSetting->setName(SessionSettingType::EVERYONE_VERIFIED);
}
$everyoneVerifiedSetting->setValue('true');
$this->entityManager->persist($everyoneVerifiedSetting);
$this->entityManager->flush();
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$topic = '/game/hub/' . $session->getId();
$message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages.";
try {
$this->hub->publish(new Update($topic, json_encode([0, $message])));
@@ -896,7 +892,7 @@ class GameResponseService
return $this->readVerificationFile($player, $file);
}
$physicalPath = 'assets/game1/filesystem' . $file;
$physicalPath = $this->projectDir . '/assets/game1/filesystem' . $file;
if (!file_exists($physicalPath)) {
return ['File does not exist'];
}
+1 -1
View File
@@ -31,7 +31,7 @@ class ActivationController extends AbstractController
try {
$emailVerifier->handleEmailConfirmation($request, $user);
} catch (VerifyEmailExceptionInterface $exception) {
$this->addFlash('error', $exception->getReason());
$this->addFlash('error', $exception->getReason() . ' If the link has expired, you can <a href="' . $this->generateUrl('app_verify_resend_email') . '">request a new one here</a>.');
return $this->redirectToRoute('app_register');
}
+40 -1
View File
@@ -4,6 +4,8 @@ namespace App\Tech\Controller;
use App\Tech\Entity\User;
use App\Tech\Form\RegistrationFormType;
use App\Tech\Form\ResendVerificationEmailFormType;
use App\Tech\Repository\UserRepository;
use App\Tech\Service\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
@@ -39,7 +41,7 @@ class RegistrationController extends AbstractController
// generate a signed url and email it to the user
$emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from('noreply@escapepage.dev')
->from($this->getParameter('mailer_from'))
->to($user->getEmail())
->subject('Please Confirm your Email')
->htmlTemplate('tech/registration/confirmation_email.html.twig')
@@ -54,4 +56,41 @@ class RegistrationController extends AbstractController
'registrationForm' => $form->createView(),
]);
}
#[Route('/verify/resend', name: 'app_verify_resend_email')]
public function resendVerificationEmail(Request $request, UserRepository $userRepository, EmailVerifier $emailVerifier): Response
{
$form = $this->createForm(ResendVerificationEmailFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData();
$user = $userRepository->findOneBy(['email' => $email]);
if ($user) {
if (!$user->isVerified()) {
$emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from($this->getParameter('mailer_from'))
->to($user->getEmail())
->subject('Please Confirm your Email')
->htmlTemplate('tech/registration/confirmation_email.html.twig')
);
} else {
$this->addFlash('info', 'This email address is already verified.');
return $this->redirectToRoute('app_login');
}
}
// We show the same success message regardless of whether the user exists or not,
// to avoid revealing whether an email is registered.
$this->addFlash('success', 'If an account exists with this email, a new confirmation link has been sent.');
return $this->redirectToRoute('website_home');
}
return $this->render('tech/registration/resend_verification_email.html.twig', [
'resendForm' => $form->createView(),
]);
}
}
+15
View File
@@ -39,6 +39,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'boolean')]
private bool $isVerified = false;
#[ORM\Column(type: 'boolean')]
private bool $marketingOptIn = false;
public function getId(): ?int
{
return $this->id;
@@ -137,4 +140,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isMarketingOptIn(): bool
{
return $this->marketingOptIn;
}
public function setMarketingOptIn(bool $marketingOptIn): static
{
$this->marketingOptIn = $marketingOptIn;
return $this;
}
}
@@ -22,6 +22,10 @@ class EmailLoggerListener
public function onMessage(MessageEvent $event): void
{
if ($event->isQueued()) {
return;
}
$message = $event->getMessage();
if (!$message instanceof TemplatedEmail) {
return;
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tech\Form;
use App\Tech\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class AdminUserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'constraints' => [new NotBlank(), new Email()],
])
->add('username', TextType::class, [
'constraints' => [new NotBlank(), new Length(min: 2, max: 180)],
])
->add('plainPassword', PasswordType::class, [
'mapped' => false,
'required' => false,
'label' => 'New password',
'attr' => ['autocomplete' => 'new-password', 'placeholder' => 'Leave blank to keep current'],
])
->add('roles', ChoiceType::class, [
'choices' => [
'Player' => 'ROLE_PLAYER',
'Admin' => 'ROLE_ADMIN',
],
'multiple' => true,
'expanded' => true,
'label' => 'Roles',
])
->add('isVerified', CheckboxType::class, [
'required' => false,
'label' => 'Email verified',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
+6 -8
View File
@@ -26,15 +26,13 @@ class ChangePasswordFormType extends AbstractType
],
'first_options' => [
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 12,
'minMessage' => 'Your password should be at least {{ limit }} characters',
new NotBlank(message: 'Please enter a password'),
new Length(
min: 12,
minMessage: 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
max: 4096,
),
new PasswordStrength(),
new NotCompromisedPassword(),
],
+26 -11
View File
@@ -3,13 +3,17 @@
namespace App\Tech\Form;
use App\Tech\Entity\User;
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -21,9 +25,7 @@ class RegistrationFormType extends AbstractType
->add('email', EmailType::class)
->add('username', TextType::class, [
'constraints' => [
new NotBlank([
'message' => 'Please enter a username',
]),
new NotBlank(message: 'Please enter a username'),
],
])
->add('plainPassword', RepeatedType::class, [
@@ -33,17 +35,30 @@ class RegistrationFormType extends AbstractType
'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Repeat Password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
new NotBlank(message: 'Please enter a password'),
new Length(
min: 6,
minMessage: 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
max: 4096,
),
],
])
->add('agreeTerms', CheckboxType::class, [
'mapped' => false,
'label' => 'I agree to the Terms and Conditions',
'constraints' => [
new IsTrue(message: 'You must agree to the Terms and Conditions.'),
],
])
->add('marketingOptIn', CheckboxType::class, [
'required' => false,
'label' => 'Keep me informed about future projects and updates',
])
->add('captcha', Recaptcha3Type::class, [
'constraints' => new Recaptcha3(),
'action_name' => 'registration',
])
;
}
@@ -0,0 +1,34 @@
<?php
namespace App\Tech\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
class ResendVerificationEmailFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'constraints' => [
new NotBlank([
'message' => 'Please enter your email',
]),
new Email([
'message' => 'Please enter a valid email address',
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
@@ -16,9 +16,7 @@ class ResetPasswordRequestFormType extends AbstractType
->add('email', EmailType::class, [
'attr' => ['autocomplete' => 'email'],
'constraints' => [
new NotBlank([
'message' => 'Please enter your email',
]),
new NotBlank(message: 'Please enter your email'),
],
])
;
+1 -1
View File
@@ -43,7 +43,7 @@ class EmailVerifier
*/
public function handleEmailConfirmation(Request $request, User $user): void
{
$this->verifyEmailHelper->validateEmailConfirmation($request->getUri(), (string) $user->getId(), $user->getEmail());
$this->verifyEmailHelper->validateEmailConfirmationFromRequest($request, (string) $user->getId(), $user->getEmail());
$user->setIsVerified(true);
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Tech\Service;
use App\Tech\Entity\User;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -16,7 +17,7 @@ class UserChecker implements UserCheckerInterface
}
if (!$user->isVerified()) {
throw new CustomUserMessageAuthenticationException('Your email address is not verified.');
throw new CustomUserMessageAuthenticationException('Your email address is not verified.', ['%resend_link%' => '/verify/resend']);
}
}
@@ -15,4 +15,11 @@ final class HomeController extends AbstractController
return $this->render(
'website/home/index.html.twig');
}
#[Route(path: '/briefing', name: 'website_intro')]
public function intro(): Response
{
return $this->render(
'website/home/intro.html.twig');
}
}
+21
View File
@@ -35,6 +35,18 @@
"migrations/.gitignore"
]
},
"karser/karser-recaptcha3-bundle": {
"version": "0.3",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "0.1",
"ref": "c51ce07c10331d506762efe25b6f5843c1a5ea17"
},
"files": [
"./config/packages/karser_recaptcha3.yaml"
]
},
"phpunit/phpunit": {
"version": "11.5",
"recipe": {
@@ -146,6 +158,15 @@
"config/packages/mailer.yaml"
]
},
"symfony/mailgun-mailer": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.4",
"ref": "808e7052971863a6b94df61807f41a71d480885b"
}
},
"symfony/maker-bundle": {
"version": "1.64",
"recipe": {
+6 -2
View File
@@ -5,14 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ 'site.name'|trans }}{% endblock %}</title>
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{{ encore_entry_link_tags('app') }}
{% endblock %}
</head>
<body>
{% block header %}
<header>
<nav>
{% set pathinfo = app.request.pathinfo %}
<a href="/">{{ 'nav.home'|trans }}</a> |
<a href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a> |
{% if app.user %}
@@ -23,6 +22,8 @@
{% endif %}
</nav>
</header>
{% endblock %}
{% block main %}
<main>
{% for label, messages in app.flashes %}
{% for message in messages %}
@@ -33,9 +34,12 @@
{% endfor %}
{% block body %}{% endblock %}
</main>
{% endblock %}
{% block footer %}
<footer>
<small>&copy; {{ "now"|date("Y") }} {{ 'site.name'|trans }}</small>
</footer>
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="{{ app.request ? app.request.locale : 'en' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ 'site.name'|trans }}{% endblock %}</title>
</head>
<body style="margin:0; padding:0; background-color:#f2f4f6; font-family:Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f2f4f6; padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px; width:100%; background-color:#ffffff; border-radius:8px; overflow:hidden;">
<tr>
<td align="center" style="background-color:#3a7ca5; padding:24px;">
<img src="{{ absolute_url(asset('images/logo.png')) }}" alt="{{ 'site.name'|trans }}" height="140" style="display:block; border:0;">
</td>
</tr>
<tr>
<td style="padding:32px; color:#333333; font-size:15px; line-height:1.6;">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td style="padding:16px 32px; background-color:#f2f4f6; color:#8a8f98; font-size:12px; text-align:center;">
&copy; {{ "now"|date("Y") }} {{ 'site.name'|trans }}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
{% extends 'layout/site.html.twig' %}
{% block main %}
<div style="display: flex; min-height: calc(100vh - 60px);">
{# ── Sidebar ──────────────────────────────────────────────────────── #}
<nav style="
width: 220px;
flex-shrink: 0;
background: #1e293b;
color: #cbd5e1;
display: flex;
flex-direction: column;
padding: 0;
">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid #334155;">
<span style="font-weight: 700; font-size: 1rem; color: #f1f5f9;">Game Admin</span>
</div>
<ul style="list-style: none; margin: 0; padding: 0.5rem 0; flex: 1;">
{% set current = app.request.attributes.get('_route') %}
{% set navItems = [
{ route: 'game_admin_dashboard', label: 'Dashboard', icon: '▦' },
{ route: 'game_admin_users', label: 'Users', icon: '👤' },
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
{ route: 'game_admin_sessions', label: 'Sessions', icon: '▶' },
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
] %}
{% for item in navItems %}
{% set isActive = current starts with item.route %}
<li>
<a href="{{ path(item.route) }}" style="
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1.5rem;
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
background: {{ isActive ? '#334155' : 'transparent' }};
text-decoration: none;
font-size: 0.9rem;
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
">
<span>{{ item.icon }}</span>
{{ item.label }}
</a>
</li>
{% endfor %}
</ul>
<div style="padding: 1rem 1.5rem; border-top: 1px solid #334155;">
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
← Back to game
</a>
</div>
</nav>
{# ── Content ──────────────────────────────────────────────────────── #}
<main style="flex: 1; background: #f8fafc; padding: 2rem; overflow-x: auto;">
{% for label, messages in app.flashes %}
{% for message in messages %}
<div style="
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 6px;
background: {{ label == 'success' ? '#dcfce7' : (label == 'danger' ? '#fee2e2' : '#fef9c3') }};
color: {{ label == 'success' ? '#166534' : (label == 'danger' ? '#991b1b' : '#854d0e') }};
border: 1px solid {{ label == 'success' ? '#86efac' : (label == 'danger' ? '#fca5a5' : '#fde047') }};
">{{ message }}</div>
{% endfor %}
{% endfor %}
{% block admin_body %}{% endblock %}
</main>
</div>
{% endblock %}
@@ -0,0 +1,49 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Email Log — Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Email Log</h1>
<span style="color: #64748b; font-size: 0.9rem;">{{ logs|length }} entries</span>
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">User</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Email</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Type</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Sent at</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ log.id }}</td>
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ log.user.username }}</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ log.user.email }}</td>
<td style="padding: 0.75rem 1rem;">
<span style="
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
background: #dbeafe;
color: #1e40af;
">{{ log.emailIdentifier }}</span>
</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ log.sentAt|date('Y-m-d H:i:s') }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No emails logged yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Edit Game — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Edit game: {{ game.name }}</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 500px;">
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
<div>
{{ form_label(form.name, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.name, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.name) }}
</div>
<div>
{{ form_label(form.numberOfPlayers, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.numberOfPlayers, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.numberOfPlayers) }}
</div>
<div>
{{ form_label(form.status, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.status, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.status) }}
</div>
<div>
{{ form_label(form.totalTime, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.totalTime, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">In seconds — e.g. 3600 = 1 hour</small>
{{ form_errors(form.totalTime) }}
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" style="
padding: 0.6rem 1.25rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
">Save changes</button>
<a href="{{ path('game_admin_games') }}" style="
padding: 0.6rem 1.25rem;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
@@ -0,0 +1,60 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Games — Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Games</h1>
<span style="color: #64748b; font-size: 0.9rem;">{{ games|length }} total</span>
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Name</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Players</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Status</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Sessions</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
</tr>
</thead>
<tbody>
{% for game in games %}
{% set statusColor = {
'inDevelopment': '#fef3c7:#92400e',
'locked': '#fee2e2:#991b1b',
'open': '#dcfce7:#166534',
} %}
{% set colors = (statusColor[game.status.value] ?? '#f1f5f9:#475569')|split(':') %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ game.id }}</td>
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ game.name }}</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ game.numberOfPlayers }}</td>
<td style="padding: 0.75rem 1rem;">
<span style="
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
background: {{ colors[0] }};
color: {{ colors[1] }};
">{{ game.status.value }}</span>
</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ game.sessions|length }}</td>
<td style="padding: 0.75rem 1rem;">
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="padding: 2rem; text-align: center; color: #94a3b8;">No games found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+84 -76
View File
@@ -1,82 +1,90 @@
{% extends 'base.html.twig' %}
{% extends 'game/admin/base.html.twig' %}
{% block title %}Game Admin Dashboard{% endblock %}
{% block title %}Admin Dashboard{% endblock %}
{% block body %}
<h1>Game Admin Dashboard</h1>
{% block admin_body %}
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Dashboard</h1>
<div style="display: flex; gap: 2rem;">
<div style="flex: 1;">
<h2>All Players ({{ players|length }})</h2>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #f2f2f2;">
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Roles</th>
<th>Verified</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr>
<td>{{ player.id }}</td>
<td>{{ player.username }}</td>
<td>{{ player.email }}</td>
<td>{{ player.roles|join(', ') }}</td>
<td>{{ player.isVerified ? 'Yes' : 'No' }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">No players found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
{% set stats = [
{ label: 'Total Users', value: totalUsers, color: '#3b82f6' },
{ label: 'Players', value: totalPlayers, color: '#8b5cf6' },
{ label: 'Admins', value: totalAdmins, color: '#f59e0b' },
{ label: 'Games', value: totalGames, color: '#10b981' },
{ label: 'Active Sessions', value: activeSessions, color: '#ef4444' },
{ label: 'Total Sessions', value: totalSessions, color: '#64748b' },
] %}
<div style="flex: 2;">
<h2>All Sessions ({{ sessions|length }})</h2>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #f2f2f2;">
<th>ID</th>
<th>Game</th>
<th>Status</th>
<th>Players Joined</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td>{{ session.id }}</td>
<td>{{ session.game.name }}</td>
<td>{{ session.status.value }}</td>
<td>
<ul>
{% for p in session.players %}
<li>{{ p.user.username }} (Screen: {{ p.screen ?? 'N/A' }})</li>
{% else %}
<li>No players</li>
{% endfor %}
</ul>
({{ session.players|length }} / {{ session.game.numberOfPlayers }})
</td>
<td>{{ session.created|date('Y-m-d H:i') }}</td>
<td>
<a href="{{ path('game_admin_view_session', {session: session.id}) }}">View Game Logs</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No sessions found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for stat in stats %}
<div style="
background: #fff;
border-radius: 8px;
padding: 1.25rem;
border-left: 4px solid {{ stat.color }};
box-shadow: 0 1px 3px rgba(0,0,0,.07);
">
<div style="font-size: 1.75rem; font-weight: 700; color: {{ stat.color }};">{{ stat.value }}</div>
<div style="font-size: 0.8rem; color: #64748b; margin-top: 0.25rem;">{{ stat.label }}</div>
</div>
{% endfor %}
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem;">
<a href="{{ path('game_admin_users') }}" style="
background: #fff;
border-radius: 8px;
padding: 1.25rem;
text-decoration: none;
color: #1e293b;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
">
<span style="font-size: 1.5rem;">👤</span> Manage Users
</a>
<a href="{{ path('game_admin_games') }}" style="
background: #fff;
border-radius: 8px;
padding: 1.25rem;
text-decoration: none;
color: #1e293b;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
">
<span style="font-size: 1.5rem;">🎮</span> Manage Games
</a>
<a href="{{ path('game_admin_sessions') }}" style="
background: #fff;
border-radius: 8px;
padding: 1.25rem;
text-decoration: none;
color: #1e293b;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
">
<span style="font-size: 1.5rem;">▶</span> View Sessions
</a>
<a href="{{ path('game_admin_email_log') }}" style="
background: #fff;
border-radius: 8px;
padding: 1.25rem;
text-decoration: none;
color: #1e293b;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
">
<span style="font-size: 1.5rem;">✉</span> Email Log
</a>
</div>
{% endblock %}
@@ -0,0 +1,79 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Sessions — Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Sessions</h1>
<span style="color: #64748b; font-size: 0.9rem;">{{ sessions|length }} total</span>
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Game</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Status</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Players</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Created</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
{% set statusColors = {
'created': '#ede9fe:#5b21b6',
'ready': '#fef3c7:#92400e',
'playing': '#dbeafe:#1e40af',
'won': '#dcfce7:#166534',
'lost': '#fee2e2:#991b1b',
} %}
{% set colors = (statusColors[session.status.value] ?? '#f1f5f9:#475569')|split(':') %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ session.id }}</td>
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ session.game.name }}</td>
<td style="padding: 0.75rem 1rem;">
<span style="
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
background: {{ colors[0] }};
color: {{ colors[1] }};
">{{ session.status.value }}</span>
</td>
<td style="padding: 0.75rem 1rem; color: #475569;">
{{ session.players|map(p => p.user.username)|join(', ') ?: '—' }}
<span style="color: #94a3b8;">({{ session.players|length }}/{{ session.game.numberOfPlayers }})</span>
</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ session.created|date('Y-m-d H:i') }}</td>
<td style="padding: 0.75rem 1rem; white-space: nowrap;">
<a href="{{ path('game_admin_view_session', {session: session.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">View logs</a>
{% if session.status.value not in ['won', 'lost'] %}
<form method="post" action="{{ path('game_admin_session_close', {id: session.id}) }}" style="display: inline;" onsubmit="return confirm('Force-close session #{{ session.id }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('close_session_' ~ session.id) }}">
<button type="submit" style="
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
">Close</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="padding: 2rem; text-align: center; color: #94a3b8;">No sessions found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,83 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Session #{{ session.id }} logs — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_sessions') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Sessions</a>
</div>
<h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1>
<p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p>
{% if playersLogs is empty %}
<div style="background: #fff; border-radius: 8px; padding: 2rem; text-align: center; color: #94a3b8; box-shadow: 0 1px 3px rgba(0,0,0,.07);">
No players in this session.
</div>
{% else %}
{# Tab buttons #}
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0;">
{% for playerLog in playersLogs %}
<button
onclick="openTab('player-{{ loop.index }}')"
id="tab-{{ loop.index }}"
style="
padding: 0.6rem 1.25rem;
border: 1px solid {{ loop.first ? '#3b82f6' : '#e2e8f0' }};
border-bottom: {{ loop.first ? '1px solid #fff' : '1px solid #e2e8f0' }};
background: {{ loop.first ? '#fff' : '#f8fafc' }};
color: {{ loop.first ? '#1e40af' : '#64748b' }};
font-size: 0.9rem;
font-weight: {{ loop.first ? '600' : '400' }};
cursor: pointer;
border-radius: 6px 6px 0 0;
margin-bottom: -1px;
"
>{{ playerLog.username }}</button>
{% endfor %}
</div>
{# Tab content #}
{% for playerLog in playersLogs %}
<div id="player-{{ loop.index }}" style="
display: {{ loop.first ? 'block' : 'none' }};
background: #fff;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
">
<pre style="
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
line-height: 1.5;
margin: 0;
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
</div>
{% endfor %}
{% endif %}
<script>
function openTab(id) {
document.querySelectorAll('[id^="player-"]').forEach(el => el.style.display = 'none');
document.getElementById(id).style.display = 'block';
const idx = id.split('-')[1];
document.querySelectorAll('[id^="tab-"]').forEach((btn, i) => {
const active = String(i + 1) === idx;
btn.style.background = active ? '#fff' : '#f8fafc';
btn.style.color = active ? '#1e40af' : '#64748b';
btn.style.fontWeight = active ? '600' : '400';
btn.style.borderColor = active ? '#3b82f6' : '#e2e8f0';
btn.style.borderBottom = active ? '1px solid #fff' : '1px solid #e2e8f0';
});
}
</script>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Edit User — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_users') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Users</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Edit user: {{ user.username }}</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 500px;">
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
<div>
{{ form_label(form.email, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.email, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.email) }}
</div>
<div>
{{ form_label(form.username, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.username, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.username) }}
</div>
<div>
{{ form_label(form.plainPassword, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.plainPassword, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.plainPassword) }}
</div>
<div>
{{ form_label(form.roles, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.5rem; color: #374151; font-size: 0.875rem;'}}) }}
<div style="display: flex; flex-direction: column; gap: 0.4rem;">
{{ form_widget(form.roles) }}
</div>
{{ form_errors(form.roles) }}
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
{{ form_widget(form.isVerified) }}
{{ form_label(form.isVerified, null, {label_attr: {style: 'font-weight: 600; color: #374151; font-size: 0.875rem; cursor: pointer;'}}) }}
{{ form_errors(form.isVerified) }}
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" style="
padding: 0.6rem 1.25rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
">Save changes</button>
<a href="{{ path('game_admin_users') }}" style="
padding: 0.6rem 1.25rem;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
@@ -0,0 +1,91 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Users — Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Users</h1>
<span style="color: #64748b; font-size: 0.9rem;">{{ users|length }} total &middot; {{ marketingOptInCount }} opted in to marketing</span>
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Username</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Email</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Roles</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Verified</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Marketing</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ user.id }}</td>
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ user.username }}</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ user.email }}</td>
<td style="padding: 0.75rem 1rem;">
{% for role in user.roles %}
{% if role != 'ROLE_USER' %}
<span style="
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
background: {{ role == 'ROLE_ADMIN' ? '#fef3c7' : '#ede9fe' }};
color: {{ role == 'ROLE_ADMIN' ? '#92400e' : '#5b21b6' }};
margin-right: 2px;
">{{ role }}</span>
{% endif %}
{% endfor %}
</td>
<td style="padding: 0.75rem 1rem;">
{% if user.verified %}
<span style="color: #16a34a; font-weight: 500;">✓ Yes</span>
{% else %}
<span style="color: #dc2626;">✗ No</span>
{% endif %}
</td>
<td style="padding: 0.75rem 1rem;">
{% if user.marketingOptIn %}
<span style="color: #16a34a; font-weight: 500;">✓ Yes</span>
{% else %}
<span style="color: #dc2626;">✗ No</span>
{% endif %}
</td>
<td style="padding: 0.75rem 1rem;">
<a href="{{ path('game_admin_user_edit', {id: user.id}) }}" style="
color: #3b82f6;
text-decoration: none;
font-size: 0.85rem;
margin-right: 0.75rem;
">Edit</a>
{% if user != app.user %}
<form method="post" action="{{ path('game_admin_user_delete', {id: user.id}) }}" style="display: inline;" onsubmit="return confirm('Delete user {{ user.username }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_user_' ~ user.id) }}">
<button type="submit" style="
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No users found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+57 -41
View File
@@ -1,42 +1,57 @@
{% extends 'base.html.twig' %}
{% extends 'layout/site.html.twig' %}
{% block title %}Game Dashboard{% endblock %}
{% block body %}
<h1>Game Dashboard</h1>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0">Game Dashboard</h1>
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('game_admin_dashboard') }}" class="btn btn-outline-secondary">Go to Game Admin Dashboard</a>
{% endif %}
</div>
{% if is_granted('ROLE_ADMIN') %}
<p><a href="{{ path('game_admin_dashboard') }}">Go to Game Admin Dashboard</a></p>
{% endif %}
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-secondary text-white">Create New Session</div>
<div class="card-body">
{% if availableGames is not empty %}
<form method="post">
<select name="game_id" class="form-select mb-3">
{% for game in availableGames %}
<option value="{{ game.id }}">
{{ game.name }} ({{ game.numberOfPlayers }} players)
{% if is_granted('ROLE_ADMIN') %}
[{{ game.status.value }}]
{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" name="create_session" class="btn btn-primary">Create Session</button>
</form>
{% else %}
<p class="text-muted mb-0">No games available to start.</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-secondary text-white">Join Session</div>
<div class="card-body">
<form method="post" class="d-flex gap-2">
<input type="text" name="invite_code" class="form-control" placeholder="Enter Invite Code" required>
<button type="submit" name="join_session" class="btn btn-primary text-nowrap">Join Session</button>
</form>
</div>
</div>
</div>
</div>
<h2>Create New Session</h2>
{% if availableGames is not empty %}
<form method="post">
<select name="game_id">
{% for game in availableGames %}
<option value="{{ game.id }}">
{{ game.name }} ({{ game.numberOfPlayers }} players)
{% if is_granted('ROLE_ADMIN') %}
[{{ game.status.value }}]
{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" name="create_session">Create Session</button>
</form>
{% else %}
<p>No games available to start.</p>
{% endif %}
<h2>Join Session</h2>
<form method="post">
<input type="text" name="invite_code" placeholder="Enter Invite Code" required>
<button type="submit" name="join_session">Join Session</button>
</form>
<h2>Your Sessions</h2>
<h2 class="h4">Your Sessions</h2>
{% if sessions is not empty %}
<table>
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th>ID</th>
@@ -52,7 +67,7 @@
<tr>
<td>{{ session.id }}</td>
<td>{{ session.game.name }}</td>
<td>{{ session.status.value }}</td>
<td><span class="badge bg-info text-dark">{{ session.status.value }}</span></td>
<td>{{ session.created|date('Y-m-d H:i') }}</td>
<td>
{% set inviteCode = '' %}
@@ -65,25 +80,25 @@
{% if inviteCode %}
<code>{{ inviteCode }}</code>
{% else %}
<form method="post" style="display:inline;">
<form method="post" class="d-inline">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="create_invite">Generate Invite</button>
<button type="submit" name="create_invite" class="btn btn-sm btn-outline-secondary">Generate Invite</button>
</form>
{% endif %}
</td>
<td>
<a href="{{ path('game', {session: session.id}) }}">Enter Game</a>
<a href="{{ path('game', {session: session.id}) }}" class="btn btn-sm btn-primary">Enter Game</a>
{% if session.status.value == 'created' %}
{% if session.players|length >= session.game.numberOfPlayers %}
<form method="post" style="display:inline;">
<form method="post" class="d-inline">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="start_session">Start Session</button>
<button type="submit" name="start_session" class="btn btn-sm btn-success">Start Session</button>
</form>
{% endif %}
{% if session.timer == 0 %}
<form method="post" style="display:inline;">
<form method="post" class="d-inline">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="leave_session" onclick="return confirm('Are you sure you want to leave this session?')">Leave Session</button>
<button type="submit" name="leave_session" class="btn btn-sm btn-outline-danger" onclick="return confirm('Are you sure you want to leave this session?')">Leave Session</button>
</form>
{% endif %}
{% endif %}
@@ -92,7 +107,8 @@
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>You are not part of any sessions.</p>
<p class="text-muted">You are not part of any sessions.</p>
{% endif %}
{% endblock %}
+5 -3
View File
@@ -15,15 +15,17 @@
<div id="game-container">
<div id="mercure-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
data-topic="{{ (mercure_topic_base ~ '/game/hub-' ~ session.id)|e('html_attr') }}"
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
data-api-ping-url="{{ path('game_api_ping')|e('html_attr') }}"
data-api-echo-url="{{ path('game_api_message')|e('html_attr') }}"
data-api-check-finished-url="{{ path('game_api_check_finished', {session: session.id})|e('html_attr') }}"
data-lost-url="{{ path('game_lost', {session: session.id})|e('html_attr') }}"
data-screen="{{ screen|e('html_attr') }}"
style="display:none">
</div>
<div id="game-timer">
00:30:00
<div id="game-timer" data-end-time="{{ session.timer }}">
--:--:--
</div>
<div id="message-container">
+125
View File
@@ -0,0 +1,125 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Game Lost - {{ session.game.name }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.site-main {
background-color: #1a1a1a;
color: #e0e0e0;
}
.card {
background-color: #2d2d2d;
border-color: #444;
color: #e0e0e0;
}
.form-label {
font-weight: bold;
}
.story-container {
font-style: italic;
border-left: 4px solid #dc3545;
padding-left: 20px;
margin-bottom: 30px;
}
.donation-section {
background-color: #333;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
text-align: center;
}
.form-range::-webkit-slider-runnable-track {
background-color: #444;
}
.form-range::-moz-range-track {
background-color: #444;
}
.form-range::-webkit-slider-thumb {
background-color: #dc3545;
}
.form-range::-moz-range-thumb {
background-color: #dc3545;
}
</style>
{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg">
<div class="card-header bg-danger text-white">
<h3 class="card-title mb-0">Game Over - Time's Up!</h3>
</div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<hr>
<div class="story-container">
<p>
The screens flickered one last time before going completely dark. The hum of the servers ceased, replaced by an eerie silence.
The server seems to have shut itself down before the AI virus could complete its task in decoding the undercover agents.
</p>
<p>
In the days after some people are arrested for sabotaging the agency, but in the end nobody is convicted for the actions.
Lets hope the agents which were undercover are safe and the information needed is still saved to put the other criminals behind bars.
</p>
</div>
<div class="donation-section">
<h5>Support the Developer</h5>
<p>If you enjoyed the experience (even if you lost!), please consider a small donation to help me create more games.</p>
<a href="https://www.paypal.com/donate?hosted_button_id=X9X8KB6R6GMRU" target="_blank" class="btn btn-primary">
<i class="bi bi-paypal"></i> Donate via PayPal
</a>
</div>
<div class="feedback-form mt-4">
<h5>Feedback</h5>
<form method="post">
<div class="mb-3">
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Absolutely not</small>
-
<small class="text-muted">Absolutely</small>
</div>
</div>
<div class="mb-3">
<label for="entertaining" class="form-label">How entertaining was it? (<span id="entertaining-val">5</span>/10)</label>
<input type="range" class="form-range" min="1" max="10" step="1" id="entertaining" name="entertaining" value="5" oninput="document.getElementById('entertaining-val').innerText = this.value">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Absolutely not</small>
-
<small class="text-muted">Absolutely</small>
</div>
</div>
<div class="mb-3">
<label for="theme" class="form-label">How was the theme? (<span id="theme-val">5</span>/10)</label>
<input type="range" class="form-range" min="1" max="10" step="1" id="theme" name="theme" value="5" oninput="document.getElementById('theme-val').innerText = this.value">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Absolutely not</small>
-
<small class="text-muted">Absolutely</small>
</div>
</div>
<div class="mb-3">
<label for="feedback" class="form-label">Additional Feedback</label>
<textarea class="form-control" id="feedback" name="feedback" rows="4" placeholder="Tell us more about your experience..."></textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success">Submit Feedback & Return to Dashboard</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+78 -70
View File
@@ -1,77 +1,75 @@
{% extends 'base.html.twig' %}
{% extends 'layout/site.html.twig' %}
{% block title %}Waiting for players - {{ session.game.name }}{% endblock %}
{% block body %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">Waiting for all players to be ready</h3>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">Waiting for all players to be ready</h3>
</div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>Welcome to the game! Please wait for all players to join and signal they are ready to start.</p>
<div class="game-info">
Please keep the following things in mind:
<ul>
<li>This game is best played in full screen mode. For windows, press F11. For Mac, press Cmd+Ctrl+F.</li>
<li>There is no need to reload the page. There is even a chance this could break the game.</li>
<li>If your internet connection is lost, you can get back in the game after internet has been fixed.</li>
</ul>
</div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>Welcome to the game! Please wait for all players to join and signal they are ready to start.</p>
<div class="game-info">
Please keep the following things in mind:
<ul>
<li>This game is best played in full screen mode. For windows, press F11. For Mac, press Cmd+Ctrl+F.</li>
<li>There is no need to reload the page. There is even a chance this could break the game.</li>
<li>If your internet connection is lost, you can get back in the game after internet has been fixed.</li>
</ul>
</div>
<div class="alert alert-info">
<strong>Game Information:</strong>
<ul class="mb-0">
<li>Number of players: {{ session.game.numberOfPlayers }}</li>
<li>Current players: {{ session.players|length }} / {{ session.game.numberOfPlayers }}</li>
</ul>
</div>
<div class="alert alert-info">
<strong>Game Information:</strong>
<ul class="mb-0">
<li>Number of players: {{ session.game.numberOfPlayers }}</li>
<li>Current players: {{ session.players|length }} / {{ session.game.numberOfPlayers }}</li>
</ul>
</div>
<div class="mt-4">
<h5>Players status:</h5>
<ul class="list-group">
{% for player in session.players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ player.user.username }}
{% set playerReady = false %}
{% set settingName = 'ReadyAtForPlayer' ~ player.screen %}
{% for setting in session.settings %}
{% if setting.name.value == settingName and setting.player == player %}
{% set playerReady = true %}
{% endif %}
{% endfor %}
{% if playerReady %}
<span class="badge bg-success">Ready</span>
{% else %}
<span class="badge bg-secondary">Not ready</span>
<div class="mt-4">
<h5>Players status:</h5>
<ul class="list-group">
{% for player in session.players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ player.user.username }}
{% set playerReady = false %}
{% set settingName = 'ReadyAtForPlayer' ~ player.screen %}
{% for setting in session.settings %}
{% if setting.name.value == settingName and setting.player == player %}
{% set playerReady = true %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% if playerReady %}
<span class="badge bg-success">Ready</span>
{% else %}
<span class="badge bg-secondary">Not ready</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<hr>
<form method="post" class="mt-4">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }}>
<label class="form-check-label" for="toggle_ready">
<strong>I am ready to start!</strong>
</label>
</div>
<p class="text-muted small">
Note: If all players are not ready within 1 minute of you checking this, your status will be reset automatically.
</p>
</form>
<hr>
<form method="post" class="mt-4">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }}>
<label class="form-check-label" for="toggle_ready">
<strong>I am ready to start!</strong>
</label>
</div>
<p class="text-muted small">
Note: If all players are not ready within 1 minute of you checking this, your status will be reset automatically.
</p>
</form>
<div class="mt-3">
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
<div class="mt-3">
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
</div>
</div>
@@ -80,7 +78,7 @@
<div id="mercure-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
data-topic="{{ (mercure_topic_base ~ '/game/hub-' ~ session.id)|e('html_attr') }}"
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
data-ready-at="{{ readyAt|e('html_attr') }}"
style="display:none">
</div>
@@ -91,6 +89,18 @@
const topic = config.dataset.topic;
const readyAt = config.dataset.readyAt;
let reloading = false;
const reloadOnce = (eventSource) => {
if (reloading) {
return;
}
reloading = true;
if (eventSource) {
eventSource.close();
}
window.location.reload();
};
if (publicUrl && topic) {
const url = new URL(publicUrl);
url.searchParams.append('topic', topic);
@@ -99,7 +109,7 @@
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'all_ready' || data.type === 'player_ready') {
window.location.reload();
reloadOnce(eventSource);
}
};
}
@@ -113,12 +123,10 @@
const timeLeft = timeoutMs - timeElapsed;
if (timeLeft > 0) {
setTimeout(() => {
window.location.reload();
}, timeLeft);
setTimeout(() => reloadOnce(), timeLeft);
} else {
// Already timed out, reload to sync with server
window.location.reload();
reloadOnce();
}
}
</script>
+12
View File
@@ -0,0 +1,12 @@
{% extends 'layout/site.html.twig' %}
{% block body %}
<div class="auth-card">
<div class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<h1 class="h3 mb-4 text-center">{{ block('title') }}</h1>
{% block auth_body %}{% endblock %}
</div>
</div>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends 'base.html.twig' %}
{% block header %}
<header class="site-header">
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="{{ path('website_home') }}">
<img src="{{ asset('images/logo.png') }}" alt="{{ 'site.name'|trans }}">
{{ 'site.name'|trans }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#siteNav"
aria-controls="siteNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="siteNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item">
<a class="nav-link" href="{{ path('website_home') }}">{{ 'nav.home'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a>
</li>
{% if app.user %}
{% if is_granted('ROLE_ADMIN') %}
<li class="nav-item">
<a class="nav-link" href="{{ path('game_admin_dashboard') }}">{{ 'nav.admin'|trans }}</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ path('app_logout') }}">{{ 'nav.logout'|trans }}</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ path('app_register') }}">{{ 'nav.register'|trans }}</a>
</li>
<li class="nav-item">
<a class="btn btn-sm btn-outline-light ms-lg-2" href="{{ path('app_login') }}">{{ 'nav.login'|trans }}</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
</header>
{% endblock %}
{% block main %}
<main class="site-main">
<div class="container py-4">
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %}
</div>
</main>
{% endblock %}
{% block footer %}
<footer class="site-footer py-4">
<div class="container text-center small">
&copy; {{ "now"|date("Y") }} {{ 'site.name'|trans }}
</div>
</footer>
{% endblock %}
@@ -1,11 +1,34 @@
<h1>Hi! Please confirm your email!</h1>
{% extends 'emails/layout.html.twig' %}
<p>
Please confirm your email address by clicking the following link: <br><br>
<a href="{{ signedUrl }}">Confirm my Email</a>.
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
</p>
{% block title %}Confirm your email{% endblock %}
<p>
Cheers!
</p>
{% block content %}
<h1 style="margin:0 0 16px; font-size:20px; color:#222222;">Hi! Please confirm your email</h1>
<p style="margin:0 0 24px;">
Thanks for signing up. Please confirm your email address by clicking the button below.
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="border-radius:4px; background-color:#3a7ca5;">
<a href="{{ signedUrl }}" style="display:inline-block; padding:12px 24px; color:#ffffff; text-decoration:none; font-weight:bold;">
Confirm my email
</a>
</td>
</tr>
</table>
<p style="margin:0 0 24px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ signedUrl }}" style="color:#3a7ca5; word-break:break-all;">{{ signedUrl }}</a>
</p>
<p style="margin:0 0 8px;">
If the link has expired or doesn't work, you can
<a href="{{ url('app_verify_resend_email') }}" style="color:#3a7ca5;">request a new one</a>.
</p>
<p style="margin:24px 0 0;">Cheers!</p>
{% endblock %}
+12 -5
View File
@@ -1,17 +1,24 @@
{% extends 'base.html.twig' %}
{% extends 'layout/auth.html.twig' %}
{% block title %}Register{% endblock %}
{% block body %}
<h1>Register</h1>
{% block auth_body %}
{{ form_errors(registrationForm) }}
{{ form_start(registrationForm) }}
{{ form_row(registrationForm.email) }}
{{ form_row(registrationForm.username) }}
{{ form_row(registrationForm.plainPassword) }}
{{ form_row(registrationForm.agreeTerms) }}
{{ form_row(registrationForm.marketingOptIn) }}
{{ form_row(registrationForm.captcha) }}
<button type="submit" class="btn">Register</button>
<div class="d-grid mt-3">
<button type="submit" class="btn btn-primary">Register</button>
</div>
{{ form_end(registrationForm) }}
<div class="mt-3 text-center">
<a href="{{ path('app_login') }}">Already have an account? Log in</a>
</div>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends 'layout/auth.html.twig' %}
{% block title %}Resend verification email{% endblock %}
{% block auth_body %}
<p class="text-muted">Enter your email address and we will send you a new link to verify your account.</p>
{{ form_errors(resendForm) }}
{{ form_start(resendForm) }}
{{ form_row(resendForm.email) }}
<div class="d-grid mt-3">
<button type="submit" class="btn btn-primary">Resend email</button>
</div>
{{ form_end(resendForm) }}
{% endblock %}
@@ -1,11 +1,11 @@
{% extends 'base.html.twig' %}
{% extends 'layout/auth.html.twig' %}
{% block title %}Password Reset Email Sent{% endblock %}
{% block body %}
{% block auth_body %}
<p>
If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.
This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.
</p>
<p>If you don't receive an email please check your spam folder or <a href="{{ path('app_forgot_password_request') }}">try again</a>.</p>
<p class="mb-0">If you don't receive an email please check your spam folder or <a href="{{ path('app_forgot_password_request') }}">try again</a>.</p>
{% endblock %}
+27 -5
View File
@@ -1,9 +1,31 @@
<h1>Hi!</h1>
{% extends 'emails/layout.html.twig' %}
<p>To reset your password, please visit the following link</p>
{% block title %}Reset your password{% endblock %}
<a href="{{ url('app_reset_password', {token: resetToken.token}) }}">{{ url('app_reset_password', {token: resetToken.token}) }}</a>
{% block content %}
<h1 style="margin:0 0 16px; font-size:20px; color:#222222;">Hi!</h1>
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
<p style="margin:0 0 24px;">
We received a request to reset your password. Click the button below to choose a new one.
This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.
</p>
<p>Cheers!</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="border-radius:4px; background-color:#3a7ca5;">
<a href="{{ url('app_reset_password', {token: resetToken.token}) }}" style="display:inline-block; padding:12px 24px; color:#ffffff; text-decoration:none; font-weight:bold;">
Reset my password
</a>
</td>
</tr>
</table>
<p style="margin:0 0 24px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ url('app_reset_password', {token: resetToken.token}) }}" style="color:#3a7ca5; word-break:break-all;">{{ url('app_reset_password', {token: resetToken.token}) }}</a>
</p>
<p style="margin:0 0 8px;">If you didn't request a password reset, you can safely ignore this email.</p>
<p style="margin:24px 0 0;">Cheers!</p>
{% endblock %}
@@ -1,22 +1,20 @@
{% extends 'base.html.twig' %}
{% extends 'layout/auth.html.twig' %}
{% block title %}Reset your password{% endblock %}
{% block body %}
{% block auth_body %}
{% for flash_error in app.flashes('reset_password_error') %}
<div class="alert alert-danger" role="alert">{{ flash_error }}</div>
{% endfor %}
<h1>Reset your password</h1>
{{ form_start(requestForm) }}
{{ form_row(requestForm.email) }}
<div>
<small>
Enter your email address, and we will send you a
link to reset your password.
</small>
</div>
<p class="text-muted small">
Enter your email address, and we will send you a link to reset your password.
</p>
<button class="btn btn-primary">Send password reset email</button>
<div class="d-grid">
<button class="btn btn-primary">Send password reset email</button>
</div>
{{ form_end(requestForm) }}
{% endblock %}
@@ -1,12 +1,12 @@
{% extends 'base.html.twig' %}
{% extends 'layout/auth.html.twig' %}
{% block title %}Reset your password{% endblock %}
{% block body %}
<h1>Reset your password</h1>
{% block auth_body %}
{{ form_start(resetForm) }}
{{ form_row(resetForm.plainPassword) }}
<button class="btn btn-primary">Reset password</button>
<div class="d-grid mt-3">
<button class="btn btn-primary">Reset password</button>
</div>
{{ form_end(resetForm) }}
{% endblock %}
+29 -21
View File
@@ -1,45 +1,53 @@
{% extends 'base.html.twig' %}
{% extends 'layout/auth.html.twig' %}
{% block title %}Log in!{% endblock %}
{% block title %}Log in{% endblock %}
{% block body %}
{% block auth_body %}
<form method="post">
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security')|raw }}
{% if error.messageData['%resend_link%'] is defined %}
<a href="{{ error.messageData['%resend_link%'] }}">Resend activation link</a>
{% endif %}
</div>
{% endif %}
{% if app.user %}
<div class="mb-3">
<div class="alert alert-info">
You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a>
</div>
{% endif %}
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
<label for="inputUsername">Username</label>
<input type="text" value="{{ last_username }}" name="username" id="inputUsername" class="form-control" autocomplete="username" required autofocus>
<label for="inputPassword">Password</label>
<input type="password" name="password" id="inputPassword" class="form-control" autocomplete="current-password" required>
<div class="mb-3">
<label for="inputUsername" class="form-label">Username</label>
<input type="text" value="{{ last_username }}" name="username" id="inputUsername" class="form-control" autocomplete="username" required autofocus>
</div>
<div class="mb-3">
<label for="inputPassword" class="form-label">Password</label>
<input type="password" name="password" id="inputPassword" class="form-control" autocomplete="current-password" required>
</div>
<input type="hidden" name="_csrf_token"
value="{{ csrf_token('authenticate') }}"
>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
{#
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
See https://symfony.com/doc/current/security/remember_me.html
<div class="checkbox mb-3">
<label>
<input type="checkbox" name="_remember_me"> Remember me
</label>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="rememberMe" name="_remember_me">
<label class="form-check-label" for="rememberMe">Remember me</label>
</div>
#}
<button class="btn btn-lg btn-primary" type="submit">
Sign in
</button>
<div class="mt-3">
<div class="d-grid">
<button class="btn btn-primary" type="submit">Sign in</button>
</div>
<div class="mt-3 text-center">
<a href="{{ path('app_forgot_password_request') }}">Forgot your password?</a>
</div>
<div class="mt-1 text-center">
<a href="{{ path('app_verify_resend_email') }}">Didn't receive activation email?</a>
</div>
</form>
{% endblock %}
+75 -5
View File
@@ -1,9 +1,79 @@
{% extends 'base.html.twig' %}
{% extends 'layout/site.html.twig' %}
{% block title %}{{ 'home.title'|trans({'%site%': ('site.name'|trans)}) }}{% endblock %}
{% block title %}AI Virus Deflection | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.breach-hero {
background: linear-gradient(135deg, #0d1f26 0%, #16323f 100%);
color: #e6f2f5;
border-radius: .75rem;
overflow: hidden;
}
.breach-hero img {
filter: drop-shadow(0 0 25px rgba(47, 143, 174, .55));
}
.breach-hero .badge-alert {
letter-spacing: .08em;
animation: breach-blink 1.6s infinite;
}
@keyframes breach-blink {
0%, 100% { opacity: 1; }
50% { opacity: .35; }
}
.dev-watermark-wrap {
position: relative;
display: inline-block;
}
.dev-watermark-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-12deg);
background: rgba(220, 53, 69, .85);
color: #fff;
font-weight: 800;
letter-spacing: .12em;
text-transform: uppercase;
padding: .35rem 1.25rem;
border: 2px solid rgba(255, 255, 255, .85);
border-radius: .25rem;
font-size: .9rem;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, .35);
pointer-events: none;
}
</style>
{% endblock %}
{% block body %}
<h1>{{ 'home.h1'|trans({'%site%': ('site.name'|trans)}) }}</h1>
<p>{{ 'home.description'|trans }}</p>
<p><a href="{{ path('game_dashboard') }}">{{ 'link.enter_game'|trans }}</a></p>
<div class="breach-hero p-4 p-md-5 mb-5">
<div class="row align-items-center g-4">
<div class="col-lg-7">
<span class="badge bg-danger badge-alert mb-3">&#9888; SYSTEM BREACH DETECTED</span>
<h1 class="display-6 fw-bold">The mainframe has been compromised.</h1>
<p class="lead" style="color: #a9c6cf;">
An AI virus is loose in the network and it's decoding classified files &mdash; hunting for the
names of every undercover agent in the field. You and two fellow agents have one shot to shut
it down before the countdown ends.
</p>
<div class="d-flex flex-wrap gap-3 mt-4">
<a href="{{ path('website_intro') }}" class="btn btn-outline-light btn-lg">Read the Briefing</a>
<a href="{{ path('game_dashboard') }}" class="btn btn-primary btn-lg">{{ 'link.enter_game'|trans }}</a>
</div>
</div>
<div class="col-lg-5 text-center">
<div class="dev-watermark-wrap">
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 260px;">
<span class="dev-watermark-badge">In Development</span>
</div>
</div>
</div>
</div>
<div class="text-center py-4">
<img src="{{ asset('images/logo.png') }}" alt="{{ 'site.name'|trans }}" height="100" class="mb-3">
<p class="col-lg-8 mx-auto text-secondary">{{ 'home.description'|trans }}</p>
</div>
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Agent Briefing | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.briefing-card {
background-color: #12262e;
color: #dfeef2;
border: 1px solid #1c5a70;
border-radius: .75rem;
}
.briefing-stamp {
display: inline-block;
border: 2px solid #dc3545;
color: #dc3545;
padding: .15rem .75rem;
font-weight: 700;
letter-spacing: .15em;
transform: rotate(-3deg);
border-radius: .25rem;
}
.briefing-body p {
line-height: 1.8;
}
.briefing-body p:first-of-type::first-letter {
font-size: 2.2em;
font-weight: 700;
color: var(--brand-teal-light, #6fb8d1);
padding-right: .1em;
}
.dev-watermark-wrap {
position: relative;
display: inline-block;
}
.dev-watermark-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-12deg);
background: rgba(220, 53, 69, .85);
color: #fff;
font-weight: 800;
letter-spacing: .12em;
text-transform: uppercase;
padding: .35rem 1.25rem;
border: 2px solid rgba(255, 255, 255, .85);
border-radius: .25rem;
font-size: .9rem;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, .35);
pointer-events: none;
}
</style>
{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-lg-9">
<div class="briefing-card shadow-lg p-4 p-md-5">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="text-uppercase small" style="letter-spacing: .1em; color: #6fb8d1;">Incident Report &mdash; AI Virus Deflection</div>
<h1 class="h2 fw-bold mb-0">Agent Briefing</h1>
</div>
<span class="briefing-stamp">CLASSIFIED</span>
</div>
<div class="text-center mb-4">
<div class="dev-watermark-wrap">
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 220px;">
<span class="dev-watermark-badge">In Development</span>
</div>
</div>
<div class="briefing-body">
<p>
04:00 hours. You badge into your terminal at the Agency for what should have been a quiet
shift. Then every alarm in the building goes off at once.
</p>
<p>
The mainframe has been breached &mdash; not by a person, but by something worse. A self-learning
AI virus has burrowed into the core systems, and it is already decoding classified files one
by one, hunting for the identity of every undercover agent in the field. Every file it cracks
is another agent exposed. Another life on the line.
</p>
<p>
You are not alone. The mainframe itself is still fighting back, feeding you what help it can,
and two fellow agents are jacked in beside you, racing against the same clock.
</p>
<p>
To reach the virus at its source, you'll need to unlock directories and files scattered across
the system &mdash; and the keys are split between you and your colleagues. Trade them, decode
them, move fast.
</p>
<p>
Delete the AI virus completely before the timer hits zero, and every agent stays hidden. Run
out of time, and&hellip; well. Let's just say the Agency would rather not think about that
outcome.
</p>
</div>
<hr class="my-4" style="border-color: #1c5a70;">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<a href="{{ path('website_home') }}" class="link-light">&larr; {{ 'link.back_to_website'|trans }}</a>
<a href="{{ path('game_dashboard') }}" class="btn btn-primary btn-lg">Accept the Mission</a>
</div>
</div>
</div>
</div>
{% endblock %}
+4
View File
@@ -2,6 +2,10 @@
site.name: EscapePage
nav.home: Home
nav.game: Game
nav.admin: Admin
nav.login: Login
nav.register: Register
nav.logout: Logout
link.enter_game: Enter the Game Area
link.back_to_website: Back to Website

Some files were not shown because too many files have changed in this diff Show More