Compare commits
169
Commits
d8268363fd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba45e06972 | ||
|
|
2913b8d2a2 | ||
|
|
335697e520 | ||
|
|
daa37390d0 | ||
|
|
a9cb0fa57f | ||
|
|
2bfc2aca1a | ||
|
|
846cfbc44a | ||
|
|
207346d571 | ||
|
|
dfa75719d0 | ||
|
|
444f54b6c6 | ||
|
|
98cf48b29d | ||
|
|
f6df9f7ba6 | ||
|
|
fc93486367 | ||
|
|
98066118a6 | ||
|
|
1be07440e3 | ||
|
|
c28abef5b7 | ||
|
|
886208c5c0 | ||
|
|
2941cfca49 | ||
|
|
3c58e153dc | ||
|
|
3a7bc3ed49 | ||
|
|
aa667df889 | ||
|
|
c06c45cb52 | ||
|
|
51679d9a6a | ||
|
|
e8be7919ef | ||
|
|
cb2e945419 | ||
|
|
3984a33282 | ||
|
|
eee6c3a369 | ||
|
|
e6ba469ef9 | ||
|
|
f6a0d62017 | ||
|
|
6fd0b5d993 | ||
|
|
6db9d42852 | ||
|
|
1e644eb13b | ||
|
|
8554f04735 | ||
|
|
839113c356 | ||
|
|
7dbb738da8 | ||
|
|
ec34a1ddb9 | ||
|
|
703d2af3d8 | ||
|
|
5b03bd1d1c | ||
|
|
05f4f2c32f | ||
|
|
ddd2726687 | ||
|
|
3721abcc5d | ||
|
|
e76d0b0f07 | ||
|
|
b7693bd9d0 | ||
|
|
b4f6a531c9 | ||
|
|
c4e5139ec4 | ||
|
|
238705495e | ||
|
|
4a5d83ef53 | ||
|
|
0e12e7fa8f | ||
|
|
3924b42ce0 | ||
|
|
58c9c60ad2 | ||
|
|
b11c50c237 | ||
|
|
2316266b80 | ||
|
|
2a45ebb953 | ||
|
|
a1ff6df721 | ||
|
|
487019d360 | ||
|
|
c045922c5b | ||
|
|
aa56617e50 | ||
|
|
831603e04e | ||
|
|
ac57385a9d | ||
|
|
0d8335dd2c | ||
|
|
6c40288fd8 | ||
|
|
994bbafba2 | ||
|
|
a05adfd316 | ||
|
|
9205db6611 | ||
|
|
3b1d3a5aad | ||
|
|
8b3bf68954 | ||
|
|
74dadf102d | ||
|
|
74d5cd15b6 | ||
|
|
4e696af231 | ||
|
|
539a243353 | ||
|
|
eb3d36c99f | ||
|
|
8564257761 | ||
|
|
be01de83ed | ||
|
|
700160e198 | ||
|
|
96c2e4ca61 | ||
|
|
e5f959210a | ||
|
|
e14cf8457b | ||
|
|
2c970fa9e7 | ||
|
|
714496fa77 | ||
|
|
2b7e667bb3 | ||
|
|
b8c1fecea2 | ||
|
|
4f40c96ce5 | ||
|
|
4545572b65 | ||
|
|
1c27c093c7 | ||
|
|
6d8d30e91a | ||
|
|
5c8a5f0bf5 | ||
|
|
6b5c205a27 | ||
|
|
70f5aa785e | ||
|
|
6c96d45a04 | ||
|
|
8d4e08e4bc | ||
|
|
6fba1cbcf3 | ||
|
|
1512a3dde9 | ||
|
|
5f4e2acd27 | ||
|
|
203dfaa13b | ||
|
|
799858d52c | ||
|
|
ce79b77244 | ||
|
|
5f17c9b89f | ||
|
|
5335e62cfd | ||
|
|
04b1ff6243 | ||
|
|
84b7b1c64a | ||
|
|
b0af41c5d2 | ||
|
|
a94b8cbef3 | ||
|
|
3f33ec6fef | ||
|
|
ab80d4de83 | ||
|
|
b2817c5d8c | ||
|
|
c59d131b80 | ||
|
|
e8a6c9cc7a | ||
|
|
e84f2c274e | ||
|
|
d9bc8c352f | ||
|
|
ece3b05a9c | ||
|
|
4cf614a98c | ||
|
|
24f51bf38d | ||
|
|
0acf60760f | ||
|
|
342858fa0e | ||
|
|
3c9bd13cc2 | ||
|
|
eeda97cef8 | ||
|
|
5a6616484f | ||
|
|
f0bcc9fe06 | ||
|
|
550cc1b2ed | ||
|
|
b1910a63c3 | ||
|
|
60d52d7b5e | ||
|
|
0c7bbc2670 | ||
|
|
3e73cba942 | ||
|
|
6027dcb56c | ||
|
|
81b91e052c | ||
|
|
a5a895b67f | ||
|
|
28c663749c | ||
|
|
b063e17863 | ||
|
|
2008a379a8 | ||
|
|
c0fc0b8e6e | ||
|
|
928ab3cbfe | ||
|
|
4d021e7cf1 | ||
|
|
834f12c40f | ||
|
|
30aa73bf53 | ||
|
|
9d9de0fd0d | ||
|
|
979623b35e | ||
|
|
2984a6acb2 | ||
|
|
cdb469456f | ||
|
|
0ac2618f7d | ||
|
|
b6c09c267f | ||
|
|
229d55390d | ||
|
|
5eff66c24d | ||
|
|
7c86c919c7 | ||
|
|
cdd5bc3fd8 | ||
|
|
e69e794e27 | ||
|
|
173407cd26 | ||
|
|
90e5fd904f | ||
|
|
225c14124a | ||
|
|
a932161973 | ||
|
|
01b0522bd1 | ||
|
|
9c5b3fbe4e | ||
|
|
38d134bbac | ||
|
|
25664bbc4f | ||
|
|
4dfc8e7b15 | ||
|
|
bf7d1ee379 | ||
|
|
5188b6e697 | ||
|
|
812dc06988 | ||
|
|
376ed3f592 | ||
|
|
0543ed43b9 | ||
|
|
b486ca64d3 | ||
|
|
50b7300649 | ||
|
|
c9294d6df8 | ||
|
|
93f162b306 | ||
|
|
55a46a42b2 | ||
|
|
9cf04be857 | ||
|
|
654b4036b4 | ||
|
|
b58da74967 | ||
|
|
18821e2463 | ||
|
|
3b071eec9b |
@@ -1,25 +0,0 @@
|
|||||||
###> 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 ###
|
|
||||||
+12
-16
@@ -11,12 +11,15 @@
|
|||||||
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
|
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
|
||||||
# https://symfony.com/doc/current/configuration/secrets.html
|
# https://symfony.com/doc/current/configuration/secrets.html
|
||||||
#
|
#
|
||||||
|
# Copy this file to .env (and .env.dev / .env.prod / .env.test as needed) and
|
||||||
|
# fill in real values. Those files are gitignored and never committed.
|
||||||
|
#
|
||||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=prod
|
APP_ENV=prod
|
||||||
APP_SECRET=695679907f9c3818e6924d547f872651
|
APP_SECRET=CHANGEME_APP_SECRET
|
||||||
TRUSTED_PROXIES=127.0.0.1,172.20.0.1,172.20.0.0/16
|
TRUSTED_PROXIES=127.0.0.1,172.20.0.1,172.20.0.0/16
|
||||||
TRUSTED_HOSTS=^.*$
|
TRUSTED_HOSTS=^.*$
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
@@ -33,11 +36,11 @@ DB_DRIVER=pdo_mysql
|
|||||||
DB_SERVER_VERSION=8.0.32
|
DB_SERVER_VERSION=8.0.32
|
||||||
DB_CHARSET=utf8mb4
|
DB_CHARSET=utf8mb4
|
||||||
DB_USER=escapepage
|
DB_USER=escapepage
|
||||||
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
|
DB_PASSWORD=CHANGEME_DB_PASSWORD
|
||||||
DB_HOST=database
|
DB_HOST=database
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=escapepage
|
DB_NAME=escapepage
|
||||||
MYSQL_ROOT_PASSWORD=root
|
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD
|
||||||
DATABASE_URL="${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?serverVersion=${DB_SERVER_VERSION}&charset=${DB_CHARSET}"
|
DATABASE_URL="${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?serverVersion=${DB_SERVER_VERSION}&charset=${DB_CHARSET}"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
@@ -50,20 +53,13 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
|||||||
|
|
||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
# Development: use Mailpit (docker compose override provides service `mailer` on port 1025)
|
# Development: use Mailpit (docker compose override provides service `mailer` on port 1025)
|
||||||
BREVO_API_KEY=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
|
MAILGUN_API_KEY=REPLACE_WITH_MAILGUN_API_KEY
|
||||||
MAILER_DSN=brevo+api://${BREVO_API_KEY}@default
|
MAILGUN_DOMAIN=REPLACE_WITH_MAILGUN_SENDING_DOMAIN
|
||||||
|
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu
|
||||||
MAILER_FROM=mailer@escapepage.nl
|
MAILER_FROM=mailer@escapepage.nl
|
||||||
# Production/Stage (uncomment and set SENDGRID_API_KEY in real env or secrets):
|
|
||||||
# MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%
|
|
||||||
# Alternatively, via SMTP (no extra package needed):
|
|
||||||
# MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"
|
|
||||||
# Optional default sender (used by test command if --from not passed):
|
# Optional default sender (used by test command if --from not passed):
|
||||||
###< symfony/mailer ###
|
###< symfony/mailer ###
|
||||||
|
|
||||||
###> symfony/sendgrid-mailer ###
|
|
||||||
# MAILER_DSN=sendgrid://KEY@default
|
|
||||||
###< symfony/sendgrid-mailer ###
|
|
||||||
|
|
||||||
###> mercure ###
|
###> mercure ###
|
||||||
# Internal hub URL used by the PHP app (reachable from the php container)
|
# Internal hub URL used by the PHP app (reachable from the php container)
|
||||||
MERCURE_URL=http://mercure/.well-known/mercure
|
MERCURE_URL=http://mercure/.well-known/mercure
|
||||||
@@ -71,7 +67,7 @@ MERCURE_URL=http://mercure/.well-known/mercure
|
|||||||
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
|
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
|
||||||
# Shared secret for signing JWTs (dev only). In prod, set via real env/secrets.
|
# Shared secret for signing JWTs (dev only). In prod, set via real env/secrets.
|
||||||
MERCURE_JWT_SECRET=!ChangeThisMercureJWTSignedBySymfonySecretKey!
|
MERCURE_JWT_SECRET=!ChangeThisMercureJWTSignedBySymfonySecretKey!
|
||||||
# Pre-generated JWT tokens for convenience
|
# Pre-generated JWT tokens for convenience (signed with the dev secret above)
|
||||||
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
|
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
|
||||||
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
|
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
|
||||||
# CORS allowed origins (default)
|
# CORS allowed origins (default)
|
||||||
@@ -87,6 +83,6 @@ GROUP_ID=1000
|
|||||||
|
|
||||||
###> karser/karser-recaptcha3-bundle ###
|
###> karser/karser-recaptcha3-bundle ###
|
||||||
# Get your API key and secret from https://g.co/recaptcha/v3
|
# Get your API key and secret from https://g.co/recaptcha/v3
|
||||||
RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
|
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
|
||||||
RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
|
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
|
||||||
###< karser/karser-recaptcha3-bundle ###
|
###< karser/karser-recaptcha3-bundle ###
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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 ###
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
|
/.env
|
||||||
|
/.env.dev
|
||||||
|
/.env.prod
|
||||||
|
/.env.test
|
||||||
/.env.local
|
/.env.local
|
||||||
/.env.local.php
|
/.env.local.php
|
||||||
/.env.*.local
|
/.env.*.local
|
||||||
@@ -30,3 +34,7 @@ yarn-error.log
|
|||||||
###< symfony/webpack-encore-bundle ###
|
###< symfony/webpack-encore-bundle ###
|
||||||
|
|
||||||
/.idea
|
/.idea
|
||||||
|
|
||||||
|
###> docker env ###
|
||||||
|
/docker/.env
|
||||||
|
###< docker env ###
|
||||||
|
|||||||
@@ -25,23 +25,25 @@ This repository contains a Symfony 7.3 (PHP >= 8.5.1) application for a collabor
|
|||||||
- `php bin/console doctrine:migrations:migrate -n`
|
- `php bin/console doctrine:migrations:migrate -n`
|
||||||
4. App is at http://localhost:8080
|
4. App is at http://localhost:8080
|
||||||
|
|
||||||
## Email (Mailpit in dev, SendGrid for prod)
|
## Email (Mailpit in dev, Mailgun for prod)
|
||||||
- Dev: a `mailer` service (Mailpit) runs in Docker.
|
- Dev: a `mailer` service (Mailpit) runs in Docker.
|
||||||
- SMTP DSN in `.env`: `MAILER_DSN=smtp://mailer:1025`
|
- SMTP DSN in `.env`: `MAILER_DSN=smtp://mailer:1025`
|
||||||
- Mailpit UI: http://localhost:8025 (or mapped port 8025)
|
- Mailpit UI: http://localhost:8025 (or mapped port 8025)
|
||||||
- Send a test mail: `php bin/console app:mail:test you@example.com`
|
- Send a test mail: `php bin/console app:mail:test you@example.com`
|
||||||
- Staging/Prod: use SendGrid.
|
- Staging/Prod: use Mailgun.
|
||||||
- Require package (already in composer): `symfony/sendgrid-mailer`.
|
- Require package (already in composer): `symfony/mailgun-mailer`.
|
||||||
- Set environment variables (do NOT commit secrets):
|
- Set environment variables (do NOT commit secrets):
|
||||||
- `MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%`
|
- `MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu`
|
||||||
- `SENDGRID_API_KEY=YOUR_REAL_KEY`
|
- `MAILGUN_API_KEY=YOUR_REAL_KEY`
|
||||||
|
- `MAILGUN_DOMAIN=YOUR_SENDING_DOMAIN` (e.g. `mg.escapepage.nl`)
|
||||||
- Optional: `MAILER_FROM=no-reply@your-domain.tld`
|
- Optional: `MAILER_FROM=no-reply@your-domain.tld`
|
||||||
|
- Drop `region=eu` (or use `region=us`) depending on which region your Mailgun domain was created in.
|
||||||
- Alternatively via SMTP (no extra package):
|
- Alternatively via SMTP (no extra package):
|
||||||
- `MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"`
|
- `MAILER_DSN="mailgun+smtp://USERNAME:PASSWORD@default?region=eu"`
|
||||||
|
|
||||||
Troubleshooting:
|
Troubleshooting:
|
||||||
- If emails don’t appear in dev, open Mailpit at http://localhost:8025 and verify messages.
|
- If emails don’t appear in dev, open Mailpit at http://localhost:8025 and verify messages.
|
||||||
- In prod, check logs for HTTP 2xx responses from SendGrid and verify sender domain is verified in SendGrid.
|
- In prod, check logs for HTTP 2xx responses from Mailgun and verify sender domain is verified (SPF/DKIM) in Mailgun.
|
||||||
|
|
||||||
## Frontend assets with Webpack Encore
|
## Frontend assets with Webpack Encore
|
||||||
We use Webpack Encore to build and minify JS/CSS from the `assets/` directory into `public/build/`.
|
We use Webpack Encore to build and minify JS/CSS from the `assets/` directory into `public/build/`.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
-3
@@ -1,6 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
* Welcome to your app's main JavaScript file!
|
* Welcome to your app's main JavaScript file!
|
||||||
*/
|
*/
|
||||||
import './styles/app.css';
|
import './styles/app.scss';
|
||||||
|
import 'bootstrap/js/dist/collapse';
|
||||||
console.log('This log comes from assets/app.js built by Webpack Encore! 🎉');
|
import 'bootstrap/js/dist/alert';
|
||||||
|
import 'bootstrap/js/dist/dropdown';
|
||||||
|
import './game-waiting';
|
||||||
|
import './game-lobby';
|
||||||
|
import './admin-session-tabs';
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+205
-20
@@ -3,8 +3,14 @@ import './styles/game1.css';
|
|||||||
|
|
||||||
let sequenceFinished = false;
|
let sequenceFinished = false;
|
||||||
let stillPlayingSound = true;
|
let stillPlayingSound = true;
|
||||||
|
let navigatingAway = false;
|
||||||
|
|
||||||
function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
|
function goTo(url) {
|
||||||
|
navigatingAway = true;
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl) {
|
||||||
try {
|
try {
|
||||||
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
|
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
@@ -14,7 +20,15 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log('[Mercure][game1] Update:', data);
|
console.log('[Mercure][game1] Update:', data);
|
||||||
|
|
||||||
// data is [sendTo, message]
|
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)
|
||||||
if (Array.isArray(data) && data.length >= 2) {
|
if (Array.isArray(data) && data.length >= 2) {
|
||||||
const sendTo = parseInt(data[0]);
|
const sendTo = parseInt(data[0]);
|
||||||
// Filter: 0 means everyone, otherwise must match myScreen
|
// Filter: 0 means everyone, otherwise must match myScreen
|
||||||
@@ -25,12 +39,7 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
|
|||||||
|
|
||||||
const messageContainer = document.getElementById('message-container');
|
const messageContainer = document.getElementById('message-container');
|
||||||
if (messageContainer) {
|
if (messageContainer) {
|
||||||
const msgEl = document.createElement('div');
|
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe');
|
||||||
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);
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
if(stillPlayingSound)
|
if(stillPlayingSound)
|
||||||
playSound();
|
playSound();
|
||||||
@@ -77,6 +86,148 @@ function flashRed() {
|
|||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lockRevealTimer = null;
|
||||||
|
let lockExpireTimer = null;
|
||||||
|
let lockCountdownTimer = null;
|
||||||
|
let currentLockedAt = null;
|
||||||
|
|
||||||
|
function lockMessageClass(messageType) {
|
||||||
|
if (messageType === 'virus') return 'message-virus';
|
||||||
|
if (messageType === 'mainframe') return 'message-mainframe';
|
||||||
|
if (messageType === 'hint') return 'message-hint';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendResultMessage(container, text, messageType) {
|
||||||
|
const msgEl = document.createElement('div');
|
||||||
|
msgEl.className = ('message ' + lockMessageClass(messageType)).trim();
|
||||||
|
msgEl.textContent = text;
|
||||||
|
container.appendChild(msgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputDisabled(disabled) {
|
||||||
|
const inputField = document.getElementById('input-message');
|
||||||
|
if (inputField) {
|
||||||
|
inputField.disabled = disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLockTimers() {
|
||||||
|
if (lockRevealTimer) { clearTimeout(lockRevealTimer); lockRevealTimer = null; }
|
||||||
|
if (lockExpireTimer) { clearTimeout(lockExpireTimer); lockExpireTimer = null; }
|
||||||
|
if (lockCountdownTimer) { clearInterval(lockCountdownTimer); lockCountdownTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLock() {
|
||||||
|
clearLockTimers();
|
||||||
|
currentLockedAt = null;
|
||||||
|
document.body.classList.remove('locked');
|
||||||
|
const banner = document.getElementById('lock-banner');
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
|
setInputDisabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLockCountdown(unlockAtMs) {
|
||||||
|
const countdownEl = document.getElementById('lock-countdown');
|
||||||
|
if (!countdownEl) return;
|
||||||
|
const remaining = Math.max(0, Math.ceil((unlockAtMs - Date.now()) / 1000));
|
||||||
|
countdownEl.textContent = remaining + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLockReveal(apiEchoUrl, messageContainer) {
|
||||||
|
if (!apiEchoUrl) return;
|
||||||
|
try {
|
||||||
|
const response = await fetchJson(apiEchoUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { message: '', ts: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
const result = response && response.result;
|
||||||
|
if (result && Array.isArray(result.result)) {
|
||||||
|
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
|
||||||
|
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 = {}) {
|
async function fetchJson(url, options = {}) {
|
||||||
const opts = { ...options };
|
const opts = { ...options };
|
||||||
const headers = new Headers(opts.headers || {});
|
const headers = new Headers(opts.headers || {});
|
||||||
@@ -113,8 +264,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Look for config injected by Twig in the page
|
// Look for config injected by Twig in the page
|
||||||
const cfgEl = document.getElementById('mercure-config');
|
const cfgEl = document.getElementById('mercure-config');
|
||||||
|
|
||||||
// Prevent/warn on page reload
|
// Prevent/warn on page reload, except for our own win/lose redirects
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
|
if (navigatingAway) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Standard way to trigger the browser's confirmation dialog
|
// Standard way to trigger the browser's confirmation dialog
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// Included for compatibility with older browsers
|
// Included for compatibility with older browsers
|
||||||
@@ -133,9 +287,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
||||||
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
|
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
|
||||||
const lostUrl = cfgEl.dataset.lostUrl;
|
const lostUrl = cfgEl.dataset.lostUrl;
|
||||||
|
const wonUrl = cfgEl.dataset.wonUrl;
|
||||||
|
const lockLockedAt = cfgEl.dataset.lockLockedAt;
|
||||||
|
const lockRevealAt = cfgEl.dataset.lockRevealAt;
|
||||||
|
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
|
||||||
|
const filesRemovalDeadline = cfgEl.dataset.filesRemovalDeadline;
|
||||||
|
|
||||||
|
// Resume the auto-restore timer after a page refresh, if a window is already running
|
||||||
|
if (filesRemovalDeadline) {
|
||||||
|
scheduleFilesRemovalCheck(parseInt(filesRemovalDeadline, 10), apiEchoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (mercurePublicUrl && topic) {
|
if (mercurePublicUrl && topic) {
|
||||||
subscribeToMercure(mercurePublicUrl, topic, screen);
|
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl);
|
||||||
} else {
|
} else {
|
||||||
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
||||||
}
|
}
|
||||||
@@ -157,7 +321,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
|
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
|
||||||
if (response && response.finished) {
|
if (response && response.finished) {
|
||||||
window.location.href = lostUrl;
|
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl);
|
||||||
return; // Stop the timer loop
|
return; // Stop the timer loop
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -236,7 +400,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
msgEl.className = 'message ' + extraClass;
|
msgEl.className = 'message ' + extraClass;
|
||||||
msgEl.textContent = msg[0];
|
msgEl.textContent = msg[0];
|
||||||
msgEl.style.marginBottom = '10px';
|
|
||||||
messageContainer.appendChild(msgEl);
|
messageContainer.appendChild(msgEl);
|
||||||
window.scrollTo(0, document.body.scrollHeight);
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
|
||||||
@@ -265,7 +428,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const msgEl = document.createElement('div');
|
const msgEl = document.createElement('div');
|
||||||
msgEl.className = 'message';
|
msgEl.className = 'message';
|
||||||
msgEl.textContent = message;
|
msgEl.textContent = message;
|
||||||
msgEl.style.marginBottom = '10px';
|
|
||||||
messageContainer.appendChild(msgEl);
|
messageContainer.appendChild(msgEl);
|
||||||
|
|
||||||
if (message && apiEchoUrl) {
|
if (message && apiEchoUrl) {
|
||||||
@@ -277,15 +439,29 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
console.log('[API][game1] message sent →', response);
|
console.log('[API][game1] message sent →', response);
|
||||||
if (response && response.result && Array.isArray(response.result.result)) {
|
if (response && response.result && Array.isArray(response.result.result)) {
|
||||||
response.result.result.forEach(text => {
|
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType));
|
||||||
const msgEl = document.createElement('div');
|
|
||||||
msgEl.className = 'message';
|
|
||||||
msgEl.textContent = text;
|
|
||||||
msgEl.style.marginBottom = '10px';
|
|
||||||
messageContainer.appendChild(msgEl);
|
|
||||||
});
|
|
||||||
window.scrollTo(0, document.body.scrollHeight);
|
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) {
|
} catch (err) {
|
||||||
console.error('[API][game1] Failed to send message:', err);
|
console.error('[API][game1] Failed to send message:', err);
|
||||||
}
|
}
|
||||||
@@ -296,6 +472,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
console.log('[Game1] message-container height changed to 400vh and input enabled');
|
console.log('[Game1] message-container height changed to 400vh and input enabled');
|
||||||
sequenceFinished = true;
|
sequenceFinished = true;
|
||||||
console.log('[Game1] sequenceFinished is now TRUE');
|
console.log('[Game1] sequenceFinished is now TRUE');
|
||||||
|
|
||||||
|
// Restore an in-progress lock after a page refresh
|
||||||
|
if (lockUnlockAt && parseInt(lockUnlockAt, 10) * 1000 > Date.now()) {
|
||||||
|
applyLock({
|
||||||
|
lockedAt: parseInt(lockLockedAt, 10),
|
||||||
|
revealAt: parseInt(lockRevealAt, 10),
|
||||||
|
unlockAt: parseInt(lockUnlockAt, 10),
|
||||||
|
}, apiEchoUrl, messageContainer);
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# /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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# /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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
archive-node-04
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Debian GNU/Linux 12 \n \l
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Welcome to archive-node-04.
|
||||||
|
All connections are logged and monitored for internal review purposes.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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/*;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
passwd: files
|
||||||
|
group: files
|
||||||
|
shadow: files
|
||||||
|
hosts: files dns
|
||||||
|
networks: files
|
||||||
|
protocols: db files
|
||||||
|
services: db files
|
||||||
|
ethers: db files
|
||||||
|
rpc: db files
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
nameserver 1.1.1.1
|
||||||
|
nameserver 9.9.9.9
|
||||||
|
options edns0
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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:::
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Host *
|
||||||
|
SendEnv LANG LC_*
|
||||||
|
HashKnownHosts yes
|
||||||
|
GSSAPIAuthentication yes
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Port 22
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication yes
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
X11Forwarding no
|
||||||
|
PrintMotd no
|
||||||
|
Subsystem sftp /usr/lib/openssh/sftp-server
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Europe/Amsterdam
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
app:
|
||||||
|
name: internal-archive-sync
|
||||||
|
version: 2.3.1
|
||||||
|
log_level: info
|
||||||
|
port: 8080
|
||||||
|
database:
|
||||||
|
driver: sqlite
|
||||||
|
path: /opt/app/data.db
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apt update
|
||||||
|
apt upgrade -y
|
||||||
|
systemctl restart nginx
|
||||||
|
df -h
|
||||||
|
journalctl -xe
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ~/.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'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZ8pQxT2mN0vRkLwYb6cJhU3sEoAeKdVmXpZq7tRnBs admin@archive-node-04
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[ OK ] Started Network Manager.
|
||||||
|
[ OK ] Started OpenSSH server daemon.
|
||||||
|
[ OK ] Started Nginx HTTP server.
|
||||||
|
[ OK ] Reached target Multi-User System.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[ 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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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.
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
body {
|
|
||||||
background-color: skyblue;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Brand palette derived from public/images/logo.png (teal key/globe on dark navy)
|
||||||
|
$brand-teal: #2f8fae;
|
||||||
|
$brand-teal-dark: #1c5a70;
|
||||||
|
$brand-teal-light:#6fb8d1;
|
||||||
|
$brand-navy: #0d1f26;
|
||||||
|
$brand-navy-soft: #16323f;
|
||||||
|
$brand-bg: #f4f8f9;
|
||||||
|
|
||||||
|
// Bootstrap variable overrides (must come before the bootstrap import)
|
||||||
|
$primary: $brand-teal;
|
||||||
|
$secondary: $brand-navy-soft;
|
||||||
|
$dark: $brand-navy;
|
||||||
|
$body-bg: $brand-bg;
|
||||||
|
$body-color: $brand-navy-soft;
|
||||||
|
$link-color: $brand-teal;
|
||||||
|
$link-hover-color: $brand-teal-dark;
|
||||||
|
$border-radius: .5rem;
|
||||||
|
|
||||||
|
@import "bootstrap/scss/bootstrap";
|
||||||
|
@import "bootstrap-icons/font/bootstrap-icons.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brand-teal: #{$brand-teal};
|
||||||
|
--brand-teal-dark: #{$brand-teal-dark};
|
||||||
|
--brand-navy: #{$brand-navy};
|
||||||
|
--brand-navy-soft: #{$brand-navy-soft};
|
||||||
|
--brand-bg: #{$brand-bg};
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-main {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
background: linear-gradient(90deg, $brand-navy, $brand-navy-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header .navbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header .navbar-brand img {
|
||||||
|
height: 36px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: $brand-navy;
|
||||||
|
color: rgba(255, 255, 255, .65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
max-width: 440px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
}
|
||||||
+52
-3
@@ -47,12 +47,61 @@ div#message-container {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
min-height: calc(100vh - 100px); /* Fill most of the viewport initially */
|
min-height: calc(100vh - 100px); /* Fill most of the viewport initially */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 20px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.message {
|
div.message {
|
||||||
color: #C0C0C0;
|
color: #C0C0C0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.message-virus {
|
||||||
|
color: #F00;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.message-mainframe {
|
||||||
|
color: #0F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.message-hint {
|
||||||
|
color: #FF0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#lock-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 68px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background-color: #200;
|
||||||
|
border-top: 1px solid #F00;
|
||||||
|
border-bottom: 1px solid #F00;
|
||||||
|
color: #F00;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
z-index: 99;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
animation: lock-banner-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lock-banner-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
background-color: #200;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-color: #400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.locked div#message-container {
|
||||||
|
padding-top: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div#input {
|
div#input {
|
||||||
@@ -61,11 +110,11 @@ div#input {
|
|||||||
|
|
||||||
input#input-message {
|
input#input-message {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: 6px 10px;
|
||||||
background: #111;
|
background: #111;
|
||||||
border: 1px solid #A00000;
|
border: 1px solid #A00000;
|
||||||
color: #C0C0C0;
|
color: #C0C0C0;
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-18
@@ -7,49 +7,50 @@
|
|||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"doctrine/dbal": "^3",
|
"doctrine/dbal": "^3.10.5",
|
||||||
"doctrine/doctrine-bundle": "^2.16",
|
"doctrine/doctrine-bundle": "^2.18.3",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
"doctrine/doctrine-migrations-bundle": "^3.7",
|
||||||
"doctrine/orm": "^3.5",
|
"doctrine/orm": "^3.6.7",
|
||||||
"karser/karser-recaptcha3-bundle": "^0.3.0",
|
"karser/karser-recaptcha3-bundle": "^0.3.0",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6.7",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3.2",
|
||||||
"symfony/asset": "7.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/asset-mapper": "7.4.*",
|
"symfony/asset-mapper": "7.4.*",
|
||||||
"symfony/brevo-mailer": "7.4.*",
|
|
||||||
"symfony/console": "7.4.*",
|
"symfony/console": "7.4.*",
|
||||||
"symfony/doctrine-messenger": "7.4.*",
|
"symfony/doctrine-messenger": "7.4.*",
|
||||||
"symfony/dotenv": "7.4.*",
|
"symfony/dotenv": "7.4.*",
|
||||||
"symfony/expression-language": "7.4.*",
|
"symfony/expression-language": "7.4.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2.11",
|
||||||
"symfony/form": "7.4.*",
|
"symfony/form": "7.4.*",
|
||||||
"symfony/framework-bundle": "7.4.*",
|
"symfony/framework-bundle": "7.4.*",
|
||||||
"symfony/http-client": "7.4.*",
|
"symfony/http-client": "7.4.*",
|
||||||
"symfony/intl": "7.4.*",
|
"symfony/intl": "7.4.*",
|
||||||
"symfony/mailer": "7.4.*",
|
"symfony/mailer": "7.4.*",
|
||||||
"symfony/mercure-bundle": "^0.3",
|
"symfony/mailgun-mailer": "7.4.*",
|
||||||
|
"symfony/mercure-bundle": "^0.3.9",
|
||||||
"symfony/mime": "7.4.*",
|
"symfony/mime": "7.4.*",
|
||||||
"symfony/monolog-bundle": "^3.0",
|
"symfony/monolog-bundle": "^3.11.2",
|
||||||
"symfony/notifier": "7.4.*",
|
"symfony/notifier": "7.4.*",
|
||||||
"symfony/process": "7.4.*",
|
"symfony/process": "7.4.*",
|
||||||
"symfony/property-access": "7.4.*",
|
"symfony/property-access": "7.4.*",
|
||||||
"symfony/property-info": "7.4.*",
|
"symfony/property-info": "7.4.*",
|
||||||
|
"symfony/rate-limiter": "7.4.*",
|
||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "7.4.*",
|
||||||
"symfony/security-bundle": "7.4.*",
|
"symfony/security-bundle": "7.4.*",
|
||||||
"symfony/serializer": "7.4.*",
|
"symfony/serializer": "7.4.*",
|
||||||
"symfony/stimulus-bundle": "^2.30",
|
"symfony/stimulus-bundle": "^2.36",
|
||||||
"symfony/string": "7.4.*",
|
"symfony/string": "7.4.*",
|
||||||
"symfony/translation": "7.4.*",
|
"symfony/translation": "7.4.*",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/twig-bundle": "7.4.*",
|
||||||
"symfony/ux-turbo": "^2.30",
|
"symfony/ux-turbo": "^2.36",
|
||||||
"symfony/validator": "7.4.*",
|
"symfony/validator": "7.4.*",
|
||||||
"symfony/web-link": "7.4.*",
|
"symfony/web-link": "7.4.*",
|
||||||
"symfony/webpack-encore-bundle": "^2.1",
|
"symfony/webpack-encore-bundle": "^2.4.1",
|
||||||
"symfony/yaml": "7.4.*",
|
"symfony/yaml": "7.4.*",
|
||||||
"symfonycasts/reset-password-bundle": "^1.24",
|
"symfonycasts/reset-password-bundle": "^1.25",
|
||||||
"symfonycasts/verify-email-bundle": "^1.18",
|
"symfonycasts/verify-email-bundle": "^1.18",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.24",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.28.0"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -103,11 +104,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.5",
|
"phpunit/phpunit": "^11.5.55",
|
||||||
"symfony/browser-kit": "7.4.*",
|
"symfony/browser-kit": "7.4.*",
|
||||||
"symfony/css-selector": "7.4.*",
|
"symfony/css-selector": "7.4.*",
|
||||||
"symfony/debug-bundle": "7.4.*",
|
"symfony/debug-bundle": "7.4.*",
|
||||||
"symfony/maker-bundle": "^1.0",
|
"symfony/maker-bundle": "^1.67",
|
||||||
"symfony/stopwatch": "7.4.*",
|
"symfony/stopwatch": "7.4.*",
|
||||||
"symfony/web-profiler-bundle": "7.4.*"
|
"symfony/web-profiler-bundle": "7.4.*"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+494
-434
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ framework:
|
|||||||
storage_factory_id: session.storage.factory.native
|
storage_factory_id: session.storage.factory.native
|
||||||
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
|
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
|
||||||
|
|
||||||
|
rate_limiter:
|
||||||
|
invite_code_join:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: 10
|
||||||
|
interval: '1 minute'
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
session:
|
session:
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ when@prod:
|
|||||||
excluded_http_codes: [404, 405]
|
excluded_http_codes: [404, 405]
|
||||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||||
nested:
|
nested:
|
||||||
|
type: group
|
||||||
|
members: [nested_file, nested_stderr]
|
||||||
|
nested_file:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/php/prod.log"
|
||||||
|
level: debug
|
||||||
|
formatter: monolog.formatter.json
|
||||||
|
nested_stderr:
|
||||||
type: stream
|
type: stream
|
||||||
path: php://stderr
|
path: php://stderr
|
||||||
level: debug
|
level: debug
|
||||||
@@ -56,7 +64,14 @@ when@prod:
|
|||||||
process_psr_3_messages: false
|
process_psr_3_messages: false
|
||||||
channels: ["!event", "!doctrine"]
|
channels: ["!event", "!doctrine"]
|
||||||
deprecation:
|
deprecation:
|
||||||
type: stream
|
type: group
|
||||||
channels: [deprecation]
|
channels: [deprecation]
|
||||||
|
members: [deprecation_file, deprecation_stderr]
|
||||||
|
deprecation_file:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/php/deprecation.log"
|
||||||
|
formatter: monolog.formatter.json
|
||||||
|
deprecation_stderr:
|
||||||
|
type: stream
|
||||||
path: php://stderr
|
path: php://stderr
|
||||||
formatter: monolog.formatter.json
|
formatter: monolog.formatter.json
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ framework:
|
|||||||
notifier:
|
notifier:
|
||||||
chatter_transports:
|
chatter_transports:
|
||||||
texter_transports:␍
|
texter_transports:␍
|
||||||
sendgrid: '%env(MAILER_DSN)%'
|
|
||||||
channel_policy:
|
channel_policy:
|
||||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
||||||
urgent: ['email']
|
urgent: ['email']
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ security:
|
|||||||
enable_csrf: true
|
enable_csrf: true
|
||||||
username_parameter: username
|
username_parameter: username
|
||||||
password_parameter: password
|
password_parameter: password
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 5
|
||||||
|
interval: '15 minutes'
|
||||||
logout:
|
logout:
|
||||||
path: app_logout
|
path: app_logout
|
||||||
# where to redirect after logout
|
# where to redirect after logout
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
twig:
|
twig:
|
||||||
|
form_themes: ['bootstrap_5_layout.html.twig']
|
||||||
globals:
|
globals:
|
||||||
mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'
|
mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'
|
||||||
mercure_topic_base: '%env(MERCURE_TOPIC_BASE)%'
|
mercure_topic_base: '%env(MERCURE_TOPIC_BASE)%'
|
||||||
|
|||||||
+13
-10
@@ -1,9 +1,9 @@
|
|||||||
# Email Delivery: Dev Mailcatcher & Production SendGrid
|
# Email Delivery: Dev Mailcatcher & Production Mailgun
|
||||||
|
|
||||||
This application uses Symfony Mailer. We separate development and production delivery:
|
This application uses Symfony Mailer. We separate development and production delivery:
|
||||||
|
|
||||||
- Development: Mailpit (mailcatcher) via SMTP in Docker.
|
- Development: Mailpit (mailcatcher) via SMTP in Docker.
|
||||||
- Production: SendGrid via API transport.
|
- Production: Mailgun via API transport.
|
||||||
|
|
||||||
## Development (Mailpit)
|
## Development (Mailpit)
|
||||||
|
|
||||||
@@ -22,24 +22,27 @@ MAILER_DSN=smtp://mailer:1025
|
|||||||
2. Send an email from the app.
|
2. Send an email from the app.
|
||||||
3. Open http://localhost:8025 to view captured emails.
|
3. Open http://localhost:8025 to view captured emails.
|
||||||
|
|
||||||
## Production (SendGrid)
|
## Production (Mailgun)
|
||||||
|
|
||||||
Use the SendGrid API transport. Do not commit secrets.
|
Use the Mailgun API transport (`symfony/mailgun-mailer` bridge). Do not commit secrets.
|
||||||
|
|
||||||
- Example configuration is in `.env.prod`:
|
- Example configuration is in `.env.prod`:
|
||||||
|
|
||||||
```
|
```
|
||||||
MAILER_DSN=sendgrid+api://%env(resolve:SENDGRID_API_KEY)%@default
|
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu
|
||||||
```
|
```
|
||||||
|
|
||||||
- Provide `SENDGRID_API_KEY` via:
|
- `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.
|
||||||
- Real environment variable on the server/container, or
|
- Provide `MAILGUN_API_KEY` and `MAILGUN_DOMAIN` via:
|
||||||
- Symfony secrets: `php bin/console secrets:set SENDGRID_API_KEY` (and dump for prod), or
|
- Real environment variables on the server/container, or
|
||||||
|
- Symfony secrets: `php bin/console secrets:set MAILGUN_API_KEY` (and dump for prod), or
|
||||||
- Orchestration secret stores (e.g., Docker/K8s).
|
- Orchestration secret stores (e.g., Docker/K8s).
|
||||||
|
- The sending domain must be added and DNS-verified (SPF/DKIM/tracking CNAME) in the Mailgun dashboard before production sending will work reliably; unverified domains are rate-limited/sandboxed.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- No Mailpit container is defined in the base `compose.yaml`, only in `compose.override.yaml`. This ensures it is used in development only.
|
- No Mailpit container is defined in the base `compose.yaml`, only in `compose.override.yaml`. This ensures it is used in development only.
|
||||||
- To test email locally without Docker, you can:
|
- To test email locally without Docker, you can:
|
||||||
- Run Mailpit on your host (ports 1025/8025) and set `MAILER_DSN=smtp://127.0.0.1:1025` in `.env.local`.
|
- Run Mailpit on your host (ports 1025/8025) and set `MAILER_DSN=smtp://127.0.0.1:1025` in `.env.local`.
|
||||||
- If you need to use SendGrid SMTP instead of API, a DSN example:
|
- If you need to use Mailgun SMTP instead of API, a DSN example:
|
||||||
`smtp://apikey:YOUR_SENDGRID_API_KEY@smtp.sendgrid.net:587`.
|
`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.
|
||||||
|
|||||||
-54
@@ -1,54 +0,0 @@
|
|||||||
# 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 ###
|
|
||||||
+11
-9
@@ -7,23 +7,25 @@ APP_ENV=prod
|
|||||||
SITE_BASE_URL=https://escapepage.com
|
SITE_BASE_URL=https://escapepage.com
|
||||||
|
|
||||||
# Mailer
|
# Mailer
|
||||||
MAILER_DSN=sendgrid://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@default
|
MAILGUN_API_KEY=CHANGEME_MAILGUN_API_KEY
|
||||||
|
MAILGUN_DOMAIN=CHANGEME_MAILGUN_DOMAIN
|
||||||
|
MAILER_DSN=mailgun+api://CHANGEME_MAILGUN_API_KEY:CHANGEME_MAILGUN_DOMAIN@default?region=eu
|
||||||
MAILER_FROM=mailer@escapepage.nl
|
MAILER_FROM=mailer@escapepage.nl
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=mysql://escapepage:Zr1aOYU5NpCbS3dhpxa64cZp@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4
|
DATABASE_URL=mysql://escapepage:CHANGEME_DB_PASSWORD@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4
|
||||||
DB_NAME=escapepage
|
DB_NAME=escapepage
|
||||||
DB_USER=escapepage
|
DB_USER=escapepage
|
||||||
DB_PASSWORD=Zr1aOYU5NpCbS3dhpxa64cZp
|
DB_PASSWORD=CHANGEME_DB_PASSWORD
|
||||||
MYSQL_ROOT_PASSWORD=root
|
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD
|
||||||
|
|
||||||
# Mercure
|
# Mercure
|
||||||
MERCURE_URL=http://mercure/.well-known/mercure
|
MERCURE_URL=http://mercure/.well-known/mercure
|
||||||
MERCURE_PUBLIC_URL=https://escapepage.com/.well-known/mercure
|
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
|
||||||
MERCURE_JWT_SECRET=55UtgFXsZu09TSTdeIA7ljK4HUo9DLkRzEB7MD5tqOLjRfAb
|
MERCURE_JWT_SECRET=CHANGEME_MERCURE_JWT_SECRET
|
||||||
MERCURE_CORS_ALLOWED_ORIGINS=https://escapepage.com
|
MERCURE_CORS_ALLOWED_ORIGINS=https://www.escapepage.com https://escapepage.com
|
||||||
MERCURE_TOPIC_BASE=https://escapepage.com
|
MERCURE_TOPIC_BASE=https://escapepage.com
|
||||||
|
|
||||||
# Recaptcha
|
# Recaptcha
|
||||||
RECAPTCHA3_KEY=6LdIvk0sAAAAAC2jMbBXtjDQC24mmNbwHWBulxFu
|
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
|
||||||
RECAPTCHA3_SECRET=6LdIvk0sAAAAAE9TCGAQoczQFwR6l2dxkkwcPKsk
|
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
|
||||||
|
|||||||
@@ -66,6 +66,39 @@ services:
|
|||||||
# ipv4_address: 172.23.0.11
|
# ipv4_address: 172.23.0.11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
php-cron:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/php/Dockerfile
|
||||||
|
args:
|
||||||
|
USER_ID: ${USER_ID}
|
||||||
|
GROUP_ID: ${GROUP_ID}
|
||||||
|
container_name: escapepage-php-cron
|
||||||
|
volumes:
|
||||||
|
- ../:/var/www/html:delegated
|
||||||
|
- /etc/hosts:/etc/hosts:ro
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV}
|
||||||
|
SITE_BASE_URL: ${SITE_BASE_URL}
|
||||||
|
MAILER_DSN: ${MAILER_DSN}
|
||||||
|
MAILER_FROM: ${MAILER_FROM}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
MERCURE_URL: ${MERCURE_URL}
|
||||||
|
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
|
||||||
|
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
|
||||||
|
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
|
||||||
|
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
|
||||||
|
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
|
||||||
|
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
- mercure
|
||||||
|
command: ["crond", "-f", "-l", "2"]
|
||||||
|
# networks:
|
||||||
|
# backend:
|
||||||
|
# ipv4_address: 172.23.0.16
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.29.4-alpine
|
image: nginx:1.29.4-alpine
|
||||||
container_name: escapepage-nginx
|
container_name: escapepage-nginx
|
||||||
|
|||||||
+12
-2
@@ -12,7 +12,8 @@ RUN apk add --no-cache \
|
|||||||
make \
|
make \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
shadow
|
shadow \
|
||||||
|
logrotate
|
||||||
|
|
||||||
# Install PHP extension installer
|
# Install PHP extension installer
|
||||||
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
|
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
@@ -41,6 +42,15 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|||||||
# Configure PHP
|
# Configure PHP
|
||||||
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
|
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
|
||||||
|
|
||||||
|
# Cron daemon (BusyBox's built-in crond) for the php-cron container.
|
||||||
|
# Harmless for the php/php-worker containers too, since they never invoke crond.
|
||||||
|
COPY docker/php/crontab /etc/crontabs/root
|
||||||
|
RUN chmod 0600 /etc/crontabs/root
|
||||||
|
|
||||||
|
# Log rotation for the cron hint-check and PHP error logs: 25MB per file, kept for 3 months.
|
||||||
|
COPY docker/php/logrotate/cron-hints.conf /etc/logrotate.d/cron-hints
|
||||||
|
COPY docker/php/logrotate/php-logs.conf /etc/logrotate.d/php-logs
|
||||||
|
|
||||||
# Adjust www-data UID/GID to match host user (default 1000)
|
# Adjust www-data UID/GID to match host user (default 1000)
|
||||||
ARG USER_ID=1000
|
ARG USER_ID=1000
|
||||||
ARG GROUP_ID=1000
|
ARG GROUP_ID=1000
|
||||||
@@ -56,7 +66,7 @@ RUN if [ ${USER_ID:-0} -ne 0 ] && [ ${GROUP_ID:-0} -ne 0 ]; then \
|
|||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
# Set permissions for Symfony directories
|
# Set permissions for Symfony directories
|
||||||
RUN mkdir -p var/cache var/log var/sessions && \
|
RUN mkdir -p var/cache var/log/cron var/log/php var/sessions && \
|
||||||
chown -R www-data:www-data var
|
chown -R www-data:www-data var
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* * * * * 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/var/www/html/var/log/cron/cron.log {
|
||||||
|
size 25M
|
||||||
|
rotate 100
|
||||||
|
maxage 90
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
dateext
|
||||||
|
dateformat -%Y%m%d-%s
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/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
@@ -9,6 +9,6 @@ opcache.validate_timestamps=1
|
|||||||
opcache.revalidate_freq=0
|
opcache.revalidate_freq=0
|
||||||
|
|
||||||
log_errors=On
|
log_errors=On
|
||||||
error_log=/var/www/html/var/log/errorlog_php.log
|
error_log=/var/www/html/var/log/php/error.log
|
||||||
session.gc_maxlifetime=1440
|
session.gc_maxlifetime=1440
|
||||||
session.cookie_lifetime=0
|
session.cookie_lifetime=0
|
||||||
|
|||||||
Regular → Executable
+2
-2
@@ -12,7 +12,7 @@ echo "Stopping and removing containers..."
|
|||||||
docker network rm escapepage_network || true
|
docker network rm escapepage_network || true
|
||||||
docker network rm $(docker network ls -q --filter name=escapepage) || true
|
docker network rm $(docker network ls -q --filter name=escapepage) || true
|
||||||
docker network prune -f || true
|
docker network prune -f || true
|
||||||
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker || true
|
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker escapepage-php-cron || true
|
||||||
docker system prune -f || true
|
docker system prune -f || true
|
||||||
|
|
||||||
echo "Clearing Docker build cache..."
|
echo "Clearing Docker build cache..."
|
||||||
@@ -21,7 +21,7 @@ docker builder prune -af
|
|||||||
echo "Setting permissions for var/volumes/db and var directories..."
|
echo "Setting permissions for var/volumes/db and var directories..."
|
||||||
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
||||||
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
|
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
|
||||||
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/sessions"
|
||||||
sudo chown -R 1000:1000 "$ROOT_DIR/var" || true
|
sudo chown -R 1000:1000 "$ROOT_DIR/var" || true
|
||||||
sudo chmod -R 777 "$ROOT_DIR/var" || true
|
sudo chmod -R 777 "$ROOT_DIR/var" || true
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+4
@@ -143,6 +143,10 @@ Common commands:
|
|||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
|
||||||
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-cron) # crond scheduler activity
|
||||||
|
tail -f "$ROOT_DIR/var/log/cron/cron.log" # hint-check command output
|
||||||
|
tail -f "$ROOT_DIR/var/log/php/error.log" # raw PHP errors
|
||||||
|
tail -f "$ROOT_DIR/var/log/php/prod.log" # Symfony app errors (prod only)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260704160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add marketing_opt_in column to user table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE `user` ADD marketing_opt_in TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE `user` DROP marketing_opt_in');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+494
@@ -8,6 +8,11 @@
|
|||||||
"name": "escapepage",
|
"name": "escapepage",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"bootstrap-icons": "^1.13.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.0",
|
"@babel/core": "^7.25.0",
|
||||||
"@babel/preset-env": "^7.25.0",
|
"@babel/preset-env": "^7.25.0",
|
||||||
@@ -17,6 +22,8 @@
|
|||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"mini-css-extract-plugin": "^2.9.2",
|
"mini-css-extract-plugin": "^2.9.2",
|
||||||
"regenerator-runtime": "^0.14.1",
|
"regenerator-runtime": "^0.14.1",
|
||||||
|
"sass": "^1.101.0",
|
||||||
|
"sass-loader": "^14.2.1",
|
||||||
"webpack": "^5.95.0",
|
"webpack": "^5.95.0",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-notifier": "^1.15.0"
|
"webpack-notifier": "^1.15.0"
|
||||||
@@ -1743,6 +1750,340 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.27.8",
|
"version": "0.27.8",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||||
@@ -2884,6 +3225,41 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -3718,6 +4094,17 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"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": {
|
"node_modules/detect-node": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||||
@@ -4777,6 +5164,13 @@
|
|||||||
"postcss": "^8.1.0"
|
"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": {
|
"node_modules/import-local": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||||
@@ -5554,6 +5948,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-forge": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||||
@@ -6861,6 +7263,98 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/schema-utils": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||||
|
|||||||
@@ -18,8 +18,15 @@
|
|||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"mini-css-extract-plugin": "^2.9.2",
|
"mini-css-extract-plugin": "^2.9.2",
|
||||||
"regenerator-runtime": "^0.14.1",
|
"regenerator-runtime": "^0.14.1",
|
||||||
|
"sass": "^1.101.0",
|
||||||
|
"sass-loader": "^14.2.1",
|
||||||
"webpack": "^5.95.0",
|
"webpack": "^5.95.0",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-notifier": "^1.15.0"
|
"webpack-notifier": "^1.15.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"bootstrap-icons": "^1.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,41 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ use Symfony\Component\Mime\Email;
|
|||||||
|
|
||||||
#[AsCommand(
|
#[AsCommand(
|
||||||
name: 'app:mail:test',
|
name: 'app:mail:test',
|
||||||
description: 'Sends a simple test email using the configured mail transport (SendGrid in prod).'
|
description: 'Sends a simple test email using the configured mail transport (Mailgun in prod).'
|
||||||
)]
|
)]
|
||||||
final class TestEmailCommand extends Command
|
final class TestEmailCommand extends Command
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Game\Controller;
|
|||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Repository\GameRepository;
|
use App\Game\Repository\GameRepository;
|
||||||
|
use App\Game\Repository\LobbyMessageRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Tech\Repository\UserRepository;
|
use App\Tech\Repository\UserRepository;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -49,15 +50,14 @@ final class GameAdminController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])]
|
#[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])]
|
||||||
public function viewSession(Session $session): Response
|
public function viewSession(Session $session, LobbyMessageRepository $lobbyMessageRepository): Response
|
||||||
{
|
{
|
||||||
$playersLogs = [];
|
$playersLogs = [];
|
||||||
foreach ($session->getPlayers() as $player) {
|
foreach ($session->getPlayers() as $player) {
|
||||||
$username = $player->getUser()->getUsername();
|
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $player->getLogFileBasename() . '.txt';
|
||||||
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
|
|
||||||
|
|
||||||
$playersLogs[] = [
|
$playersLogs[] = [
|
||||||
'username' => $username,
|
'username' => $player->getUser()->getUsername(),
|
||||||
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
|
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,7 @@ final class GameAdminController extends AbstractController
|
|||||||
return $this->render('game/admin/sessions/view.html.twig', [
|
return $this->render('game/admin/sessions/view.html.twig', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'playersLogs' => $playersLogs,
|
'playersLogs' => $playersLogs,
|
||||||
|
'lobbyMessages' => $lobbyMessageRepository->findForSession($session),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ final class GameAdminSessionController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$session->setStatus(SessionStatus::LOST);
|
$session->setStatus(SessionStatus::LOST);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
|
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ final class GameAdminUserController extends AbstractController
|
|||||||
#[Route('', name: 'game_admin_users', methods: ['GET'])]
|
#[Route('', name: 'game_admin_users', methods: ['GET'])]
|
||||||
public function index(UserRepository $userRepository): Response
|
public function index(UserRepository $userRepository): Response
|
||||||
{
|
{
|
||||||
|
$users = $userRepository->findBy([], ['id' => 'ASC']);
|
||||||
|
|
||||||
return $this->render('game/admin/users/index.html.twig', [
|
return $this->render('game/admin/users/index.html.twig', [
|
||||||
'users' => $userRepository->findBy([], ['id' => 'ASC']),
|
'users' => $users,
|
||||||
|
'marketingOptInCount' => count(array_filter($users, static fn (User $user) => $user->isMarketingOptIn())),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,11 +66,12 @@ final class GameAdminUserController extends AbstractController
|
|||||||
return $this->redirectToRoute('game_admin_users');
|
return $this->redirectToRoute('game_admin_users');
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = $user->getUsername();
|
if (!$user->isDeleted()) {
|
||||||
$em->remove($user);
|
$user->setDeletedAt(new \DateTimeImmutable());
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
$this->addFlash('success', sprintf('User "%s" deleted.', $username));
|
$this->addFlash('success', sprintf('User "%s" deleted.', $user->getUsername()));
|
||||||
|
|
||||||
return $this->redirectToRoute('game_admin_users');
|
return $this->redirectToRoute('game_admin_users');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ final class GameApiController extends AbstractController
|
|||||||
if ($session->getStatus() === SessionStatus::PLAYING) {
|
if ($session->getStatus() === SessionStatus::PLAYING) {
|
||||||
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
|
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
|
||||||
$session->setStatus(SessionStatus::LOST);
|
$session->setStatus(SessionStatus::LOST);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
$isFinished = true;
|
$isFinished = true;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use App\Game\Repository\GameRepository;
|
|||||||
use App\Game\Repository\PlayerRepository;
|
use App\Game\Repository\PlayerRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Game\Service\GameDashboardService;
|
use App\Game\Service\GameDashboardService;
|
||||||
|
use App\Game\Service\GameResponseService;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use App\Game\Service\PlayerService;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -22,6 +22,8 @@ use Symfony\Component\Routing\Annotation\Route;
|
|||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
use Symfony\Component\ExpressionLanguage\Expression;
|
use Symfony\Component\ExpressionLanguage\Expression;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
|
|
||||||
final class GameController extends AbstractController
|
final class GameController extends AbstractController
|
||||||
{
|
{
|
||||||
@@ -39,13 +41,20 @@ final class GameController extends AbstractController
|
|||||||
GameRepository $gameRepository,
|
GameRepository $gameRepository,
|
||||||
SessionRepository $sessionRepository,
|
SessionRepository $sessionRepository,
|
||||||
GameDashboardService $dashboardService,
|
GameDashboardService $dashboardService,
|
||||||
Security $security
|
Security $security,
|
||||||
|
#[Target('invite_code_join')]
|
||||||
|
RateLimiterFactoryInterface $inviteCodeJoinLimiter
|
||||||
): Response {
|
): Response {
|
||||||
$user = $security->getUser();
|
$user = $security->getUser();
|
||||||
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST')) {
|
||||||
if ($request->request->has('create_session')) {
|
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');
|
$gameId = $request->request->get('game_id');
|
||||||
$game = $gameRepository->find($gameId);
|
$game = $gameRepository->find($gameId);
|
||||||
|
|
||||||
@@ -55,6 +64,17 @@ final class GameController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif ($request->request->has('join_session')) {
|
} 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');
|
$inviteCode = $request->request->get('invite_code');
|
||||||
if ($dashboardService->joinSession($inviteCode, $user)) {
|
if ($dashboardService->joinSession($inviteCode, $user)) {
|
||||||
$this->addFlash('success', 'Joined session successfully!');
|
$this->addFlash('success', 'Joined session successfully!');
|
||||||
@@ -70,6 +90,11 @@ final class GameController extends AbstractController
|
|||||||
return $this->redirectToRoute('game_dashboard');
|
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);
|
$inviteCode = $dashboardService->generateInviteCode($session, $user, $isAdmin);
|
||||||
if ($inviteCode) {
|
if ($inviteCode) {
|
||||||
$this->addFlash('success', 'Invite link created: ' . $inviteCode);
|
$this->addFlash('success', 'Invite link created: ' . $inviteCode);
|
||||||
@@ -79,6 +104,11 @@ final class GameController extends AbstractController
|
|||||||
$session = $sessionRepository->find($sessionId);
|
$session = $sessionRepository->find($sessionId);
|
||||||
|
|
||||||
if ($session) {
|
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)) {
|
if ($dashboardService->leaveSession($session, $user)) {
|
||||||
$this->addFlash('success', 'Left session successfully.');
|
$this->addFlash('success', 'Left session successfully.');
|
||||||
} else {
|
} else {
|
||||||
@@ -90,6 +120,11 @@ final class GameController extends AbstractController
|
|||||||
$session = $sessionRepository->find($sessionId);
|
$session = $sessionRepository->find($sessionId);
|
||||||
|
|
||||||
if ($session) {
|
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)) {
|
if ($dashboardService->startSession($session)) {
|
||||||
$this->addFlash('success', 'Session started! Screens have been assigned.');
|
$this->addFlash('success', 'Session started! Screens have been assigned.');
|
||||||
} else {
|
} else {
|
||||||
@@ -115,7 +150,8 @@ final class GameController extends AbstractController
|
|||||||
Request $request,
|
Request $request,
|
||||||
Security $security,
|
Security $security,
|
||||||
PlayerRepository $playerRepository,
|
PlayerRepository $playerRepository,
|
||||||
GameDashboardService $dashboardService
|
GameDashboardService $dashboardService,
|
||||||
|
GameResponseService $gameResponseService
|
||||||
): Response
|
): Response
|
||||||
{
|
{
|
||||||
$user = $security->getUser();
|
$user = $security->getUser();
|
||||||
@@ -126,13 +162,51 @@ final class GameController extends AbstractController
|
|||||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||||
|
|
||||||
if ($request->isMethod('POST') && $request->request->has('toggle_ready')) {
|
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);
|
$dashboardService->toggleReady($session, $user);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodically check readiness timeout
|
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
|
||||||
$dashboardService->checkAllPlayersReady($session);
|
$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) {
|
if ($session->getStatus() === SessionStatus::READY) {
|
||||||
$isReady = false;
|
$isReady = false;
|
||||||
$readyAt = null;
|
$readyAt = null;
|
||||||
@@ -157,11 +231,15 @@ final class GameController extends AbstractController
|
|||||||
|
|
||||||
$screen = $player ? $player->getScreen() : 0;
|
$screen = $player ? $player->getScreen() : 0;
|
||||||
$session_id = $session->getId();
|
$session_id = $session->getId();
|
||||||
|
$lock = $player ? $gameResponseService->getPublicLockState($player) : null;
|
||||||
|
$filesRemovalDeadline = $gameResponseService->getPublicLockedFilesDeadline($session);
|
||||||
|
|
||||||
return $this->render('game/index.html.twig', [
|
return $this->render('game/index.html.twig', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'screen' => $screen,
|
'screen' => $screen,
|
||||||
'session_id' => $session_id,
|
'session_id' => $session_id,
|
||||||
|
'lock' => $lock,
|
||||||
|
'filesRemovalDeadline' => $filesRemovalDeadline,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,14 +250,13 @@ final class GameController extends AbstractController
|
|||||||
Session $session,
|
Session $session,
|
||||||
Request $request,
|
Request $request,
|
||||||
Security $security,
|
Security $security,
|
||||||
PlayerService $playerService,
|
PlayerRepository $playerRepository
|
||||||
GameDashboardService $dashboardService
|
|
||||||
): Response {
|
): Response {
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $security->getUser();
|
$user = $security->getUser();
|
||||||
$player = $playerService->GetCurrentlyActiveAsPlayer($user);
|
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
$difficulty = $request->request->get('difficulty');
|
$difficulty = $request->request->get('difficulty');
|
||||||
$entertaining = $request->request->get('entertaining');
|
$entertaining = $request->request->get('entertaining');
|
||||||
$theme = $request->request->get('theme');
|
$theme = $request->request->get('theme');
|
||||||
@@ -198,6 +275,38 @@ 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
|
private function saveFeedback(Session $session, Player $player, $difficulty, $entertaining, $theme, $feedback): void
|
||||||
{
|
{
|
||||||
$settings = [
|
$settings = [
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,4 +66,19 @@ class Player
|
|||||||
|
|
||||||
return $this;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class Session
|
|||||||
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||||
private ?\DateTimeInterface $created = null;
|
private ?\DateTimeInterface $created = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeInterface $finishedAt = null;
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
|
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
|
||||||
private Collection $players;
|
private Collection $players;
|
||||||
|
|
||||||
@@ -97,6 +100,18 @@ class Session
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getFinishedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFinishedAt(?\DateTimeInterface $finishedAt): static
|
||||||
|
{
|
||||||
|
$this->finishedAt = $finishedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Player>
|
* @return Collection<int, Player>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Game\Enum;
|
|||||||
|
|
||||||
enum DecodeMessage: string
|
enum DecodeMessage: string
|
||||||
{
|
{
|
||||||
case TEST = 'This is a test decoding message.';
|
case PLAYER_1 = 'Sudo is now available';
|
||||||
case SECRET = 'The secret code is 42.';
|
case PLAYER_2 = 'AI virus protects its own files by replacing them';
|
||||||
case WELCOME = 'Welcome to the system, agent.';
|
case PLAYER_3 = 'The locked up bash files should be removed to lock it up';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,4 +74,15 @@ enum SessionSettingType: string
|
|||||||
case FEEDBACK_ENTERTAINING = 'FeedbackEntertaining';
|
case FEEDBACK_ENTERTAINING = 'FeedbackEntertaining';
|
||||||
case FEEDBACK_THEME = 'FeedbackTheme';
|
case FEEDBACK_THEME = 'FeedbackTheme';
|
||||||
case FEEDBACK_TEXT = 'FeedbackText';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,15 @@ enum SessionStatus: string
|
|||||||
case PLAYING = 'playing';
|
case PLAYING = 'playing';
|
||||||
case WON = 'won';
|
case WON = 'won';
|
||||||
case LOST = 'lost';
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Game\Service;
|
namespace App\Game\Service;
|
||||||
|
|
||||||
use App\Game\Entity\Game;
|
use App\Game\Entity\Game;
|
||||||
|
use App\Game\Entity\LobbyMessage;
|
||||||
use App\Game\Entity\Player;
|
use App\Game\Entity\Player;
|
||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Entity\SessionSetting;
|
use App\Game\Entity\SessionSetting;
|
||||||
@@ -11,6 +12,7 @@ use App\Game\Enum\GameStatus;
|
|||||||
use App\Game\Enum\SessionSettingType;
|
use App\Game\Enum\SessionSettingType;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Repository\GameRepository;
|
use App\Game\Repository\GameRepository;
|
||||||
|
use App\Game\Repository\LobbyMessageRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -20,9 +22,14 @@ use Symfony\Component\Mercure\Update;
|
|||||||
|
|
||||||
final class GameDashboardService
|
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(
|
public function __construct(
|
||||||
private readonly GameRepository $gameRepository,
|
private readonly GameRepository $gameRepository,
|
||||||
private readonly SessionRepository $sessionRepository,
|
private readonly SessionRepository $sessionRepository,
|
||||||
|
private readonly LobbyMessageRepository $lobbyMessageRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly HubInterface $hub,
|
private readonly HubInterface $hub,
|
||||||
) {
|
) {
|
||||||
@@ -120,6 +127,8 @@ final class GameDashboardService
|
|||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->publishLobbyEvent($session, 'player_joined');
|
||||||
|
|
||||||
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
|
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
|
||||||
$this->startSession($session);
|
$this->startSession($session);
|
||||||
}
|
}
|
||||||
@@ -202,7 +211,7 @@ final class GameDashboardService
|
|||||||
$setting->setSession($player->getSession());
|
$setting->setSession($player->getSession());
|
||||||
$setting->setPlayer($player);
|
$setting->setPlayer($player);
|
||||||
$setting->setName($pwdSettingName);
|
$setting->setName($pwdSettingName);
|
||||||
$setting->setValue('var/home/' . $player->getUser()->getUsername());
|
$setting->setValue('/var/home/' . $player->getUser()->getUsername());
|
||||||
$this->entityManager->persist($setting);
|
$this->entityManager->persist($setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,15 +290,93 @@ final class GameDashboardService
|
|||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->publishLobbyEvent($session, 'session_started');
|
||||||
|
|
||||||
return true;
|
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
|
public function toggleReady(Session $session, User $user): bool
|
||||||
{
|
{
|
||||||
if ($session->getStatus() !== SessionStatus::READY) {
|
if ($session->getStatus() !== SessionStatus::READY) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$user->isVerified()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$player = null;
|
$player = null;
|
||||||
foreach ($session->getPlayers() as $p) {
|
foreach ($session->getPlayers() as $p) {
|
||||||
if ($p->getUser() === $user) {
|
if ($p->getUser() === $user) {
|
||||||
@@ -309,11 +396,12 @@ final class GameDashboardService
|
|||||||
|
|
||||||
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
||||||
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
||||||
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
$existingSetting = $settingRepo->getSetting($session, $settingName, $player);
|
||||||
|
$nowReady = $existingSetting === null;
|
||||||
|
|
||||||
if ($setting) {
|
if ($existingSetting) {
|
||||||
$session->removeSetting($setting);
|
$session->removeSetting($existingSetting);
|
||||||
$this->entityManager->remove($setting);
|
$this->entityManager->remove($existingSetting);
|
||||||
} else {
|
} else {
|
||||||
$setting = new SessionSetting();
|
$setting = new SessionSetting();
|
||||||
$setting->setSession($session);
|
$setting->setSession($session);
|
||||||
@@ -326,16 +414,80 @@ final class GameDashboardService
|
|||||||
$this->checkAllPlayersReady($session);
|
$this->checkAllPlayersReady($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
try {
|
// If this toggle just made everyone ready, checkAllPlayersReady() already
|
||||||
$topic = '/game/hub/' . $session->getId();
|
// transitioned the session out of READY and published 'all_ready' —
|
||||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting])));
|
// don't also publish a redundant 'player_ready'.
|
||||||
} catch (\Exception $e) {
|
if ($session->getStatus() === SessionStatus::READY) {
|
||||||
// Mercure might be down, but we don't want to crash the game
|
$this->publishPlayerReady($session, $player->getScreen(), $nowReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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))));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Mercure might be down, but we don't want to crash the game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function checkAllPlayersReady(Session $session): void
|
public function checkAllPlayersReady(Session $session): void
|
||||||
{
|
{
|
||||||
if ($session->getStatus() !== SessionStatus::READY) {
|
if ($session->getStatus() !== SessionStatus::READY) {
|
||||||
@@ -351,7 +503,6 @@ final class GameDashboardService
|
|||||||
|
|
||||||
$readyPlayersCount = 0;
|
$readyPlayersCount = 0;
|
||||||
$now = new \DateTime();
|
$now = new \DateTime();
|
||||||
$anyReset = false;
|
|
||||||
|
|
||||||
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
||||||
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
||||||
@@ -363,27 +514,21 @@ final class GameDashboardService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
||||||
if ($setting) {
|
if (!$setting) {
|
||||||
$readyAtTimestamp = (int)$setting->getValue();
|
continue;
|
||||||
// Check timeout: 1 minute = 60 seconds
|
|
||||||
if (($now->getTimestamp() - $readyAtTimestamp) > 60) {
|
|
||||||
$session->removeSetting($setting);
|
|
||||||
$this->entityManager->remove($setting);
|
|
||||||
$anyReset = true;
|
|
||||||
} else {
|
|
||||||
$readyPlayersCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($anyReset) {
|
$readyAtTimestamp = (int)$setting->getValue();
|
||||||
|
if (($now->getTimestamp() - $readyAtTimestamp) >= self::READY_TIMEOUT_SECONDS) {
|
||||||
|
$session->removeSetting($setting);
|
||||||
|
$this->entityManager->remove($setting);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
try {
|
|
||||||
$topic = '/game/hub/' . $session->getId();
|
$this->publishPlayerReady($session, $player->getScreen(), false);
|
||||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready'])));
|
continue;
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Mercure might be down
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$readyPlayersCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($readyPlayersCount === $numPlayers) {
|
if ($readyPlayersCount === $numPlayers) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ namespace App\Game\Service;
|
|||||||
|
|
||||||
use App\Game\Enum\DecodeMessage;
|
use App\Game\Enum\DecodeMessage;
|
||||||
use App\Game\Enum\SessionSettingType;
|
use App\Game\Enum\SessionSettingType;
|
||||||
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Entity\Player;
|
use App\Game\Entity\Player;
|
||||||
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Entity\SessionSetting;
|
use App\Game\Entity\SessionSetting;
|
||||||
use App\Game\Repository\SessionSettingRepository;
|
use App\Game\Repository\SessionSettingRepository;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
@@ -15,6 +17,10 @@ use Symfony\Component\Mercure\Update;
|
|||||||
|
|
||||||
class GameResponseService
|
class GameResponseService
|
||||||
{
|
{
|
||||||
|
private const LOCK_REVEAL_AFTER_SECONDS = 30;
|
||||||
|
private const LOCK_DURATION_SECONDS = 45;
|
||||||
|
private const LOCK_PASSCODE_LENGTH = 12;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private PlayerService $playerService,
|
private PlayerService $playerService,
|
||||||
@@ -25,6 +31,25 @@ 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
|
public function getGameResponse(string $raw) : array
|
||||||
{
|
{
|
||||||
$info = json_decode($raw, true);
|
$info = json_decode($raw, true);
|
||||||
@@ -45,15 +70,19 @@ class GameResponseService
|
|||||||
if(!$player)
|
if(!$player)
|
||||||
return ['error' => 'You are not in a game.'];
|
return ['error' => 'You are not in a game.'];
|
||||||
|
|
||||||
|
$this->enforceLockedFilesRemovalDeadline($player->getSession());
|
||||||
|
|
||||||
$this->logSessionActivity($player, 'PLAYER: ' . $message);
|
$this->logSessionActivity($player, 'PLAYER: ' . $message);
|
||||||
|
|
||||||
$data = [];
|
$data = $this->handleLockedPlayer($message, $player);
|
||||||
|
|
||||||
|
if ($data === null) {
|
||||||
if (str_starts_with($message, '/')) {
|
if (str_starts_with($message, '/')) {
|
||||||
$data = $this->checkGameCommando($message, $player);
|
$data = $this->checkGameCommando($message, $player);
|
||||||
} else {
|
} else {
|
||||||
$data = $this->checkConsoleCommando($message, $player);
|
$data = $this->checkConsoleCommando($message, $player);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$responseLog = '';
|
$responseLog = '';
|
||||||
if (isset($data['result']) && is_array($data['result'])) {
|
if (isset($data['result']) && is_array($data['result'])) {
|
||||||
@@ -78,14 +107,13 @@ class GameResponseService
|
|||||||
private function logSessionActivity(Player $player, string $content): void
|
private function logSessionActivity(Player $player, string $content): void
|
||||||
{
|
{
|
||||||
$sessionId = $player->getSession()->getId();
|
$sessionId = $player->getSession()->getId();
|
||||||
$username = $player->getUser()->getUsername();
|
|
||||||
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
|
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
|
||||||
|
|
||||||
if (!is_dir($logDir)) {
|
if (!is_dir($logDir)) {
|
||||||
mkdir($logDir, 0777, true);
|
mkdir($logDir, 0777, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$logFile = $logDir . '/' . $username . '.txt';
|
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
|
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
|
||||||
|
|
||||||
@@ -202,7 +230,33 @@ class GameResponseService
|
|||||||
return ['result' => ['You are not allowed to remove this file.']];
|
return ['result' => ['You are not allowed to remove this file.']];
|
||||||
|
|
||||||
$this->playerService->addDeletedFileToSession($player, $fullPath);
|
$this->playerService->addDeletedFileToSession($player, $fullPath);
|
||||||
return ['result' => ['File removed: ' . $filename]];
|
|
||||||
|
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,
|
||||||
|
];
|
||||||
case 'sudo':
|
case 'sudo':
|
||||||
if(!in_array('sudo', $rechten))
|
if(!in_array('sudo', $rechten))
|
||||||
return ['result' => ['Unknown command']];
|
return ['result' => ['Unknown command']];
|
||||||
@@ -211,6 +265,17 @@ class GameResponseService
|
|||||||
$message = implode(' ', $messagePart);
|
$message = implode(' ', $messagePart);
|
||||||
|
|
||||||
return $this->checkConsoleCommando($message, $player, true);
|
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:
|
default:
|
||||||
return ['result' => ['Unknown command']];
|
return ['result' => ['Unknown command']];
|
||||||
}
|
}
|
||||||
@@ -287,6 +352,12 @@ class GameResponseService
|
|||||||
$messages[] = ' USAGE: sudo {command}';
|
$messages[] = ' USAGE: sudo {command}';
|
||||||
$messages[] = '';
|
$messages[] = '';
|
||||||
break;
|
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':
|
case 'verify':
|
||||||
$messages[] = '/verify';
|
$messages[] = '/verify';
|
||||||
$messages[] = ' You can verify yourself by using this command.';
|
$messages[] = ' You can verify yourself by using this command.';
|
||||||
@@ -470,24 +541,213 @@ class GameResponseService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleDecodeMessage(string $message, Player $player)
|
private function handleDecodeMessage(string $message, Player $player): string
|
||||||
{
|
{
|
||||||
$userNumber = $player->getScreen();
|
$userNumber = $player->getScreen();
|
||||||
|
|
||||||
preg_match('/\d+/', $message, $matches);
|
preg_match('/\d/', $message, $matches);
|
||||||
$num = $matches[0] ?? null;
|
$num = $matches[0] ?? null;
|
||||||
$randomString = $this->generateRandomString(250, 500);
|
$randomString = $this->generateRandomString(250, 500);
|
||||||
|
|
||||||
if(is_null($num) || $num != $userNumber)
|
if (is_null($num) || (int)$num !== $userNumber) {
|
||||||
return $randomString;
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $randomString;
|
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateRandomString(int $min, int $max): string
|
private function generateRandomString(int $min, int $max): string
|
||||||
@@ -635,7 +895,7 @@ class GameResponseService
|
|||||||
private function checkIfAllPlayersVerified(Player $player): void
|
private function checkIfAllPlayersVerified(Player $player): void
|
||||||
{
|
{
|
||||||
$session = $player->getSession();
|
$session = $player->getSession();
|
||||||
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::EVERYONE_VERIFIED, $player);
|
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::EVERYONE_VERIFIED);
|
||||||
|
|
||||||
if ($everyoneVerifiedSetting && $everyoneVerifiedSetting->getValue() === 'true') {
|
if ($everyoneVerifiedSetting && $everyoneVerifiedSetting->getValue() === 'true') {
|
||||||
return;
|
return;
|
||||||
@@ -663,7 +923,6 @@ class GameResponseService
|
|||||||
if (!$everyoneVerifiedSetting) {
|
if (!$everyoneVerifiedSetting) {
|
||||||
$everyoneVerifiedSetting = new SessionSetting();
|
$everyoneVerifiedSetting = new SessionSetting();
|
||||||
$everyoneVerifiedSetting->setSession($session);
|
$everyoneVerifiedSetting->setSession($session);
|
||||||
$everyoneVerifiedSetting->setPlayer($player);
|
|
||||||
$everyoneVerifiedSetting->setName(SessionSettingType::EVERYONE_VERIFIED);
|
$everyoneVerifiedSetting->setName(SessionSettingType::EVERYONE_VERIFIED);
|
||||||
}
|
}
|
||||||
$everyoneVerifiedSetting->setValue('true');
|
$everyoneVerifiedSetting->setValue('true');
|
||||||
@@ -738,6 +997,61 @@ class GameResponseService
|
|||||||
$paths[] = '/etc/handle';
|
$paths[] = '/etc/handle';
|
||||||
$paths[] = '/etc/freak';
|
$paths[] = '/etc/freak';
|
||||||
$paths[] = '/etc/host';
|
$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';
|
$paths[] = '/var/home';
|
||||||
|
|
||||||
@@ -771,13 +1085,146 @@ class GameResponseService
|
|||||||
if(in_array('sudo', $rights) || $sudo)
|
if(in_array('sudo', $rights) || $sudo)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
$sudoFiles = [
|
return !in_array($file, $this->getLockedFiles());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLockedFiles() : array
|
||||||
|
{
|
||||||
|
return [
|
||||||
'/var/arrest/handle.sh',
|
'/var/arrest/handle.sh',
|
||||||
'/var/arrest/cell.sh',
|
'/var/arrest/cell.sh',
|
||||||
'/var/marriage/divorce.sh',
|
'/var/marriage/divorce.sh',
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return !in_array($file, $sudoFiles);
|
/**
|
||||||
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function fileExists(string $file, Player $player) : bool
|
private function fileExists(string $file, Player $player) : bool
|
||||||
@@ -821,6 +1268,127 @@ class GameResponseService
|
|||||||
$files[] = '/var/rapports/011_130-62.txt';
|
$files[] = '/var/rapports/011_130-62.txt';
|
||||||
$files[] = '/var/rapports/index.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) {
|
if ($player === null) {
|
||||||
return $files;
|
return $files;
|
||||||
}
|
}
|
||||||
@@ -893,7 +1461,7 @@ class GameResponseService
|
|||||||
return $this->readVerificationFile($player, $file);
|
return $this->readVerificationFile($player, $file);
|
||||||
}
|
}
|
||||||
|
|
||||||
$physicalPath = 'assets/game1/filesystem' . $file;
|
$physicalPath = $this->projectDir . '/assets/game1/filesystem' . $file;
|
||||||
if (!file_exists($physicalPath)) {
|
if (!file_exists($physicalPath)) {
|
||||||
return ['File does not exist'];
|
return ['File does not exist'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,4 +93,21 @@ class PlayerService
|
|||||||
$this->entityManager->flush();
|
$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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ class EmailLog
|
|||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
private ?User $user = null;
|
private ?User $user = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ResetPasswordRequest implements ResetPasswordRequestInterface
|
|||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
#[ORM\ManyToOne]
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
private ?User $user = null;
|
private ?User $user = null;
|
||||||
|
|
||||||
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(type: 'boolean')]
|
#[ORM\Column(type: 'boolean')]
|
||||||
private bool $isVerified = false;
|
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
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -137,4 +146,45 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
|
|
||||||
return $this;
|
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,6 +22,10 @@ class EmailLoggerListener
|
|||||||
|
|
||||||
public function onMessage(MessageEvent $event): void
|
public function onMessage(MessageEvent $event): void
|
||||||
{
|
{
|
||||||
|
if ($event->isQueued()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$message = $event->getMessage();
|
$message = $event->getMessage();
|
||||||
if (!$message instanceof TemplatedEmail) {
|
if (!$message instanceof TemplatedEmail) {
|
||||||
return;
|
return;
|
||||||
@@ -38,6 +42,11 @@ class EmailLoggerListener
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user->isDeleted()) {
|
||||||
|
$event->reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$emailLog = new EmailLog();
|
$emailLog = new EmailLog();
|
||||||
$emailLog->setUser($user);
|
$emailLog->setUser($user);
|
||||||
$emailLog->setSentAt(new \DateTimeImmutable());
|
$emailLog->setSentAt(new \DateTimeImmutable());
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
use Symfony\Component\Validator\Constraints\Email;
|
use Symfony\Component\Validator\Constraints\Email;
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Component\Validator\Constraints\Regex;
|
||||||
|
|
||||||
class AdminUserType extends AbstractType
|
class AdminUserType extends AbstractType
|
||||||
{
|
{
|
||||||
@@ -26,7 +27,14 @@ class AdminUserType extends AbstractType
|
|||||||
'constraints' => [new NotBlank(), new Email()],
|
'constraints' => [new NotBlank(), new Email()],
|
||||||
])
|
])
|
||||||
->add('username', TextType::class, [
|
->add('username', TextType::class, [
|
||||||
'constraints' => [new NotBlank(), new Length(min: 2, max: 180)],
|
'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.',
|
||||||
|
),
|
||||||
|
],
|
||||||
])
|
])
|
||||||
->add('plainPassword', PasswordType::class, [
|
->add('plainPassword', PasswordType::class, [
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?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
Reference in New Issue
Block a user