125 Commits
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
214 changed files with 5535 additions and 12242 deletions
+16 -12
View File
@@ -11,15 +11,12 @@
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html # https://symfony.com/doc/current/configuration/secrets.html
# #
# Copy this file to .env (and .env.dev / .env.prod / .env.test as needed) and
# fill in real values. Those files are gitignored and never committed.
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). # 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 # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
APP_ENV=prod APP_ENV=prod
APP_SECRET=CHANGEME_APP_SECRET APP_SECRET=695679907f9c3818e6924d547f872651
TRUSTED_PROXIES=127.0.0.1,172.20.0.1,172.20.0.0/16 TRUSTED_PROXIES=127.0.0.1,172.20.0.1,172.20.0.0/16
TRUSTED_HOSTS=^.*$ TRUSTED_HOSTS=^.*$
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
@@ -36,11 +33,11 @@ DB_DRIVER=pdo_mysql
DB_SERVER_VERSION=8.0.32 DB_SERVER_VERSION=8.0.32
DB_CHARSET=utf8mb4 DB_CHARSET=utf8mb4
DB_USER=escapepage DB_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
DB_HOST=database DB_HOST=database
DB_PORT=3306 DB_PORT=3306
DB_NAME=escapepage DB_NAME=escapepage
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD 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}" DATABASE_URL="${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?serverVersion=${DB_SERVER_VERSION}&charset=${DB_CHARSET}"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
@@ -53,13 +50,20 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ### ###> symfony/mailer ###
# Development: use Mailpit (docker compose override provides service `mailer` on port 1025) # Development: use Mailpit (docker compose override provides service `mailer` on port 1025)
MAILGUN_API_KEY=REPLACE_WITH_MAILGUN_API_KEY BREVO_API_KEY=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
MAILGUN_DOMAIN=REPLACE_WITH_MAILGUN_SENDING_DOMAIN MAILER_DSN=brevo+api://${BREVO_API_KEY}@default
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu
MAILER_FROM=mailer@escapepage.nl 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): # Optional default sender (used by test command if --from not passed):
###< symfony/mailer ### ###< symfony/mailer ###
###> symfony/sendgrid-mailer ###
# MAILER_DSN=sendgrid://KEY@default
###< symfony/sendgrid-mailer ###
###> mercure ### ###> mercure ###
# Internal hub URL used by the PHP app (reachable from the php container) # Internal hub URL used by the PHP app (reachable from the php container)
MERCURE_URL=http://mercure/.well-known/mercure MERCURE_URL=http://mercure/.well-known/mercure
@@ -67,7 +71,7 @@ MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
# Shared secret for signing JWTs (dev only). In prod, set via real env/secrets. # Shared secret for signing JWTs (dev only). In prod, set via real env/secrets.
MERCURE_JWT_SECRET=!ChangeThisMercureJWTSignedBySymfonySecretKey! MERCURE_JWT_SECRET=!ChangeThisMercureJWTSignedBySymfonySecretKey!
# Pre-generated JWT tokens for convenience (signed with the dev secret above) # Pre-generated JWT tokens for convenience
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
# CORS allowed origins (default) # CORS allowed origins (default)
@@ -83,6 +87,6 @@ GROUP_ID=1000
###> karser/karser-recaptcha3-bundle ### ###> karser/karser-recaptcha3-bundle ###
# Get your API key and secret from https://g.co/recaptcha/v3 # Get your API key and secret from https://g.co/recaptcha/v3
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
###< karser/karser-recaptcha3-bundle ### ###< 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"
-9
View File
@@ -1,14 +1,9 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
/.env
/.env.dev
/.env.prod
/.env.test
/.env.local /.env.local
/.env.local.php /.env.local.php
/.env.*.local /.env.*.local
/config/secrets/prod/prod.decrypt.private.php /config/secrets/prod/prod.decrypt.private.php
/config/reference.php
/public/bundles/ /public/bundles/
/var/ /var/
!/var/volumes/ !/var/volumes/
@@ -35,7 +30,3 @@ yarn-error.log
###< symfony/webpack-encore-bundle ### ###< symfony/webpack-encore-bundle ###
/.idea /.idea
###> docker env ###
/docker/.env
###< docker env ###
+1 -3
View File
@@ -136,13 +136,11 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/lcobucci/jwt" /> <excludeFolder url="file://$MODULE_DIR$/vendor/lcobucci/jwt" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/sendgrid-mailer" />
<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" /> <excludeFolder url="file://$MODULE_DIR$/vendor/karser/karser-recaptcha3-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailgun-mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php85" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
Generated
+1 -3
View File
@@ -146,6 +146,7 @@
<path value="$PROJECT_DIR$/vendor/monolog/monolog" /> <path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" /> <path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" /> <path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/sendgrid-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/webpack-encore-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/webpack-encore-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure" /> <path value="$PROJECT_DIR$/vendor/symfony/mercure" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
@@ -153,9 +154,6 @@
<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" /> <path value="$PROJECT_DIR$/vendor/karser/karser-recaptcha3-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mailgun-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/rate-limiter" />
</include_path> </include_path>
</component> </component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" /> <component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
+7 -9
View File
@@ -25,25 +25,23 @@ This repository contains a Symfony 7.3 (PHP >= 8.5.1) application for a collabor
- `php bin/console doctrine:migrations:migrate -n` - `php bin/console doctrine:migrations:migrate -n`
4. App is at http://localhost:8080 4. App is at http://localhost:8080
## Email (Mailpit in dev, Mailgun 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 (or mapped port 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 Mailgun. - Staging/Prod: use SendGrid.
- Require package (already in composer): `symfony/mailgun-mailer`. - Require package (already in composer): `symfony/sendgrid-mailer`.
- Set environment variables (do NOT commit secrets): - Set environment variables (do NOT commit secrets):
- `MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu` - `MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%`
- `MAILGUN_API_KEY=YOUR_REAL_KEY` - `SENDGRID_API_KEY=YOUR_REAL_KEY`
- `MAILGUN_DOMAIN=YOUR_SENDING_DOMAIN` (e.g. `mg.escapepage.nl`)
- Optional: `MAILER_FROM=no-reply@your-domain.tld` - Optional: `MAILER_FROM=no-reply@your-domain.tld`
- Drop `region=eu` (or use `region=us`) depending on which region your Mailgun domain was created in.
- Alternatively via SMTP (no extra package): - Alternatively via SMTP (no extra package):
- `MAILER_DSN="mailgun+smtp://USERNAME:PASSWORD@default?region=eu"` - `MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"`
Troubleshooting: Troubleshooting:
- If emails don’t appear in dev, open Mailpit at http://localhost:8025 and verify messages. - If emails don’t appear in dev, open Mailpit at http://localhost:8025 and verify messages.
- In prod, check logs for HTTP 2xx responses from Mailgun and verify sender domain is verified (SPF/DKIM) in Mailgun. - In prod, check logs for HTTP 2xx responses from SendGrid and verify sender domain is verified in SendGrid.
## Frontend assets with Webpack Encore ## Frontend assets with Webpack Encore
We use Webpack Encore to build and minify JS/CSS from the `assets/` directory into `public/build/`. We use Webpack Encore to build and minify JS/CSS from the `assets/` directory into `public/build/`.
-22
View File
@@ -1,22 +0,0 @@
import { DataTable } from 'simple-datatables';
import 'simple-datatables/dist/style.css';
// Paginate/search/sort any admin table opted in via class="admin-datatable".
// Kept off tables that are admin-curated and stay small (games) or where row
// order is meaningful and must not be disturbed by sorting (hints).
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('table.admin-datatable').forEach((table) => {
new DataTable(table, {
perPage: 25,
perPageSelect: [10, 25, 50, 100],
searchable: true,
sortable: true,
labels: {
placeholder: 'Search...',
perPage: '{select} entries per page',
noRows: 'No matching entries found',
info: 'Showing {start} to {end} of {rows} entries',
},
});
});
});
-27
View File
@@ -1,27 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const buttons = document.querySelectorAll('[data-tab-target]');
if (!buttons.length) {
return;
}
const activate = (id) => {
document.querySelectorAll('.admin-tab-panel').forEach(el => el.style.display = 'none');
const target = document.getElementById(id);
if (target) {
target.style.display = 'block';
}
buttons.forEach(btn => {
const active = btn.dataset.tabTarget === id;
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';
});
};
buttons.forEach(btn => {
btn.addEventListener('click', () => activate(btn.dataset.tabTarget));
});
});
+3 -8
View File
@@ -1,11 +1,6 @@
/* /*
* Welcome to your app's main JavaScript file! * Welcome to your app's main JavaScript file!
*/ */
import './styles/app.scss'; import './styles/app.css';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/alert'; console.log('This log comes from assets/app.js built by Webpack Encore! 🎉');
import 'bootstrap/js/dist/dropdown';
import './game-waiting';
import './game-lobby';
import './admin-session-tabs';
import './admin-datatables';
-85
View File
@@ -1,85 +0,0 @@
/*
* Small single-purpose Leaflet maps:
* - #escaperoom-detail-map : read-only pin on a room's detail page
* - #escaperoom-suggest-map : click to drop / move a pin on the "suggest a room"
* form, writing the coordinates into the hidden latitude/longitude fields.
*/
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
const OSM = {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
opts: {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
};
function initDetailMap(el) {
const lat = parseFloat(el.dataset.lat);
const lng = parseFloat(el.dataset.lng);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
return;
}
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
L.tileLayer(OSM.url, OSM.opts).addTo(map);
L.marker([lat, lng]).addTo(map);
}
function initSuggestMap(el) {
const form = el.closest('form');
const latField = form && form.querySelector('input[name$="[latitude]"]');
const lngField = form && form.querySelector('input[name$="[longitude]"]');
if (!latField || !lngField) {
return;
}
const startLat = parseFloat(latField.value);
const startLng = parseFloat(lngField.value);
const hasStart = !Number.isNaN(startLat) && !Number.isNaN(startLng);
const map = L.map(el).setView(hasStart ? [startLat, startLng] : [20, 0], hasStart ? 15 : 2);
L.tileLayer(OSM.url, OSM.opts).addTo(map);
let marker = hasStart ? L.marker([startLat, startLng], { draggable: true }).addTo(map) : null;
const write = (latlng) => {
latField.value = latlng.lat.toFixed(7);
lngField.value = latlng.lng.toFixed(7);
latField.dispatchEvent(new Event('change', { bubbles: true }));
};
if (marker) {
marker.on('dragend', () => write(marker.getLatLng()));
}
map.on('click', (e) => {
if (marker) {
marker.setLatLng(e.latlng);
} else {
marker = L.marker(e.latlng, { draggable: true }).addTo(map);
marker.on('dragend', () => write(marker.getLatLng()));
}
write(e.latlng);
});
}
document.addEventListener('DOMContentLoaded', () => {
const detail = document.getElementById('escaperoom-detail-map');
if (detail) {
initDetailMap(detail);
}
const suggest = document.getElementById('escaperoom-suggest-map');
if (suggest) {
initSuggestMap(suggest);
}
});
-151
View File
@@ -1,151 +0,0 @@
/*
* Public "physical escape rooms" map + list.
*
* One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
* marker clustering, so a worldwide dataset stays responsive) and the searchable
* table underneath it.
*/
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { DataTable } from 'simple-datatables';
import 'simple-datatables/dist/style.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
function slugify(name) {
return (name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'room';
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function ratingLabel(avg, count) {
if (!count) {
return '—';
}
return `${avg.toFixed(1)} ★ (${count})`;
}
function detailUrl(template, room) {
return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
}
function buildMap(el, rooms, detailTemplate) {
const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const cluster = L.markerClusterGroup({ chunkedLoading: true });
const bounds = [];
rooms.forEach((room) => {
if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
return;
}
const where = [room.city, room.country].filter(Boolean).join(', ');
const popup = `
<strong>${escapeHtml(room.name)}</strong><br>
${where ? `${escapeHtml(where)}<br>` : ''}
<span>${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</span><br>
<a href="${detailUrl(detailTemplate, room)}">View &amp; review &raquo;</a>
`;
const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
cluster.addLayer(marker);
bounds.push([room.lat, room.lng]);
});
map.addLayer(cluster);
if (bounds.length) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
}
function buildList(table, rooms, detailTemplate) {
const tbody = table.querySelector('tbody');
const frag = document.createDocumentFragment();
rooms.forEach((room) => {
const tr = document.createElement('tr');
const where = room.city ? escapeHtml(room.city) : '';
const country = room.country ? escapeHtml(room.country) : '';
const ratingValue = room.reviewCount ? room.avgRating : 0;
tr.innerHTML = `
<td><a href="${detailUrl(detailTemplate, room)}">${escapeHtml(room.name)}</a></td>
<td>${where}</td>
<td>${country}</td>
<td data-order="${ratingValue}">${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</td>
`;
frag.appendChild(tr);
});
tbody.replaceChildren(frag);
new DataTable(table, {
perPage: 25,
perPageSelect: [25, 50, 100],
searchable: true,
sortable: true,
labels: {
placeholder: 'Search rooms…',
perPage: '{select} per page',
noRows: 'No matching rooms',
info: 'Showing {start}–{end} of {rows} rooms',
},
});
}
document.addEventListener('DOMContentLoaded', () => {
const mapEl = document.getElementById('escaperoom-map');
if (!mapEl) {
return;
}
const endpoint = mapEl.dataset.endpoint;
const detailTemplate = mapEl.dataset.detailTemplate;
const listEl = document.getElementById('escaperoom-list');
const statusEl = document.getElementById('escaperoom-status');
fetch(endpoint, { headers: { Accept: 'application/json' } })
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then((payload) => {
const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
buildMap(mapEl, rooms, detailTemplate);
if (listEl) {
buildList(listEl, rooms, detailTemplate);
}
if (statusEl) {
statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
}
})
.catch((err) => {
if (statusEl) {
statusEl.textContent = 'Could not load escape rooms right now.';
}
console.error('escaperoom-map:', err);
});
});
-62
View File
@@ -1,62 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const config = document.getElementById('game-lobby-config');
if (!config) {
return;
}
const publicUrl = config.dataset.mercurePublicUrl;
const topic = config.dataset.topic;
const chatLog = document.getElementById('lobby-chat-log');
function appendLobbyMessage(username, content, createdAt) {
if (!chatLog) {
return;
}
const emptyNotice = document.getElementById('lobby-chat-empty');
if (emptyNotice) {
emptyNotice.remove();
}
const time = createdAt
? new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '';
const wrapper = document.createElement('div');
wrapper.className = 'lobby-message';
const author = document.createElement('strong');
author.textContent = username;
const timestamp = document.createElement('span');
timestamp.className = 'text-muted small';
timestamp.textContent = ' ' + time;
const body = document.createElement('div');
body.textContent = content;
wrapper.appendChild(author);
wrapper.appendChild(timestamp);
wrapper.appendChild(body);
chatLog.appendChild(wrapper);
chatLog.scrollTop = chatLog.scrollHeight;
}
if (publicUrl && topic) {
const url = new URL(publicUrl);
url.searchParams.append('topic', topic);
const eventSource = new EventSource(url);
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'lobby_message') {
appendLobbyMessage(data.username, data.content, data.createdAt);
} else if (data.type === 'player_joined' || data.type === 'session_started') {
window.location.reload();
}
};
}
if (chatLog) {
chatLog.scrollTop = chatLog.scrollHeight;
}
});
-68
View File
@@ -1,68 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const config = document.getElementById('game-waiting-config');
if (!config) {
return;
}
const publicUrl = config.dataset.mercurePublicUrl;
const topic = config.dataset.topic;
const readyAt = config.dataset.readyAt;
let reloading = false;
const reloadOnce = (eventSource) => {
if (reloading) {
return;
}
reloading = true;
if (eventSource) {
eventSource.close();
}
window.location.reload();
};
if (publicUrl && topic) {
const url = new URL(publicUrl);
url.searchParams.append('topic', topic);
const eventSource = new EventSource(url);
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'all_ready' || data.type === 'player_ready') {
reloadOnce(eventSource);
}
};
}
// Our own ready status expires 60s after we set it - proactively tell the
// server as close to that deadline as possible, so the other players find
// out live instead of only whenever someone else's request happens to
// trigger the lazy check.
if (readyAt) {
const timeoutMs = 61000; // slightly more than the server-side 60s
const readyAtMs = readyAt * 1000;
const countdownEl = document.getElementById('ready-countdown');
const expireForm = document.getElementById('expire-ready-form');
const updateCountdown = () => {
const remaining = Math.max(0, Math.ceil((readyAtMs + timeoutMs - Date.now()) / 1000));
if (countdownEl) {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdownEl.textContent = m + ':' + s.toString().padStart(2, '0');
}
return remaining;
};
const remaining = updateCountdown();
if (remaining <= 0) {
expireForm?.submit();
} else {
const countdownInterval = setInterval(() => {
if (updateCountdown() <= 0) {
clearInterval(countdownInterval);
expireForm?.submit();
}
}, 1000);
}
}
});
+24 -217
View File
@@ -3,14 +3,8 @@ import './styles/game1.css';
let sequenceFinished = false; let sequenceFinished = false;
let stillPlayingSound = true; let stillPlayingSound = true;
let navigatingAway = false;
function goTo(url) { function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
navigatingAway = true;
window.location.href = url;
}
function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl) {
try { try {
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic); const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
const es = new EventSource(url); const es = new EventSource(url);
@@ -20,15 +14,7 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl)
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log('[Mercure][game1] Update:', data); console.log('[Mercure][game1] Update:', data);
if (data && !Array.isArray(data) && data.type === 'game_finished') { // data is [sendTo, message]
const destination = data.status === 'won' ? wonUrl : lostUrl;
if (destination) {
goTo(destination);
}
return;
}
// data is [sendTo, message, messageType?] - messageType defaults to 'mainframe' (green)
if (Array.isArray(data) && data.length >= 2) { if (Array.isArray(data) && data.length >= 2) {
const sendTo = parseInt(data[0]); const sendTo = parseInt(data[0]);
// Filter: 0 means everyone, otherwise must match myScreen // Filter: 0 means everyone, otherwise must match myScreen
@@ -39,7 +25,13 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl)
const messageContainer = document.getElementById('message-container'); const messageContainer = document.getElementById('message-container');
if (messageContainer) { if (messageContainer) {
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe'); const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = data[1];
msgEl.style.color = '#0F0'; // Green for incoming messages
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
if(stillPlayingSound) if(stillPlayingSound)
playSound(); playSound();
console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished); console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished);
@@ -85,147 +77,6 @@ function flashRed() {
}, 150); }, 150);
} }
let lockRevealTimer = null;
let lockExpireTimer = null;
let lockCountdownTimer = null;
let currentLockedAt = null;
function lockMessageClass(messageType) {
if (messageType === 'virus') return 'message-virus';
if (messageType === 'mainframe') return 'message-mainframe';
if (messageType === 'hint') return 'message-hint';
return '';
}
function appendResultMessage(container, text, messageType) {
const msgEl = document.createElement('div');
msgEl.className = ('message ' + lockMessageClass(messageType)).trim();
msgEl.textContent = text;
container.appendChild(msgEl);
}
function setInputDisabled(disabled) {
const inputField = document.getElementById('input-message');
if (inputField) {
inputField.disabled = disabled;
}
}
function clearLockTimers() {
if (lockRevealTimer) { clearTimeout(lockRevealTimer); lockRevealTimer = null; }
if (lockExpireTimer) { clearTimeout(lockExpireTimer); lockExpireTimer = null; }
if (lockCountdownTimer) { clearInterval(lockCountdownTimer); lockCountdownTimer = null; }
}
function clearLock() {
clearLockTimers();
currentLockedAt = null;
document.body.classList.remove('locked');
const banner = document.getElementById('lock-banner');
if (banner) banner.style.display = 'none';
setInputDisabled(false);
}
function updateLockCountdown(unlockAtMs) {
const countdownEl = document.getElementById('lock-countdown');
if (!countdownEl) return;
const remaining = Math.max(0, Math.ceil((unlockAtMs - Date.now()) / 1000));
countdownEl.textContent = remaining + 's';
}
async function fetchLockReveal(apiEchoUrl, messageContainer) {
if (!apiEchoUrl) return;
try {
const response = await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message: '', ts: new Date().toISOString() },
});
const result = response && response.result;
if (result && Array.isArray(result.result)) {
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
}
if (result && result.locked === false) {
clearLock();
return;
}
// Code has been revealed (or already was), let the player try /unlock
setInputDisabled(false);
} catch (e) {
console.error('[Game1] Failed to fetch lock reveal:', e);
}
}
function applyLock(lockData, apiEchoUrl, messageContainer) {
if (currentLockedAt === lockData.lockedAt) {
return; // already tracking this lock, avoid re-fetching/duplicating messages
}
currentLockedAt = lockData.lockedAt;
clearLockTimers();
const banner = document.getElementById('lock-banner');
if (banner) banner.style.display = 'flex';
document.body.classList.add('locked');
const revealAtMs = lockData.revealAt * 1000;
const unlockAtMs = lockData.unlockAt * 1000;
const now = Date.now();
if (now < revealAtMs) {
setInputDisabled(true);
lockRevealTimer = setTimeout(() => fetchLockReveal(apiEchoUrl, messageContainer), revealAtMs - now);
} else {
fetchLockReveal(apiEchoUrl, messageContainer);
}
lockExpireTimer = setTimeout(() => clearLock(), Math.max(0, unlockAtMs - now));
updateLockCountdown(unlockAtMs);
lockCountdownTimer = setInterval(() => {
updateLockCountdown(unlockAtMs);
if (Date.now() >= unlockAtMs) {
clearInterval(lockCountdownTimer);
lockCountdownTimer = null;
}
}, 1000);
}
let filesRemovalTimer = null;
let scheduledFilesRemovalDeadline = null;
async function pingFilesRemovalDeadline(apiEchoUrl) {
if (!apiEchoUrl) return;
try {
// A no-op message is enough to make the server evaluate the deadline server-side;
// the actual restore notice (if any) arrives for everyone via the Mercure broadcast.
await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message: '', ts: new Date().toISOString() },
});
} catch (e) {
console.error('[Game1] Failed to ping files-removal deadline:', e);
}
}
function scheduleFilesRemovalCheck(deadline, apiEchoUrl) {
if (!deadline || scheduledFilesRemovalDeadline === deadline) {
return; // nothing to (re)schedule
}
scheduledFilesRemovalDeadline = deadline;
if (filesRemovalTimer) {
clearTimeout(filesRemovalTimer);
filesRemovalTimer = null;
}
const delay = Math.max(0, deadline * 1000 - Date.now());
filesRemovalTimer = setTimeout(() => {
filesRemovalTimer = null;
scheduledFilesRemovalDeadline = null;
pingFilesRemovalDeadline(apiEchoUrl);
}, delay);
}
async function fetchJson(url, options = {}) { async function fetchJson(url, options = {}) {
const opts = { ...options }; const opts = { ...options };
const headers = new Headers(opts.headers || {}); const headers = new Headers(opts.headers || {});
@@ -262,11 +113,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// Look for config injected by Twig in the page // Look for config injected by Twig in the page
const cfgEl = document.getElementById('mercure-config'); const cfgEl = document.getElementById('mercure-config');
// Prevent/warn on page reload, except for our own win/lose redirects // Prevent/warn on page reload
window.addEventListener('beforeunload', (event) => { window.addEventListener('beforeunload', (event) => {
if (navigatingAway) {
return;
}
// Standard way to trigger the browser's confirmation dialog // Standard way to trigger the browser's confirmation dialog
event.preventDefault(); event.preventDefault();
// Included for compatibility with older browsers // Included for compatibility with older browsers
@@ -285,19 +133,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const apiEchoUrl = cfgEl.dataset.apiEchoUrl; const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl; const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
const lostUrl = cfgEl.dataset.lostUrl; const lostUrl = cfgEl.dataset.lostUrl;
const wonUrl = cfgEl.dataset.wonUrl;
const lockLockedAt = cfgEl.dataset.lockLockedAt;
const lockRevealAt = cfgEl.dataset.lockRevealAt;
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
const filesRemovalDeadline = cfgEl.dataset.filesRemovalDeadline;
// Resume the auto-restore timer after a page refresh, if a window is already running
if (filesRemovalDeadline) {
scheduleFilesRemovalCheck(parseInt(filesRemovalDeadline, 10), apiEchoUrl);
}
if (mercurePublicUrl && topic) { if (mercurePublicUrl && topic) {
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl); subscribeToMercure(mercurePublicUrl, topic, screen);
} else { } else {
console.warn('[Mercure][game1] Missing data attributes on #mercure-config'); console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
} }
@@ -319,7 +157,7 @@ document.addEventListener('DOMContentLoaded', async () => {
try { try {
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' }); const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
if (response && response.finished) { if (response && response.finished) {
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl); window.location.href = lostUrl;
return; // Stop the timer loop return; // Stop the timer loop
} }
} catch (e) { } catch (e) {
@@ -376,7 +214,7 @@ document.addEventListener('DOMContentLoaded', async () => {
let messages = [ let messages = [
['System initializing...', 500], ['System initializing...', 500],
['Connection established.', 200], ['Connection established.', 200],
['Welcome agent ' + screen + ' to the mainframe.', 1000], ['Welcome agent to the mainframe.', 1000],
['Scanning...', 3000], ['Scanning...', 3000],
['Virus detected.', 500], ['Virus detected.', 500],
['Starting Mainframe help modus...', 2000], ['Starting Mainframe help modus...', 2000],
@@ -398,7 +236,9 @@ document.addEventListener('DOMContentLoaded', async () => {
msgEl.className = 'message ' + extraClass; msgEl.className = 'message ' + extraClass;
msgEl.textContent = msg[0]; msgEl.textContent = msg[0];
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl); messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
playSound(); playSound();
@@ -415,17 +255,6 @@ document.addEventListener('DOMContentLoaded', async () => {
const inputField = document.getElementById('input-message'); const inputField = document.getElementById('input-message');
inputField.disabled = false; inputField.disabled = false;
let lastCommand = '';
// Recall the last submitted command on ArrowUp, cursor at the end
inputField.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
inputField.value = lastCommand;
inputField.setSelectionRange(inputField.value.length, inputField.value.length);
}
});
// Add event listener for Enter key // Add event listener for Enter key
inputField.addEventListener('keypress', async (e) => { inputField.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -436,10 +265,10 @@ document.addEventListener('DOMContentLoaded', async () => {
const msgEl = document.createElement('div'); const msgEl = document.createElement('div');
msgEl.className = 'message'; msgEl.className = 'message';
msgEl.textContent = message; msgEl.textContent = message;
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl); messageContainer.appendChild(msgEl);
if (message && apiEchoUrl) { if (message && apiEchoUrl) {
lastCommand = message;
inputField.value = ''; inputField.value = '';
try { try {
const response = await fetchJson(apiEchoUrl, { const response = await fetchJson(apiEchoUrl, {
@@ -448,27 +277,14 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
console.log('[API][game1] message sent →', response); console.log('[API][game1] message sent →', response);
if (response && response.result && Array.isArray(response.result.result)) { if (response && response.result && Array.isArray(response.result.result)) {
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType)); response.result.result.forEach(text => {
} const msgEl = document.createElement('div');
if (response && response.result) { msgEl.className = 'message';
if (response.result.gameWon === true && wonUrl) { msgEl.textContent = text;
goTo(wonUrl); msgEl.style.marginBottom = '10px';
return; messageContainer.appendChild(msgEl);
} });
window.scrollTo(0, document.body.scrollHeight);
if (response.result.locked === true) {
applyLock({
lockedAt: response.result.lockedAt,
revealAt: response.result.revealAt,
unlockAt: response.result.unlockAt,
}, apiEchoUrl, messageContainer);
} else if (response.result.locked === false) {
clearLock();
}
if (response.result.filesRemovalDeadline) {
scheduleFilesRemovalCheck(response.result.filesRemovalDeadline, apiEchoUrl);
}
} }
} catch (err) { } catch (err) {
console.error('[API][game1] Failed to send message:', err); console.error('[API][game1] Failed to send message:', err);
@@ -480,15 +296,6 @@ document.addEventListener('DOMContentLoaded', async () => {
console.log('[Game1] message-container height changed to 400vh and input enabled'); console.log('[Game1] message-container height changed to 400vh and input enabled');
sequenceFinished = true; sequenceFinished = true;
console.log('[Game1] sequenceFinished is now TRUE'); console.log('[Game1] sequenceFinished is now TRUE');
// Restore an in-progress lock after a page refresh
if (lockUnlockAt && parseInt(lockUnlockAt, 10) * 1000 > Date.now()) {
applyLock({
lockedAt: parseInt(lockLockedAt, 10),
revealAt: parseInt(lockRevealAt, 10),
unlockAt: parseInt(lockUnlockAt, 10),
}, apiEchoUrl, messageContainer);
}
}, 2000); }, 2000);
} }
}; };
@@ -1,8 +0,0 @@
ServerRoot "/etc/apache2"
Listen 80
User www-data
Group www-data
ErrorLog ${APACHE_LOG_DIR}/error.log
LogLevel warn
IncludeOptional mods-enabled/*.load
IncludeOptional sites-enabled/*.conf
@@ -1,3 +0,0 @@
deb http://deb.debian.org/debian bookworm main contrib non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main contrib non-free-firmware
deb http://security.debian.org/debian-security bookworm-security main contrib non-free-firmware
-8
View File
@@ -1,8 +0,0 @@
# /etc/crontab: system-wide crontab
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily
47 6 * * 7 root test -x /usr/sbin/anacron || run-parts --report /etc/cron.weekly
52 6 1 * * root test -x /usr/sbin/anacron || run-parts --report /etc/cron.monthly
-1
View File
@@ -1 +0,0 @@
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
-4
View File
@@ -1,4 +0,0 @@
# /etc/fstab: static file system information.
UUID=8f14e45f-ceea-4a63-9b3f-1a2b3c4d5e6f / ext4 errors=remount-ro 0 1
UUID=1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d /boot ext4 defaults 0 2
/swapfile none swap sw 0 0
-1
View File
@@ -1 +0,0 @@
archive-node-04
-6
View File
@@ -1,6 +0,0 @@
127.0.0.1 localhost
127.0.1.1 archive-node-04
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
10.0.0.4 archive-node-04.internal
-2
View File
@@ -1,2 +0,0 @@
Debian GNU/Linux 12 \n \l
-2
View File
@@ -1,2 +0,0 @@
Welcome to archive-node-04.
All connections are logged and monitored for internal review purposes.
@@ -1,10 +0,0 @@
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
auto eth0
iface eth0 inet static
address 10.0.0.4
netmask 255.255.255.0
gateway 10.0.0.1
@@ -1,14 +0,0 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 768;
}
http {
sendfile on;
keepalive_timeout 65;
include /etc/nginx/mime.types;
include /etc/nginx/sites-enabled/*;
}
@@ -1,9 +0,0 @@
passwd: files
group: files
shadow: files
hosts: files dns
networks: files
protocols: db files
services: db files
ethers: db files
rpc: db files
-8
View File
@@ -1,8 +0,0 @@
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
-11
View File
@@ -1,11 +0,0 @@
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
sshd:x:105:65534::/run/sshd:/usr/sbin/nologin
admin:x:1000:1000:admin,,,:/home/admin:/bin/bash
guest:x:1001:1001:guest,,,:/home/guest:/bin/bash
-3
View File
@@ -1,3 +0,0 @@
nameserver 1.1.1.1
nameserver 9.9.9.9
options edns0
-7
View File
@@ -1,7 +0,0 @@
root:$6$rounds=656000$xJ2kLQmZ$aFq9zN3vQwErTyUiOpAsDfGhJkLzXcVbNm1234567890abcdefgh:19700:0:99999:7:::
daemon:*:19700:0:99999:7:::
bin:*:19700:0:99999:7:::
sys:*:19700:0:99999:7:::
sshd:*:19700:0:99999:7:::
admin:$6$rounds=656000$k3PqR8tW$bGr0oPqLmNbVcXzAsDfGhJkLqWeRtYuIoP0987654321zyxwvu:19700:0:99999:7:::
guest:*:19700:0:99999:7:::
@@ -1,4 +0,0 @@
Host *
SendEnv LANG LC_*
HashKnownHosts yes
GSSAPIAuthentication yes
@@ -1,7 +0,0 @@
Port 22
PermitRootLogin no
PasswordAuthentication yes
PubkeyAuthentication yes
X11Forwarding no
PrintMotd no
Subsystem sftp /usr/lib/openssh/sftp-server
-1
View File
@@ -1 +0,0 @@
Europe/Amsterdam
@@ -1,8 +0,0 @@
app:
name: internal-archive-sync
version: 2.3.1
log_level: info
port: 8080
database:
driver: sqlite
path: /opt/app/data.db
@@ -1,5 +0,0 @@
apt update
apt upgrade -y
systemctl restart nginx
df -h
journalctl -xe
-10
View File
@@ -1,10 +0,0 @@
# ~/.bashrc: executed by bash for non-login shells
case $- in
*i*) ;;
*) return;;
esac
export PS1='\u@\h:\w\$ '
alias ll='ls -alF'
alias la='ls -A'
@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZ8pQxT2mN0vRkLwYb6cJhU3sEoAeKdVmXpZq7tRnBs admin@archive-node-04
@@ -1,2 +0,0 @@
update-alternatives 2026-05-30 03:10:04: link group editor updated to point to /usr/bin/vim.basic
update-alternatives 2026-05-30 03:10:04: link group pager updated to point to /usr/bin/less
-6
View File
@@ -1,6 +0,0 @@
Jun 12 09:41:55 archive-node-04 sshd[10233]: Accepted publickey for admin from 10.0.0.7 port 51422 ssh2
Jun 12 09:41:55 archive-node-04 sshd[10233]: pam_unix(sshd:session): session opened for user admin by (uid=0)
Jun 12 09:55:02 archive-node-04 sudo: admin : TTY=pts/0 ; PWD=/home/admin ; USER=root ; COMMAND=/usr/bin/apt update
Jun 12 10:12:40 archive-node-04 sshd[10233]: pam_unix(sshd:session): session closed for user admin
Jun 12 22:03:11 archive-node-04 sshd[15092]: Failed password for invalid user test from 203.0.113.44 port 39102 ssh2
Jun 12 22:03:14 archive-node-04 sshd[15092]: Connection closed by 203.0.113.44 port 39102 [preauth]
-4
View File
@@ -1,4 +0,0 @@
[ OK ] Started Network Manager.
[ OK ] Started OpenSSH server daemon.
[ OK ] Started Nginx HTTP server.
[ OK ] Reached target Multi-User System.
-2
View File
@@ -1,2 +0,0 @@
Jun 12 04:00:11 archive-node-04 CRON[9021]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily)
Jun 12 18:30:02 archive-node-04 CRON[15544]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.hourly)
@@ -1,2 +0,0 @@
Jun 12 03:00:05 archive-node-04 systemd-udevd[512]: Using default interface naming scheme 'v252'.
Jun 12 03:00:11 archive-node-04 dbus-daemon[601]: [system] Successfully activated service 'org.freedesktop.hostname1'
-4
View File
@@ -1,4 +0,0 @@
[ 0.000000] Linux version 6.1.0-21-amd64 (debian-kernel@lists.debian.org)
[ 0.004211] Command line: BOOT_IMAGE=/boot/vmlinuz-6.1.0-21-amd64 root=UUID=8f14e45f
[ 0.512033] ACPI: Core revision 20221020
[ 1.221004] usb 1-1: new high-speed USB device number 2
-4
View File
@@ -1,4 +0,0 @@
2026-05-30 03:10:02 startup archives unpack
2026-05-30 03:10:04 install curl:amd64 <none> 8.4.0-2
2026-05-30 03:10:05 status installed curl:amd64 8.4.0-2
2026-06-02 09:44:11 upgrade openssh-server:amd64 1:9.2p1-2 1:9.2p1-2+deb12u2
-4
View File
@@ -1,4 +0,0 @@
Jun 12 03:00:02 archive-node-04 kernel: [ 0.000000] Linux version 6.1.0-21-amd64
Jun 12 03:00:02 archive-node-04 kernel: [ 0.004211] Command line: BOOT_IMAGE=/boot/vmlinuz-6.1.0-21-amd64 root=UUID=8f14e45f
Jun 12 03:00:03 archive-node-04 kernel: [ 1.221004] usb 1-1: new high-speed USB device number 2
Jun 12 03:00:03 archive-node-04 kernel: [ 1.552210] eth0: link up, 1000Mbps, full-duplex
-2
View File
@@ -1,2 +0,0 @@
Jun 12 05:11:02 archive-node-04 postfix/qmgr[812]: 3F2A1C0021: removed
Jun 12 05:11:02 archive-node-04 postfix/smtp[9944]: 3F2A1C0021: to=<root@localhost>, status=sent
-8
View File
@@ -1,8 +0,0 @@
Jun 12 03:12:01 archive-node-04 systemd[1]: Starting Daily apt download activities...
Jun 12 03:12:04 archive-node-04 systemd[1]: apt-daily.service: Deactivated successfully.
Jun 12 04:00:11 archive-node-04 CRON[9021]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily)
Jun 12 06:25:00 archive-node-04 anacron[1122]: Job `cron.daily' terminated
Jun 12 09:41:55 archive-node-04 sshd[10233]: Accepted publickey for admin from 10.0.0.7 port 51422 ssh2
Jun 12 09:41:55 archive-node-04 sshd[10233]: pam_unix(sshd:session): session opened for user admin
Jun 12 12:03:19 archive-node-04 systemd[1]: Reloading nginx.service
Jun 12 18:30:02 archive-node-04 CRON[15544]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.hourly)
-4
View File
@@ -1,4 +0,0 @@
From cron@archive-node-04 Wed Jun 10 06:25:01 2026
Subject: Cron <root@archive-node-04> run-parts --report /etc/cron.daily
Daily housekeeping completed without errors.
-362
View File
@@ -1,362 +0,0 @@
/* Pregame (commandline tutorial) entry point built with Webpack Encore */
import './styles/pregame.css';
function appendMessage(container, text, extraClass = '') {
const el = document.createElement('div');
el.className = ('message ' + extraClass).trim();
el.textContent = text;
container.appendChild(el);
window.scrollTo(0, document.body.scrollHeight);
}
function dockBackButton(inputField) {
const backButton = document.getElementById('back-button');
const inputContainer = document.getElementById('input');
if (!backButton || !inputContainer) {
return;
}
inputField.style.display = 'none';
backButton.classList.add('docked');
inputContainer.appendChild(backButton);
}
document.addEventListener('DOMContentLoaded', () => {
console.log('Pregame bundle loaded');
const messageContainer = document.getElementById('message-container');
const inputField = document.getElementById('input-message');
if (!messageContainer || !inputField) {
return;
}
const bootMessages = [
['Booting tutorial terminal...', 500],
['Loading command line basics...', 800],
['"Welcome agent. Lets go through the basics together.', 2000],
['Let us start with the help command. Type help in the terminal (the input field at the bottom of the screen and press enter.', 500],
];
// Same command help text as GameResponseService::getHelpCommand(), minus "scan".
const helpMessages = [
'Help function:',
'',
'pwd',
' This message will let you know what your current location is.',
' It will show you the folder you are in so you can continue navigating the server.',
' USAGE: pwd',
'',
'cd',
' Use cd to move to a different directory.',
' You can go into a folder by using cd {foldername}, or a folder up by using "cd ..".',
' Using cd / moves you to the root directory.',
' USAGE: cd {directory}',
'',
'ls',
' To show all the files in the current directory, use ls.',
' This will print the full list of directories and files of the current location on your screen.',
' USAGE: ls',
'',
'cat',
' To read a file, use cat {filename}.',
' This will print the full content of the file on the screen.',
' USAGE: cat {filename}',
'',
'rm',
' Use rm to delete a file.',
' Be careful with this command. It can not be undone and we do not want to lose any valuable data.',
' USAGE: rm {filename}',
'',
'sudo',
' If you do not have enough rights to execute a command, you can use sudo to execute it as root.',
' This is only possible for verified users. To verify yourself, use the /verify command.',
' USAGE: sudo {command}',
'',
];
// Narrative shown after the help output finishes, introducing the next step.
// Each entry is [delayBeforeShowing, text, cssClass].
const helpFollowUp = [
[2000, 'Well done, you now have a complete overview of the terminal commands this game uses.', 'message-mainframe'],
[500, 'Not all commands will be available immediately. But lets go through them one by one.', 'message-mainframe'],
[1000, 'Lets start with the command pwd. Enter this in the terminal (input field) now and press enter.', 'message-mainframe'],
];
const pwdFollowUp = [
[1000, 'You now know the location you are in on the terminal. /var/home/agent.', 'message-mainframe'],
[500, 'You can change directories with the cd command.', 'message-mainframe'],
[500, "If you type 'cd ..' in the terminal, you will go one directory up to /var/home", 'message-mainframe'],
[500, 'Try this now.', 'message-mainframe'],
];
const cdFollowUp = [
[1000, 'Good. Please check the directory you are in now.', 'message-mainframe'],
];
const pwd2FollowUp = [
[1000, 'As you can see, you are now in the /var/home directory', 'message-mainframe'],
[500, 'You can move between directories like this.', 'message-mainframe'],
];
const wrongPwdMessage = "That is not the command which gives you the location you are in. You can check your current folder with the pwd command.";
const lsIntro = [
[1000, 'Now lets see what is actually in this directory. The ls command lists all files and folders in your current location.', 'message-mainframe'],
[500, 'Type ls now.', 'message-mainframe'],
];
const lsListing = [
'agent,dir',
'peter,dir',
'james,dir',
'william,dir',
'system,dir',
];
const lsFollowUp = [
[1000, 'You can see several folders here, including your own: agent.', 'message-mainframe'],
[500, 'Lets go back to your own directory. Use cd agent to enter it.', 'message-mainframe'],
[250, 'Remember, cd /var/home/agent also works to get there directly from anywhere on the server.', 'message-mainframe'],
];
const cdAgentFollowUp = [
[1000, 'Lets check if you now really are in your own home folder.', 'message-mainframe'],
];
const verifyAgentFollowUp = [
[1000, "Well done, you're right back where you started.", 'message-mainframe'],
[500, 'Now lets move to the next command: cat.', 'message-mainframe'],
[500, 'First, lets see what is inside this folder.', 'message-mainframe'],
[500, 'Type ls to check the content of /var/home/agent.', 'message-mainframe'],
];
const lsAgentListing = [
'HelloWorld.txt,file',
'notes.txt,file',
'schedule.txt,file',
'contacts.db,file',
'keycard.dat,file',
'backup.zip,file',
];
const lsAgentFinalListing = [
'notes.txt,file',
'schedule.txt,file',
'contacts.db,file',
'keycard.dat,file',
'backup.zip,file',
];
const lsAgentFollowUp = [
[1000, 'There it is: HelloWorld.txt, right there in the list.', 'message-mainframe'],
[500, 'Lets read what is inside. Type cat HelloWorld.txt to open the file.', 'message-mainframe'],
];
const helloWorldContent = [
'Day 1 at the agency. My mentor says curiosity keeps you sharp -- and alive.',
"- I keep a spare set of shoelaces in my desk. You'd be surprised how handy that is.",
'- The coffee machine on this floor is cursed. Never trust it before 9am.',
'- Jared Williams owes me a favor after the incident with the elevator. He should not have forgotten that.',
'',
'If you are reading this, agent: welcome to the team. Stay sharp out there.',
];
const catFollowUp = [
[1000, 'Nice work. cat is perfect for quickly reading any file on the server.', 'message-mainframe'],
];
const rmIntro = [
[1000, "There's one more command left in the basics: rm.", 'message-mainframe'],
[500, 'This command permanently deletes a file, so use it with caution.', 'message-mainframe'],
[500, 'Try removing HelloWorld.txt now. Type rm HelloWorld.txt.', 'message-mainframe'],
];
const rmDeniedMessages = [
'Permission denied: HelloWorld.txt is protected against removal.',
'You need sudo rights to remove this file.',
];
const rmFollowUp = [
[1000, 'sudo can technically be used for any command, at any moment.', 'message-mainframe'],
[500, 'But from a security standpoint, it is better not to use it unless it is truly necessary.', 'message-mainframe'],
[500, 'Only reach for sudo in extreme cases -- like this one, where a file is actively protected.', 'message-mainframe'],
[500, 'Try it now. Type sudo rm HelloWorld.txt.', 'message-mainframe'],
];
const sudoRmFollowUp = [
[1000, 'Well done. Since you used sudo, the protection was bypassed and the file was deleted.', 'message-mainframe'],
[500, 'Lets check the folder once more to confirm it is really gone. Type ls now.', 'message-mainframe'],
];
const tutorialEndFollowUp = [
[1000, 'As you can see, HelloWorld.txt is no longer in the list. It has been permanently removed.', 'message-mainframe'],
[500, 'That concludes the basics of the terminal, agent. You are ready for the real mission.', 'message-mainframe'],
[1000, 'Please be aware more commands can exist to help this mission succeed. These commands will show up in the help command. Good luck!', 'message-mainframe']
];
function playSequence(items, container, onDone) {
let i = 0;
const step = () => {
if (i >= items.length) {
if (onDone) onDone();
return;
}
const [delay, text, cls] = items[i];
i++;
setTimeout(() => {
appendMessage(container, text, cls);
step();
}, delay);
};
step();
}
// Tracks which command the tutorial is currently guiding the player towards.
let tutorialStage = 'help';
let currentMessageIndex = 0;
const printNextMessage = () => {
if (currentMessageIndex < bootMessages.length) {
const [text, delay] = bootMessages[currentMessageIndex];
appendMessage(messageContainer, text, 'message-mainframe');
currentMessageIndex++;
setTimeout(printNextMessage, delay);
return;
}
inputField.disabled = false;
inputField.focus();
inputField.addEventListener('keypress', (e) => {
if (e.key !== 'Enter') {
return;
}
const value = inputField.value.trim();
inputField.value = '';
if (!value) {
return;
}
appendMessage(messageContainer, value);
if (value.toLowerCase() === 'help' || value.toLowerCase() === '/help') {
// No extraClass -> default "message" color, same as the real game's help output.
helpMessages.forEach((line) => appendMessage(messageContainer, line));
if (tutorialStage === 'help') {
playSequence(helpFollowUp, messageContainer, () => {
tutorialStage = 'pwd';
});
}
return;
}
if (tutorialStage === 'pwd' && value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home/agent');
playSequence(pwdFollowUp, messageContainer, () => {
tutorialStage = 'cd';
});
return;
}
if (tutorialStage === 'cd' && value.toLowerCase() === 'cd ..') {
playSequence(cdFollowUp, messageContainer, () => {
tutorialStage = 'pwd2';
});
return;
}
if (tutorialStage === 'pwd2') {
if (value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home');
playSequence(pwd2FollowUp, messageContainer, () => {
playSequence(lsIntro, messageContainer, () => {
tutorialStage = 'ls';
});
});
} else {
appendMessage(messageContainer, wrongPwdMessage, 'message-hint');
}
return;
}
if (tutorialStage === 'ls' && value.toLowerCase() === 'ls') {
lsListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(lsFollowUp, messageContainer, () => {
tutorialStage = 'cd-agent';
});
return;
}
if (
tutorialStage === 'cd-agent' &&
(value.toLowerCase() === 'cd agent' || value.toLowerCase() === 'cd /var/home/agent')
) {
playSequence(cdAgentFollowUp, messageContainer, () => {
tutorialStage = 'verify-agent';
});
return;
}
if (tutorialStage === 'verify-agent' && value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home/agent');
playSequence(verifyAgentFollowUp, messageContainer, () => {
tutorialStage = 'ls-agent';
});
return;
}
if (tutorialStage === 'ls-agent' && value.toLowerCase() === 'ls') {
lsAgentListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(lsAgentFollowUp, messageContainer, () => {
tutorialStage = 'cat-file';
});
return;
}
if (tutorialStage === 'cat-file' && value.toLowerCase() === 'cat helloworld.txt') {
helloWorldContent.forEach((line) => appendMessage(messageContainer, line));
playSequence(catFollowUp, messageContainer, () => {
playSequence(rmIntro, messageContainer, () => {
tutorialStage = 'rm';
});
});
return;
}
if (tutorialStage === 'rm' && value.toLowerCase() === 'rm helloworld.txt') {
rmDeniedMessages.forEach((line) => appendMessage(messageContainer, line, 'message-hint'));
playSequence(rmFollowUp, messageContainer, () => {
tutorialStage = 'sudo-rm';
});
return;
}
if (tutorialStage === 'sudo-rm' && value.toLowerCase() === 'sudo rm helloworld.txt') {
appendMessage(messageContainer, 'File removed: HelloWorld.txt');
playSequence(sudoRmFollowUp, messageContainer, () => {
tutorialStage = 'ls-confirm';
});
return;
}
if (tutorialStage === 'ls-confirm' && value.toLowerCase() === 'ls') {
lsAgentFinalListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(tutorialEndFollowUp, messageContainer, () => {
tutorialStage = 'complete';
inputField.disabled = true;
dockBackButton(inputField);
});
return;
}
// Placeholder response until the rest of the tutorial content is defined.
appendMessage(messageContainer, 'Command not recognized yet.', 'message-hint');
});
};
printNextMessage();
});
+3
View File
@@ -0,0 +1,3 @@
body {
background-color: skyblue;
}
-66
View File
@@ -1,66 +0,0 @@
// Brand palette derived from public/images/logo.png (teal key/globe on dark navy)
$brand-teal: #2f8fae;
$brand-teal-dark: #1c5a70;
$brand-teal-light:#6fb8d1;
$brand-navy: #0d1f26;
$brand-navy-soft: #16323f;
$brand-bg: #f4f8f9;
// Bootstrap variable overrides (must come before the bootstrap import)
$primary: $brand-teal;
$secondary: $brand-navy-soft;
$dark: $brand-navy;
$body-bg: $brand-bg;
$body-color: $brand-navy-soft;
$link-color: $brand-teal;
$link-hover-color: $brand-teal-dark;
$border-radius: .5rem;
@import "bootstrap/scss/bootstrap";
@import "bootstrap-icons/font/bootstrap-icons.css";
:root {
--brand-teal: #{$brand-teal};
--brand-teal-dark: #{$brand-teal-dark};
--brand-navy: #{$brand-navy};
--brand-navy-soft: #{$brand-navy-soft};
--brand-bg: #{$brand-bg};
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.site-main {
flex: 1 0 auto;
}
.site-header {
background: linear-gradient(90deg, $brand-navy, $brand-navy-soft);
}
.site-header .navbar-brand {
display: flex;
align-items: center;
gap: .5rem;
font-weight: 700;
letter-spacing: .02em;
}
.site-header .navbar-brand img {
height: 36px;
width: auto;
}
.site-footer {
flex-shrink: 0;
background: $brand-navy;
color: rgba(255, 255, 255, .65);
}
.auth-card {
max-width: 440px;
margin: 3rem auto;
}
+3 -52
View File
@@ -47,61 +47,12 @@ div#message-container {
justify-content: flex-end; justify-content: flex-end;
min-height: calc(100vh - 100px); /* Fill most of the viewport initially */ min-height: calc(100vh - 100px); /* Fill most of the viewport initially */
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 20px;
} }
div.message { div.message {
color: #C0C0C0; color: #C0C0C0;
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.35;
margin-bottom: 2px;
}
div.message-virus {
color: #F00;
font-weight: bold;
}
div.message-mainframe {
color: #0F0;
}
div.message-hint {
color: #FF0;
font-weight: bold;
}
div#lock-banner {
position: fixed;
top: 68px;
left: 0;
width: 100%;
padding: 12px 20px;
background-color: #200;
border-top: 1px solid #F00;
border-bottom: 1px solid #F00;
color: #F00;
font-size: 18px;
font-weight: bold;
letter-spacing: 1px;
z-index: 99;
display: flex;
justify-content: space-between;
align-items: center;
animation: lock-banner-pulse 1s ease-in-out infinite;
}
@keyframes lock-banner-pulse {
0%, 100% {
background-color: #200;
}
50% {
background-color: #400;
}
}
body.locked div#message-container {
padding-top: 130px;
} }
div#input { div#input {
@@ -110,11 +61,11 @@ div#input {
input#input-message { input#input-message {
width: 100%; width: 100%;
padding: 6px 10px; padding: 10px;
background: #111; background: #111;
border: 1px solid #A00000; border: 1px solid #A00000;
color: #C0C0C0; color: #C0C0C0;
font-size: 14px; font-size: 18px;
box-sizing: border-box; box-sizing: border-box;
font-family: monospace; font-family: monospace;
} }
-100
View File
@@ -1,100 +0,0 @@
/* Styles for the pregame (commandline tutorial) terminal, mirroring game1's look */
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 1px;
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track {
background: #000;
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb {
background: #F00;
}
body {
background-color: #000;
min-height: 100vh;
font-family: monospace;
margin: 0;
scrollbar-width: thin;
scrollbar-color: #F00 #000;
}
div#message-container {
padding: 20px;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: calc(100vh - 80px);
box-sizing: border-box;
font-size: 14px;
}
div.message {
color: #C0C0C0;
white-space: pre-wrap;
line-height: 1.35;
margin-bottom: 2px;
}
div.message-mainframe {
color: #0F0;
}
div.message-virus {
color: #F00;
font-weight: bold;
}
div.message-hint {
color: #FF0;
font-weight: bold;
}
div#input {
padding: 20px;
}
input#input-message {
width: 100%;
padding: 6px 10px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 14px;
box-sizing: border-box;
font-family: monospace;
}
a#back-button {
position: fixed;
top: 16px;
left: 16px;
padding: 6px 14px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 13px;
font-family: monospace;
text-decoration: none;
z-index: 200;
}
a#back-button:hover {
background: #1a1a1a;
color: #FFF;
}
a#back-button.docked {
position: static;
display: block;
width: 100%;
padding: 6px 10px;
font-size: 14px;
text-align: center;
box-sizing: border-box;
}
+18 -19
View File
@@ -7,50 +7,49 @@
"php": ">=8.2", "php": ">=8.2",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"doctrine/dbal": "^4.4.4", "doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.18.3", "doctrine/doctrine-bundle": "^2.16",
"doctrine/doctrine-migrations-bundle": "^3.7", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.6.7", "doctrine/orm": "^3.5",
"karser/karser-recaptcha3-bundle": "^0.3.0", "karser/karser-recaptcha3-bundle": "^0.3.0",
"phpdocumentor/reflection-docblock": "^6.0.3", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3.2", "phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.4.*", "symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*", "symfony/asset-mapper": "7.4.*",
"symfony/brevo-mailer": "7.4.*",
"symfony/console": "7.4.*", "symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*", "symfony/doctrine-messenger": "7.4.*",
"symfony/dotenv": "7.4.*", "symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*", "symfony/expression-language": "7.4.*",
"symfony/flex": "^2.11", "symfony/flex": "^2",
"symfony/form": "7.4.*", "symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*", "symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*", "symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*", "symfony/intl": "7.4.*",
"symfony/mailer": "7.4.*", "symfony/mailer": "7.4.*",
"symfony/mailgun-mailer": "7.4.*", "symfony/mercure-bundle": "^0.3",
"symfony/mercure-bundle": "^0.5.0",
"symfony/mime": "7.4.*", "symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^4.0.2", "symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.4.*", "symfony/notifier": "7.4.*",
"symfony/process": "7.4.*", "symfony/process": "7.4.*",
"symfony/property-access": "7.4.*", "symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*", "symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*", "symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*", "symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*", "symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.36", "symfony/stimulus-bundle": "^2.30",
"symfony/string": "7.4.*", "symfony/string": "7.4.*",
"symfony/translation": "7.4.*", "symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*", "symfony/twig-bundle": "7.4.*",
"symfony/ux-turbo": "^2.36", "symfony/ux-turbo": "^2.30",
"symfony/validator": "7.4.*", "symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*", "symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.4.1", "symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.4.*", "symfony/yaml": "7.4.*",
"symfonycasts/reset-password-bundle": "^1.25", "symfonycasts/reset-password-bundle": "^1.24",
"symfonycasts/verify-email-bundle": "^1.18", "symfonycasts/verify-email-bundle": "^1.18",
"twig/extra-bundle": "^2.12|^3.24", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.28.0" "twig/twig": "^2.12|^3.0"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@@ -104,11 +103,11 @@
} }
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11.5.55", "phpunit/phpunit": "^11.5",
"symfony/browser-kit": "7.4.*", "symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*", "symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*", "symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.67", "symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.4.*", "symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*" "symfony/web-profiler-bundle": "7.4.*"
} }
Generated
+613 -677
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,9 +15,11 @@ doctrine:
#server_version: '16' #server_version: '16'
profiling_collect_backtrace: '%kernel.debug%' profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm: orm:
auto_generate_proxy_classes: true auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true auto_mapping: true
-6
View File
@@ -15,12 +15,6 @@ framework:
storage_factory_id: session.storage.factory.native storage_factory_id: session.storage.factory.native
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%' save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
rate_limiter:
invite_code_join:
policy: 'sliding_window'
limit: 10
interval: '1 minute'
when@prod: when@prod:
framework: framework:
session: session:
+1 -16
View File
@@ -47,14 +47,6 @@ when@prod:
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested: nested:
type: group
members: [nested_file, nested_stderr]
nested_file:
type: stream
path: "%kernel.logs_dir%/php/prod.log"
level: debug
formatter: monolog.formatter.json
nested_stderr:
type: stream type: stream
path: php://stderr path: php://stderr
level: debug level: debug
@@ -64,14 +56,7 @@ when@prod:
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine"] channels: ["!event", "!doctrine"]
deprecation: deprecation:
type: group type: stream
channels: [deprecation] channels: [deprecation]
members: [deprecation_file, deprecation_stderr]
deprecation_file:
type: stream
path: "%kernel.logs_dir%/php/deprecation.log"
formatter: monolog.formatter.json
deprecation_stderr:
type: stream
path: php://stderr path: php://stderr
formatter: monolog.formatter.json formatter: monolog.formatter.json
+13 -12
View File
@@ -1,12 +1,13 @@
framework: framework:
notifier: notifier:
chatter_transports: chatter_transports:
texter_transports:␍ texter_transports:
channel_policy: sendgrid: '%env(MAILER_DSN)%'
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo channel_policy:
urgent: ['email'] # use chat/slack, chat/telegram, sms/twilio or sms/nexmo
high: ['email'] urgent: ['email']
medium: ['email'] high: ['email']
low: ['email'] medium: ['email']
admin_recipients: low: ['email']
- { email: admin@example.com } admin_recipients:
- { email: admin@example.com }
-3
View File
@@ -22,9 +22,6 @@ security:
enable_csrf: true enable_csrf: true
username_parameter: username username_parameter: username
password_parameter: password password_parameter: password
login_throttling:
max_attempts: 5
interval: '15 minutes'
logout: logout:
path: app_logout path: app_logout
# where to redirect after logout # where to redirect after logout
-1
View File
@@ -1,5 +1,4 @@
twig: twig:
form_themes: ['bootstrap_5_layout.html.twig']
globals: globals:
mercure_public_url: '%env(MERCURE_PUBLIC_URL)%' mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'
mercure_topic_base: '%env(MERCURE_TOPIC_BASE)%' mercure_topic_base: '%env(MERCURE_TOPIC_BASE)%'
+10 -13
View File
@@ -1,9 +1,9 @@
# Email Delivery: Dev Mailcatcher & Production Mailgun # Email Delivery: Dev Mailcatcher & Production SendGrid
This application uses Symfony Mailer. We separate development and production delivery: This application uses Symfony Mailer. We separate development and production delivery:
- Development: Mailpit (mailcatcher) via SMTP in Docker. - Development: Mailpit (mailcatcher) via SMTP in Docker.
- Production: Mailgun via API transport. - Production: SendGrid via API transport.
## Development (Mailpit) ## Development (Mailpit)
@@ -22,27 +22,24 @@ MAILER_DSN=smtp://mailer:1025
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.
## Production (Mailgun) ## Production (SendGrid)
Use the Mailgun API transport (`symfony/mailgun-mailer` bridge). Do not commit secrets. Use the SendGrid API transport. Do not commit secrets.
- Example configuration is in `.env.prod`: - Example configuration is in `.env.prod`:
``` ```
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu MAILER_DSN=sendgrid+api://%env(resolve:SENDGRID_API_KEY)%@default
``` ```
- `region=eu` is only needed if the Mailgun account/domain was created in Mailgun's EU region (common for `.nl`/EU-based senders). Drop it (or use `region=us`) if the domain lives in the US region. - Provide `SENDGRID_API_KEY` via:
- Provide `MAILGUN_API_KEY` and `MAILGUN_DOMAIN` via: - Real environment variable on the server/container, or
- Real environment variables on the server/container, or - Symfony secrets: `php bin/console secrets:set SENDGRID_API_KEY` (and dump for prod), or
- Symfony secrets: `php bin/console secrets:set MAILGUN_API_KEY` (and dump for prod), or
- Orchestration secret stores (e.g., Docker/K8s). - Orchestration secret stores (e.g., Docker/K8s).
- The sending domain must be added and DNS-verified (SPF/DKIM/tracking CNAME) in the Mailgun dashboard before production sending will work reliably; unverified domains are rate-limited/sandboxed.
### Notes ### Notes
- No Mailpit container is defined in the base `compose.yaml`, only in `compose.override.yaml`. This ensures it is used in development only. - No Mailpit container is defined in the base `compose.yaml`, only in `compose.override.yaml`. This ensures it is used in development only.
- To test email locally without Docker, you can: - To test email locally without Docker, you can:
- Run Mailpit on your host (ports 1025/8025) and set `MAILER_DSN=smtp://127.0.0.1:1025` in `.env.local`. - Run Mailpit on your host (ports 1025/8025) and set `MAILER_DSN=smtp://127.0.0.1:1025` in `.env.local`.
- If you need to use Mailgun SMTP instead of API, a DSN example: - If you need to use SendGrid SMTP instead of API, a DSN example:
`mailgun+smtp://USERNAME:PASSWORD@default?region=eu` (username/password come from the Mailgun domain's SMTP credentials). `smtp://apikey:YOUR_SENDGRID_API_KEY@smtp.sendgrid.net:587`.
- Use `php bin/console app:mail:test you@example.com` to send a quick test email against whatever `MAILER_DSN` is currently configured.
-152
View File
@@ -1,152 +0,0 @@
# Test / staging environment (`test.escapepage.com`)
Runs a second copy of the full Docker stack on the same server as production,
behind the same Nginx Proxy Manager (NPM). Production is untouched: with no
`docker/.env` overrides, `compose.yaml` behaves exactly as before.
## How isolation works
`docker/compose.yaml` derives everything instance-specific from `docker/.env`:
| Concern | Mechanism | prod (no overrides) | test |
|---|---|---|---|
| Container names | `${STACK_NAME:-escapepage}-*` | `escapepage-php` … | `escapepage-test-php` … |
| Compose project (networks, labels) | `COMPOSE_PROJECT_NAME` | `docker` (unchanged) | `escapepage-test` |
| Published ports | `${NGINX_HTTP_PORT:-8080}` etc. | `0.0.0.0:8080/8443/3306/8090/8025` | `127.0.0.1:8081/8444/3307/8091/8026` |
| Database files | bind mount `../var/volumes/db` | per checkout | per checkout |
| Web reachability | `nginx` joined to external `nginx_default` | via NPM | via NPM |
`STACK_NAME` is a plain variable (not the Compose-managed `COMPOSE_PROJECT_NAME`),
so its `:-escapepage` default is reliable and **production needs no `docker/.env`
change** to keep its `escapepage-*` container names.
`nginx`, `mercure` and `mailer` all sit on the external `nginx_default` network,
so NPM forwards straight to `escapepage-test-nginx` / `-mercure` by name — no
public host port needed.
## Production checkout — optional
Nothing is required. Two optional tidy-ups, each needing a `docker compose up -d`
to recreate the affected container:
- The `nginx` service now also attaches to `nginx_default`. If you'd rather have
NPM forward to `escapepage-nginx` by name instead of `host:8080`, recreate it
and repoint the NPM proxy host. Otherwise the published `8080/8443` still work
as before and you can ignore this.
- Add `STACK_NAME=escapepage` and `COMPOSE_PROJECT_NAME=escapepage` to the
production `docker/.env` to move it off the implicit `docker` project name.
Cosmetic; do it only if you want the two stacks named symmetrically.
## Standing up the test stack
### 1. DNS
`test.escapepage.com` → server IP. (`mercure-test.escapepage.com` too if you want
live game features.) On Cloudflare, DNS-only ("grey cloud") keeps it low-profile.
### 2. Separate checkout
```bash
git clone <repo> /opt/escapepage-test
cd /opt/escapepage-test
git checkout <branch-to-test>
```
Use a **separate clone**, not `git worktree` — the Docker build context is the
checkout directory and full isolation avoids surprises.
### 3. Compose env
```bash
cp docker/.env.test.example docker/.env
# edit: real passwords, fresh MERCURE_JWT_SECRET (openssl rand -hex 32), reCAPTCHA keys
```
### 4. Symfony env overrides
Create `/opt/escapepage-test/.env.local` in the checkout (git-ignored):
```
APP_SECRET=<openssl rand -hex 16>
# Behind NPM the forwarded client IP lands from a Docker-private range; trust them
# all on staging so URL generation / HTTPS detection work.
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
```
`APP_ENV=prod`, `DATABASE_URL`, `SITE_BASE_URL`, `MERCURE_*` and `MAILER_DSN` are
already supplied to the containers by `docker/.env`.
### 5. Build & start
```bash
./docker/setup.test.sh
```
The test-specific script (not `setup.sh`): it refuses to run unless
`COMPOSE_PROJECT_NAME` in `docker/.env` is a non-prod value, scopes every compose
call to that project, runs the DB/cache/console steps as `www-data`, and repairs
`var/cache` / `var/log` / `var/sessions` ownership at the end (bare `docker exec`
runs as root, which otherwise leaves php-fpm unable to read its own cache — a
silent 500). It never touches `var/volumes/db`.
Builds `escapepage-test-*` images, starts the containers, creates + migrates
`escapepage_test`, builds assets. Re-run any time; `--no-build` skips the rebuild.
### 6. Nginx Proxy Manager — proxy host
- Domain: `test.escapepage.com`
- Forward to: `escapepage-test-nginx`, port **443**, scheme **https**
(the app nginx force-redirects 80→443 and serves a self-signed cert; leave
"Verify SSL" off — same arrangement as production)
- SSL: request a Let's Encrypt cert, Force SSL, HTTP/2
- Optional: add response header `X-Robots-Tag: noindex`
If you want live game screens, add a second proxy host
`mercure-test.escapepage.com` → `escapepage-test-mercure` port `80` (http).
### 7. Restrict access to your IP — NPM Access List
A host firewall on 80/443 can't help: prod and test share those ports on NPM.
Restrict at the proxy instead.
- **NPM → Access Lists → Add Access List**
- Name: e.g. `staging-allowlist`
- Authorisation: leave empty
- Access: `Allow <your.public.ip>` then a final `Deny all`
- Satisfy: **Any**
- Edit the `test.escapepage.com` proxy host → **Access List** → select it.
- Do the same on `mercure-test.escapepage.com` if you added it.
Everyone else gets `403` at the proxy. Update the IP in one place when it changes.
Defence in depth (optional): in `/opt/escapepage-test/docker/nginx/default.conf`,
inside the `server { listen 443 ... }` block:
```nginx
set_real_ip_from 172.16.0.0/12; # NPM's docker network
real_ip_header X-Forwarded-For;
allow <your.public.ip>;
deny all;
```
## Deploying a new version to test
```bash
cd /var/sites/escapepage-test
git fetch && git checkout <branch> && git pull
./docker/setup.test.sh --no-build # composer install, migrate, build assets, fix perms
```
`--no-build` skips the image rebuild; drop it if the Dockerfile changed.
## Restarting
```bash
./docker/restart.test.sh # down + up, keeps the DB and images
./docker/restart.test.sh --build # also rebuild images
./docker/restart.test.sh --fresh-db # also wipe var/volumes/db and re-init MySQL
```
## Safety notes
- Use the **`*.test.sh`** scripts on this checkout, not `setup.sh` / `restart.sh`.
Both refuse to run unless `docker/.env` names a non-prod
`COMPOSE_PROJECT_NAME`, and both scope every action to that project — they
can't reach the production stack.
- `restart.test.sh` **keeps the database** by default (you loaded prod data into
it); `--fresh-db` is the only thing that wipes it. It never runs a host-wide
`docker system prune` / `docker builder prune`, and it only repairs ownership
of `var/cache` / `var/log` / `var/sessions` — **never `var/volumes/db`**
(chowning the MySQL data dir is what corrupted it earlier this build).
- The test `docker/.env` uses its own `DB_NAME` and passwords so a config slip
can't reach the production database.
- `MAILER_DSN=smtp://mailer:1026` keeps staging mail inside Mailpit instead of
sending through Mailgun.
+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 ###
+9 -11
View File
@@ -7,25 +7,23 @@ APP_ENV=prod
SITE_BASE_URL=https://escapepage.com SITE_BASE_URL=https://escapepage.com
# Mailer # Mailer
MAILGUN_API_KEY=CHANGEME_MAILGUN_API_KEY MAILER_DSN=sendgrid://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@default
MAILGUN_DOMAIN=CHANGEME_MAILGUN_DOMAIN
MAILER_DSN=mailgun+api://CHANGEME_MAILGUN_API_KEY:CHANGEME_MAILGUN_DOMAIN@default?region=eu
MAILER_FROM=mailer@escapepage.nl MAILER_FROM=mailer@escapepage.nl
# Database # Database
DATABASE_URL=mysql://escapepage:CHANGEME_DB_PASSWORD@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4 DATABASE_URL=mysql://escapepage:Zr1aOYU5NpCbS3dhpxa64cZp@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4
DB_NAME=escapepage DB_NAME=escapepage
DB_USER=escapepage DB_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD MYSQL_ROOT_PASSWORD=root
# Mercure # Mercure
MERCURE_URL=http://mercure/.well-known/mercure MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure MERCURE_PUBLIC_URL=https://escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=CHANGEME_MERCURE_JWT_SECRET MERCURE_JWT_SECRET=55UtgFXsZu09TSTdeIA7ljK4HUo9DLkRzEB7MD5tqOLjRfAb
MERCURE_CORS_ALLOWED_ORIGINS=https://www.escapepage.com https://escapepage.com MERCURE_CORS_ALLOWED_ORIGINS=https://escapepage.com
MERCURE_TOPIC_BASE=https://escapepage.com MERCURE_TOPIC_BASE=https://escapepage.com
# Recaptcha # Recaptcha
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
-77
View File
@@ -1,77 +0,0 @@
# =============================================================================
# docker/.env for a TEST / STAGING stack (e.g. test.escapepage.com)
# =============================================================================
# Copy this to docker/.env inside the *test* checkout and fill in the blanks.
# docker/.env is git-ignored, so it never leaves the server.
#
# Compose reads this file automatically. Anything unique per stack is derived
# from COMPOSE_PROJECT_NAME + the *_PORT vars below, so the same compose.yaml
# serves both production and this copy.
#
# Two config layers, don't mix them up:
# - THIS file -> consumed by `docker compose` (container names, ports, and the
# runtime env it injects into the php containers).
# - <checkout>/.env(.local) -> consumed by Symfony itself (APP_SECRET,
# TRUSTED_PROXIES, messenger DSN, CLI database access, ...).
# =============================================================================
## --- Instance identity -------------------------------------------------------
# Both must be unique on the host.
# STACK_NAME -> prefix for every container_name (escapepage-test-php …)
# COMPOSE_PROJECT_NAME -> compose project: isolates networks, volumes and the
# labels that `docker compose down` / *.test.sh act on.
# Must be a non-prod value or setup.test.sh / restart.test.sh
# refuse to run.
STACK_NAME=escapepage-test
COMPOSE_PROJECT_NAME=escapepage-test
## --- Published host ports ---------------------------------------------------
# Bound to localhost only: the sole public entrypoint is Nginx Proxy Manager,
# which reaches the containers over the shared `nginx_default` network by name.
# Pick ports that don't clash with the production stack (8080/8443/3306/8090/8025/1025).
NGINX_HTTP_PORT=127.0.0.1:8081
NGINX_HTTPS_PORT=127.0.0.1:8444
DB_HOST_PORT=127.0.0.1:3307
MERCURE_HTTP_PORT=127.0.0.1:8091
MAILPIT_UI_PORT=127.0.0.1:8026
MAILPIT_SMTP_PORT=127.0.0.1:1026
## --- PHP image build ------------------------------------------------------
USER_ID=1000
GROUP_ID=1000
## --- Symfony runtime (injected into php / php-worker / php-cron) ------------
APP_ENV=prod
SITE_BASE_URL=https://test.escapepage.com
# Staging should NOT send real mail. Point at the bundled Mailpit and read it
# at http://127.0.0.1:8026 on the server (or via an NPM host if you expose it).
MAILER_DSN=smtp://mailer:1026
MAILER_FROM=mailer@test.escapepage.com
## --- Database -------------------------------------------------------------
# `database` is the compose *service* name and resolves inside this stack's
# own network - keep it as-is. Use its own name + fresh credentials so a mistake
# here can never point at the production database.
DB_NAME=escapepage_test
DB_USER=escapepage
DB_PASSWORD=CHANGE_ME_test_db_password
MYSQL_ROOT_PASSWORD=CHANGE_ME_test_root_password
DATABASE_URL=pdo_mysql://escapepage:CHANGE_ME_test_db_password@database:3306/escapepage_test?serverVersion=8.0.32&charset=utf8mb4
## --- Mercure -----------------------------------------------------------------
# Internal hub URL (service name, stays the same). Public URL + CORS must be the
# test domain. Add a `mercure-test.escapepage.com` proxy host in NPM ->
# <project>-mercure:80.
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure-test.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32
MERCURE_CORS_ALLOWED_ORIGINS="https://test.escapepage.com"
MERCURE_TOPIC_BASE=https://test.escapepage.com
## --- reCAPTCHA v3 ----------------------------------------------------------
# Register test.escapepage.com in the reCAPTCHA admin console and paste its keys,
# or leave the placeholders and expect the contact / "suggest a room" forms to
# fail captcha validation on staging.
RECAPTCHA3_KEY=CHANGE_ME_or_reuse_prod_if_domain_added
RECAPTCHA3_SECRET=CHANGE_ME_or_reuse_prod_if_domain_added
+2 -2
View File
@@ -17,8 +17,8 @@ services:
mailer: mailer:
image: axllent/mailpit image: axllent/mailpit
ports: ports:
- "${MAILPIT_SMTP_PORT:-1025}:1025" - "1025:1025"
- "${MAILPIT_UI_PORT:-8025}:8025" - "8025:8025"
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
+14 -58
View File
@@ -1,14 +1,5 @@
version: '3.7' version: '3.7'
# This stack can run more than once on the same host (e.g. production + a
# test.escapepage.com staging copy). Everything that must be unique per instance
# comes from docker/.env:
# STACK_NAME -> container name prefix (default: escapepage)
# COMPOSE_PROJECT_NAME -> compose project / network namespace
# NGINX_HTTP_PORT etc. -> published host ports
# With no docker/.env overrides it behaves exactly as before: containers
# escapepage-*, ports 8080/8443/3306/8090/8025.
services: services:
php: php:
build: build:
@@ -17,7 +8,7 @@ services:
args: args:
USER_ID: ${USER_ID} USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID} GROUP_ID: ${GROUP_ID}
container_name: ${STACK_NAME:-escapepage}-php container_name: escapepage-php
volumes: volumes:
- ../:/var/www/html:delegated - ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro - /etc/hosts:/etc/hosts:ro
@@ -49,7 +40,7 @@ services:
args: args:
USER_ID: ${USER_ID} USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID} GROUP_ID: ${GROUP_ID}
container_name: ${STACK_NAME:-escapepage}-php-worker container_name: escapepage-php-worker
volumes: volumes:
- ../:/var/www/html:delegated - ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro - /etc/hosts:/etc/hosts:ro
@@ -75,45 +66,12 @@ services:
# ipv4_address: 172.23.0.11 # ipv4_address: 172.23.0.11
restart: unless-stopped restart: unless-stopped
php-cron:
build:
context: ..
dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: ${STACK_NAME:-escapepage}-php-cron
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment:
APP_ENV: ${APP_ENV}
SITE_BASE_URL: ${SITE_BASE_URL}
MAILER_DSN: ${MAILER_DSN}
MAILER_FROM: ${MAILER_FROM}
DATABASE_URL: ${DATABASE_URL}
MERCURE_URL: ${MERCURE_URL}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
depends_on:
- database
- mercure
command: ["crond", "-f", "-l", "2"]
# networks:
# backend:
# ipv4_address: 172.23.0.16
restart: unless-stopped
nginx: nginx:
image: nginx:1.29.4-alpine image: nginx:1.29.4-alpine
container_name: ${STACK_NAME:-escapepage}-nginx container_name: escapepage-nginx
ports: ports:
- "${NGINX_HTTP_PORT:-8080}:80" - "8080:80"
- "${NGINX_HTTPS_PORT:-8443}:443" - "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
@@ -121,18 +79,16 @@ services:
- /etc/hosts:/etc/hosts:ro - /etc/hosts:/etc/hosts:ro
depends_on: depends_on:
- php - php
# Joined to the Nginx Proxy Manager network so NPM can forward straight to # networks:
# "<project>-nginx" without going back out to a published host port. # backend:
networks: # ipv4_address: 172.23.0.12
- default
- nginx_proxy
restart: unless-stopped restart: unless-stopped
mailer: mailer:
image: axllent/mailpit:latest image: axllent/mailpit:latest
container_name: ${STACK_NAME:-escapepage}-mailer container_name: escapepage-mailer
ports: ports:
- "${MAILPIT_UI_PORT:-8025}:8025" - "8025:8025"
volumes: volumes:
- /etc/hosts:/etc/hosts:ro - /etc/hosts:/etc/hosts:ro
networks: networks:
@@ -142,7 +98,7 @@ services:
mercure: mercure:
image: dunglas/mercure:v0.21 image: dunglas/mercure:v0.21
container_name: ${STACK_NAME:-escapepage}-mercure container_name: escapepage-mercure
environment: environment:
SERVER_NAME: "http://:80" SERVER_NAME: "http://:80"
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET} MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
@@ -154,7 +110,7 @@ services:
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS} publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
anonymous anonymous
ports: ports:
- "${MERCURE_HTTP_PORT:-8090}:80" - "8090:80"
volumes: volumes:
- /etc/hosts:/etc/hosts:ro - /etc/hosts:/etc/hosts:ro
networks: networks:
@@ -165,7 +121,7 @@ services:
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
database: database:
image: mysql:8.0 image: mysql:8.0
container_name: ${STACK_NAME:-escapepage}-db container_name: escapepage-db
environment: environment:
MYSQL_DATABASE: ${DB_NAME} MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER} MYSQL_USER: ${DB_USER}
@@ -184,7 +140,7 @@ services:
- /etc/hosts:/etc/hosts: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:
- "${DB_HOST_PORT:-3306}:3306" - "3306:3306"
# networks: # networks:
# backend: # backend:
# ipv4_address: 172.23.0.15 # ipv4_address: 172.23.0.15
+2 -13
View File
@@ -12,8 +12,7 @@ RUN apk add --no-cache \
make \ make \
nodejs \ nodejs \
npm \ npm \
shadow \ shadow
logrotate
# Install PHP extension installer # Install PHP extension installer
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
@@ -42,16 +41,6 @@ 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
# Cron daemon (BusyBox's built-in crond) for the php-cron container.
# Harmless for the php/php-worker containers too, since they never invoke crond.
COPY docker/php/crontab /etc/crontabs/root
RUN chmod 0600 /etc/crontabs/root
# Log rotation for the cron hint-check and PHP error logs: 25MB per file, kept for 3 months.
COPY docker/php/logrotate/cron-hints.conf /etc/logrotate.d/cron-hints
COPY docker/php/logrotate/php-logs.conf /etc/logrotate.d/php-logs
RUN chmod 0644 /etc/logrotate.d/cron-hints /etc/logrotate.d/php-logs
# Adjust www-data UID/GID to match host user (default 1000) # Adjust www-data UID/GID to match host user (default 1000)
ARG USER_ID=1000 ARG USER_ID=1000
ARG GROUP_ID=1000 ARG GROUP_ID=1000
@@ -67,7 +56,7 @@ RUN if [ ${USER_ID:-0} -ne 0 ] && [ ${GROUP_ID:-0} -ne 0 ]; then \
WORKDIR /var/www/html WORKDIR /var/www/html
# Set permissions for Symfony directories # Set permissions for Symfony directories
RUN mkdir -p var/cache var/log/cron var/log/php var/sessions && \ RUN mkdir -p var/cache var/log var/sessions && \
chown -R www-data:www-data var chown -R www-data:www-data var
# Default command # Default command
-2
View File
@@ -1,2 +0,0 @@
* * * * * php /var/www/html/bin/console app:hints:check >> /var/www/html/var/log/cron/cron.log 2>&1
5 3 * * * logrotate -s /var/www/html/var/log/.logrotate.state /etc/logrotate.d/cron-hints /etc/logrotate.d/php-logs
-11
View File
@@ -1,11 +0,0 @@
/var/www/html/var/log/cron/cron.log {
size 25M
rotate 100
maxage 90
missingok
notifempty
compress
delaycompress
dateext
dateformat -%Y%m%d-%s
}
-11
View File
@@ -1,11 +0,0 @@
/var/www/html/var/log/php/*.log {
size 25M
rotate 100
maxage 90
missingok
notifempty
compress
delaycompress
dateext
dateformat -%Y%m%d-%s
}
+1 -1
View File
@@ -9,6 +9,6 @@ opcache.validate_timestamps=1
opcache.revalidate_freq=0 opcache.revalidate_freq=0
log_errors=On log_errors=On
error_log=/var/www/html/var/log/php/error.log error_log=/var/www/html/var/log/errorlog_php.log
session.gc_maxlifetime=1440 session.gc_maxlifetime=1440
session.cookie_lifetime=0 session.cookie_lifetime=0
Executable → Regular
+11 -39
View File
@@ -1,55 +1,27 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Completely restart ONE project stack (prod or a test/staging copy). # Script to completely restart the project as requested
# Can be run from any directory. Everything is scoped to the Compose project # Can be run from any directory
# name so running this from the test checkout never touches the prod stack.
#
# ./docker/restart.sh # rebuild-less restart of this checkout's stack
# ./docker/restart.sh --prune-all # also run host-wide `docker system/builder prune`
# # (old behaviour; skip it when another stack shares the host)
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd) DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd) ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
PRUNE_ALL=0
for arg in "$@"; do
case "$arg" in
--prune-all) PRUNE_ALL=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
# Read the identifiers from docker/.env (same file Compose uses). STACK_NAME is
# the container-name prefix; COMPOSE_PROJECT_NAME is the compose project.
read_env() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"'" || true; }
STACK="$(read_env STACK_NAME)"; STACK="${STACK:-escapepage}"
PROJECT="$(read_env COMPOSE_PROJECT_NAME)"; PROJECT="${PROJECT:-$STACK}"
echo "Restarting stack: $STACK (compose project: $PROJECT)"
echo "Stopping and removing containers..." echo "Stopping and removing containers..."
(cd "$DOCKER_DIR" && docker compose -p "$PROJECT" -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true (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
# Belt-and-suspenders: drop anything still lingering for THIS stack only. echo "Clearing Docker build cache..."
docker rm -f \ docker builder prune -af
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
docker network rm "$net" || true
done
if [ "$PRUNE_ALL" -eq 1 ]; then
echo "Host-wide prune (containers, networks, build cache)..."
docker system prune -f || true
docker builder prune -af || true
else
echo "Skipping host-wide prune (pass --prune-all to force it)."
fi
echo "Setting permissions for var/volumes/db and var directories..." echo "Setting permissions for var/volumes/db and var directories..."
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
sudo chmod -R 777 "$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/cron" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/sessions" 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 chown -R 1000:1000 "$ROOT_DIR/var" || true
sudo chmod -R 777 "$ROOT_DIR/var" || true sudo chmod -R 777 "$ROOT_DIR/var" || true
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Restart the TEST (staging) stack. Scoped entirely to this checkout's compose
# project, so it can never touch the production stack. Unlike docker/restart.sh
# it:
# - keeps the database by default (you loaded prod data into it) — pass
# --fresh-db to wipe var/volumes/db and let MySQL re-initialise
# - never runs a host-wide `docker system/builder prune`
# - only repairs ownership of var/cache, var/log, var/sessions — NEVER
# var/volumes/db (that dir belongs to the mysql process; chowning it is
# what corrupts the data directory)
#
# Usage:
# ./docker/restart.test.sh # down + up (keeps DB and images)
# ./docker/restart.test.sh --build # also rebuild images
# ./docker/restart.test.sh --fresh-db # also wipe + re-initialise the database
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
if [ ! -f "$DOCKER_DIR/.env" ]; then
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env first." >&2
exit 1
fi
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
case "${PROJECT:-}" in
""|escapepage|docker)
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
echo "Refusing to run a test script against the production stack." >&2
exit 1 ;;
esac
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
exit 1
fi
FRESH_DB=0; BUILD=0
for arg in "$@"; do
case "$arg" in
--fresh-db) FRESH_DB=1 ;;
--build) BUILD=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
echo "Restarting test stack: $STACK (compose project: $PROJECT)"
echo "Stopping containers..."
dc down --remove-orphans || true
# scoped fallback — only this stack
docker rm -f \
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
docker network rm "$net" || true
done
if [ "$FRESH_DB" -eq 1 ]; then
echo "Wiping the test database (var/volumes/db)..."
sudo rm -rf "$ROOT_DIR/var/volumes/db"
fi
echo "Repairing var/ ownership (cache/log/sessions only)..."
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/sessions"
sudo chown -R 1000:1000 "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
sudo chmod -R u+rwX,g+rwX "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
echo "Bringing the stack back up..."
if [ "$BUILD" -eq 1 ]; then
"$DOCKER_DIR/setup.test.sh"
else
"$DOCKER_DIR/setup.test.sh" --no-build
fi
Executable → Regular
-4
View File
@@ -143,10 +143,6 @@ Common commands:
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-cron) # crond scheduler activity
tail -f "$ROOT_DIR/var/log/cron/cron.log" # hint-check command output
tail -f "$ROOT_DIR/var/log/php/error.log" # raw PHP errors
tail -f "$ROOT_DIR/var/log/php/prod.log" # Symfony app errors (prod only)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
-134
View File
@@ -1,134 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Bootstrap / re-provision a TEST (staging) stack — e.g. test.escapepage.com.
# Same job as docker/setup.sh, but:
# - refuses to run unless docker/.env marks this as a non-prod stack
# - scopes every compose call to COMPOSE_PROJECT_NAME
# - runs DB / cache / console steps as www-data and repairs var/ ownership at
# the end, so php-fpm (www-data) can actually read the compiled container
# (bare `docker exec` runs as root and would leave var/cache root-owned)
# - NEVER touches var/volumes/db (MySQL owns that)
#
# Usage:
# ./docker/setup.test.sh # full setup (build images)
# ./docker/setup.test.sh --no-build # skip image rebuild
# ./docker/setup.test.sh --recreate # force-recreate containers
# ./docker/setup.test.sh --down # stop and remove this stack's containers
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
# --- safety gate: never let a *.test.sh script act on the production stack ----
if [ ! -f "$DOCKER_DIR/.env" ]; then
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env and fill it in." >&2
exit 1
fi
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
case "${PROJECT:-}" in
""|escapepage|docker)
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
echo "Refusing to run a test script against the production stack." >&2
echo "Set COMPOSE_PROJECT_NAME and STACK_NAME to e.g. 'escapepage-test' in docker/.env." >&2
exit 1 ;;
esac
# --- compose command -------------------------------------------------------
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
exit 1
fi
command -v docker >/dev/null 2>&1 || { echo "Error: docker is required." >&2; exit 1; }
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
pexec() { dc exec -T php "$@"; } # as root (composer / npm)
pexecwww() { dc exec -T -u www-data php "$@"; } # as www-data (console / DB / cache)
REBUILD=1; RECREATE=0; DOWN_ONLY=0
for arg in "$@"; do
case "$arg" in
--no-build) REBUILD=0 ;;
--recreate) RECREATE=1 ;;
--down) DOWN_ONLY=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
echo "Test stack: $STACK (compose project: $PROJECT)"
if [ "$DOWN_ONLY" -eq 1 ]; then
dc down --remove-orphans
exit 0
fi
BUILD_ARGS=()
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
[ "$RECREATE" -eq 1 ] && BUILD_ARGS+=("--force-recreate")
dc up -d "${BUILD_ARGS[@]}"
# --- wait for the database ------------------------------------------------
printf "Waiting for database to be healthy..."
for i in $(seq 1 60); do
DB_ID=$(dc ps -q database 2>/dev/null || true)
if [ -n "$DB_ID" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$DB_ID" 2>/dev/null || true)" = "healthy" ]; then
echo " OK"; break
fi
printf "."; sleep 2
[ "$i" -eq 60 ] && echo -e "\nWarning: database not healthy yet, continuing anyway."
done
# --- dependencies (root: writes vendor/ and node_modules/) --------------
pexec composer install --no-interaction
# --- APP_SECRET: generate into .env.local if it's set nowhere -----------
if ! grep -qsE '^APP_SECRET=.+' "$ROOT_DIR/.env" "$ROOT_DIR/.env.local"; then
echo "Generating APP_SECRET in .env.local..."
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
fi
# --- database (www-data: keeps var/ writable by php-fpm) ---------------
echo "Creating database if it doesn't exist..."
pexecwww php bin/console doctrine:database:create --if-not-exists || {
echo "Error: database creation failed." >&2; dc logs database | tail -n 40; exit 1;
}
echo "Running migrations..."
pexecwww php bin/console doctrine:migrations:migrate -n || { echo "Error: migrations failed." >&2; exit 1; }
[ -f "$ROOT_DIR/importmap.php" ] && pexec php bin/console importmap:install || true
if [ -f "$ROOT_DIR/package.json" ]; then
echo "Installing npm dependencies..."; pexec npm ci || pexec npm install
echo "Building assets..."; pexec npm run build
fi
# --- repair var/ ownership for php-fpm (www-data), never var/volumes ----
echo "Fixing var/ ownership for php-fpm..."
pexec sh -lc 'mkdir -p var/cache var/log/php var/log/cron var/sessions && chown -R www-data:www-data var/cache var/log var/sessions'
pexecwww php bin/console cache:clear
NGINX_HTTPS="$(env_val NGINX_HTTPS_PORT)"
MAILPIT_UI="$(env_val MAILPIT_UI_PORT)"
cat <<EOT
Test stack '$PROJECT' is up.
NPM upstream: ${STACK}-nginx (scheme https, port 443, "Verify SSL" off)
Local check: curl -k https://${NGINX_HTTPS:-127.0.0.1:18443}/
Mailpit UI: http://${MAILPIT_UI:-127.0.0.1:18026}
Logs: docker logs -f ${STACK}-php
Shell: docker exec -it -u www-data ${STACK}-php sh
Restart: ./docker/restart.test.sh (add --fresh-db to wipe + re-init the DB)
Re-run this script any time. Use --no-build to skip the image rebuild.
EOT
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260704160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add marketing_opt_in column to user table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE `user` ADD marketing_opt_in TINYINT(1) NOT NULL DEFAULT 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE `user` DROP marketing_opt_in');
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260711210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add deleted_at and last_login_at to user table; cascade-delete email_log and reset_password_request rows';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE `user` ADD deleted_at DATETIME DEFAULT NULL, ADD last_login_at DATETIME DEFAULT NULL');
$this->addSql('ALTER TABLE email_log DROP FOREIGN KEY FK_6FB4883A76ED395');
$this->addSql('ALTER TABLE email_log ADD CONSTRAINT FK_6FB4883A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395');
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE email_log DROP FOREIGN KEY FK_6FB4883A76ED395');
$this->addSql('ALTER TABLE email_log ADD CONSTRAINT FK_6FB4883A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395');
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE `user` DROP deleted_at, DROP last_login_at');
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260810120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add lobby_message table for pre-game lobby chat';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE lobby_message (id INT AUTO_INCREMENT NOT NULL, session_id INT NOT NULL, player_id INT NOT NULL, content VARCHAR(500) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_LOBBY_MESSAGE_SESSION (session_id), INDEX IDX_LOBBY_MESSAGE_PLAYER (player_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_SESSION FOREIGN KEY (session_id) REFERENCES session (id)');
$this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_PLAYER FOREIGN KEY (player_id) REFERENCES player (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_SESSION');
$this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_PLAYER');
$this->addSql('DROP TABLE lobby_message');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260810130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add finished_at column to session table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE session ADD finished_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE session DROP finished_at');
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260810140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create hint table and seed it with the previously-hardcoded mainframe hints for every existing game';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE hint (id INT AUTO_INCREMENT NOT NULL, game_id INT NOT NULL, hint_condition VARCHAR(30) NOT NULL, threshold_seconds INT NOT NULL, message LONGTEXT NOT NULL, sort_order INT NOT NULL, INDEX IDX_HINT_GAME (game_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE hint ADD CONSTRAINT FK_HINT_GAME FOREIGN KEY (game_id) REFERENCES `game` (id) ON DELETE CASCADE');
// Seed every existing game with the hints that used to be hardcoded in
// SendMainframeHintsCommand, so behavior doesn't regress the moment the
// command switches to reading from this table.
$seed = [
['help_used', 120, 'Have you already checked which functions are available to you through the help command?', 10],
['broadcast_done', 300, 'You should establish communications with your fellow agents.', 20],
['contacted_all_privately', 420, 'The AI virus can see all communication if you are using open communication channels. Make sure you send private messages to all other agents as well, so I know you can.', 30],
['verified', 600, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', 40],
['verified', 780, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', 50],
['left_home_directory', 900, 'You can change the folder you are working in. Check the help command for more information', 60],
['all_decoded', 1020, 'You should go to the rapports directory to check out the rapports.', 70],
['all_decoded', 1140, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", 80],
['none', 1380, 'Have you checked out the help command to see what you can do?', 90],
['none', 1560, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', 100],
['none', 1680, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', 110],
];
foreach ($seed as [$condition, $threshold, $message, $sortOrder]) {
$this->addSql(
'INSERT INTO hint (game_id, hint_condition, threshold_seconds, message, sort_order) '
. 'SELECT id, ?, ?, ?, ? FROM `game`',
[$condition, $threshold, $message, $sortOrder]
);
}
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE hint DROP FOREIGN KEY FK_HINT_GAME');
$this->addSql('DROP TABLE hint');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create contact_message table for the public contact form';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contact_message (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE contact_message');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add read_at to contact_message so admin can track which messages have been read';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message ADD read_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message DROP read_at');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add deleted_at to contact_message for soft-deleting messages from the admin list';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message ADD deleted_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message DROP deleted_at');
}
}
-79
View File
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add escape_room and escape_room_review tables for the physical escape room map and reviews';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE escape_room (
id INT AUTO_INCREMENT NOT NULL,
submitted_by_id INT DEFAULT NULL,
name VARCHAR(255) NOT NULL,
venue VARCHAR(255) DEFAULT NULL,
street VARCHAR(255) DEFAULT NULL,
house_number VARCHAR(32) DEFAULT NULL,
postcode VARCHAR(32) DEFAULT NULL,
city VARCHAR(128) DEFAULT NULL,
country VARCHAR(2) DEFAULT NULL,
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
website VARCHAR(511) DEFAULT NULL,
phone VARCHAR(64) DEFAULT NULL,
source VARCHAR(16) NOT NULL,
osm_type VARCHAR(8) DEFAULT NULL,
osm_id BIGINT DEFAULT NULL,
status VARCHAR(16) NOT NULL,
locked TINYINT(1) DEFAULT 0 NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
INDEX idx_escape_room_submitted_by (submitted_by_id),
UNIQUE INDEX uniq_escape_room_osm (osm_type, osm_id),
INDEX idx_escape_room_status (status),
INDEX idx_escape_room_bbox (latitude, longitude),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE escape_room_review (
id INT AUTO_INCREMENT NOT NULL,
escape_room_id INT NOT NULL,
author_id INT DEFAULT NULL,
rating SMALLINT NOT NULL,
title VARCHAR(150) NOT NULL,
body LONGTEXT NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
INDEX idx_escape_room_review_room (escape_room_id),
INDEX idx_escape_room_review_author (author_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql('ALTER TABLE escape_room ADD CONSTRAINT fk_escape_room_submitted_by FOREIGN KEY (submitted_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_room FOREIGN KEY (escape_room_id) REFERENCES escape_room (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_author FOREIGN KEY (author_id) REFERENCES `user` (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_room');
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_author');
$this->addSql('ALTER TABLE escape_room DROP FOREIGN KEY fk_escape_room_submitted_by');
$this->addSql('DROP TABLE escape_room_review');
$this->addSql('DROP TABLE escape_room');
}
}
+4017 -1791
View File
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -12,24 +12,14 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.0", "@babel/core": "^7.25.0",
"@babel/preset-env": "^7.25.0", "@babel/preset-env": "^7.25.0",
"@symfony/webpack-encore": "^6.0.0", "@symfony/webpack-encore": "^4.6.1",
"babel-loader": "^10.1.1", "babel-loader": "^9.1.3",
"core-js": "^3.37.1", "core-js": "^3.37.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.2", "mini-css-extract-plugin": "^2.9.2",
"regenerator-runtime": "^0.14.1", "regenerator-runtime": "^0.14.1",
"sass": "^1.101.0",
"sass-loader": "^16.0.1",
"webpack": "^5.95.0", "webpack": "^5.95.0",
"webpack-cli": "^6.0.0", "webpack-cli": "^5.1.4",
"webpack-notifier": "^1.15.0" "webpack-notifier": "^1.15.0"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

-41
View File
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Tech\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:users:purge-deleted',
description: 'Permanently removes soft-deleted users who never played a game, 3 months after deletion.'
)]
final class PurgeDeletedUsersCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$deletedBefore = new \DateTimeImmutable('-3 months');
$users = $this->userRepository->findPurgeableDeletedUsers($deletedBefore);
foreach ($users as $user) {
$this->entityManager->remove($user);
}
$this->entityManager->flush();
$output->writeln(sprintf('<info>Purged %d deleted user(s).</info>', count($users)));
return Command::SUCCESS;
}
}
-273
View File
@@ -1,273 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Enum\GameSettingType;
use App\Game\Enum\HintCondition;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Event\PushMercureMessageEvent;
use App\Game\Repository\GameSettingRepository;
use App\Game\Repository\HintRepository;
use App\Game\Repository\SessionRepository;
use App\Game\Repository\SessionSettingRepository;
use App\Game\Service\PlayerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[AsCommand(
name: 'app:hints:check',
description: 'Checks running game sessions and sends mainframe hints to players who are behind schedule.'
)]
final class SendMainframeHintsCommand extends Command
{
private const DEFAULT_TOTAL_TIME = 3600;
public function __construct(
private readonly SessionRepository $sessionRepository,
private readonly SessionSettingRepository $sessionSettingRepository,
private readonly GameSettingRepository $gameSettingRepository,
private readonly HintRepository $hintRepository,
private readonly PlayerService $playerService,
private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$sessions = $this->sessionRepository->findBy(['status' => SessionStatus::PLAYING]);
$hintsSent = 0;
$sessionsFinished = 0;
foreach ($sessions as $session) {
if ($this->finishSessionIfTimeUp($session)) {
$sessionsFinished++;
continue;
}
$elapsed = $this->getElapsedPlayingSeconds($session);
if ($elapsed === null) {
continue;
}
foreach ($session->getPlayers() as $player) {
if ($this->sendNextHint($session, $player, $elapsed)) {
$hintsSent++;
}
}
}
$output->writeln(sprintf('<info>Checked %d running session(s), sent %d hint(s), finished %d session(s).</info>', count($sessions), $hintsSent, $sessionsFinished));
return Command::SUCCESS;
}
/**
* Catches sessions whose countdown ran out. Players' own browsers also poll for
* this independently the moment their local timer hits zero, but that's a
* per-player, best-effort check (background tabs get throttled and may lag) with
* no broadcast to the others. Running every minute, this is the reliable fallback
* that guarantees every player gets the Mercure "game_finished" push within a
* minute of the game actually ending, regardless of tab state.
*/
private function finishSessionIfTimeUp(Session $session): bool
{
$timer = $session->getTimer();
if ($timer === null || time() < $timer) {
return false;
}
$session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
['type' => 'game_finished', 'status' => 'lost'],
'game_finished',
));
return true;
}
/**
* Walks this game's admin-configured hints in order. For the first distinct
* condition that's still unresolved for this player, fires the most-escalated
* hint sharing that condition whose threshold has been reached, then stops -
* later conditions are only reached once every one before them resolves, since
* they genuinely depend on it (e.g. /verify isn't even usable until a player has
* finished contacting everyone).
*/
private function sendNextHint(Session $session, Player $player, int $elapsed): bool
{
$hints = $this->hintRepository->findByGameOrdered($session->getGame());
$seenConditions = [];
foreach ($hints as $hint) {
$condition = $hint->getCondition();
if (in_array($condition, $seenConditions, true)) {
continue; // already handled this condition's group further up the list
}
$seenConditions[] = $condition;
if ($condition !== HintCondition::NONE && $this->isConditionResolved($condition, $session, $player)) {
continue; // move on to the next distinct condition
}
$group = array_filter($hints, static fn ($h) => $h->getCondition() === $condition);
$best = null;
foreach ($group as $candidate) {
if ($elapsed < $candidate->getThresholdSeconds()) {
continue;
}
if ($best === null || $candidate->getThresholdSeconds() > $best->getThresholdSeconds()) {
$best = $candidate;
}
}
if ($best !== null) {
$this->publishHint($session, $best->getMessage(), $player);
return true;
}
return false; // condition unresolved, but no threshold reached yet
}
return false;
}
private function isConditionResolved(HintCondition $condition, Session $session, Player $player): bool
{
return match ($condition) {
HintCondition::HELP_USED => $this->hasUsedHelp($session, $player),
HintCondition::BROADCAST_DONE => $this->hasBroadcast($session, $player),
HintCondition::CONTACTED_ALL_PRIVATELY => $this->hasContactedEveryonePrivately($session, $player),
HintCondition::VERIFIED => $this->isFullyVerified($session, $player),
HintCondition::LEFT_HOME_DIRECTORY => !$this->isStillInHomeDirectory($player),
HintCondition::ALL_DECODED => $this->allMessagesDecoded($session, $player),
HintCondition::NONE => false,
};
}
private function getElapsedPlayingSeconds(Session $session): ?int
{
$timer = $session->getTimer();
if ($timer === null) {
return null;
}
$totalTimeSetting = $this->gameSettingRepository->getSetting($session->getGame(), GameSettingType::TOTAL_TIME);
$totalTime = $totalTimeSetting ? (int)$totalTimeSetting->getValue() : self::DEFAULT_TOTAL_TIME;
$startedAt = $timer - $totalTime;
return time() - $startedAt;
}
private function hasUsedHelp(Session $session, Player $player): bool
{
$settingName = SessionSettingType::tryFrom('HelpUsedForPlayer' . $player->getScreen());
if (!$settingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
return $setting !== null && $setting->getValue() === 'true';
}
private function getChatTracking(Session $session, Player $player): array
{
$settingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $player->getScreen());
if (!$settingName) {
return [];
}
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
return $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
}
private function hasBroadcast(Session $session, Player $player): bool
{
return in_array(0, $this->getChatTracking($session, $player), true);
}
private function hasContactedEveryonePrivately(Session $session, Player $player): bool
{
$tracking = $this->getChatTracking($session, $player);
foreach ($session->getPlayers() as $otherPlayer) {
$otherScreen = $otherPlayer->getScreen();
if ($otherScreen === $player->getScreen()) {
continue;
}
if ($otherScreen === null || !in_array($otherScreen, $tracking, true)) {
return false;
}
}
return true;
}
private function isFullyVerified(Session $session, Player $player): bool
{
$settingName = SessionSettingType::tryFrom('VerificationProgressForPlayer' . $player->getScreen());
if (!$settingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
$progress = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
return count($progress) >= $session->getGame()->getNumberOfPlayers() - 1;
}
private function isStillInHomeDirectory(Player $player): bool
{
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
return $pwd === '/var/home/' . $player->getUser()->getUsername();
}
/**
* True once all three agents have decoded their personal message - decoding
* player 1's grants 'sudo' to everyone, player 2's grants 'scan', player 3's
* grants 'rm', so checking for all three on any player reflects the whole team.
*/
private function allMessagesDecoded(Session $session, Player $player): bool
{
$settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen());
if (!$settingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
$rights = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
return in_array('sudo', $rights, true) && in_array('scan', $rights, true) && in_array('rm', $rights, true);
}
private function publishHint(Session $session, string $message, Player $player): void
{
$screen = $player->getScreen() ?? 0;
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
[$screen, $message, 'hint'],
'hint',
$screen,
));
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Mime\Email;
#[AsCommand( #[AsCommand(
name: 'app:mail:test', name: 'app:mail:test',
description: 'Sends a simple test email using the configured mail transport (Mailgun in prod).' description: 'Sends a simple test email using the configured mail transport (SendGrid in prod).'
)] )]
final class TestEmailCommand extends Command final class TestEmailCommand extends Command
{ {

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