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
139 changed files with 1134 additions and 4707 deletions
+16 -12
View File
@@ -11,15 +11,12 @@
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Copy this file to .env (and .env.dev / .env.prod / .env.test as needed) and
# fill in real values. Those files are gitignored and never committed.
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=CHANGEME_APP_SECRET
APP_SECRET=695679907f9c3818e6924d547f872651
TRUSTED_PROXIES=127.0.0.1,172.20.0.1,172.20.0.0/16
TRUSTED_HOSTS=^.*$
###< symfony/framework-bundle ###
@@ -36,11 +33,11 @@ DB_DRIVER=pdo_mysql
DB_SERVER_VERSION=8.0.32
DB_CHARSET=utf8mb4
DB_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
DB_HOST=database
DB_PORT=3306
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}"
###< doctrine/doctrine-bundle ###
@@ -53,13 +50,20 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ###
# Development: use Mailpit (docker compose override provides service `mailer` on port 1025)
MAILGUN_API_KEY=REPLACE_WITH_MAILGUN_API_KEY
MAILGUN_DOMAIN=REPLACE_WITH_MAILGUN_SENDING_DOMAIN
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu
BREVO_API_KEY=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
MAILER_DSN=brevo+api://${BREVO_API_KEY}@default
MAILER_FROM=mailer@escapepage.nl
# Production/Stage (uncomment and set SENDGRID_API_KEY in real env or secrets):
# MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%
# Alternatively, via SMTP (no extra package needed):
# MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"
# Optional default sender (used by test command if --from not passed):
###< symfony/mailer ###
###> symfony/sendgrid-mailer ###
# MAILER_DSN=sendgrid://KEY@default
###< symfony/sendgrid-mailer ###
###> mercure ###
# Internal hub URL used by the PHP app (reachable from the php container)
MERCURE_URL=http://mercure/.well-known/mercure
@@ -67,7 +71,7 @@ MERCURE_URL=http://mercure/.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.
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_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
# CORS allowed origins (default)
@@ -83,6 +87,6 @@ GROUP_ID=1000
###> karser/karser-recaptcha3-bundle ###
# Get your API key and secret from https://g.co/recaptcha/v3
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
###< karser/karser-recaptcha3-bundle ###
+25
View File
@@ -0,0 +1,25 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=620e9ce5f88a714b636179eb39d5be4f
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
DB_USER=escapepage
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
# Dev uses Mailpit (started via docker compose override)
MAILER_DSN=smtp://mailer:1025
###< symfony/mailer ###
###> mercure ###
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_CORS_ALLOWED_ORIGINS=http://localhost:8080
MERCURE_TOPIC_BASE=https://escapepage.dev
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
###< mercure ###
+39
View File
@@ -0,0 +1,39 @@
APP_ENV=prod
APP_SECRET=a8f89e179e8c338423697669d6728c2c
### Compiled or real environment variables should be used in production.
### Configure MAILER_DSN to use SendGrid API transport.
### Prefer storing SENDGRID_API_KEY using Symfony Secrets or real env vars.
###> symfony/mailer ###
BREVO_API_KEY=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
MAILER_DSN=brevo+api://${BREVO_API_KEY}@default
MAILER_FROM=mailer@escapepage.nl
###< symfony/mailer ###
###> symfony/framework-bundle ###
TRUSTED_PROXIES=127.0.0.1,172.20.0.1,172.20.0.0/16
TRUSTED_HOSTS=^.*$
###< symfony/framework-bundle ###
SITE_BASE_URL=https://escapepage.com
###> mercure ###
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=55UtgFXsZu09TSTdeIA7ljK4HUo9DLkRzEB7MD5tqOLjRfAb
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.qMVdzh7buYK78e-gwCQx7v6qCxk1Js83SAEKK-GZSrI
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.OCnRPXfCoke27ntAxby2R5jkgpTZdw83DPq1yhvkLbw
MERCURE_CORS_ALLOWED_ORIGINS="https://escapepage.com"
MERCURE_TOPIC_BASE=https://escapepage.com
###< mercure ###
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
DB_USER=escapepage
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
###> docker ###
USER_ID=1000
GROUP_ID=1000
###< docker ###
+18
View File
@@ -0,0 +1,18 @@
APP_ENV=test
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
###> mercure ###
MERCURE_CORS_ALLOWED_ORIGINS=http://localhost:8080
MERCURE_TOPIC_BASE=http://test
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
###< mercure ###
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage_test
DB_USER=escapepage
DB_PASSWORD="b.0nqrxJ/D*Luf9N"
-8
View File
@@ -1,9 +1,5 @@
###> symfony/framework-bundle ###
/.env
/.env.dev
/.env.prod
/.env.test
/.env.local
/.env.local.php
/.env.*.local
@@ -34,7 +30,3 @@ yarn-error.log
###< symfony/webpack-encore-bundle ###
/.idea
###> docker env ###
/docker/.env
###< docker env ###
+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`
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.
- SMTP DSN in `.env`: `MAILER_DSN=smtp://mailer:1025`
- Mailpit UI: http://localhost:8025 (or mapped port 8025)
- Send a test mail: `php bin/console app:mail:test you@example.com`
- Staging/Prod: use Mailgun.
- Require package (already in composer): `symfony/mailgun-mailer`.
- Staging/Prod: use SendGrid.
- Require package (already in composer): `symfony/sendgrid-mailer`.
- Set environment variables (do NOT commit secrets):
- `MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu`
- `MAILGUN_API_KEY=YOUR_REAL_KEY`
- `MAILGUN_DOMAIN=YOUR_SENDING_DOMAIN` (e.g. `mg.escapepage.nl`)
- `MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%`
- `SENDGRID_API_KEY=YOUR_REAL_KEY`
- Optional: `MAILER_FROM=no-reply@your-domain.tld`
- Drop `region=eu` (or use `region=us`) depending on which region your Mailgun domain was created in.
- Alternatively via SMTP (no extra package):
- `MAILER_DSN="mailgun+smtp://USERNAME:PASSWORD@default?region=eu"`
- `MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"`
Troubleshooting:
- If emails dont appear in dev, open Mailpit at http://localhost:8025 and verify messages.
- In prod, check logs for HTTP 2xx responses from 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
We use Webpack Encore to build and minify JS/CSS from the `assets/` directory into `public/build/`.
-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 -7
View File
@@ -1,10 +1,6 @@
/*
* Welcome to your app's main JavaScript file!
*/
import './styles/app.scss';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/alert';
import 'bootstrap/js/dist/dropdown';
import './game-waiting';
import './game-lobby';
import './admin-session-tabs';
import './styles/app.css';
console.log('This log comes from assets/app.js built by Webpack Encore! 🎉');
-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);
}
}
});
+20 -205
View File
@@ -3,14 +3,8 @@ import './styles/game1.css';
let sequenceFinished = false;
let stillPlayingSound = true;
let navigatingAway = false;
function goTo(url) {
navigatingAway = true;
window.location.href = url;
}
function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl) {
function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
try {
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
const es = new EventSource(url);
@@ -20,15 +14,7 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl)
const data = JSON.parse(event.data);
console.log('[Mercure][game1] Update:', data);
if (data && !Array.isArray(data) && data.type === 'game_finished') {
const destination = data.status === 'won' ? wonUrl : lostUrl;
if (destination) {
goTo(destination);
}
return;
}
// data is [sendTo, message, messageType?] - messageType defaults to 'mainframe' (green)
// data is [sendTo, message]
if (Array.isArray(data) && data.length >= 2) {
const sendTo = parseInt(data[0]);
// Filter: 0 means everyone, otherwise must match myScreen
@@ -39,7 +25,12 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl)
const messageContainer = document.getElementById('message-container');
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)
playSound();
@@ -86,148 +77,6 @@ function flashRed() {
}, 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));
window.scrollTo(0, document.body.scrollHeight);
}
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 = {}) {
const opts = { ...options };
const headers = new Headers(opts.headers || {});
@@ -264,11 +113,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// Look for config injected by Twig in the page
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) => {
if (navigatingAway) {
return;
}
// Standard way to trigger the browser's confirmation dialog
event.preventDefault();
// Included for compatibility with older browsers
@@ -287,19 +133,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
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) {
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl);
subscribeToMercure(mercurePublicUrl, topic, screen);
} else {
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
}
@@ -321,7 +157,7 @@ document.addEventListener('DOMContentLoaded', async () => {
try {
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
if (response && response.finished) {
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl);
window.location.href = lostUrl;
return; // Stop the timer loop
}
} catch (e) {
@@ -400,6 +236,7 @@ document.addEventListener('DOMContentLoaded', async () => {
msgEl.className = 'message ' + extraClass;
msgEl.textContent = msg[0];
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
@@ -428,6 +265,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = message;
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
if (message && apiEchoUrl) {
@@ -439,29 +277,15 @@ document.addEventListener('DOMContentLoaded', async () => {
});
console.log('[API][game1] message sent →', response);
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');
msgEl.className = 'message';
msgEl.textContent = text;
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
});
window.scrollTo(0, document.body.scrollHeight);
}
if (response && response.result) {
if (response.result.gameWon === true && wonUrl) {
goTo(wonUrl);
return;
}
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) {
console.error('[API][game1] Failed to send message:', err);
}
@@ -472,15 +296,6 @@ document.addEventListener('DOMContentLoaded', async () => {
console.log('[Game1] message-container height changed to 400vh and input enabled');
sequenceFinished = 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);
}
};
@@ -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.
+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;
min-height: calc(100vh - 100px); /* Fill most of the viewport initially */
box-sizing: border-box;
font-size: 14px;
font-size: 20px;
}
div.message {
color: #C0C0C0;
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 {
@@ -110,11 +61,11 @@ div#input {
input#input-message {
width: 100%;
padding: 6px 10px;
padding: 10px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 14px;
font-size: 18px;
box-sizing: border-box;
font-family: monospace;
}
+18 -19
View File
@@ -7,50 +7,49 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3.10.5",
"doctrine/doctrine-bundle": "^2.18.3",
"doctrine/doctrine-migrations-bundle": "^3.7",
"doctrine/orm": "^3.6.7",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.16",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.5",
"karser/karser-recaptcha3-bundle": "^0.3.0",
"phpdocumentor/reflection-docblock": "^5.6.7",
"phpstan/phpdoc-parser": "^2.3.2",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*",
"symfony/brevo-mailer": "7.4.*",
"symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2.11",
"symfony/flex": "^2",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mailgun-mailer": "7.4.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/mercure-bundle": "^0.3",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.11.2",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.36",
"symfony/stimulus-bundle": "^2.30",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/ux-turbo": "^2.36",
"symfony/ux-turbo": "^2.30",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.4.1",
"symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.4.*",
"symfonycasts/reset-password-bundle": "^1.25",
"symfonycasts/reset-password-bundle": "^1.24",
"symfonycasts/verify-email-bundle": "^1.18",
"twig/extra-bundle": "^2.12|^3.24",
"twig/twig": "^2.12|^3.28.0"
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
"config": {
"allow-plugins": {
@@ -104,11 +103,11 @@
}
},
"require-dev": {
"phpunit/phpunit": "^11.5.55",
"phpunit/phpunit": "^11.5",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.67",
"symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
}
Generated
+434 -494
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -15,12 +15,6 @@ framework:
storage_factory_id: session.storage.factory.native
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
rate_limiter:
invite_code_join:
policy: 'sliding_window'
limit: 10
interval: '1 minute'
when@prod:
framework:
session:
+1 -16
View File
@@ -47,14 +47,6 @@ when@prod:
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
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
path: php://stderr
level: debug
@@ -64,14 +56,7 @@ when@prod:
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: group
type: stream
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
formatter: monolog.formatter.json
+1
View File
@@ -2,6 +2,7 @@ framework:
notifier:
chatter_transports:
texter_transports:
sendgrid: '%env(MAILER_DSN)%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
-3
View File
@@ -22,9 +22,6 @@ security:
enable_csrf: true
username_parameter: username
password_parameter: password
login_throttling:
max_attempts: 5
interval: '15 minutes'
logout:
path: app_logout
# where to redirect after logout
-1
View File
@@ -1,5 +1,4 @@
twig:
form_themes: ['bootstrap_5_layout.html.twig']
globals:
mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'
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:
- Development: Mailpit (mailcatcher) via SMTP in Docker.
- Production: Mailgun via API transport.
- Production: SendGrid via API transport.
## Development (Mailpit)
@@ -22,27 +22,24 @@ MAILER_DSN=smtp://mailer:1025
2. Send an email from the app.
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`:
```
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 `MAILGUN_API_KEY` and `MAILGUN_DOMAIN` via:
- Real environment variables on the server/container, or
- Symfony secrets: `php bin/console secrets:set MAILGUN_API_KEY` (and dump for prod), or
- Provide `SENDGRID_API_KEY` via:
- Real environment variable on the server/container, or
- Symfony secrets: `php bin/console secrets:set SENDGRID_API_KEY` (and dump for prod), or
- Orchestration secret stores (e.g., Docker/K8s).
- The sending domain must be added and DNS-verified (SPF/DKIM/tracking CNAME) in the Mailgun dashboard before production sending will work reliably; unverified domains are rate-limited/sandboxed.
### Notes
- No Mailpit container is defined in the base `compose.yaml`, only in `compose.override.yaml`. This ensures it is used in development only.
- To test email locally without Docker, you can:
- Run Mailpit on your host (ports 1025/8025) and set `MAILER_DSN=smtp://127.0.0.1:1025` in `.env.local`.
- If you need to use Mailgun SMTP instead of API, a DSN example:
`mailgun+smtp://USERNAME:PASSWORD@default?region=eu` (username/password come from the Mailgun domain's SMTP credentials).
- Use `php bin/console app:mail:test you@example.com` to send a quick test email against whatever `MAILER_DSN` is currently configured.
- If you need to use SendGrid SMTP instead of API, a DSN example:
`smtp://apikey:YOUR_SENDGRID_API_KEY@smtp.sendgrid.net:587`.
+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
# Mailer
MAILGUN_API_KEY=CHANGEME_MAILGUN_API_KEY
MAILGUN_DOMAIN=CHANGEME_MAILGUN_DOMAIN
MAILER_DSN=mailgun+api://CHANGEME_MAILGUN_API_KEY:CHANGEME_MAILGUN_DOMAIN@default?region=eu
MAILER_DSN=sendgrid://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@default
MAILER_FROM=mailer@escapepage.nl
# 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_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
MYSQL_ROOT_PASSWORD=root
# Mercure
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=CHANGEME_MERCURE_JWT_SECRET
MERCURE_CORS_ALLOWED_ORIGINS=https://www.escapepage.com https://escapepage.com
MERCURE_PUBLIC_URL=https://escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=55UtgFXsZu09TSTdeIA7ljK4HUo9DLkRzEB7MD5tqOLjRfAb
MERCURE_CORS_ALLOWED_ORIGINS=https://escapepage.com
MERCURE_TOPIC_BASE=https://escapepage.com
# Recaptcha
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
-33
View File
@@ -66,39 +66,6 @@ services:
# ipv4_address: 172.23.0.11
restart: unless-stopped
php-cron:
build:
context: ..
dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_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:
image: nginx:1.29.4-alpine
container_name: escapepage-nginx
+2 -12
View File
@@ -12,8 +12,7 @@ RUN apk add --no-cache \
make \
nodejs \
npm \
shadow \
logrotate
shadow
# Install PHP extension installer
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
@@ -42,15 +41,6 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Configure PHP
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
# 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
# Adjust www-data UID/GID to match host user (default 1000)
ARG USER_ID=1000
ARG GROUP_ID=1000
@@ -66,7 +56,7 @@ RUN if [ ${USER_ID:-0} -ne 0 ] && [ ${GROUP_ID:-0} -ne 0 ]; then \
WORKDIR /var/www/html
# 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
# 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
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.cookie_lifetime=0
Executable → Regular
+2 -2
View File
@@ -12,7 +12,7 @@ echo "Stopping and removing containers..."
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 escapepage-php-cron || true
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker || true
docker system prune -f || true
echo "Clearing Docker build cache..."
@@ -21,7 +21,7 @@ docker builder prune -af
echo "Setting permissions for var/volumes/db and var directories..."
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/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 chmod -R 777 "$ROOT_DIR/var" || true
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 php)
(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 npm run watch)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
-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');
}
}
-494
View File
@@ -8,11 +8,6 @@
"name": "escapepage",
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@babel/preset-env": "^7.25.0",
@@ -22,8 +17,6 @@
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.2",
"regenerator-runtime": "^0.14.1",
"sass": "^1.101.0",
"sass-loader": "^14.2.1",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-notifier": "^1.15.0"
@@ -1750,340 +1743,6 @@
"node": ">=4"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -3225,41 +2884,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4094,17 +3718,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -5164,13 +4777,6 @@
"postcss": "^8.1.0"
}
},
"node_modules/immutable": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"dev": true,
"license": "MIT"
},
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -5948,14 +5554,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/node-forge": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
@@ -7263,98 +6861,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/sass": {
"version": "1.101.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz",
"integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-loader": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-14.2.1.tgz",
"integrity": "sha512-G0VcnMYU18a4N7VoNDegg2OuMjYtxnqzQWARVWCIVSZwJeiL9kg8QMsuIZOplsJgTzZLF6jGxI3AClj8I9nRdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"neo-async": "^2.6.2"
},
"engines": {
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"@rspack/core": "0.x || 1.x",
"node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"sass": "^1.3.0",
"sass-embedded": "*",
"webpack": "^5.0.0"
},
"peerDependenciesMeta": {
"@rspack/core": {
"optional": true
},
"node-sass": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/sass/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
-7
View File
@@ -18,15 +18,8 @@
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.2",
"regenerator-runtime": "^0.14.1",
"sass": "^1.101.0",
"sass-loader": "^14.2.1",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-notifier": "^1.15.0"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

-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;
}
}
-134
View File
@@ -1,134 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Game\Entity\Session;
use App\Game\Enum\GameSettingType;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameSettingRepository;
use App\Game\Repository\SessionRepository;
use App\Game\Repository\SessionSettingRepository;
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\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
#[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 HubInterface $hub,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$sessions = $this->sessionRepository->findBy(['status' => SessionStatus::PLAYING]);
$hintsSent = 0;
foreach ($sessions as $session) {
if ($this->checkContactHint($session)) {
$hintsSent++;
}
}
$output->writeln(sprintf('<info>Checked %d running session(s), sent %d hint(s).</info>', count($sessions), $hintsSent));
return Command::SUCCESS;
}
/**
* "Get in touch" hint: if 5 minutes into the game the players haven't messaged
* everyone (a private message to each other player, plus one broadcast), nudge them.
* Repeats every run of this command for as long as the condition still holds.
*/
private function checkContactHint(Session $session): bool
{
$elapsed = $this->getElapsedPlayingSeconds($session);
if ($elapsed === null || $elapsed < 300) {
return false;
}
if ($this->allPlayersHaveContactedEveryone($session)) {
return false;
}
$this->publishHint($session, 'Get in contact with your fellow agents to work together on defeating this AI virus.');
return true;
}
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 allPlayersHaveContactedEveryone(Session $session): bool
{
$players = $session->getPlayers();
$screens = [];
foreach ($players as $player) {
if ($player->getScreen() === null) {
return false;
}
$screens[] = $player->getScreen();
}
foreach ($players as $player) {
$screen = $player->getScreen();
$trackingSettingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $screen);
if (!$trackingSettingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $trackingSettingName, $player);
$tracking = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
if (!in_array(0, $tracking)) {
return false;
}
foreach ($screens as $otherScreen) {
if ($otherScreen !== $screen && !in_array($otherScreen, $tracking)) {
return false;
}
}
}
return true;
}
private function publishHint(Session $session, string $message): void
{
$topic = '/game/hub/' . $session->getId();
try {
$this->hub->publish(new Update($topic, json_encode([0, $message, 'hint'])));
} catch (\Exception $e) {
// Mercure might be down
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Mime\Email;
#[AsCommand(
name: 'app:mail:test',
description: 'Sends a simple test email using the configured mail transport (Mailgun in prod).'
description: 'Sends a simple test email using the configured mail transport (SendGrid in prod).'
)]
final class TestEmailCommand extends Command
{
+4 -5
View File
@@ -7,7 +7,6 @@ namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository;
use App\Game\Repository\LobbyMessageRepository;
use App\Game\Repository\SessionRepository;
use App\Tech\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -50,14 +49,15 @@ final class GameAdminController extends AbstractController
}
#[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])]
public function viewSession(Session $session, LobbyMessageRepository $lobbyMessageRepository): Response
public function viewSession(Session $session): Response
{
$playersLogs = [];
foreach ($session->getPlayers() as $player) {
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $player->getLogFileBasename() . '.txt';
$username = $player->getUser()->getUsername();
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
$playersLogs[] = [
'username' => $player->getUser()->getUsername(),
'username' => $username,
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
];
}
@@ -65,7 +65,6 @@ final class GameAdminController extends AbstractController
return $this->render('game/admin/sessions/view.html.twig', [
'session' => $session,
'playersLogs' => $playersLogs,
'lobbyMessages' => $lobbyMessageRepository->findForSession($session),
]);
}
}
@@ -35,7 +35,6 @@ final class GameAdminSessionController extends AbstractController
}
$session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$em->flush();
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
@@ -22,11 +22,8 @@ final class GameAdminUserController extends AbstractController
#[Route('', name: 'game_admin_users', methods: ['GET'])]
public function index(UserRepository $userRepository): Response
{
$users = $userRepository->findBy([], ['id' => 'ASC']);
return $this->render('game/admin/users/index.html.twig', [
'users' => $users,
'marketingOptInCount' => count(array_filter($users, static fn (User $user) => $user->isMarketingOptIn())),
'users' => $userRepository->findBy([], ['id' => 'ASC']),
]);
}
@@ -66,12 +63,11 @@ final class GameAdminUserController extends AbstractController
return $this->redirectToRoute('game_admin_users');
}
if (!$user->isDeleted()) {
$user->setDeletedAt(new \DateTimeImmutable());
$username = $user->getUsername();
$em->remove($user);
$em->flush();
}
$this->addFlash('success', sprintf('User "%s" deleted.', $user->getUsername()));
$this->addFlash('success', sprintf('User "%s" deleted.', $username));
return $this->redirectToRoute('game_admin_users');
}
@@ -43,7 +43,6 @@ final class GameApiController extends AbstractController
if ($session->getStatus() === SessionStatus::PLAYING) {
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
$session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session);
$this->entityManager->flush();
$isFinished = true;
+8 -117
View File
@@ -12,8 +12,8 @@ use App\Game\Repository\GameRepository;
use App\Game\Repository\PlayerRepository;
use App\Game\Repository\SessionRepository;
use App\Game\Service\GameDashboardService;
use App\Game\Service\GameResponseService;
use App\Tech\Entity\User;
use App\Game\Service\PlayerService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -22,8 +22,6 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
final class GameController extends AbstractController
{
@@ -41,20 +39,13 @@ final class GameController extends AbstractController
GameRepository $gameRepository,
SessionRepository $sessionRepository,
GameDashboardService $dashboardService,
Security $security,
#[Target('invite_code_join')]
RateLimiterFactoryInterface $inviteCodeJoinLimiter
Security $security
): Response {
$user = $security->getUser();
$isAdmin = $this->isGranted('ROLE_ADMIN');
if ($request->isMethod('POST')) {
if ($request->request->has('create_session')) {
if (!$this->isCsrfTokenValid('create_session', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
$gameId = $request->request->get('game_id');
$game = $gameRepository->find($gameId);
@@ -64,17 +55,6 @@ final class GameController extends AbstractController
}
}
} elseif ($request->request->has('join_session')) {
if (!$this->isCsrfTokenValid('join_session', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
$limiter = $inviteCodeJoinLimiter->create($user->getUserIdentifier());
if (!$limiter->consume(1)->isAccepted()) {
$this->addFlash('error', 'Too many attempts. Please wait a moment and try again.');
return $this->redirectToRoute('game_dashboard');
}
$inviteCode = $request->request->get('invite_code');
if ($dashboardService->joinSession($inviteCode, $user)) {
$this->addFlash('success', 'Joined session successfully!');
@@ -90,11 +70,6 @@ final class GameController extends AbstractController
return $this->redirectToRoute('game_dashboard');
}
if (!$this->isCsrfTokenValid('create_invite_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
$inviteCode = $dashboardService->generateInviteCode($session, $user, $isAdmin);
if ($inviteCode) {
$this->addFlash('success', 'Invite link created: ' . $inviteCode);
@@ -104,11 +79,6 @@ final class GameController extends AbstractController
$session = $sessionRepository->find($sessionId);
if ($session) {
if (!$this->isCsrfTokenValid('leave_session_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
if ($dashboardService->leaveSession($session, $user)) {
$this->addFlash('success', 'Left session successfully.');
} else {
@@ -120,11 +90,6 @@ final class GameController extends AbstractController
$session = $sessionRepository->find($sessionId);
if ($session) {
if (!$this->isCsrfTokenValid('start_session_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
if ($dashboardService->startSession($session)) {
$this->addFlash('success', 'Session started! Screens have been assigned.');
} else {
@@ -150,8 +115,7 @@ final class GameController extends AbstractController
Request $request,
Security $security,
PlayerRepository $playerRepository,
GameDashboardService $dashboardService,
GameResponseService $gameResponseService
GameDashboardService $dashboardService
): Response
{
$user = $security->getUser();
@@ -162,51 +126,13 @@ final class GameController extends AbstractController
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
if ($request->isMethod('POST') && $request->request->has('toggle_ready')) {
if (!$this->isCsrfTokenValid('toggle_ready_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
} elseif (!$user->isVerified()) {
$this->addFlash('error', 'You must verify your email address before you can mark yourself as ready.');
} else {
$dashboardService->toggleReady($session, $user);
}
return $this->redirectToRoute('game', ['session' => $session->getId()]);
}
if ($request->isMethod('POST') && $request->request->has('expire_ready')) {
if ($this->isCsrfTokenValid('expire_ready_' . $session->getId(), $request->request->get('_token'))) {
$dashboardService->expireOwnReadyIfDue($session, $user);
}
return $this->redirectToRoute('game', ['session' => $session->getId()]);
}
if ($request->isMethod('POST') && $request->request->has('send_message')) {
if ($this->isCsrfTokenValid('send_message_' . $session->getId(), $request->request->get('_token'))) {
$dashboardService->postLobbyMessage($session, $user, (string) $request->request->get('content', ''));
}
return $this->redirectToRoute('game', ['session' => $session->getId()]);
}
// Lazily pick up readiness changes from other players since our last request
// Periodically check readiness timeout
$dashboardService->checkAllPlayersReady($session);
if ($dashboardService->isLobbyChatOpen($session)) {
return $this->render('game/lobby.html.twig', [
'session' => $session,
'messages' => $dashboardService->getLobbyMessages($session),
'player' => $player,
'mercure_public_url' => $this->mercurePublicUrl,
]);
}
if ($session->getStatus() === SessionStatus::WON) {
return $this->redirectToRoute('game_won', ['session' => $session->getId()]);
}
if ($session->getStatus() === SessionStatus::LOST) {
return $this->redirectToRoute('game_lost', ['session' => $session->getId()]);
}
if ($session->getStatus() === SessionStatus::READY) {
$isReady = false;
$readyAt = null;
@@ -231,15 +157,11 @@ final class GameController extends AbstractController
$screen = $player ? $player->getScreen() : 0;
$session_id = $session->getId();
$lock = $player ? $gameResponseService->getPublicLockState($player) : null;
$filesRemovalDeadline = $gameResponseService->getPublicLockedFilesDeadline($session);
return $this->render('game/index.html.twig', [
'session' => $session,
'screen' => $screen,
'session_id' => $session_id,
'lock' => $lock,
'filesRemovalDeadline' => $filesRemovalDeadline,
]);
}
@@ -250,13 +172,14 @@ final class GameController extends AbstractController
Session $session,
Request $request,
Security $security,
PlayerRepository $playerRepository
PlayerService $playerService,
GameDashboardService $dashboardService
): Response {
/** @var User $user */
$user = $security->getUser();
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
$player = $playerService->GetCurrentlyActiveAsPlayer($user);
if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
if ($request->isMethod('POST')) {
$difficulty = $request->request->get('difficulty');
$entertaining = $request->request->get('entertaining');
$theme = $request->request->get('theme');
@@ -275,38 +198,6 @@ final class GameController extends AbstractController
]);
}
#[Route(path: '/won/{session}', name: 'game_won', methods: ['GET', 'POST'])]
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
#[IsGranted('SESSION_VIEW', subject: 'session')]
public function won(
Session $session,
Request $request,
Security $security,
PlayerRepository $playerRepository
): Response {
/** @var User $user */
$user = $security->getUser();
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
$difficulty = $request->request->get('difficulty');
$entertaining = $request->request->get('entertaining');
$theme = $request->request->get('theme');
$feedback = $request->request->get('feedback');
// Save feedback
if ($player) {
$this->saveFeedback($session, $player, $difficulty, $entertaining, $theme, $feedback);
$this->addFlash('success', 'Thank you for your feedback!');
return $this->redirectToRoute('game_dashboard');
}
}
return $this->render('game/won.html.twig', [
'session' => $session,
]);
}
private function saveFeedback(Session $session, Player $player, $difficulty, $entertaining, $theme, $feedback): void
{
$settings = [
-88
View File
@@ -1,88 +0,0 @@
<?php
namespace App\Game\Entity;
use App\Game\Repository\LobbyMessageRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: LobbyMessageRepository::class)]
#[ORM\Table(name: 'lobby_message')]
class LobbyMessage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Session::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Session $session = null;
#[ORM\ManyToOne(targetEntity: Player::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Player $player = null;
#[ORM\Column(length: 500)]
private ?string $content = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getSession(): ?Session
{
return $this->session;
}
public function setSession(?Session $session): static
{
$this->session = $session;
return $this;
}
public function getPlayer(): ?Player
{
return $this->player;
}
public function setPlayer(?Player $player): static
{
$this->player = $player;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}
-15
View File
@@ -66,19 +66,4 @@ class Player
return $this;
}
/**
* A filesystem-safe basename derived from the player's username, for use when
* building per-player log file paths. Usernames are validated to only contain
* safe characters at registration time, but this sanitizes defensively too, so
* a path segment can never traverse outside its intended directory regardless
* of what ends up stored on the user.
*/
public function getLogFileBasename(): string
{
$username = $this->user?->getUsername() ?? '';
$safe = preg_replace('/[^A-Za-z0-9_-]/', '_', $username);
return $safe !== null && $safe !== '' ? $safe : ('player-' . $this->id);
}
}
-15
View File
@@ -31,9 +31,6 @@ class Session
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $created = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $finishedAt = null;
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
private Collection $players;
@@ -100,18 +97,6 @@ class Session
return $this;
}
public function getFinishedAt(): ?\DateTimeInterface
{
return $this->finishedAt;
}
public function setFinishedAt(?\DateTimeInterface $finishedAt): static
{
$this->finishedAt = $finishedAt;
return $this;
}
/**
* @return Collection<int, Player>
*/
+3 -3
View File
@@ -4,7 +4,7 @@ namespace App\Game\Enum;
enum DecodeMessage: string
{
case PLAYER_1 = 'Sudo is now available';
case PLAYER_2 = 'AI virus protects its own files by replacing them';
case PLAYER_3 = 'The locked up bash files should be removed to lock it up';
case TEST = 'This is a test decoding message.';
case SECRET = 'The secret code is 42.';
case WELCOME = 'Welcome to the system, agent.';
}
-11
View File
@@ -74,15 +74,4 @@ enum SessionSettingType: string
case FEEDBACK_ENTERTAINING = 'FeedbackEntertaining';
case FEEDBACK_THEME = 'FeedbackTheme';
case FEEDBACK_TEXT = 'FeedbackText';
case LOCK_FOR_PLAYER1 = 'LockForPlayer1';
case LOCK_FOR_PLAYER2 = 'LockForPlayer2';
case LOCK_FOR_PLAYER3 = 'LockForPlayer3';
case LOCK_FOR_PLAYER4 = 'LockForPlayer4';
case LOCK_FOR_PLAYER5 = 'LockForPlayer5';
case LOCK_FOR_PLAYER6 = 'LockForPlayer6';
case LOCK_FOR_PLAYER7 = 'LockForPlayer7';
case LOCK_FOR_PLAYER8 = 'LockForPlayer8';
case LOCK_FOR_PLAYER9 = 'LockForPlayer9';
case LOCK_FOR_PLAYER10 = 'LockForPlayer10';
case LOCKED_FILES_REMOVAL_DEADLINE = 'LockedFilesRemovalDeadline';
}
-11
View File
@@ -9,15 +9,4 @@ enum SessionStatus: string
case PLAYING = 'playing';
case WON = 'won';
case LOST = 'lost';
public function label(): string
{
return match ($this) {
self::CREATED => 'Waiting for players',
self::READY => 'Waiting for players to be ready',
self::PLAYING => 'In progress',
self::WON => 'Completed - Won',
self::LOST => 'Completed - Lost',
};
}
}
@@ -1,33 +0,0 @@
<?php
namespace App\Game\Repository;
use App\Game\Entity\LobbyMessage;
use App\Game\Entity\Session;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<LobbyMessage>
*/
class LobbyMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, LobbyMessage::class);
}
/**
* @return LobbyMessage[]
*/
public function findForSession(Session $session, int $limit = 100): array
{
return $this->createQueryBuilder('m')
->andWhere('m.session = :session')
->setParameter('session', $session)
->orderBy('m.createdAt', 'ASC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}
+25 -170
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Game\Service;
use App\Game\Entity\Game;
use App\Game\Entity\LobbyMessage;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
@@ -12,7 +11,6 @@ use App\Game\Enum\GameStatus;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository;
use App\Game\Repository\LobbyMessageRepository;
use App\Game\Repository\SessionRepository;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
@@ -22,14 +20,9 @@ use Symfony\Component\Mercure\Update;
final class GameDashboardService
{
private const READY_TIMEOUT_SECONDS = 60;
private const LOBBY_MESSAGE_MAX_LENGTH = 500;
private const LOBBY_CHAT_GRACE_PERIOD_SECONDS = 3600;
public function __construct(
private readonly GameRepository $gameRepository,
private readonly SessionRepository $sessionRepository,
private readonly LobbyMessageRepository $lobbyMessageRepository,
private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub,
) {
@@ -127,8 +120,6 @@ final class GameDashboardService
$this->entityManager->flush();
$this->publishLobbyEvent($session, 'player_joined');
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
$this->startSession($session);
}
@@ -211,7 +202,7 @@ final class GameDashboardService
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($pwdSettingName);
$setting->setValue('/var/home/' . $player->getUser()->getUsername());
$setting->setValue('var/home/' . $player->getUser()->getUsername());
$this->entityManager->persist($setting);
}
@@ -290,93 +281,15 @@ final class GameDashboardService
$this->entityManager->persist($session);
$this->entityManager->flush();
$this->publishLobbyEvent($session, 'session_started');
return true;
}
/**
* @return LobbyMessage[]
*/
public function getLobbyMessages(Session $session): array
{
return $this->lobbyMessageRepository->findForSession($session);
}
/**
* The lobby chat is open while a session is still gathering players, and stays
* open for a grace period after the game ends so players can wrap up the
* conversation before it disappears.
*/
public function isLobbyChatOpen(Session $session): bool
{
if ($session->getStatus() === SessionStatus::CREATED) {
return true;
}
if (!in_array($session->getStatus(), [SessionStatus::WON, SessionStatus::LOST], true)) {
return false;
}
$finishedAt = $session->getFinishedAt();
if ($finishedAt === null) {
return false;
}
return (new \DateTime())->getTimestamp() - $finishedAt->getTimestamp() < self::LOBBY_CHAT_GRACE_PERIOD_SECONDS;
}
public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage
{
if (!$this->isLobbyChatOpen($session)) {
return null;
}
$player = null;
foreach ($session->getPlayers() as $sessionPlayer) {
if ($sessionPlayer->getUser() === $user) {
$player = $sessionPlayer;
break;
}
}
if (!$player) {
return null;
}
$content = trim($content);
if ($content === '') {
return null;
}
$content = mb_substr($content, 0, self::LOBBY_MESSAGE_MAX_LENGTH);
$message = new LobbyMessage();
$message->setSession($session);
$message->setPlayer($player);
$message->setContent($content);
$this->entityManager->persist($message);
$this->entityManager->flush();
$this->publishLobbyEvent($session, 'lobby_message', [
'username' => $user->getUserIdentifier(),
'content' => $content,
'createdAt' => $message->getCreatedAt()->format(DATE_ATOM),
]);
return $message;
}
public function toggleReady(Session $session, User $user): bool
{
if ($session->getStatus() !== SessionStatus::READY) {
return false;
}
if (!$user->isVerified()) {
return false;
}
$player = null;
foreach ($session->getPlayers() as $p) {
if ($p->getUser() === $user) {
@@ -396,12 +309,11 @@ final class GameDashboardService
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
$existingSetting = $settingRepo->getSetting($session, $settingName, $player);
$nowReady = $existingSetting === null;
$setting = $settingRepo->getSetting($session, $settingName, $player);
if ($existingSetting) {
$session->removeSetting($existingSetting);
$this->entityManager->remove($existingSetting);
if ($setting) {
$session->removeSetting($setting);
$this->entityManager->remove($setting);
} else {
$setting = new SessionSetting();
$setting->setSession($session);
@@ -414,78 +326,14 @@ final class GameDashboardService
$this->checkAllPlayersReady($session);
$this->entityManager->flush();
// If this toggle just made everyone ready, checkAllPlayersReady() already
// transitioned the session out of READY and published 'all_ready' —
// don't also publish a redundant 'player_ready'.
if ($session->getStatus() === SessionStatus::READY) {
$this->publishPlayerReady($session, $player->getScreen(), $nowReady);
}
return true;
}
/**
* Explicitly (and idempotently) expires the current user's own ready status once
* their 60-second window is genuinely up. Called by the ready player's own browser
* on a timer, so the "not ready" broadcast goes out as close to the deadline as
* possible instead of waiting for someone else's request to lazily discover it.
*/
public function expireOwnReadyIfDue(Session $session, User $user): void
{
if ($session->getStatus() !== SessionStatus::READY) {
return;
}
$player = null;
foreach ($session->getPlayers() as $p) {
if ($p->getUser() === $user) {
$player = $p;
break;
}
}
if (!$player) {
return;
}
$settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen());
if (!$settingName) {
return;
}
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
$setting = $settingRepo->getSetting($session, $settingName, $player);
if (!$setting) {
return; // Already not ready
}
$readyAtTimestamp = (int)$setting->getValue();
if ((new \DateTime())->getTimestamp() - $readyAtTimestamp < self::READY_TIMEOUT_SECONDS) {
return; // Not actually due yet
}
$session->removeSetting($setting);
$this->entityManager->remove($setting);
$this->entityManager->flush();
$this->publishPlayerReady($session, $player->getScreen(), false);
}
private function publishPlayerReady(Session $session, int $screen, bool $ready): void
{
$this->publishLobbyEvent($session, 'player_ready', ['player' => $screen, 'ready' => $ready]);
}
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
{
try {
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra))));
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting])));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
}
return true;
}
public function checkAllPlayersReady(Session $session): void
@@ -503,6 +351,7 @@ final class GameDashboardService
$readyPlayersCount = 0;
$now = new \DateTime();
$anyReset = false;
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
@@ -514,21 +363,27 @@ final class GameDashboardService
}
$setting = $settingRepo->getSetting($session, $settingName, $player);
if (!$setting) {
continue;
}
if ($setting) {
$readyAtTimestamp = (int)$setting->getValue();
if (($now->getTimestamp() - $readyAtTimestamp) >= self::READY_TIMEOUT_SECONDS) {
// Check timeout: 1 minute = 60 seconds
if (($now->getTimestamp() - $readyAtTimestamp) > 60) {
$session->removeSetting($setting);
$this->entityManager->remove($setting);
$this->entityManager->flush();
$this->publishPlayerReady($session, $player->getScreen(), false);
continue;
$anyReset = true;
} else {
$readyPlayersCount++;
}
}
}
$readyPlayersCount++;
if ($anyReset) {
$this->entityManager->flush();
try {
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready'])));
} catch (\Exception $e) {
// Mercure might be down
}
}
if ($readyPlayersCount === $numPlayers) {
+15 -583
View File
@@ -4,9 +4,7 @@ namespace App\Game\Service;
use App\Game\Enum\DecodeMessage;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Repository\SessionSettingRepository;
use App\Tech\Entity\User;
@@ -17,10 +15,6 @@ use Symfony\Component\Mercure\Update;
class GameResponseService
{
private const LOCK_REVEAL_AFTER_SECONDS = 30;
private const LOCK_DURATION_SECONDS = 45;
private const LOCK_PASSCODE_LENGTH = 12;
public function __construct(
private Security $security,
private PlayerService $playerService,
@@ -31,25 +25,6 @@ class GameResponseService
) {
}
/**
* Public, code-free view of a player's current lock state, safe to expose on page load
* (e.g. so the UI can restore the lock banner/countdown after a refresh).
*/
public function getPublicLockState(Player $player): ?array
{
$lock = $this->getLockState($player);
if ($lock === null || time() >= $lock['unlockAt']) {
return null;
}
return [
'lockedAt' => $lock['lockedAt'],
'revealAt' => $lock['revealAt'],
'unlockAt' => $lock['unlockAt'],
];
}
public function getGameResponse(string $raw) : array
{
$info = json_decode($raw, true);
@@ -70,19 +45,15 @@ class GameResponseService
if(!$player)
return ['error' => 'You are not in a game.'];
$this->enforceLockedFilesRemovalDeadline($player->getSession());
$this->logSessionActivity($player, 'PLAYER: ' . $message);
$data = $this->handleLockedPlayer($message, $player);
$data = [];
if ($data === null) {
if(str_starts_with($message, '/')) {
$data = $this->checkGameCommando($message, $player);
} else {
$data = $this->checkConsoleCommando($message, $player);
}
}
$responseLog = '';
if (isset($data['result']) && is_array($data['result'])) {
@@ -107,13 +78,14 @@ class GameResponseService
private function logSessionActivity(Player $player, string $content): void
{
$sessionId = $player->getSession()->getId();
$username = $player->getUser()->getUsername();
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
if (!is_dir($logDir)) {
mkdir($logDir, 0777, true);
}
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
$logFile = $logDir . '/' . $username . '.txt';
$timestamp = date('Y-m-d H:i:s');
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
@@ -230,33 +202,7 @@ class GameResponseService
return ['result' => ['You are not allowed to remove this file.']];
$this->playerService->addDeletedFileToSession($player, $fullPath);
if ($this->checkFilesRemovalWin($player)) {
return [
'result' => [
'File removed: ' . $filename,
'MAINFRAME: All protected files purged. The AI virus has been contained. Well done, agents.',
],
'messageType' => 'mainframe',
'gameWon' => true,
];
}
$filesRemovalDeadline = $this->startLockedFilesRemovalDeadline($player->getSession(), $fullPath);
$lock = $this->triggerLock($player);
return [
'result' => [
'File removed: ' . $filename,
'AI VIRUS: Intrusion detected. Locking down your terminal...',
],
'messageType' => 'virus',
'locked' => true,
'lockedAt' => $lock['lockedAt'],
'revealAt' => $lock['revealAt'],
'unlockAt' => $lock['unlockAt'],
'filesRemovalDeadline' => $filesRemovalDeadline,
];
return ['result' => ['File removed: ' . $filename]];
case 'sudo':
if(!in_array('sudo', $rechten))
return ['result' => ['Unknown command']];
@@ -265,17 +211,6 @@ class GameResponseService
$message = implode(' ', $messagePart);
return $this->checkConsoleCommando($message, $player, true);
case 'scan':
if(!in_array('scan', $rechten))
return ['result' => ['Unknown command']];
$result = ['Running quarantine scan...', 'Locked files detected:'];
foreach ($this->getLockedFiles() as $lockedFile) {
$result[] = ' ' . $lockedFile;
}
$result[] = 'These files are protected by the AI virus. Use sudo rm {file} to remove them.';
return ['result' => $result];
default:
return ['result' => ['Unknown command']];
}
@@ -352,12 +287,6 @@ class GameResponseService
$messages[] = ' USAGE: sudo {command}';
$messages[] = '';
break;
case 'scan':
$messages[] = 'scan';
$messages[] = ' Runs a quarantine scan that reveals which files are locked by the AI virus.';
$messages[] = ' USAGE: scan';
$messages[] = '';
break;
case 'verify':
$messages[] = '/verify';
$messages[] = ' You can verify yourself by using this command.';
@@ -541,213 +470,24 @@ class GameResponseService
}
}
private function handleDecodeMessage(string $message, Player $player): string
private function handleDecodeMessage(string $message, Player $player)
{
$userNumber = $player->getScreen();
preg_match('/\d/', $message, $matches);
preg_match('/\d+/', $message, $matches);
$num = $matches[0] ?? null;
$randomString = $this->generateRandomString(250, 500);
if (is_null($num) || (int)$num !== $userNumber) {
if(is_null($num) || $num != $userNumber)
return $randomString;
}
$decodeMessage = match ($userNumber) {
1 => DecodeMessage::PLAYER_1,
2 => DecodeMessage::PLAYER_2,
3 => DecodeMessage::PLAYER_3,
default => null,
};
if ($decodeMessage === null) {
return $randomString;
}
if ($decodeMessage === DecodeMessage::PLAYER_1) {
$this->grantRightToAllPlayers($player->getSession(), 'sudo');
$this->grantRightToAllPlayers($player->getSession(), 'rm');
}
if ($decodeMessage === DecodeMessage::PLAYER_2) {
$this->grantRightToAllPlayers($player->getSession(), 'scan');
}
foreach (DecodeMessage::cases() as $decodeMessage) {
if ($decodeMessage->name === $message) {
return $decodeMessage->value;
}
private function grantRightToAllPlayers(Session $session, string $right): void
{
$updated = false;
foreach ($session->getPlayers() as $sessionPlayer) {
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $sessionPlayer->getScreen());
if (!$rightsSettingName) {
continue;
}
$setting = $this->sessionSettingRepository->getSetting($session, $rightsSettingName, $sessionPlayer);
if (!$setting) {
continue;
}
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
if (!in_array($right, $rights)) {
$rights[] = $right;
$setting->setValue(json_encode($rights));
$this->entityManager->persist($setting);
$updated = true;
}
}
if ($updated) {
$this->entityManager->flush();
}
}
/**
* Returns null if the player isn't locked (or their lock already expired), otherwise
* a result array to short-circuit normal command handling.
*/
private function handleLockedPlayer(string $message, Player $player): ?array
{
$lock = $this->getLockState($player);
if ($lock === null) {
return null;
}
$now = time();
if ($now >= $lock['unlockAt']) {
return null;
}
$trimmed = trim($message);
if (stripos($trimmed, '/unlock ') === 0) {
$attempt = trim(substr($trimmed, 8));
if ($now >= $lock['revealAt'] && hash_equals($lock['code'], $attempt)) {
$this->setLockUnlockAt($player, $now);
return [
'result' => ['ACCESS RESTORED. Welcome back, agent.'],
'messageType' => 'mainframe',
'locked' => false,
];
}
return $this->lockStatusResponse('ACCESS DENIED: Incorrect passcode.', 'virus', $lock);
}
if ($now >= $lock['revealAt']) {
return $this->lockStatusResponse(
'MAINFRAME: Recovery code acquired: ' . $lock['code'] . '. Use /unlock ' . $lock['code'] . ' to restore access.',
'mainframe',
$lock
);
}
return $this->lockStatusResponse(
'AI VIRUS: SYSTEM LOCKED. Countermeasures engaged. Stand by for mainframe recovery protocol.',
'virus',
$lock
);
}
private function lockStatusResponse(string $text, string $messageType, array $lock): array
{
return [
'result' => [$text],
'messageType' => $messageType,
'locked' => true,
'lockedAt' => $lock['lockedAt'],
'revealAt' => $lock['revealAt'],
'unlockAt' => $lock['unlockAt'],
];
}
private function getLockState(Player $player): ?array
{
$settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen());
if (!$settingName) {
return null;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player);
if (!$setting || !$setting->getValue()) {
return null;
}
$state = json_decode($setting->getValue(), true);
if (!is_array($state) || !isset($state['lockedAt'], $state['revealAt'], $state['unlockAt'], $state['code'])) {
return null;
}
return $state;
}
private function triggerLock(Player $player): array
{
$now = time();
$state = [
'lockedAt' => $now,
'revealAt' => $now + self::LOCK_REVEAL_AFTER_SECONDS,
'unlockAt' => $now + self::LOCK_DURATION_SECONDS,
'code' => $this->generatePasscode(),
];
$settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen());
if ($settingName) {
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player);
if (!$setting) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($settingName);
}
$setting->setValue(json_encode($state));
$this->entityManager->persist($setting);
$this->entityManager->flush();
}
return $state;
}
private function setLockUnlockAt(Player $player, int $unlockAt): void
{
$settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen());
if (!$settingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player);
if (!$setting || !$setting->getValue()) {
return;
}
$state = json_decode($setting->getValue(), true);
if (!is_array($state)) {
return;
}
$state['unlockAt'] = $unlockAt;
$setting->setValue(json_encode($state));
$this->entityManager->persist($setting);
$this->entityManager->flush();
}
private function generatePasscode(): string
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$code = '';
for ($i = 0; $i < self::LOCK_PASSCODE_LENGTH; $i++) {
$code .= $characters[random_int(0, $charactersLength - 1)];
}
return $code;
return $randomString;
}
private function generateRandomString(int $min, int $max): string
@@ -895,7 +635,7 @@ class GameResponseService
private function checkIfAllPlayersVerified(Player $player): void
{
$session = $player->getSession();
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::EVERYONE_VERIFIED);
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::EVERYONE_VERIFIED, $player);
if ($everyoneVerifiedSetting && $everyoneVerifiedSetting->getValue() === 'true') {
return;
@@ -923,6 +663,7 @@ class GameResponseService
if (!$everyoneVerifiedSetting) {
$everyoneVerifiedSetting = new SessionSetting();
$everyoneVerifiedSetting->setSession($session);
$everyoneVerifiedSetting->setPlayer($player);
$everyoneVerifiedSetting->setName(SessionSettingType::EVERYONE_VERIFIED);
}
$everyoneVerifiedSetting->setValue('true');
@@ -997,61 +738,6 @@ class GameResponseService
$paths[] = '/etc/handle';
$paths[] = '/etc/freak';
$paths[] = '/etc/host';
$paths[] = '/etc/ssh';
$paths[] = '/etc/nginx';
$paths[] = '/etc/apache2';
$paths[] = '/etc/systemd';
$paths[] = '/etc/cron.d';
$paths[] = '/etc/network';
$paths[] = '/etc/apt';
$paths[] = '/etc/default';
$paths[] = '/etc/init.d';
$paths[] = '/etc/security';
$paths[] = '/etc/skel';
$paths[] = '/etc/logrotate.d';
$paths[] = '/bin';
$paths[] = '/boot';
$paths[] = '/dev';
$paths[] = '/home';
$paths[] = '/home/admin';
$paths[] = '/home/guest';
$paths[] = '/home/backup';
$paths[] = '/lib';
$paths[] = '/lib64';
$paths[] = '/media';
$paths[] = '/mnt';
$paths[] = '/opt';
$paths[] = '/opt/app';
$paths[] = '/proc';
$paths[] = '/root';
$paths[] = '/root/.ssh';
$paths[] = '/run';
$paths[] = '/sbin';
$paths[] = '/srv';
$paths[] = '/sys';
$paths[] = '/tmp';
$paths[] = '/usr';
$paths[] = '/usr/bin';
$paths[] = '/usr/sbin';
$paths[] = '/usr/lib';
$paths[] = '/usr/local';
$paths[] = '/usr/local/bin';
$paths[] = '/usr/local/sbin';
$paths[] = '/usr/share';
$paths[] = '/usr/share/doc';
$paths[] = '/usr/share/man';
$paths[] = '/usr/include';
$paths[] = '/usr/src';
$paths[] = '/var/log';
$paths[] = '/var/lib';
$paths[] = '/var/cache';
$paths[] = '/var/spool';
$paths[] = '/var/backups';
$paths[] = '/var/tmp';
$paths[] = '/var/mail';
$paths[] = '/var/run';
$paths[] = '/var/home';
@@ -1085,146 +771,13 @@ class GameResponseService
if(in_array('sudo', $rights) || $sudo)
return true;
return !in_array($file, $this->getLockedFiles());
}
private function getLockedFiles() : array
{
return [
$sudoFiles = [
'/var/arrest/handle.sh',
'/var/arrest/cell.sh',
'/var/marriage/divorce.sh',
];
}
/**
* Checks whether all locked files are currently removed and, if so, marks the session
* as won (once) and broadcasts a "game finished" signal to every connected player.
*/
private function checkFilesRemovalWin(Player $player): bool
{
$session = $player->getSession();
if ($session->getStatus() !== SessionStatus::PLAYING) {
return $session->getStatus() === SessionStatus::WON;
}
$deletedFiles = $this->playerService->getDeletedFilesOfSession($player);
$lockedFiles = $this->getLockedFiles();
if (count(array_intersect($lockedFiles, $deletedFiles)) < count($lockedFiles)) {
return false;
}
$session->setStatus(SessionStatus::WON);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session);
$this->entityManager->flush();
$topic = '/game/hub/' . $session->getId();
try {
$this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'won'])));
} catch (\Exception $e) {
// Mercure might be down
}
return true;
}
/**
* Starts (if not already running) the 60-second window within which all locked files
* must be removed, or the ones already removed get restored by the virus. Returns the
* (new or already-running) deadline as a unix timestamp, or null if the removed file
* wasn't a locked one.
*/
private function startLockedFilesRemovalDeadline(Session $session, string $removedFile): ?int
{
if (!in_array($removedFile, $this->getLockedFiles())) {
return null;
}
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
if ($setting && $setting->getValue()) {
return (int)$setting->getValue(); // Window already running
}
if (!$setting) {
$setting = new SessionSetting();
$setting->setSession($session);
$setting->setName(SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
}
$deadline = time() + 60;
$setting->setValue((string)$deadline);
$this->entityManager->persist($setting);
$this->entityManager->flush();
return $deadline;
}
/**
* Public, session-wide view of the active locked-files removal deadline (if any), safe
* to expose on page load so the UI can schedule its auto-check timer after a refresh.
*/
public function getPublicLockedFilesDeadline(Session $session): ?int
{
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
if (!$setting || !$setting->getValue()) {
return null;
}
$deadline = (int)$setting->getValue();
return $deadline > time() ? $deadline : null;
}
/**
* Lazily checked on every player interaction: if the 60-second window to remove all
* locked files has expired without all of them being removed, restore whichever ones
* were removed and clear the window.
*/
private function enforceLockedFilesRemovalDeadline(Session $session): void
{
if ($session->getStatus() !== SessionStatus::PLAYING) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
if (!$setting || !$setting->getValue()) {
return;
}
if (time() < (int)$setting->getValue()) {
return;
}
$player = $session->getPlayers()->first() ?: null;
$lockedFiles = $this->getLockedFiles();
if ($player) {
$deletedFiles = $this->playerService->getDeletedFilesOfSession($player);
$stillLocked = array_intersect($lockedFiles, $deletedFiles);
if (count($stillLocked) < count($lockedFiles)) {
foreach ($stillLocked as $file) {
$this->playerService->removeDeletedFileFromSession($player, $file);
}
if (!empty($stillLocked)) {
$topic = '/game/hub/' . $session->getId();
$message = 'AI VIRUS: Integrity check complete. Restored files that were not fully purged in time.';
try {
$this->hub->publish(new Update($topic, json_encode([0, $message, 'virus'])));
} catch (\Exception $e) {
// Mercure might be down
}
}
}
}
$setting->setValue(null);
$this->entityManager->persist($setting);
$this->entityManager->flush();
return !in_array($file, $sudoFiles);
}
private function fileExists(string $file, Player $player) : bool
@@ -1268,127 +821,6 @@ class GameResponseService
$files[] = '/var/rapports/011_130-62.txt';
$files[] = '/var/rapports/index.txt';
$files[] = '/etc/passwd';
$files[] = '/etc/shadow';
$files[] = '/etc/hostname';
$files[] = '/etc/hosts';
$files[] = '/etc/os-release';
$files[] = '/etc/motd';
$files[] = '/etc/issue';
$files[] = '/etc/timezone';
$files[] = '/etc/resolv.conf';
$files[] = '/etc/crontab';
$files[] = '/etc/nsswitch.conf';
$files[] = '/etc/environment';
$files[] = '/etc/fstab';
$files[] = '/etc/ssh/sshd_config';
$files[] = '/etc/ssh/ssh_config';
$files[] = '/etc/nginx/nginx.conf';
$files[] = '/etc/apache2/apache2.conf';
$files[] = '/etc/network/interfaces';
$files[] = '/etc/apt/sources.list';
$files[] = '/etc/cron.d/backup-cron.sh';
$files[] = '/etc/init.d/nginx.sh';
$files[] = '/etc/init.d/ssh.sh';
$files[] = '/etc/init.d/cron.sh';
$files[] = '/etc/logrotate.d/rsyslog.sh';
$files[] = '/etc/logrotate.d/apt.sh';
$files[] = '/etc/skel/bashrc.sh';
$files[] = '/etc/skel/profile.sh';
$files[] = '/var/log/syslog';
$files[] = '/var/log/auth.log';
$files[] = '/var/log/kern.log';
$files[] = '/var/log/boot.log';
$files[] = '/var/log/dmesg';
$files[] = '/var/log/dpkg.log';
$files[] = '/var/log/cron.log';
$files[] = '/var/log/mail.log';
$files[] = '/var/log/daemon.log';
$files[] = '/var/log/alternatives.log';
$files[] = '/var/lib/dpkg-status.sh';
$files[] = '/var/lib/apt-extended-states.sh';
$files[] = '/var/cache/apt-archives.sh';
$files[] = '/var/spool/cron-crontabs.sh';
$files[] = '/var/spool/mail-root.sh';
$files[] = '/var/backups/passwd.bak.sh';
$files[] = '/var/backups/group.bak.sh';
$files[] = '/var/mail/root';
$files[] = '/root/.bashrc';
$files[] = '/root/.bash_history';
$files[] = '/root/.ssh/authorized_keys';
$files[] = '/home/admin/notes.sh';
$files[] = '/home/admin/todo.sh';
$files[] = '/home/guest/readme.sh';
$files[] = '/home/backup/backup.sh';
$files[] = '/opt/app/config.yml';
$files[] = '/opt/app/app.sh';
$files[] = '/opt/app/start.sh';
$files[] = '/usr/bin/ls.sh';
$files[] = '/usr/bin/cat.sh';
$files[] = '/usr/bin/grep.sh';
$files[] = '/usr/bin/awk.sh';
$files[] = '/usr/bin/sed.sh';
$files[] = '/usr/bin/bash.sh';
$files[] = '/usr/bin/python3.sh';
$files[] = '/usr/bin/perl.sh';
$files[] = '/usr/bin/curl.sh';
$files[] = '/usr/bin/wget.sh';
$files[] = '/usr/bin/ssh.sh';
$files[] = '/usr/bin/scp.sh';
$files[] = '/usr/bin/rsync.sh';
$files[] = '/usr/bin/tar.sh';
$files[] = '/usr/bin/gzip.sh';
$files[] = '/usr/bin/vim.sh';
$files[] = '/usr/bin/nano.sh';
$files[] = '/usr/bin/top.sh';
$files[] = '/usr/bin/ps.sh';
$files[] = '/usr/bin/kill.sh';
$files[] = '/usr/bin/chmod.sh';
$files[] = '/usr/bin/chown.sh';
$files[] = '/usr/bin/systemctl.sh';
$files[] = '/usr/bin/docker.sh';
$files[] = '/usr/bin/git.sh';
$files[] = '/usr/bin/find.sh';
$files[] = '/usr/bin/sort.sh';
$files[] = '/usr/bin/uniq.sh';
$files[] = '/usr/bin/head.sh';
$files[] = '/usr/bin/tail.sh';
$files[] = '/bin/sh.sh';
$files[] = '/bin/mount.sh';
$files[] = '/bin/umount.sh';
$files[] = '/bin/ping.sh';
$files[] = '/bin/netstat.sh';
$files[] = '/bin/ifconfig.sh';
$files[] = '/bin/hostname.sh';
$files[] = '/bin/date.sh';
$files[] = '/bin/ln.sh';
$files[] = '/bin/cp.sh';
$files[] = '/bin/mv.sh';
$files[] = '/bin/rm.sh';
$files[] = '/bin/mkdir.sh';
$files[] = '/bin/rmdir.sh';
$files[] = '/bin/touch.sh';
$files[] = '/bin/echo.sh';
$files[] = '/sbin/init.sh';
$files[] = '/sbin/reboot.sh';
$files[] = '/sbin/shutdown.sh';
$files[] = '/sbin/fsck.sh';
$files[] = '/sbin/ifup.sh';
$files[] = '/sbin/ifdown.sh';
$files[] = '/sbin/iptables.sh';
$files[] = '/sbin/sysctl.sh';
$files[] = '/usr/local/bin/composer.sh';
$files[] = '/usr/local/bin/node.sh';
$files[] = '/usr/local/bin/npm.sh';
if ($player === null) {
return $files;
}
@@ -1461,7 +893,7 @@ class GameResponseService
return $this->readVerificationFile($player, $file);
}
$physicalPath = $this->projectDir . '/assets/game1/filesystem' . $file;
$physicalPath = 'assets/game1/filesystem' . $file;
if (!file_exists($physicalPath)) {
return ['File does not exist'];
}
-17
View File
@@ -93,21 +93,4 @@ class PlayerService
$this->entityManager->flush();
}
}
public function removeDeletedFileFromSession(Player $player, string $filename): void
{
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), SessionSettingType::SET_OF_DELETED_FILES);
if (!$setting || !$setting->getValue()) {
return;
}
$deletedFiles = json_decode($setting->getValue(), true) ?? [];
$newDeletedFiles = array_values(array_diff($deletedFiles, [$filename]));
if (count($newDeletedFiles) !== count($deletedFiles)) {
$setting->setValue(json_encode($newDeletedFiles));
$this->entityManager->persist($setting);
$this->entityManager->flush();
}
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tech\Controller;
use App\Tech\Entity\User;
use App\Tech\Form\ProfileChangeEmailFormType;
use App\Tech\Form\ProfileChangePasswordFormType;
use App\Tech\Repository\UserRepository;
use App\Tech\Service\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ProfileController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
private readonly EmailVerifier $emailVerifier,
) {
}
#[Route('/profile', name: 'app_profile', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function index(Request $request): Response
{
$user = $this->getUser();
if (!$user instanceof User) {
throw $this->createAccessDeniedException();
}
$passwordForm = $this->createForm(ProfileChangePasswordFormType::class);
$passwordForm->handleRequest($request);
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
$currentPassword = (string) $passwordForm->get('currentPassword')->getData();
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
$passwordForm->get('currentPassword')->addError(new FormError('Incorrect password.'));
} else {
$plainPassword = (string) $passwordForm->get('plainPassword')->getData();
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$this->entityManager->flush();
$this->addFlash('success', 'Your password has been updated.');
return $this->redirectToRoute('app_profile');
}
}
$emailForm = $this->createForm(ProfileChangeEmailFormType::class, ['email' => $user->getEmail()]);
$emailForm->handleRequest($request);
if ($emailForm->isSubmitted() && $emailForm->isValid()) {
$currentPassword = (string) $emailForm->get('currentPassword')->getData();
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
$emailForm->get('currentPassword')->addError(new FormError('Incorrect password.'));
} else {
$newEmail = (string) $emailForm->get('email')->getData();
if ($newEmail === $user->getEmail()) {
$this->addFlash('info', 'That is already your email address.');
return $this->redirectToRoute('app_profile');
}
$existing = $this->userRepository->findOneBy(['email' => $newEmail]);
if ($existing) {
$emailForm->get('email')->addError(new FormError('This email address is already in use.'));
} else {
$user->setEmail($newEmail);
$user->setIsVerified(false);
$this->entityManager->flush();
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from($this->getParameter('mailer_from'))
->to($newEmail)
->subject('Please confirm your new email address')
->htmlTemplate('tech/registration/confirmation_email.html.twig')
);
$this->addFlash('success', 'Your email address has been updated. You must verify it (check your inbox) before you can log in again.');
return $this->redirectToRoute('app_profile');
}
}
}
return $this->render('tech/profile/index.html.twig', [
'user' => $user,
'passwordForm' => $passwordForm,
'emailForm' => $emailForm,
]);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ class EmailLog
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255)]
+1 -1
View File
@@ -19,7 +19,7 @@ class ResetPasswordRequest implements ResetPasswordRequestInterface
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
-50
View File
@@ -39,15 +39,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'boolean')]
private bool $isVerified = false;
#[ORM\Column(type: 'boolean')]
private bool $marketingOptIn = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastLoginAt = null;
public function getId(): ?int
{
return $this->id;
@@ -146,45 +137,4 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isMarketingOptIn(): bool
{
return $this->marketingOptIn;
}
public function setMarketingOptIn(bool $marketingOptIn): static
{
$this->marketingOptIn = $marketingOptIn;
return $this;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
public function getLastLoginAt(): ?\DateTimeImmutable
{
return $this->lastLoginAt;
}
public function setLastLoginAt(?\DateTimeImmutable $lastLoginAt): static
{
$this->lastLoginAt = $lastLoginAt;
return $this;
}
}
@@ -22,10 +22,6 @@ class EmailLoggerListener
public function onMessage(MessageEvent $event): void
{
if ($event->isQueued()) {
return;
}
$message = $event->getMessage();
if (!$message instanceof TemplatedEmail) {
return;
@@ -42,11 +38,6 @@ class EmailLoggerListener
continue;
}
if ($user->isDeleted()) {
$event->reject();
return;
}
$emailLog = new EmailLog();
$emailLog->setUser($user);
$emailLog->setSentAt(new \DateTimeImmutable());
@@ -1,28 +0,0 @@
<?php
namespace App\Tech\EventListener;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
#[AsEventListener(event: LoginSuccessEvent::class, method: 'onLoginSuccess')]
class LastLoginListener
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
}
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$user = $event->getUser();
if (!$user instanceof User) {
return;
}
$user->setLastLoginAt(new \DateTimeImmutable());
$this->entityManager->flush();
}
}
+1 -9
View File
@@ -16,7 +16,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Regex;
class AdminUserType extends AbstractType
{
@@ -27,14 +26,7 @@ class AdminUserType extends AbstractType
'constraints' => [new NotBlank(), new Email()],
])
->add('username', TextType::class, [
'constraints' => [
new NotBlank(),
new Length(min: 2, max: 32),
new Regex(
pattern: '/^[A-Za-z0-9_-]+$/',
message: 'Username may only contain letters, numbers, underscores, and hyphens.',
),
],
'constraints' => [new NotBlank(), new Length(min: 2, max: 180)],
])
->add('plainPassword', PasswordType::class, [
'mapped' => false,
@@ -1,36 +0,0 @@
<?php
namespace App\Tech\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
class ProfileChangeEmailFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => 'New email address',
'constraints' => [new NotBlank(), new Email()],
])
->add('currentPassword', PasswordType::class, [
'label' => 'Current password',
'attr' => ['autocomplete' => 'current-password'],
'constraints' => [
new NotBlank(message: 'Please enter your current password'),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
@@ -1,59 +0,0 @@
<?php
namespace App\Tech\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
use Symfony\Component\Validator\Constraints\PasswordStrength;
class ProfileChangePasswordFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => 'Current password',
'attr' => ['autocomplete' => 'current-password'],
'constraints' => [
new NotBlank(message: 'Please enter your current password'),
],
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => [
'autocomplete' => 'new-password',
],
],
'first_options' => [
'constraints' => [
new NotBlank(message: 'Please enter a password'),
new Length(
min: 12,
minMessage: 'Your password should be at least {{ limit }} characters',
max: 4096,
),
new PasswordStrength(),
new NotCompromisedPassword(),
],
'label' => 'New password',
],
'second_options' => [
'label' => 'Repeat new password',
],
'invalid_message' => 'The password fields must match.',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

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