New /profile route (nav link between Admin and Logout) with two
independent forms, both requiring the current password before
applying a change:
- Change password: standard current/new/repeat flow, same password
strength rules as registration.
- Change email: re-checks the new address isn't already taken (avoids
a 500 from the unique constraint), then marks the account
unverified and re-sends the confirmation email via the existing
EmailVerifier/verify-email flow, same as registration. The current
session stays logged in, but the user needs to verify the new
address before their next login (UserChecker already blocks
unverified accounts).
GameController::index() only special-cased READY; every other status
(including a freshly-created session still waiting for players, and
even an already WON/LOST one) fell straight through to rendering the
live game terminal - which is broken there since screens/rights/pwd
are never initialized before startSession() flips CREATED -> READY.
The dashboard's "Enter Game" button links to this route regardless of
status, so any player clicking it on a CREATED session hit this
directly. Now CREATED redirects back to the dashboard with an
explanatory flash, and WON/LOST redirect to their respective pages
instead of re-rendering a dead terminal.
Fixes the 500 on admin user deletion: deleting a user with an
email_log row (i.e. basically anyone who received any email) hit an
unhandled ForeignKeyConstraintViolationException, since email_log,
reset_password_request and player all have non-nullable FKs to user
with no cascade at the DB level.
Instead of cascading the delete (which would be fine for email_log
and reset_password_request but risky for player - removing a player
row could corrupt other real players' session state), admin delete
now sets a deletedAt timestamp instead of removing the row:
- UserChecker blocks login for deleted users (checkPreAuth).
- EmailLoggerListener rejects the message before send for deleted
recipients (checked at actual send time, not at queue time).
- Added a last_login_at column + a LoginSuccessEvent listener to
populate it, and surfaced both last login and status in the admin
users list.
Added `app:users:purge-deleted`, a command intended to run on a
schedule that permanently removes users who were soft-deleted more
than 3 months ago and never played a game (join to Player via a
NOT EXISTS subquery). For that eventual hard-delete to actually
succeed, email_log and reset_password_request now cascade-delete at
the DB level (migration drops+recreates both FK constraints with ON
DELETE CASCADE) - player intentionally still isn't cascaded, so a
user with game history can never be purged this way even by mistake.
Cancelled donations now show "Donation failed, but thank you for
trying anyway." and successful ones show "Thank you for the
donation!" on the dashboard after redirect.
The audit fix (presumably run with --force) jumped @symfony/webpack-encore
from ^4.6.1 to ^7.1.0, which requires @babel/core ^8.0.0 as a peer -
but @babel/core was left at ^7.25.0, so a clean `npm install` failed
outright with ERESOLVE. Since docker/setup.sh and docker/restart.sh
both run npm install unconditionally, this would have broken every
future deploy/rebuild.
Reverted webpack-encore (and the incidentally-downgraded
webpack-notifier) back to the versions that were actually working,
regenerated package-lock.json, and verified both `npm install` and
`npm run build` succeed cleanly.
Ready status previously expired 60 seconds after a player checked the
box, evaluated per-player against their own timestamp. Unless every
player happened to click ready within the same 60-second window, the
earliest player's readiness would silently expire before the last one
joined, so the session could get stuck on "Waiting for all players to
be ready" indefinitely, especially after a reload re-triggered the
timeout check.
Ready state is now durable: once checked, it stays until the player
unchecks it or the whole group is simultaneously ready, at which point
the session always transitions to PLAYING regardless of how long that
took. session.timer is still only ever set once during that one-way
READY -> PLAYING transition, so a reload never restarts or desyncs the
countdown between players.
Removing all 3 locked files while the timer is still running now marks
the session WON immediately (checked right after every successful rm)
and broadcasts a "game_finished" signal over Mercure so every
connected player gets redirected together, not just the one who
removed the last file. A new /won/{session} route + won.html.twig
mirrors the existing lost flow (victory narrative + the same feedback
form).
The existing timer-expiry path already set LOST but always redirected
to lostUrl regardless of actual status; it now picks won/lost based on
the status the server reports.
Also fixes a pre-existing bug on the lost page (and would-be bug on
the new won page): PlayerService::GetCurrentlyActiveAsPlayer() only
matches players in READY/PLAYING sessions, so by the time a session
has ended it always returned null there, silently breaking the
feedback form. Both pages now look the player up directly via
PlayerRepository instead.
Added a navigatingAway flag so the page's "confirm before leaving"
prompt doesn't block our own win/lose redirects.
Previously the restore check only ran lazily on a player's next
message, so files could stay wrongly-removed for an arbitrary amount
of time after the window expired. The frontend now schedules a
setTimeout (using the server-provided deadline, mirroring the terminal
lock's reveal timer) that pings the backend right at the deadline so
the check runs promptly regardless of player activity. Restored via
data-files-removal-deadline on page load too, so a refresh mid-window
doesn't lose the timer.
Removing one of the 3 AI-virus-protected files now starts a 60-second
window (tracked session-wide via LockedFilesRemovalDeadline). If the
other locked files aren't also removed before it expires, the virus
restores whichever ones were deleted and broadcasts a red warning to
the whole session, forcing players to coordinate the removal instead
of picking them off one at a time.
Also extends the Mercure broadcast payload with an optional 3rd
"messageType" element so pushed messages can render red (virus) or
green (mainframe) instead of always defaulting to green.
Message font dropped from 20px to 14px and the 10px inline margin-bottom
(set from JS on every message) replaced with a 2px CSS margin so the
terminal reads like dense console output instead of spaced-out chat
bubbles. Colors are unchanged.
Connects the previously-disconnected /decode command to per-player
messages: player 1 unlocks sudo for everyone, player 2 unlocks a scan
command that reveals which files the AI virus has locked, player 3
learns those files should be removed.
Also adds a retaliation mechanic: successfully removing a file locks
the player's terminal. The AI virus locks it out in red immediately;
the mainframe reveals a 12-character recovery code in green after 30
seconds, which can be submitted via /unlock to restore access early,
otherwise the terminal auto-recovers after 45 seconds.
Overlay a rotated CSS badge on the homepage hero and briefing page
image instead of baking it into the PNG, so it's easy to remove once
the game ships.
Give the public homepage a themed breach-alert hero and add a /briefing
page with the full mission narrative, both linking into the existing
game dashboard flow.
Adds a Marketing column to the admin users table and a total opt-in
count in the header, so admins can see who signed up for updates on
future projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Registration now requires agreeing to the Terms and Conditions and lets
users opt in to hear about future projects. The opt-in is persisted on
the user via a new marketing_opt_in column (migration included).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admins now see an Admin link in the main navigation, and the admin
section inherits the branded header/footer from layout/site.html.twig
instead of the bare base layout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
checkIfAllPlayersVerified() read and wrote the EveryoneVerified
setting scoped to whichever player happened to trigger the check
(getSetting(..., $player) / setPlayer($player)), but it's meant to be
a single session-wide flag. getFileContent() correctly reads it as
session-global (no player, filters player IS NULL), so the two never
matched: the "everyone verified" Mercure message still fired (that
code path only checks its own player-scoped copy), but the special
code injection into the Doyle/Vega/Lennox report files never ran
since getFileContent() never found the setting. Store and read it
consistently as session-global.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
getFileContent() built the game1 filesystem path as a relative
string with no leading slash. That only resolves correctly when
PHP's cwd happens to be the project root (e.g. CLI), but under
PHP-FPM/nginx the cwd is nginx's document root (public/), so
file_exists() always failed for real requests even though the file
existed and the in-memory virtual file list (used by ls) said it
should. Use the already-injected $projectDir (%kernel.project_dir%),
matching how the class already builds the session log path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every possible path/file in GameResponseService uses a leading slash
(e.g. /var/home/{username}), but a player's initial pwd was seeded as
'var/home/{username}' without one. getAllCurrentFilesInDirectory()
matches entries by comparing getPrevPath() (which always has the
leading slash) against pwd, so a fresh player's ls always came back
empty until their first cd command happened to normalize the format.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When the last player clicked ready, toggleReady() published a
redundant 'player_ready' event on top of checkAllPlayersReady()'s
'all_ready', and the client reloaded on every message with no guard
and without closing the EventSource. Chrome kept the old page's
script (and its EventSource) alive across the overlapping reload
calls, causing repeated reconnects to the Mercure hub; Firefox
apparently tore the page down fast enough to mask it. Skip the
redundant publish server-side, and make the client reload idempotent
by tracking whether it already fired and closing the EventSource
before reloading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docker/.env feeds MERCURE_EXTRA_DIRECTIVES, a Caddyfile-style config
block, via Compose variable substitution. Quoting a space-separated
value there makes Caddy treat both origins as one single malformed
token rather than two arguments, which crash-loops the Mercure
container on startup. Compose's .env parsing for values with spaces
doesn't require quotes (unlike Symfony's Dotenv), so drop them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Production's MERCURE_CORS_ALLOWED_ORIGINS only allowed
https://escapepage.com, but nginx has no www redirect
(server_name _;), so the site is also reachable at
https://www.escapepage.com. Visitors on the www host got a CORS
error on the Mercure EventSource connection since the Origin header
didn't match the allow-list. Dev's .env already allowed both; bring
docker/.env.dist in line, and fix its stale MERCURE_PUBLIC_URL
(bare domain instead of the mercure. subdomain actually used).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
confirmation_email.html.twig and reset_password/email.html.twig were
plain unstyled HTML with no shared structure. Extract a table-based
layout (templates/emails/layout.html.twig) with header/logo, content
block, and footer, so future transactional emails can extend it
instead of starting from scratch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mailer dispatches MessageEvent twice when routed through Messenger:
once when queuing (queued=true) and once on the actual send from the
worker (queued=false). EmailLoggerListener logged on both, creating
two rows per email actually sent. Skip the queued dispatch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The project now sends mail via Mailgun (symfony/mailgun-mailer), not
SendGrid, so the tracked template should reflect the real transport
in use.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.env/.env.dev/.env.prod/.env.test are now gitignored, leaving no
tracked reference for what variables a fresh checkout needs. Add a
placeholder-only .env.dist (mirroring docker/.env.dist) to copy from.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.env, .env.dev, .env.prod, .env.test, and docker/.env contained real
production secrets and were tracked in git despite the Symfony
convention of keeping them local-only. Untrack them and ignore them
going forward; docker/.env.dist stays as a template but now uses
placeholder values instead of live credentials.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
setup.sh was forcing --env-file ../.env (root .env with placeholder secret)
instead of letting Docker Compose use docker/.env (real secret). Mercure
config now generates the publisher JWT from MERCURE_JWT_SECRET directly,
removing the need for a separate pre-generated token.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>