Author SHA1 Message Date
Frank van den BergandClaude Sonnet 4.6 d8268363fd 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 a7c3560a6d 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 b14721db6a Prod env 2026-06-27 16:19:55 +02:00
Frank van den Berg aa7450e76c Migration game 1 2026-06-27 16:07:22 +02:00
Frank van den Berg fe283eaa00 Admin panels 2026-06-27 15:53:43 +02:00
Frank van den Berg 8605a5caac Command voor aanmaken user 2026-06-27 15:34:36 +02:00
Frank van den Berg 332417751e Fix mailcatcher 2026-06-27 15:13:17 +02:00
Frank van den Berg 2be7ff5ba5 Dev settings 2026-06-27 15:04:30 +02:00
Frank van den Berg 95a3226b14 Rechttrekken .env bestand 2026-06-27 14:40:44 +02:00
Frank van den Berg 8b7abb6a9f Updates 2026-06-27 14:26:04 +02:00
Frank van den Berg c452909f41 Brevo ipv Sendgrid 2026-06-27 14:11:04 +02:00
Frank van den Berg 9aadebde9a Claude aanpassingen 2026-06-27 13:23:08 +02:00
Frank van den Berg 730b264f12 changes for ssl 2026-06-27 12:07:06 +02:00
Frank 0a9c0f0052 Fixed network v4 2026-03-11 07:59:23 +01:00
Frank 57af01ea6f Fixed network v3 2026-03-11 07:57:49 +01:00
Frank 4b0f8fb5c6 Fixed network v2 2026-03-11 07:55:47 +01:00
Frank a68eeb3ac6 Fixed network 2026-03-11 07:52:11 +01:00
Frank cc6c6a6456 Fixed ip addresses 2026-03-10 22:13:22 +01:00
Frank 184a5ff0de Fix database container creation 2026-03-10 22:04:23 +01:00
Frank 170ae2ce43 Unfixed ips 2026-03-10 22:00:08 +01:00
Frank 16625c557c Fixed ips 2026-03-10 21:55:21 +01:00
Frank a15edfabed network defined 2026-03-10 21:52:05 +01:00
Frank 66a682f69f Prod development 2026-02-21 14:28:48 +00:00
Frank 576d86f602 Mercure ssl 2026-01-17 22:56:34 +01:00
Frank 891c1b4ecb Mercure cors error 3 2026-01-17 19:36:01 +01:00
Frank f5bac0fe2d Mercure cors error 2 2026-01-17 19:27:01 +01:00
Frank 0276199488 Mercure cors error 2026-01-17 19:03:25 +01:00
Frank 83381ef57f Mercure links 2026-01-17 15:49:58 +01:00
Frank c6153c62f1 nullable screen 2026-01-17 15:35:26 +01:00
Frank 4f7471ab0f Remote allowance 2026-01-17 15:12:08 +01:00
Frank 9237d9ad49 captcha keys 2026-01-17 14:51:14 +01:00
Frank a3573d5a09 Update passwords MySql 2026-01-17 14:29:04 +01:00
Frank 5e87ae90d8 executable 2026-01-17 14:24:54 +01:00
Frank 05a514bad6 restart via 1 script. 2026-01-17 14:22:08 +01:00
Frank 3a34266461 Verification mails solving try 1 2026-01-17 14:12:57 +01:00
Frank 7fe8f9322a Verification mails solving try 1 2026-01-17 13:47:56 +01:00
Frank 27827bd2a9 Added domain to verification mail 2026-01-16 21:01:01 +01:00
Frank de1c6f4ed2 Send variables to containers. 2026-01-14 14:00:11 +01:00
Frank a6df6cbf0c Request resend of verification mail 2026-01-14 13:09:32 +01:00
Frank a90489da28 captcha 2026-01-13 21:54:26 +01:00
Frank 498ba1bfca Verifying mail addresses 2026-01-13 17:43:17 +01:00
Frank f96e51420f Mailer From 2026-01-11 23:10:30 +01:00
Frank f3bf472bc6 Try to fix 3 2026-01-11 16:00:43 +01:00
Frank 34d89129ae Try to fix 2 2026-01-11 15:56:52 +01:00
Frank 0c0c71c7b4 Try to fix 1 2026-01-11 15:46:46 +01:00
Frank d70ef9282e Revert compose files 2026-01-11 15:41:40 +01:00
Frank de67d95d4f Niet weggooien van images 4 2026-01-11 15:38:33 +01:00
Frank bcb42a27b8 Niet weggooien van images 3 2026-01-11 15:37:13 +01:00
Frank bc4f7a8c79 Niet weggooien van images 2 2026-01-11 15:35:10 +01:00
Frank 3b98e8650b onbekende flag 5 2026-01-11 15:30:21 +01:00
Frank 09f9abcfb8 onbekende flag 4 2026-01-11 15:28:40 +01:00
Frank 3b3cb69aa7 onbekende flag 3 2026-01-11 15:26:34 +01:00
Frank badca1af53 onbekende flag 2 2026-01-11 15:25:03 +01:00
Frank b649d48250 onbekende flag 2026-01-11 15:21:19 +01:00
Frank 0e3a3992fa container en cache clear erin en image clear eruit 2026-01-10 17:21:59 +01:00
Frank cfac6d10ec csrf error solve. try 6 2026-01-10 16:25:07 +01:00
Frank a10ad7de58 csrf error solve. try 5 2026-01-10 14:28:03 +01:00
Frank f810ad07b7 csrf error solve. try 4 2026-01-10 14:19:57 +01:00
Frank 41f3547f6f csrf error solve. try 3 2026-01-10 14:06:29 +01:00
Frank 09b7e78fdd csrf error solve. try 2 2026-01-10 13:37:14 +01:00
Frank 47091cd4e3 csrf error solve. try 1 2026-01-10 00:39:33 +01:00
Frank ac4c5ef261 Validation fails 2026-01-10 00:25:57 +01:00
Frank 490f730c97 pass on token 2026-01-10 00:17:36 +01:00
Frank 73d6ea478c Restart containers 2026-01-09 23:40:25 +01:00
Frank 9da6a60dbe Add error log 2026-01-09 23:36:50 +01:00
Frank 7ca0bec145 Mercure en hostfile 2026-01-09 18:38:57 +01:00
Frank ea54c87426 database volume 2026-01-09 16:53:47 +01:00
Frank 12e87edc4d Fixed ips 2026-01-09 16:42:15 +01:00
Frank 85416f5a07 rights to user 2026-01-09 16:10:44 +01:00
Frank 2f81a60ff7 Meer updates. Next try 7 2026-01-09 15:47:44 +01:00
Frank 0e27217ab4 Meer updates. Next try 6 2026-01-09 15:42:43 +01:00
Frank b3531b5d7c Meer updates. Next try 5 2026-01-09 15:33:52 +01:00
Frank 239b1e136a Meer updates. Next try 4 2026-01-09 15:25:41 +01:00
Frank db04cafcb1 Meer updates. Next try 3 2026-01-09 15:18:25 +01:00
Frank 5ce7e15565 Meer updates. Next try 2 2026-01-09 15:10:44 +01:00
Frank df12c4bd11 Meer updates. Next try 2026-01-09 15:01:12 +01:00
Frank 769d8b4e74 Meer updates. Hopelijk beter nu. 2026-01-09 14:51:31 +01:00
Frank 14871336a3 Updated dockerfile om migrations uit te kunnen voeren 2026-01-09 14:41:59 +01:00
Frank 3604c63940 Running containers 2026-01-09 14:30:05 +01:00
Frank 7257c51bdf 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 641573842c Settings from env files 2026-01-09 13:08:09 +01:00
Frank abc3712d97 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 66cb356955 Remove .env.* files from tracking and update .gitignore 2026-01-09 12:13:31 +01:00
Frank ffd20f5535 Lost page 2026-01-08 20:32:21 +01:00
Frank 1b52f80448 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
Frank b965f0f085 Added mercure to update when everyone is ready 2026-01-08 20:11:14 +01:00
Frank c4c989db4c Trying to add waiting pages 2026-01-08 19:32:13 +01:00
Frank 37507bd169 Merge pull request 'admin-side' (#10) from admin-side into main
Reviewed-on: #10
2026-01-08 17:34:48 +00:00
Frank 732148a533 Look into session logfiles 2026-01-08 18:26:32 +01:00
Frank 50d7ce745c Logfiles for sessions 2026-01-08 18:14:56 +01:00
Frank a475c1a268 Merge pull request 'set-correct-screens-for-players' (#9) from set-correct-screens-for-players into main
Reviewed-on: #9
2026-01-08 17:02:10 +00:00
Frank 1ae0f651d8 Most of the cat command 2026-01-08 17:02:16 +01:00
Frank 8e933631b2 Dynamic number of players 2026-01-08 15:49:47 +01:00
Frank f8746207e6 Merge pull request 'plaatsen-return-messages' (#8) from plaatsen-return-messages into main
Reviewed-on: #8
2026-01-08 14:35:12 +00:00
Frank e42966e618 Message when everyone is verified 2026-01-08 15:34:43 +01:00
Frank e4976e51fa JWT token in env file 2026-01-08 14:40:51 +01:00
Frank van den Berg 65070688ec Make mercure work correctly 2026-01-08 13:05:40 +01:00
Frank e54c870f05 Post return messages 2026-01-08 11:41:46 +01:00
Frank f5aabafcc5 Merge pull request 'Verification done' (#7) from Rechten into main
Reviewed-on: #7
2026-01-07 19:53:58 +00:00
Frank de4b7bca6a Verification done 2026-01-07 20:06:28 +01:00
Frank c6adb00219 Merge pull request 'continue-puzzle-progress' (#6) from continue-puzzle-progress into main
Reviewed-on: #6
2026-01-07 16:50:36 +00:00
Frank 5f6ea89179 ls 2026-01-07 17:45:17 +01:00
Frank c384fc3dd5 Sudo, rm and setup for ls 2026-01-07 15:00:28 +01:00
Frank 78b570bd75 Hint for first part 2026-01-07 14:33:10 +01:00
Frank 74f52db002 Merge pull request 'Created a dashboard and created an invite code for game sessions.' (#5) from game-pages into main
Reviewed-on: #5
2026-01-06 19:24:15 +00:00
Frank 56590a901f Created a dashboard and created an invite code for game sessions. 2026-01-06 20:23:46 +01:00
Frank 49045bc696 Merge pull request 'Game1-layout' (#4) from Game1-layout into main
Reviewed-on: #4
2026-01-06 19:21:58 +00:00
Frank 28ee969c29 Message on reload to hopefully stop the user. 2026-01-06 20:20:33 +01:00
Frank 7230520551 Layout done, probably need rework later on 2026-01-06 20:20:33 +01:00
Frank 59c1d97d84 Rechten van setup.sh 2026-01-06 20:20:17 +01:00
Frank van den Berg 3de1e907f2 Made it workable on docker containers 2026-01-06 19:48:33 +01:00
Frank 91c0c3e6d1 Merge pull request 'Commando's-given' (#3) from Commando's-given into main
Reviewed-on: #3
2026-01-06 11:05:48 +00:00
Frank 8e3807e079 Toevoeging van meer responses op messages 2026-01-05 23:37:36 +01:00
Frank 10c3dbc066 Updated rechten voor speler. Settings toegevoegd en onderdelen voor game1 toegevoegd. 2026-01-05 17:07:32 +01:00
Frank af13be2196 Messages handling voor spel 1 2026-01-05 15:27:37 +01:00
Frank c0aa2ad44e Message ipv echo voor route 2026-01-05 12:16:30 +01:00
Frank d3cff2ef58 Ignored 2026-01-05 12:16:01 +01:00
Frank 24b76f9700 ignore idea 2026-01-05 12:15:41 +01:00
Frank 03994dd54e Merge pull request 'Registration' (#2) from Registration into main
Reviewed-on: #2
2026-01-05 11:13:03 +00:00
Frank 499e699dbd Forgot password 2026-01-03 22:57:45 +01:00
Frank c8b0a6e966 Maillog 2026-01-03 22:35:56 +01:00
Frank 5b6bfaf5ad Quite some work done here. 2026-01-03 22:12:51 +01:00
Frank af61a3b920 Some settings 2026-01-03 13:16:58 +01:00
Frank 0d6628e7c9 Startup 2026-01-02 20:27:56 +01:00
Frank 534175efb3 Setup 2025-09-06 16:50:16 +02:00
81 changed files with 3575 additions and 1412 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
+92
View File
@@ -0,0 +1,92 @@
# 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
#
# 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=695679907f9c3818e6924d547f872651
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=Zr1aOYU5NpCbS3dhpxa64cZp
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
MYSQL_ROOT_PASSWORD=root
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)
BREVO_API_KEY=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
MAILER_DSN=brevo+api://${BREVO_API_KEY}@default
MAILER_FROM=mailer@escapepage.nl
# Production/Stage (uncomment and set SENDGRID_API_KEY in real env or secrets):
# MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%
# Alternatively, via SMTP (no extra package needed):
# MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"
# Optional default sender (used by test command if --from not passed):
###< symfony/mailer ###
###> symfony/sendgrid-mailer ###
# MAILER_DSN=sendgrid://KEY@default
###< symfony/sendgrid-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
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=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
###< karser/karser-recaptcha3-bundle ###
+25
View File
@@ -0,0 +1,25 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=620e9ce5f88a714b636179eb39d5be4f
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
DB_USER=escapepage
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
# Dev uses Mailpit (started via docker compose override)
MAILER_DSN=smtp://mailer:1025
###< symfony/mailer ###
###> mercure ###
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_CORS_ALLOWED_ORIGINS=http://localhost:8080
MERCURE_TOPIC_BASE=https://escapepage.dev
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
###< mercure ###
+39
View File
@@ -0,0 +1,39 @@
APP_ENV=prod
APP_SECRET=a8f89e179e8c338423697669d6728c2c
### Compiled or real environment variables should be used in production.
### Configure MAILER_DSN to use SendGrid API transport.
### Prefer storing SENDGRID_API_KEY using Symfony Secrets or real env vars.
###> symfony/mailer ###
BREVO_API_KEY=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
MAILER_DSN=brevo+api://${BREVO_API_KEY}@default
MAILER_FROM=mailer@escapepage.nl
###< symfony/mailer ###
###> symfony/framework-bundle ###
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
###> mercure ###
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=55UtgFXsZu09TSTdeIA7ljK4HUo9DLkRzEB7MD5tqOLjRfAb
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.qMVdzh7buYK78e-gwCQx7v6qCxk1Js83SAEKK-GZSrI
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.OCnRPXfCoke27ntAxby2R5jkgpTZdw83DPq1yhvkLbw
MERCURE_CORS_ALLOWED_ORIGINS="https://escapepage.com"
MERCURE_TOPIC_BASE=https://escapepage.com
###< mercure ###
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
DB_USER=escapepage
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
###> docker ###
USER_ID=1000
GROUP_ID=1000
###< docker ###
+18
View File
@@ -0,0 +1,18 @@
APP_ENV=test
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
###> mercure ###
MERCURE_CORS_ALLOWED_ORIGINS=http://localhost:8080
MERCURE_TOPIC_BASE=http://test
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
###< mercure ###
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage_test
DB_USER=escapepage
DB_PASSWORD="b.0nqrxJ/D*Luf9N"
+3
View File
@@ -6,6 +6,9 @@
/config/secrets/prod/prod.decrypt.private.php /config/secrets/prod/prod.decrypt.private.php
/public/bundles/ /public/bundles/
/var/ /var/
!/var/volumes/
/var/volumes/*
!/var/volumes/.gitignore
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
+1
View File
@@ -140,6 +140,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webpack-encore-bundle" /> <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/reset-password-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/verify-email-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/verify-email-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/karser/karser-recaptcha3-bundle" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
Generated
+1
View File
@@ -153,6 +153,7 @@
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" /> <path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" /> <path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" /> <path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
<path value="$PROJECT_DIR$/vendor/karser/karser-recaptcha3-bundle" />
</include_path> </include_path>
</component> </component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" /> <component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
+4 -10
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` 6. Run tests: `vendor/bin/phpunit`
- With Docker: - With Docker:
1. From `docker/`: `docker compose up -d` 1. `cd docker && docker compose up -d`
2. Install vendors inside the PHP container: 2. Install vendors inside the PHP container:
- `docker compose exec php bash` - `docker compose exec php bash`
- `composer install` - `composer install`
@@ -28,7 +28,7 @@ This repository contains a Symfony 7.3 (PHP >= 8.5.1) application for a collabor
## Email (Mailpit in dev, SendGrid for prod) ## Email (Mailpit in dev, SendGrid for prod)
- Dev: a `mailer` service (Mailpit) runs in Docker. - Dev: a `mailer` service (Mailpit) runs in Docker.
- SMTP DSN in `.env`: `MAILER_DSN=smtp://mailer:1025` - 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` - Send a test mail: `php bin/console app:mail:test you@example.com`
- Staging/Prod: use SendGrid. - Staging/Prod: use SendGrid.
- Require package (already in composer): `symfony/sendgrid-mailer`. - Require package (already in composer): `symfony/sendgrid-mailer`.
@@ -81,9 +81,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). We use a Mercure hub (Docker service) to push server updates to browsers via ServerSent Events (SSE).
Quick start (dev): 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. 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: 2. Install PHP deps inside the PHP container if you haven't yet:
@@ -91,12 +91,6 @@ Quick start (dev):
docker compose exec php bash docker compose exec php bash
composer install 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. You should see a console log like `[Mercure] Update received: { ... }` on the Game Hub page.
Configuration: Configuration:
+48
View File
@@ -131,6 +131,8 @@ document.addEventListener('DOMContentLoaded', async () => {
const screen = cfgEl.dataset.screen; const screen = cfgEl.dataset.screen;
const apiPingUrl = cfgEl.dataset.apiPingUrl; const apiPingUrl = cfgEl.dataset.apiPingUrl;
const apiEchoUrl = cfgEl.dataset.apiEchoUrl; const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
const lostUrl = cfgEl.dataset.lostUrl;
if (mercurePublicUrl && topic) { if (mercurePublicUrl && topic) {
subscribeToMercure(mercurePublicUrl, topic, screen); subscribeToMercure(mercurePublicUrl, topic, screen);
@@ -138,6 +140,52 @@ document.addEventListener('DOMContentLoaded', async () => {
console.warn('[Mercure][game1] Missing data attributes on #mercure-config'); 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 // Demo API calls
try { try {
if (apiPingUrl) { if (apiPingUrl) {
-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 ###
+33 -32
View File
@@ -11,40 +11,41 @@
"doctrine/doctrine-bundle": "^2.16", "doctrine/doctrine-bundle": "^2.16",
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.5", "doctrine/orm": "^3.5",
"karser/karser-recaptcha3-bundle": "^0.3.0",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.3.*", "symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.3.*", "symfony/asset-mapper": "7.4.*",
"symfony/console": "7.3.*", "symfony/brevo-mailer": "7.4.*",
"symfony/doctrine-messenger": "7.3.*", "symfony/console": "7.4.*",
"symfony/dotenv": "7.3.*", "symfony/doctrine-messenger": "7.4.*",
"symfony/expression-language": "7.3.*", "symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.3.*", "symfony/form": "7.4.*",
"symfony/framework-bundle": "7.3.*", "symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.3.*", "symfony/http-client": "7.4.*",
"symfony/intl": "7.3.*", "symfony/intl": "7.4.*",
"symfony/mailer": "7.3.*", "symfony/mailer": "7.4.*",
"symfony/mercure-bundle": "^0.3", "symfony/mercure-bundle": "^0.3",
"symfony/mime": "7.3.*", "symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.3.*", "symfony/notifier": "7.4.*",
"symfony/process": "7.3.*", "symfony/process": "7.4.*",
"symfony/property-access": "7.3.*", "symfony/property-access": "7.4.*",
"symfony/property-info": "7.3.*", "symfony/property-info": "7.4.*",
"symfony/runtime": "7.3.*", "symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.3.*", "symfony/security-bundle": "7.4.*",
"symfony/sendgrid-mailer": "7.3.*", "symfony/serializer": "7.4.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.30", "symfony/stimulus-bundle": "^2.30",
"symfony/string": "7.3.*", "symfony/string": "7.4.*",
"symfony/translation": "7.3.*", "symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.3.*", "symfony/twig-bundle": "7.4.*",
"symfony/ux-turbo": "^2.30", "symfony/ux-turbo": "^2.30",
"symfony/validator": "7.3.*", "symfony/validator": "7.4.*",
"symfony/web-link": "7.3.*", "symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.1", "symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.3.*", "symfony/yaml": "7.4.*",
"symfonycasts/reset-password-bundle": "^1.24", "symfonycasts/reset-password-bundle": "^1.24",
"symfonycasts/verify-email-bundle": "^1.18", "symfonycasts/verify-email-bundle": "^1.18",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
@@ -98,16 +99,16 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.3.*" "require": "7.4.*"
} }
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11.5", "phpunit/phpunit": "^11.5",
"symfony/browser-kit": "7.3.*", "symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.3.*", "symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.3.*", "symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0", "symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.3.*", "symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.3.*" "symfony/web-profiler-bundle": "7.4.*"
} }
} }
Generated
+1362 -1122
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], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::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 # Enable stateless CSRF protection for forms and logins/logouts
framework: framework:
form: # form:
csrf_protection: # csrf_protection:
token_id: submit # token_id: submit
# csrf_protection:
csrf_protection: # stateless_token_ids:
stateless_token_ids: # - submit
- submit # - authenticate
- authenticate # - logout
- logout
+9 -1
View File
@@ -1,6 +1,14 @@
doctrine: doctrine:
dbal: 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, # IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file) # either here or in the DATABASE_URL env var (see .env file)
+15 -1
View File
@@ -8,7 +8,21 @@ framework:
fallbacks: ['en', 'nl'] fallbacks: ['en', 'nl']
# Note that the session will be started ONLY if you read or write from it. # 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 #esi: true
#fragments: 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: router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands. # 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 # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost default_uri: '%env(SITE_BASE_URL)%'
when@prod: when@prod:
framework: framework:
+1
View File
@@ -30,6 +30,7 @@ security:
# Easy way to control access for large sections of your site # Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
# - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER }
@@ -0,0 +1,2 @@
karser_recaptcha3:
enabled: false
+1
View File
@@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # 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 # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
mailer_from: '%env(MAILER_FROM)%'
services: services:
# default configuration for services in *this* file # 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 ## Top-Level
- docker/compose.yaml / docker/compose.override.yaml — Docker services. - 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. - composer.json / composer.lock — Dependencies and scripts.
- importmap.php — Importmap configuration for JS dependencies. - importmap.php — Importmap configuration for JS dependencies.
- phpunit.dist.xml — PHPUnit configuration. - 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) - mailer (dev only via compose.override.yaml): Mailpit (SMTP/UI)
## Prerequisites ## Prerequisites
- Docker and Docker Compose (v2) - Docker and Docker Compose (docker compose)
## Usage ## Usage
@@ -21,36 +21,42 @@ App will be served at http://localhost:8080
Alternatively (manual): 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 ### 2) Install dependencies
The setup script already runs composer install. To run manually: 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 ### 3) Prepare DB
The setup script already prepares the DB. To run manually: 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 cd docker
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php php bin/console doctrine:migrations:migrate -n 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 ### 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 ### 5) Logs
``` ```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml logs -f nginx cd docker
docker compose -f docker/compose.yaml -f docker/compose.override.yaml logs -f php docker compose logs -f nginx
docker compose logs -f php
``` ```
### 6) Stop ### 6) Stop
``` ```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml down cd docker
docker compose down
``` ```
## Notes ## Notes
+1 -1
View File
@@ -18,7 +18,7 @@ MAILER_DSN=smtp://mailer:1025
``` ```
- Usage: - Usage:
1. Start stack: `docker compose up -d` 1. Start stack: `docker-compose up -d`
2. Send an email from the app. 2. Send an email from the app.
3. Open http://localhost:8025 to view captured emails. 3. Open http://localhost:8025 to view captured emails.
+54
View File
@@ -0,0 +1,54 @@
# Docker Compose environment file — used by docker/compose.yaml.
# Keep this in sync with the root .env and .env.prod files.
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=a8f89e179e8c338423697669d6728c2c
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 ###
DB_DRIVER=pdo_mysql
DB_SERVER_VERSION=8.0.32
DB_CHARSET=utf8mb4
DB_USER=escapepage
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
MYSQL_ROOT_PASSWORD=root
DATABASE_URL=pdo_mysql://escapepage:Zr1aOYU5NpCbS3dhpxa64cZp@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> symfony/mailer ###
BREVO_API_KEY=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
MAILER_DSN=brevo+api://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@default
MAILER_FROM=mailer@escapepage.nl
###< symfony/mailer ###
###> mercure ###
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=55UtgFXsZu09TSTdeIA7ljK4HUo9DLkRzEB7MD5tqOLjRfAb
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.qMVdzh7buYK78e-gwCQx7v6qCxk1Js83SAEKK-GZSrI
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.OCnRPXfCoke27ntAxby2R5jkgpTZdw83DPq1yhvkLbw
MERCURE_CORS_ALLOWED_ORIGINS=https://escapepage.com
MERCURE_TOPIC_BASE=https://escapepage.com
###< mercure ###
###> docker ###
USER_ID=1000
GROUP_ID=1000
###< docker ###
###> karser/karser-recaptcha3-bundle ###
RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
###< karser/karser-recaptcha3-bundle ###
+29
View File
@@ -0,0 +1,29 @@
# User and Group IDs
USER_ID=1000
GROUP_ID=1000
# Application
APP_ENV=prod
SITE_BASE_URL=https://escapepage.com
# Mailer
MAILER_DSN=sendgrid://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@default
MAILER_FROM=mailer@escapepage.nl
# Database
DATABASE_URL=mysql://escapepage:Zr1aOYU5NpCbS3dhpxa64cZp@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4
DB_NAME=escapepage
DB_USER=escapepage
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
MYSQL_ROOT_PASSWORD=root
# Mercure
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=55UtgFXsZu09TSTdeIA7ljK4HUo9DLkRzEB7MD5tqOLjRfAb
MERCURE_CORS_ALLOWED_ORIGINS=https://escapepage.com
MERCURE_TOPIC_BASE=https://escapepage.com
# Recaptcha
RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
+20 -8
View File
@@ -1,20 +1,19 @@
services: services:
php: php:
environment: environment:
XDEBUG_MODE: off XDEBUG_MODE: "off"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
depends_on: depends_on:
- mailer - mailer
# networks:
# backend:
# ipv4_address: 172.23.0.10
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
database: ###< doctrine/doctrine-bundle ###
ports:
- "3306"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ### ###> symfony/mailer ###
mailer: mailer:
image: axllent/mailpit image: axllent/mailpit
ports: ports:
@@ -23,4 +22,17 @@ services:
environment: environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 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 ### ###< symfony/mailer ###
+77 -34
View File
@@ -1,4 +1,3 @@
version: '3.7' version: '3.7'
services: services:
@@ -6,33 +5,65 @@ services:
build: build:
context: .. context: ..
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php container_name: escapepage-php
volumes: volumes:
- ../:/var/www/html:delegated - ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment: 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: depends_on:
- database - database
- mercure - mercure
networks: # networks:
- backend # backend:
# ipv4_address: 172.23.0.10
restart: unless-stopped restart: unless-stopped
php-worker: php-worker:
build: build:
context: .. context: ..
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php-worker container_name: escapepage-php-worker
volumes: volumes:
- ../:/var/www/html:delegated - ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment: 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: depends_on:
- database - database
- mercure - mercure
command: ["php", "bin/console", "messenger:consume", "async", "-vv"] command: ["php", "bin/console", "messenger:consume", "async", "-vv"]
networks: # networks:
- backend # backend:
# ipv4_address: 172.23.0.11
restart: unless-stopped restart: unless-stopped
nginx: nginx:
@@ -40,13 +71,17 @@ services:
container_name: escapepage-nginx container_name: escapepage-nginx
ports: ports:
- "8080:80" - "8080:80"
- "8443:443"
volumes: volumes:
- ../:/var/www/html:ro - ../:/var/www/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- /etc/hosts:/etc/hosts:ro
depends_on: depends_on:
- php - php
networks: # networks:
- backend # backend:
# ipv4_address: 172.23.0.12
restart: unless-stopped restart: unless-stopped
mailer: mailer:
@@ -54,60 +89,68 @@ services:
container_name: escapepage-mailer container_name: escapepage-mailer
ports: ports:
- "8025:8025" - "8025:8025"
volumes:
- /etc/hosts:/etc/hosts:ro
networks: networks:
- backend - default
- nginx_proxy
restart: unless-stopped restart: unless-stopped
mercure: mercure:
image: dunglas/mercure:v0.21 image: dunglas/mercure:v0.21
container_name: escapepage-mercure container_name: escapepage-mercure
environment: environment:
SERVER_NAME: ":80" SERVER_NAME: "http://:80"
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!' MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!' MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: http://localhost:8080 MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_PUBLISH_ALLOWED_ORIGINS: http://localhost:8080 MERCURE_PUBLISH_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_EXTRA_DIRECTIVES: | MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://localhost:8080 cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
# Allow anonymous subscribers in dev only publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
anonymous anonymous
ports: ports:
- "8090:80" - "8090:80"
volumes:
- /etc/hosts:/etc/hosts:ro
networks: networks:
- backend - default
- nginx_proxy
restart: unless-stopped restart: unless-stopped
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
database: database:
image: mysql:8.0 image: mysql:8.0
container_name: escapepage-db container_name: escapepage-db
environment: environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-app} MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${MYSQL_USER:-app} MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-!ChangeMe!} MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 30s 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: 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.) # Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
# ports: ports:
# - "3306:3306" - "3306:3306"
networks: # networks:
- backend # backend:
# ipv4_address: 172.23.0.15
restart: unless-stopped restart: unless-stopped
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
networks: networks:
backend: nginx_proxy:
driver: bridge 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 { server {
listen 80; listen 80;
server_name _; 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; root /var/www/html/public;
index index.php index.html; index index.php index.html;
@@ -18,6 +30,14 @@ server {
fastcgi_param DOCUMENT_ROOT $realpath_root; fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass php:9000; fastcgi_pass php:9000;
fastcgi_read_timeout 120; 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 { 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 \ git \
icu-dev \ icu-dev \
libzip-dev \ libzip-dev \
libxml2-dev \
oniguruma-dev \ oniguruma-dev \
g++ \ g++ \
make \ make \
nodejs \ 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 # Install PHP extensions
RUN docker-php-ext-configure intl \ RUN install-php-extensions \
&& docker-php-ext-install -j$(nproc) intl pdo pdo_mysql opcache zip intl \
pdo_mysql \
opcache \
zip \
tokenizer \
ctype \
iconv \
mbstring \
dom \
xml \
simplexml \
xmlreader \
xmlwriter
# Install composer # Install composer
ENV COMPOSER_ALLOW_SUPERUSER=1 \ ENV COMPOSER_ALLOW_SUPERUSER=1 \
@@ -24,7 +41,23 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Configure PHP # Configure PHP
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini 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 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 # Default command
CMD ["php-fpm"] CMD ["php-fpm"]
+5
View File
@@ -7,3 +7,8 @@ opcache.enable=1
opcache.enable_cli=1 opcache.enable_cli=1
opcache.validate_timestamps=1 opcache.validate_timestamps=1
opcache.revalidate_freq=0 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
+15 -7
View File
@@ -17,18 +17,18 @@ set -euo pipefail
ROOT_DIR=$(cd "$(dirname "$0")"/.. && pwd) ROOT_DIR=$(cd "$(dirname "$0")"/.. && pwd)
DOCKER_DIR="$ROOT_DIR/docker" 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 if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose" DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose" DOCKER_COMPOSE="docker-compose"
else 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 exit 1
fi fi
# Helper to run docker compose from the docker/ directory # Helper to run docker compose from the docker directory
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -f compose.yaml "$@"); } dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -f compose.yaml -f compose.override.yaml "$@"); }
REBUILD=1 REBUILD=1
RECREATE=0 RECREATE=0
@@ -61,7 +61,7 @@ if [ "$RECREATE" -eq 1 ]; then
fi fi
# Start stack # Start stack
dc up "${BUILD_ARGS[@]}" dc up -d "${BUILD_ARGS[@]}"
# Helper to run commands in php container # Helper to run commands in php container
pexec() { dc exec -T php "$@"; } pexec() { dc exec -T php "$@"; }
@@ -104,9 +104,17 @@ fi
# Prepare DB # Prepare DB
echo "Creating database if it doesn't exist..." 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..." 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) # Import JS deps (Importmap/Asset Mapper)
if [ -f "$ROOT_DIR/importmap.php" ]; then 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'");
}
}
+9
View File
@@ -1,9 +1,18 @@
<?php <?php
use App\Kernel; use App\Kernel;
use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) { 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']); 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 protected function configure(): void
{ {
$this $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('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('data', null, InputOption::VALUE_REQUIRED, 'JSON payload to send', '{"message":"Hello from Mercure!"}')
->addOption('private', null, InputOption::VALUE_NONE, 'Mark the update as private'); ->addOption('private', null, InputOption::VALUE_NONE, 'Mark the update as private');
+19 -12
View File
@@ -1,9 +1,12 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace App\Game\Controller; namespace App\Game\Controller;
use App\Game\Entity\Session; use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository;
use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionRepository;
use App\Tech\Repository\UserRepository; use App\Tech\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -25,14 +28,23 @@ final class GameAdminController extends AbstractController
#[Route('', name: 'game_admin_dashboard', methods: ['GET'])] #[Route('', name: 'game_admin_dashboard', methods: ['GET'])]
public function index( public function index(
UserRepository $userRepository, UserRepository $userRepository,
SessionRepository $sessionRepository SessionRepository $sessionRepository,
GameRepository $gameRepository,
): Response { ): Response {
$players = $userRepository->findByRole('ROLE_PLAYER'); $allUsers = $userRepository->findAll();
$sessions = $sessionRepository->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', [ return $this->render('game/admin/index.html.twig', [
'players' => $players, 'totalUsers' => count($allUsers),
'sessions' => $sessions, '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(); $username = $player->getUser()->getUsername();
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt'; $logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
$logs = '';
if (file_exists($logFile)) {
$logs = file_get_contents($logFile);
}
$playersLogs[] = [ $playersLogs[] = [
'username' => $username, '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, 'session' => $session,
'playersLogs' => $playersLogs, '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,74 @@
<?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
{
return $this->render('game/admin/users/index.html.twig', [
'users' => $userRepository->findBy([], ['id' => 'ASC']),
]);
}
#[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; namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Service\GameResponseService; use App\Game\Service\GameResponseService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -15,7 +18,9 @@ final class GameApiController extends AbstractController
{ {
public function __construct( 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'])] #[Route('/message', name: 'message', methods: ['POST'])]
public function message(Request $request): JsonResponse public function message(Request $request): JsonResponse
{ {
+70 -1
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Game\Controller; namespace App\Game\Controller;
use App\Game\Entity\Player;
use App\Game\Entity\Session; use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting; use App\Game\Entity\SessionSetting;
use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionSettingType;
@@ -12,6 +13,7 @@ use App\Game\Repository\PlayerRepository;
use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionRepository;
use App\Game\Service\GameDashboardService; use App\Game\Service\GameDashboardService;
use App\Tech\Entity\User; use App\Tech\Entity\User;
use App\Game\Service\PlayerService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -25,7 +27,8 @@ final class GameController extends AbstractController
{ {
public function __construct( public function __construct(
#[Autowire('%env(MERCURE_PUBLIC_URL)%')] #[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; $screen = $player ? $player->getScreen() : 0;
$session_id = $session->getId();
return $this->render('game/index.html.twig', [ return $this->render('game/index.html.twig', [
'session' => $session, 'session' => $session,
'screen' => $screen, '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_PLAYER8 = 'ReadyAtForPlayer8';
case READY_AT_FOR_PLAYER9 = 'ReadyAtForPlayer9'; case READY_AT_FOR_PLAYER9 = 'ReadyAtForPlayer9';
case READY_AT_FOR_PLAYER10 = 'ReadyAtForPlayer10'; 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']);
}
}
+13 -6
View File
@@ -17,7 +17,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class GameDashboardService final class GameDashboardService
{ {
@@ -26,8 +25,6 @@ final class GameDashboardService
private readonly SessionRepository $sessionRepository, private readonly SessionRepository $sessionRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub, private readonly HubInterface $hub,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private readonly string $mercureTopicBase,
) { ) {
} }
@@ -330,7 +327,7 @@ final class GameDashboardService
$this->entityManager->flush(); $this->entityManager->flush();
try { try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); $topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting]))); $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting])));
} catch (\Exception $e) { } catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game // Mercure might be down, but we don't want to crash the game
@@ -382,7 +379,7 @@ final class GameDashboardService
if ($anyReset) { if ($anyReset) {
$this->entityManager->flush(); $this->entityManager->flush();
try { try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); $topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready']))); $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready'])));
} catch (\Exception $e) { } catch (\Exception $e) {
// Mercure might be down // Mercure might be down
@@ -391,6 +388,16 @@ final class GameDashboardService
if ($readyPlayersCount === $numPlayers) { if ($readyPlayersCount === $numPlayers) {
$session->setStatus(SessionStatus::PLAYING); $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); $this->entityManager->persist($session);
// Clean up ready settings // Clean up ready settings
@@ -406,7 +413,7 @@ final class GameDashboardService
} }
try { try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); $topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready']))); $this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready'])));
} catch (\Exception $e) { } catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game // Mercure might be down, but we don't want to crash the game
+3 -6
View File
@@ -12,7 +12,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class GameResponseService class GameResponseService
{ {
@@ -23,8 +22,6 @@ class GameResponseService
private HubInterface $hub, private HubInterface $hub,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private string $projectDir, private string $projectDir,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private string $mercureTopicBase,
) { ) {
} }
@@ -336,7 +333,7 @@ class GameResponseService
if(is_null($activeGame)) if(is_null($activeGame))
return false; return false;
$topic = $this->mercureTopicBase . '/game/hub-' . $activeGame; $topic = '/game/hub/' . $activeGame;
try { try {
$this->hub->publish(new Update($topic, json_encode([$sendTo, $message]))); $this->hub->publish(new Update($topic, json_encode([$sendTo, $message])));
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -382,7 +379,7 @@ class GameResponseService
$this->entityManager->flush(); $this->entityManager->flush();
// Notify the player that their codes have changed // 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."; $notification = "Security Alert: One of your verify codes was shared and has been regenerated.";
// We send it only to this player (screen) // We send it only to this player (screen)
try { try {
@@ -673,7 +670,7 @@ class GameResponseService
$this->entityManager->persist($everyoneVerifiedSetting); $this->entityManager->persist($everyoneVerifiedSetting);
$this->entityManager->flush(); $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."; $message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages.";
try { try {
$this->hub->publish(new Update($topic, json_encode([0, $message]))); $this->hub->publish(new Update($topic, json_encode([0, $message])));
+1 -1
View File
@@ -31,7 +31,7 @@ class ActivationController extends AbstractController
try { try {
$emailVerifier->handleEmailConfirmation($request, $user); $emailVerifier->handleEmailConfirmation($request, $user);
} catch (VerifyEmailExceptionInterface $exception) { } 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'); 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\Entity\User;
use App\Tech\Form\RegistrationFormType; use App\Tech\Form\RegistrationFormType;
use App\Tech\Form\ResendVerificationEmailFormType;
use App\Tech\Repository\UserRepository;
use App\Tech\Service\EmailVerifier; use App\Tech\Service\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
@@ -39,7 +41,7 @@ class RegistrationController extends AbstractController
// generate a signed url and email it to the user // generate a signed url and email it to the user
$emailVerifier->sendEmailConfirmation('app_verify_email', $user, $emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail()) (new TemplatedEmail())
->from('noreply@escapepage.dev') ->from($this->getParameter('mailer_from'))
->to($user->getEmail()) ->to($user->getEmail())
->subject('Please Confirm your Email') ->subject('Please Confirm your Email')
->htmlTemplate('tech/registration/confirmation_email.html.twig') ->htmlTemplate('tech/registration/confirmation_email.html.twig')
@@ -54,4 +56,41 @@ class RegistrationController extends AbstractController
'registrationForm' => $form->createView(), '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(),
]);
}
} }
+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' => [ 'first_options' => [
'constraints' => [ 'constraints' => [
new NotBlank([ new NotBlank(message: 'Please enter a password'),
'message' => 'Please enter a password', new Length(
]), min: 12,
new Length([ minMessage: 'Your password should be at least {{ limit }} characters',
'min' => 12,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons // max length allowed by Symfony for security reasons
'max' => 4096, max: 4096,
]), ),
new PasswordStrength(), new PasswordStrength(),
new NotCompromisedPassword(), new NotCompromisedPassword(),
], ],
+13 -11
View File
@@ -3,6 +3,8 @@
namespace App\Tech\Form; namespace App\Tech\Form;
use App\Tech\Entity\User; 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\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -21,9 +23,7 @@ class RegistrationFormType extends AbstractType
->add('email', EmailType::class) ->add('email', EmailType::class)
->add('username', TextType::class, [ ->add('username', TextType::class, [
'constraints' => [ 'constraints' => [
new NotBlank([ new NotBlank(message: 'Please enter a username'),
'message' => 'Please enter a username',
]),
], ],
]) ])
->add('plainPassword', RepeatedType::class, [ ->add('plainPassword', RepeatedType::class, [
@@ -33,17 +33,19 @@ class RegistrationFormType extends AbstractType
'first_options' => ['label' => 'Password'], 'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Repeat Password'], 'second_options' => ['label' => 'Repeat Password'],
'constraints' => [ 'constraints' => [
new NotBlank([ new NotBlank(message: 'Please enter a password'),
'message' => 'Please enter a password', new Length(
]), min: 6,
new Length([ minMessage: 'Your password should be at least {{ limit }} characters',
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons // max length allowed by Symfony for security reasons
'max' => 4096, max: 4096,
]), ),
], ],
]) ])
->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, [ ->add('email', EmailType::class, [
'attr' => ['autocomplete' => 'email'], 'attr' => ['autocomplete' => 'email'],
'constraints' => [ 'constraints' => [
new NotBlank([ new NotBlank(message: 'Please enter your email'),
'message' => 'Please enter your email',
]),
], ],
]) ])
; ;
+1 -1
View File
@@ -43,7 +43,7 @@ class EmailVerifier
*/ */
public function handleEmailConfirmation(Request $request, User $user): void 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); $user->setIsVerified(true);
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Tech\Service; namespace App\Tech\Service;
use App\Tech\Entity\User; use App\Tech\Entity\User;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -16,7 +17,7 @@ class UserChecker implements UserCheckerInterface
} }
if (!$user->isVerified()) { 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']);
} }
} }
+12
View File
@@ -35,6 +35,18 @@
"migrations/.gitignore" "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": { "phpunit/phpunit": {
"version": "11.5", "version": "11.5",
"recipe": { "recipe": {
+2
View File
@@ -23,6 +23,7 @@
{% endif %} {% endif %}
</nav> </nav>
</header> </header>
{% block main %}
<main> <main>
{% for label, messages in app.flashes %} {% for label, messages in app.flashes %}
{% for message in messages %} {% for message in messages %}
@@ -33,6 +34,7 @@
{% endfor %} {% endfor %}
{% block body %}{% endblock %} {% block body %}{% endblock %}
</main> </main>
{% endblock %}
<footer> <footer>
<small>&copy; {{ "now"|date("Y") }} {{ 'site.name'|trans }}</small> <small>&copy; {{ "now"|date("Y") }} {{ 'site.name'|trans }}</small>
</footer> </footer>
+78
View File
@@ -0,0 +1,78 @@
{% extends 'base.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 %}
+82 -74
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 %} {% block admin_body %}
<h1>Game Admin Dashboard</h1> <h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Dashboard</h1>
<div style="display: flex; gap: 2rem;"> <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div style="flex: 1;"> {% set stats = [
<h2>All Players ({{ players|length }})</h2> { label: 'Total Users', value: totalUsers, color: '#3b82f6' },
<table style="width: 100%; border-collapse: collapse;"> { label: 'Players', value: totalPlayers, color: '#8b5cf6' },
<thead> { label: 'Admins', value: totalAdmins, color: '#f59e0b' },
<tr style="background-color: #f2f2f2;"> { label: 'Games', value: totalGames, color: '#10b981' },
<th>ID</th> { label: 'Active Sessions', value: activeSessions, color: '#ef4444' },
<th>Username</th> { label: 'Total Sessions', value: totalSessions, color: '#64748b' },
<th>Email</th> ] %}
<th>Roles</th>
<th>Verified</th> {% for stat in stats %}
</tr> <div style="
</thead> background: #fff;
<tbody> border-radius: 8px;
{% for player in players %} padding: 1.25rem;
<tr> border-left: 4px solid {{ stat.color }};
<td>{{ player.id }}</td> box-shadow: 0 1px 3px rgba(0,0,0,.07);
<td>{{ player.username }}</td> ">
<td>{{ player.email }}</td> <div style="font-size: 1.75rem; font-weight: 700; color: {{ stat.color }};">{{ stat.value }}</div>
<td>{{ player.roles|join(', ') }}</td> <div style="font-size: 0.8rem; color: #64748b; margin-top: 0.25rem;">{{ stat.label }}</div>
<td>{{ player.isVerified ? 'Yes' : 'No' }}</td> </div>
</tr>
{% else %}
<tr>
<td colspan="5">No players found.</td>
</tr>
{% endfor %} {% endfor %}
</tbody>
</table>
</div> </div>
<div style="flex: 2;"> <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem;">
<h2>All Sessions ({{ sessions|length }})</h2> <a href="{{ path('game_admin_users') }}" style="
<table style="width: 100%; border-collapse: collapse;"> background: #fff;
<thead> border-radius: 8px;
<tr style="background-color: #f2f2f2;"> padding: 1.25rem;
<th>ID</th> text-decoration: none;
<th>Game</th> color: #1e293b;
<th>Status</th> box-shadow: 0 1px 3px rgba(0,0,0,.07);
<th>Players Joined</th> display: flex;
<th>Created At</th> align-items: center;
<th>Actions</th> gap: 0.75rem;
</tr> font-weight: 600;
</thead> ">
<tbody> <span style="font-size: 1.5rem;">👤</span> Manage Users
{% for session in sessions %} </a>
<tr> <a href="{{ path('game_admin_games') }}" style="
<td>{{ session.id }}</td> background: #fff;
<td>{{ session.game.name }}</td> border-radius: 8px;
<td>{{ session.status.value }}</td> padding: 1.25rem;
<td> text-decoration: none;
<ul> color: #1e293b;
{% for p in session.players %} box-shadow: 0 1px 3px rgba(0,0,0,.07);
<li>{{ p.user.username }} (Screen: {{ p.screen ?? 'N/A' }})</li> display: flex;
{% else %} align-items: center;
<li>No players</li> gap: 0.75rem;
{% endfor %} font-weight: 600;
</ul> ">
({{ session.players|length }} / {{ session.game.numberOfPlayers }}) <span style="font-size: 1.5rem;">🎮</span> Manage Games
</td> </a>
<td>{{ session.created|date('Y-m-d H:i') }}</td> <a href="{{ path('game_admin_sessions') }}" style="
<td> background: #fff;
<a href="{{ path('game_admin_view_session', {session: session.id}) }}">View Game Logs</a> border-radius: 8px;
</td> padding: 1.25rem;
</tr> text-decoration: none;
{% else %} color: #1e293b;
<tr> box-shadow: 0 1px 3px rgba(0,0,0,.07);
<td colspan="6">No sessions found.</td> display: flex;
</tr> align-items: center;
{% endfor %} gap: 0.75rem;
</tbody> font-weight: 600;
</table> ">
</div> <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> </div>
{% endblock %} {% 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,83 @@
{% 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</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;">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;">
<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="6" style="padding: 2rem; text-align: center; color: #94a3b8;">No users found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+5 -3
View File
@@ -15,15 +15,17 @@
<div id="game-container"> <div id="game-container">
<div id="mercure-config" <div id="mercure-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}" 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-ping-url="{{ path('game_api_ping')|e('html_attr') }}"
data-api-echo-url="{{ path('game_api_message')|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') }}" data-screen="{{ screen|e('html_attr') }}"
style="display:none"> style="display:none">
</div> </div>
<div id="game-timer"> <div id="game-timer" data-end-time="{{ session.timer }}">
00:30:00 --:--:--
</div> </div>
<div id="message-container"> <div id="message-container">
+127
View File
@@ -0,0 +1,127 @@
{% extends 'base.html.twig' %}
{% block title %}Game Lost - {{ session.game.name }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
body {
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="container mt-5 mb-5">
<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>
</div>
{% endblock %}
+1 -1
View File
@@ -80,7 +80,7 @@
<div id="mercure-config" <div id="mercure-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}" 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') }}" data-ready-at="{{ readyAt|e('html_attr') }}"
style="display:none"> style="display:none">
</div> </div>
@@ -4,6 +4,7 @@
Please confirm your email address by clicking the following link: <br><br> Please confirm your email address by clicking the following link: <br><br>
<a href="{{ signedUrl }}">Confirm my Email</a>. <a href="{{ signedUrl }}">Confirm my Email</a>.
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}. This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
If the link has expired or doesn't work, you can <a href="{{ url('app_verify_resend_email') }}">request a new one</a>.
</p> </p>
<p> <p>
@@ -11,6 +11,7 @@
{{ form_row(registrationForm.email) }} {{ form_row(registrationForm.email) }}
{{ form_row(registrationForm.username) }} {{ form_row(registrationForm.username) }}
{{ form_row(registrationForm.plainPassword) }} {{ form_row(registrationForm.plainPassword) }}
{{ form_row(registrationForm.captcha) }}
<button type="submit" class="btn">Register</button> <button type="submit" class="btn">Register</button>
{{ form_end(registrationForm) }} {{ form_end(registrationForm) }}
@@ -0,0 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}Resend verification email{% endblock %}
{% block body %}
<h1>Resend verification email</h1>
<p>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) }}
<button type="submit" class="btn">Resend email</button>
{{ form_end(resendForm) }}
{% endblock %}
+9 -1
View File
@@ -5,7 +5,12 @@
{% block body %} {% block body %}
<form method="post"> <form method="post">
{% if error %} {% 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 %} {% endif %}
{% if app.user %} {% if app.user %}
@@ -41,5 +46,8 @@
<div class="mt-3"> <div class="mt-3">
<a href="{{ path('app_forgot_password_request') }}">Forgot your password?</a> <a href="{{ path('app_forgot_password_request') }}">Forgot your password?</a>
</div> </div>
<div class="mt-1">
<a href="{{ path('app_verify_resend_email') }}">Didn't receive activation email?</a>
</div>
</form> </form>
{% endblock %} {% endblock %}