169 Commits
Author SHA1 Message Date
FrankandClaude Sonnet 5 ba45e06972 Remove |raw from login error message rendering
Not exploitable today - the only custom auth message data is a
hardcoded resend link - but |raw on a translated exception message is
a latent XSS pattern if a future change ever threads user input
through the auth exception's message data. Twig's default
autoescaping is sufficient here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:53 +02:00
FrankandClaude Sonnet 5 2913b8d2a2 Add CSRF protection, login throttling, and invite-code rate limiting
The admin panel already checked CSRF tokens on destructive actions,
but the player-facing raw HTML forms (create/join/leave/start
session, toggle ready, lobby chat, feedback) had none - cookie
SameSite=Lax blunts classic cross-site auto-submit attacks but isn't
a substitute for real tokens. Adds matching csrf_token()/
isCsrfTokenValid() checks to all of them.

Also adds login_throttling (5 attempts/15 min) to stop unlimited
password guessing, and a per-user rate limiter (10/min) on the
invite-code join endpoint, since invite codes are only 32-bit and
had no protection against brute-forcing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:47 +02:00
FrankandClaude Sonnet 5 335697e520 Update dependencies to patch known CVEs
composer audit reported 37 advisories across 15 packages, including
high-severity ones in symfony/security-http. Ran composer update
within the existing 7.4.* constraints - composer audit now reports
zero advisories. Also adds symfony/rate-limiter, needed for login
throttling and invite-code rate limiting in the next commit.

Flex removed a stale, non-functional sendgrid notifier config left
over from before the app switched to Mailgun as part of the sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:36 +02:00
FrankandClaude Sonnet 5 daa37390d0 Fix path traversal via username in session log file paths
Registration only validated username with NotBlank, so a username
like "../../../../public/x" got concatenated directly into a
filesystem path for both writing (game activity logs) and reading
(admin log viewer) - reachable from the public webroot since public/
is a few directories up from where those logs are stored.

Adds a character-set validator (letters, numbers, underscore, hyphen)
to registration and admin user editing going forward, and sanitizes
at the point of use (Player::getLogFileBasename()) so any
already-stored unsafe username can't escape the log directory either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:25 +02:00
FrankandClaude Sonnet 5 a9cb0fa57f Turn admin lobby chat panel into a tab
The lobby chat was a standalone card sitting above the per-player log
tabs. Folds it into the same tab bar as the first (default-active)
tab instead, so the session log view has one consistent tab strip.
The tab-switching script now toggles by an .admin-tab-panel class
instead of assuming every panel's id starts with "player-", since
that's no longer true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 16:48:06 +02:00
FrankandClaude Sonnet 5 2bfc2aca1a Keep pregame lobby chat open for an hour after the game ends
The chat used to disappear the moment a session left CREATED status.
Sessions now record a finishedAt timestamp when they're won or lost,
and the lobby (with chat) stays reachable via /game/{session} for an
hour afterward instead of immediately redirecting to the win/lose
feedback page. The lobby template shows a distinct "game finished"
header with a link to that feedback page during this window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 16:47:57 +02:00
FrankandClaude Sonnet 5 846cfbc44a Remove dead admin/session.html.twig template
Unreferenced by any controller - superseded by
templates/game/admin/sessions/view.html.twig, which is what
GameAdminController::viewSession() actually renders. Confirmed via
grep across src/ and templates/, plus a clean lint:twig and phpunit
run after removal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:32:53 +02:00
FrankandClaude Sonnet 5 207346d571 Move inline template scripts into webpack-built assets
Several templates had their own <script> blocks instead of going
through webpack like game1.js already does. Extracted the waiting
page, lobby page, and admin session-log tab scripts into their own
files under assets/, imported from the main app.js entry. Since
app.js is already loaded on every page, each module just reads its
own data-* attributes and no-ops if its target element isn't present
on the current page - same pattern game1.js already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:32:44 +02:00
FrankandClaude Sonnet 5 dfa75719d0 Show pregame lobby chat in admin session logs
Admins can now see the full lobby chat transcript (who said what,
when) at the top of a session's log view, alongside the existing
per-player terminal logs. Also swaps the log tabs' inline onclick
handler for a data attribute, in prep for moving the tab-switching
script out of the template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:32:31 +02:00
FrankandClaude Sonnet 5 444f54b6c6 Add pregame lobby with live chat
Players used to get bounced back to the dashboard when a session
wasn't full yet. Now they land on a lobby page showing who has
joined, and can chat with each other while waiting - messages are
broadcast live over the existing Mercure hub, and the session
auto-starts (and the lobby notifies everyone) once it fills up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:32:23 +02:00
Frank 98cf48b29d Make admin panel usable on mobile
The sidebar was a fixed 220px flex column that left barely any room
for content on a phone, and tables had no min-width so columns just
squeezed into unreadable slivers instead of scrolling.

Moves the sidebar's layout rules out of inline styles (which media
queries can't override) into real CSS classes, and collapses it into
a horizontally-scrollable pill bar below 768px so the content area
gets the full screen width. Tables now get a sensible min-width so
they scroll horizontally on narrow screens instead of squishing, and
the per-player tab bar on the session log view scrolls too.
2026-08-10 14:58:12 +02:00
Frank f6df9f7ba6 Show friendly session status labels on the player dashboard
Players saw the raw enum value (e.g. "created") in the sessions
table, which doesn't mean anything to them. Adds SessionStatus::label()
mapping each status to a player-facing description like "Waiting for
players".
2026-08-10 14:58:01 +02:00
FrankandClaude Sonnet 5 fc93486367 Restore executable bit on docker/restart.sh and docker/setup.sh
Lost accidentally in the previous commit; both scripts are invoked
directly (./docker/setup.sh) rather than via bash, so they need +x.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:35:50 +02:00
FrankandClaude Sonnet 5 98066118a6 Expand terminal filesystem, add mainframe hint cron, redirect PHP logs
- Game1 terminal: flesh out the virtual filesystem with a realistic
  spread of Linux directories/files (~70 dirs, ~140 files) so it no
  longer reads as an obviously small puzzle set, without touching any
  win-condition or rapport files.
- Add app:hints:check command + a php-cron container (BusyBox crond)
  that nudges players who haven't contacted every teammate 5 minutes
  into a session, via a new 'hint' Mercure message type.
- Log the cron command's output to var/log/cron/cron.log and rotate
  it (25MB / 90 days) via logrotate, run daily from the same crontab.
- Redirect PHP's error_log and Symfony's prod app/deprecation logs
  from stderr-only into var/log/php/*.log (kept alongside stderr),
  with the same rotation policy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:34:36 +02:00
Frank 1be07440e3 Bring back 1-minute ready-status expiry with live per-player broadcasts
A player's own browser now starts a 60s timer the moment they check
"ready" (with a visible countdown) and proactively tells the server
when it's up via a new expire_ready action - idempotent, so it only
actually clears the status if the deadline has genuinely passed.
Everyone else finds out live through a Mercure player_ready broadcast
that now carries which player and their new ready/not-ready state
(previously the broadcast payload's `ready` flag was always false due
to a stale-variable bug, though nothing consumed it yet).

checkAllPlayersReady() still does the same expiry check server-side
and broadcasts on anyone else's request in the meantime, so a stalled
frontend timer doesn't leave a stale "ready" badge showing forever.

This only affects the pre-PLAYING ready phase: checkAllPlayersReady()
still flips the session to PLAYING and stops touching ready state the
instant everyone is simultaneously ready, so the earlier fix (a reload
should never send an already-started game back to the waiting room)
is unaffected.

Also fixed the ready checkbox itself: unchecking it submitted a POST
without `toggle_ready` in the body (unchecked checkboxes aren't sent),
so un-readying silently did nothing server-side. Added the standard
hidden-fallback-input pattern to fix it.
2026-07-11 23:45:41 +02:00
Frank c28abef5b7 Block unverified users from marking themselves ready
GameDashboardService::toggleReady() now rejects the toggle if the
user's email isn't verified (mirrors the same gate UserChecker already
applies at login). The waiting-room checkbox is disabled client-side
with an explanatory alert and a link to resend the verification email,
and the controller adds a flash error as a server-side fallback if it
somehow gets submitted anyway.
2026-07-11 23:25:50 +02:00
Frank 886208c5c0 Add profile page for changing password and email
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).
2026-07-11 23:23:38 +02:00
Frank 2941cfca49 Fix game route dumping players into the terminal for non-PLAYING sessions
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.
2026-07-11 23:13:30 +02:00
Frank 3c58e153dc Soft-delete users instead of hard-deleting, add last login tracking
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.
2026-07-11 23:09:52 +02:00
Frank 3a7bc3ed49 Package install 2026-07-11 20:27:32 +00:00
Frank aa667df889 Add /donation/success route and flash messages for both outcomes
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.
2026-07-11 22:25:05 +02:00
Frank c06c45cb52 Add /donation/cancel route to fix 404 on cancelled PayPal donations
Redirects to the game dashboard instead of 404ing.
2026-07-11 22:19:51 +02:00
Frank 51679d9a6a Revert webpack-encore major bump from npm audit fix
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.
2026-07-11 22:10:46 +02:00
Frank e8be7919ef npm audit fix 2026-07-11 20:05:59 +00:00
Frank cb2e945419 Remove 60-second ready-status expiry so the group can always start
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.
2026-07-11 21:48:32 +02:00
Frank 3984a33282 Add win/lose game-ending flow
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.
2026-07-11 21:41:16 +02:00
Frank eee6c3a369 Auto-trigger locked-files restoration exactly at the 60s deadline
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.
2026-07-11 17:45:08 +02:00
Frank e6ba469ef9 Restore locked files if not all removed within 60 seconds
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.
2026-07-11 17:26:59 +02:00
Frank f6a0d62017 Make terminal output more authentic: smaller font, tighter line spacing
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.
2026-07-11 17:21:41 +02:00
Frank 6fd0b5d993 Grant rm right alongside sudo when player 1 decodes their message
rm was checked everywhere but never actually added to any player's
rights, so the command was unreachable until now.
2026-07-11 17:08:44 +02:00
Frank 6db9d42852 Wire up decode puzzle chain and lock terminal on file removal
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.
2026-07-11 16:48:15 +02:00
Frank 1e644eb13b Add "Looking for Help" page for the open visual artist request
Adds a /looking-for-help page listing the open volunteer request and
links it from the site footer on every page.
2026-07-04 23:23:04 +02:00
Frank 8554f04735 Add "In Development" watermark badge over AI virus artwork
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.
2026-07-04 23:02:46 +02:00
Frank 839113c356 Add AI Virus Deflection story to homepage and new agent briefing page
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.
2026-07-04 23:00:31 +02:00
FrankandClaude Sonnet 5 7dbb738da8 Show marketing opt-in status in admin users overview
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>
2026-07-04 20:48:48 +02:00
FrankandClaude Sonnet 5 ec34a1ddb9 Add Terms and Conditions and marketing opt-in checkboxes to registration
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>
2026-07-04 20:28:52 +02:00
FrankandClaude Sonnet 5 703d2af3d8 Add admin nav link and reuse site header/footer on admin pages
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>
2026-07-04 19:15:24 +02:00
Frank 5b03bd1d1c Complete layout overhaul 2026-07-04 19:08:31 +02:00
FrankandClaude Sonnet 5 05f4f2c32f Fix EveryoneVerified setting being scoped to a specific player
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>
2026-07-04 18:19:14 +02:00
FrankandClaude Sonnet 5 ddd2726687 Fix cat command using relative path for physical file lookup
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>
2026-07-04 18:12:55 +02:00
FrankandClaude Sonnet 5 3721abcc5d Fix missing leading slash in player's initial working directory
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>
2026-07-04 17:03:30 +02:00
FrankandClaude Sonnet 5 e76d0b0f07 Fix Mercure reload spam on waiting page in Chrome
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>
2026-07-04 16:26:47 +02:00
FrankandClaude Sonnet 5 b7693bd9d0 Remove quotes around MERCURE_CORS_ALLOWED_ORIGINS in docker/.env.dist
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>
2026-07-04 15:43:03 +02:00
FrankandClaude Sonnet 5 b4f6a531c9 Fix Mercure CORS to allow both www and bare domain
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>
2026-07-04 15:26:17 +02:00
FrankandClaude Sonnet 5 c4e5139ec4 Increase email header logo size from 40px to 140px
40px made the logo too small in the header banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:55:15 +02:00
FrankandClaude Sonnet 5 238705495e Add shared HTML layout for transactional emails
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>
2026-07-04 14:51:34 +02:00
FrankandClaude Sonnet 5 4a5d83ef53 Fix duplicate EmailLog rows for a single sent email
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>
2026-07-04 14:44:03 +02:00
FrankandClaude Sonnet 5 0e12e7fa8f Switch docker/.env.dist mailer template from SendGrid to Mailgun
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>
2026-07-04 14:23:21 +02:00
Frank 3924b42ce0 Changes in install 2026-07-04 11:58:44 +00:00
Frank 58c9c60ad2 Mailer 2026-07-04 13:57:00 +02:00
Frank b11c50c237 composer files 2026-07-04 11:51:47 +00:00
Frank 2316266b80 Upgrades 2026-07-04 10:58:17 +00:00
Frank 2a45ebb953 server executable rights 2026-07-04 10:58:17 +00:00
FrankandClaude Sonnet 5 a1ff6df721 Add root .env.dist template
.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>
2026-07-04 12:49:51 +02:00
FrankandClaude Sonnet 5 487019d360 Stop tracking .env files and sanitize docker/.env.dist
.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>
2026-07-04 12:29:04 +02:00
Frank van den BergandClaude Sonnet 4.6 c045922c5b Fix Mercure JWT secret mismatch
setup.sh was forcing --env-file ../.env (root .env with placeholder secret)
instead of letting Docker Compose use docker/.env (real secret). Mercure
config now generates the publisher JWT from MERCURE_JWT_SECRET directly,
removing the need for a separate pre-generated token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:24:09 +02:00
Frank van den BergandClaude Sonnet 4.6 aa56617e50 hub location mercure adjustment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:09:35 +02:00
Frank van den Berg 831603e04e Migration game 1 2026-06-27 16:07:22 +02:00
Frank van den Berg ac57385a9d Admin panels 2026-06-27 15:53:43 +02:00
Frank van den Berg 0d8335dd2c Command voor aanmaken user 2026-06-27 15:34:36 +02:00
Frank van den Berg 6c40288fd8 Fix mailcatcher 2026-06-27 15:13:17 +02:00
Frank van den Berg 994bbafba2 Updates 2026-06-27 14:26:04 +02:00
Frank van den Berg a05adfd316 Claude aanpassingen 2026-06-27 13:23:08 +02:00
Frank van den Berg 9205db6611 changes for ssl 2026-06-27 12:07:06 +02:00
Frank 3b1d3a5aad Fixed network v4 2026-03-11 07:59:23 +01:00
Frank 8b3bf68954 Fixed network v3 2026-03-11 07:57:49 +01:00
Frank 74dadf102d Fixed network v2 2026-03-11 07:55:47 +01:00
Frank 74d5cd15b6 Fixed network 2026-03-11 07:52:11 +01:00
Frank 4e696af231 Fixed ip addresses 2026-03-10 22:13:22 +01:00
Frank 539a243353 Fix database container creation 2026-03-10 22:04:23 +01:00
Frank eb3d36c99f Unfixed ips 2026-03-10 22:00:08 +01:00
Frank 8564257761 Fixed ips 2026-03-10 21:55:21 +01:00
Frank be01de83ed network defined 2026-03-10 21:52:05 +01:00
Frank 700160e198 Mercure ssl 2026-01-17 22:56:34 +01:00
Frank 96c2e4ca61 Mercure cors error 3 2026-01-17 19:36:01 +01:00
Frank e5f959210a Mercure cors error 2 2026-01-17 19:27:01 +01:00
Frank e14cf8457b Mercure cors error 2026-01-17 19:03:25 +01:00
Frank 2c970fa9e7 nullable screen 2026-01-17 15:35:26 +01:00
Frank 714496fa77 Remote allowance 2026-01-17 15:12:08 +01:00
Frank 2b7e667bb3 captcha keys 2026-01-17 14:51:14 +01:00
Frank b8c1fecea2 executable 2026-01-17 14:24:54 +01:00
Frank 4f40c96ce5 restart via 1 script. 2026-01-17 14:22:08 +01:00
Frank 4545572b65 Verification mails solving try 1 2026-01-17 14:12:57 +01:00
Frank 1c27c093c7 Verification mails solving try 1 2026-01-17 13:47:56 +01:00
Frank 6d8d30e91a Added domain to verification mail 2026-01-16 21:01:01 +01:00
Frank 5c8a5f0bf5 Send variables to containers. 2026-01-14 14:00:11 +01:00
Frank 6b5c205a27 Request resend of verification mail 2026-01-14 13:09:32 +01:00
Frank 70f5aa785e captcha 2026-01-13 21:54:26 +01:00
Frank 6c96d45a04 Verifying mail addresses 2026-01-13 17:43:17 +01:00
Frank 8d4e08e4bc Mailer From 2026-01-11 23:10:30 +01:00
Frank 6fba1cbcf3 Try to fix 1 2026-01-11 15:46:46 +01:00
Frank 1512a3dde9 Revert compose files 2026-01-11 15:41:40 +01:00
Frank 5f4e2acd27 Niet weggooien van images 4 2026-01-11 15:38:33 +01:00
Frank 203dfaa13b Niet weggooien van images 3 2026-01-11 15:37:13 +01:00
Frank 799858d52c Niet weggooien van images 2 2026-01-11 15:35:10 +01:00
Frank ce79b77244 onbekende flag 5 2026-01-11 15:30:21 +01:00
Frank 5f17c9b89f onbekende flag 4 2026-01-11 15:28:40 +01:00
Frank 5335e62cfd onbekende flag 3 2026-01-11 15:26:34 +01:00
Frank 04b1ff6243 onbekende flag 2 2026-01-11 15:25:03 +01:00
Frank 84b7b1c64a onbekende flag 2026-01-11 15:21:19 +01:00
Frank b0af41c5d2 container en cache clear erin en image clear eruit 2026-01-10 17:21:59 +01:00
Frank a94b8cbef3 csrf error solve. try 5 2026-01-10 14:28:03 +01:00
Frank 3f33ec6fef csrf error solve. try 4 2026-01-10 14:19:57 +01:00
Frank ab80d4de83 csrf error solve. try 3 2026-01-10 14:06:29 +01:00
Frank b2817c5d8c csrf error solve. try 2 2026-01-10 13:37:14 +01:00
Frank c59d131b80 csrf error solve. try 1 2026-01-10 00:39:33 +01:00
Frank e8a6c9cc7a Validation fails 2026-01-10 00:25:57 +01:00
Frank e84f2c274e pass on token 2026-01-10 00:17:36 +01:00
Frank d9bc8c352f Restart containers 2026-01-09 23:40:25 +01:00
Frank ece3b05a9c Add error log 2026-01-09 23:36:50 +01:00
Frank 4cf614a98c Mercure en hostfile 2026-01-09 18:38:57 +01:00
Frank 24f51bf38d database volume 2026-01-09 16:53:47 +01:00
Frank 0acf60760f Fixed ips 2026-01-09 16:42:15 +01:00
Frank 342858fa0e rights to user 2026-01-09 16:10:44 +01:00
Frank 3c9bd13cc2 Meer updates. Next try 7 2026-01-09 15:47:44 +01:00
Frank eeda97cef8 Meer updates. Next try 6 2026-01-09 15:42:43 +01:00
Frank 5a6616484f Meer updates. Next try 5 2026-01-09 15:33:52 +01:00
Frank f0bcc9fe06 Meer updates. Next try 4 2026-01-09 15:25:41 +01:00
Frank 550cc1b2ed Meer updates. Next try 3 2026-01-09 15:18:25 +01:00
Frank b1910a63c3 Meer updates. Next try 2 2026-01-09 15:10:44 +01:00
Frank 60d52d7b5e Meer updates. Next try 2026-01-09 15:01:12 +01:00
Frank 0c7bbc2670 Meer updates. Hopelijk beter nu. 2026-01-09 14:51:31 +01:00
Frank 3e73cba942 Updated dockerfile om migrations uit te kunnen voeren 2026-01-09 14:41:59 +01:00
Frank 6027dcb56c Running containers 2026-01-09 14:30:05 +01:00
Frank 81b91e052c Merge pull request 'Settings from env files' (#13) from env-settings into main
Reviewed-on: #13
2026-01-09 13:21:02 +00:00
Frank a5a895b67f Settings from env files 2026-01-09 13:08:09 +01:00
Frank 28c663749c Merge pull request 'timer-af-laten-lopen' (#12) from timer-af-laten-lopen into main
Reviewed-on: #12
2026-01-09 11:23:39 +00:00
Frank b063e17863 Remove .env.* files from tracking and update .gitignore 2026-01-09 12:13:31 +01:00
Frank 2008a379a8 Lost page 2026-01-08 20:32:21 +01:00
Frank c0fc0b8e6e Merge pull request 'start-all-at-the-same-time' (#11) from start-all-at-the-same-time into main
Reviewed-on: #11
2026-01-08 19:12:17 +00:00
Frank 928ab3cbfe Added mercure to update when everyone is ready 2026-01-08 20:11:14 +01:00
Frank 4d021e7cf1 Trying to add waiting pages 2026-01-08 19:32:13 +01:00
Frank 834f12c40f Merge pull request 'admin-side' (#10) from admin-side into main
Reviewed-on: #10
2026-01-08 17:34:48 +00:00
Frank 30aa73bf53 Look into session logfiles 2026-01-08 18:26:32 +01:00
Frank 9d9de0fd0d Logfiles for sessions 2026-01-08 18:14:56 +01:00
Frank 979623b35e Merge pull request 'set-correct-screens-for-players' (#9) from set-correct-screens-for-players into main
Reviewed-on: #9
2026-01-08 17:02:10 +00:00
Frank 2984a6acb2 Most of the cat command 2026-01-08 17:02:16 +01:00
Frank cdb469456f Dynamic number of players 2026-01-08 15:49:47 +01:00
Frank 0ac2618f7d Merge pull request 'plaatsen-return-messages' (#8) from plaatsen-return-messages into main
Reviewed-on: #8
2026-01-08 14:35:12 +00:00
Frank b6c09c267f Message when everyone is verified 2026-01-08 15:34:43 +01:00
Frank van den Berg 229d55390d Make mercure work correctly 2026-01-08 13:05:40 +01:00
Frank 5eff66c24d Post return messages 2026-01-08 11:41:46 +01:00
Frank 7c86c919c7 Merge pull request 'Verification done' (#7) from Rechten into main
Reviewed-on: #7
2026-01-07 19:53:58 +00:00
Frank cdd5bc3fd8 Verification done 2026-01-07 20:06:28 +01:00
Frank e69e794e27 Merge pull request 'continue-puzzle-progress' (#6) from continue-puzzle-progress into main
Reviewed-on: #6
2026-01-07 16:50:36 +00:00
Frank 173407cd26 ls 2026-01-07 17:45:17 +01:00
Frank 90e5fd904f Sudo, rm and setup for ls 2026-01-07 15:00:28 +01:00
Frank 225c14124a Hint for first part 2026-01-07 14:33:10 +01:00
Frank a932161973 Merge pull request 'Created a dashboard and created an invite code for game sessions.' (#5) from game-pages into main
Reviewed-on: #5
2026-01-06 19:24:15 +00:00
Frank 01b0522bd1 Created a dashboard and created an invite code for game sessions. 2026-01-06 20:23:46 +01:00
Frank 9c5b3fbe4e Merge pull request 'Game1-layout' (#4) from Game1-layout into main
Reviewed-on: #4
2026-01-06 19:21:58 +00:00
Frank 38d134bbac Message on reload to hopefully stop the user. 2026-01-06 20:20:33 +01:00
Frank 25664bbc4f Layout done, probably need rework later on 2026-01-06 20:20:33 +01:00
Frank 4dfc8e7b15 Rechten van setup.sh 2026-01-06 20:20:17 +01:00
Frank van den Berg bf7d1ee379 Made it workable on docker containers 2026-01-06 19:48:33 +01:00
Frank 5188b6e697 Merge pull request 'Commando's-given' (#3) from Commando's-given into main
Reviewed-on: #3
2026-01-06 11:05:48 +00:00
Frank 812dc06988 Toevoeging van meer responses op messages 2026-01-05 23:37:36 +01:00
Frank 376ed3f592 Updated rechten voor speler. Settings toegevoegd en onderdelen voor game1 toegevoegd. 2026-01-05 17:07:32 +01:00
Frank 0543ed43b9 Messages handling voor spel 1 2026-01-05 15:27:37 +01:00
Frank b486ca64d3 Message ipv echo voor route 2026-01-05 12:16:30 +01:00
Frank 50b7300649 Ignored 2026-01-05 12:16:01 +01:00
Frank c9294d6df8 ignore idea 2026-01-05 12:15:41 +01:00
Frank 93f162b306 Merge pull request 'Registration' (#2) from Registration into main
Reviewed-on: #2
2026-01-05 11:13:03 +00:00
Frank 55a46a42b2 Forgot password 2026-01-03 22:57:45 +01:00
Frank 9cf04be857 Maillog 2026-01-03 22:35:56 +01:00
Frank 654b4036b4 Quite some work done here. 2026-01-03 22:12:51 +01:00
Frank b58da74967 Some settings 2026-01-03 13:16:58 +01:00
Frank 18821e2463 Startup 2026-01-02 20:27:56 +01:00
Frank 3b071eec9b Setup 2025-09-06 16:50:16 +02:00
139 changed files with 4706 additions and 1133 deletions
-25
View File
@@ -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
View File
@@ -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 ###
-39
View File
@@ -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 ###
-18
View File
@@ -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"
+8
View File
@@ -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 ###
+9 -7
View File
@@ -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 dont appear in dev, open Mailpit at http://localhost:8025 and verify messages. - If emails dont 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/`.
+27
View File
@@ -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
View File
@@ -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';
+62
View File
@@ -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;
}
});
+68
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+4
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
archive-node-04
+6
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
Debian GNU/Linux 12 \n \l
+2
View File
@@ -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
+8
View File
@@ -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"
+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
nameserver 1.1.1.1
nameserver 9.9.9.9
options edns0
+7
View File
@@ -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
+1
View File
@@ -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
+10
View File
@@ -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
+6
View File
@@ -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]
+4
View File
@@ -0,0 +1,4 @@
[ OK ] Started Network Manager.
[ OK ] Started OpenSSH server daemon.
[ OK ] Started Nginx HTTP server.
[ OK ] Reached target Multi-User System.
+2
View File
@@ -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'
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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
+8
View File
@@ -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)
+4
View File
@@ -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.
-3
View File
@@ -1,3 +0,0 @@
body {
background-color: skyblue;
}
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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:
+16 -1
View File
@@ -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
-1
View File
@@ -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']
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+11
View File
@@ -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
}
+11
View File
@@ -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
View File
@@ -9,6 +9,6 @@ opcache.validate_timestamps=1
opcache.revalidate_freq=0 opcache.revalidate_freq=0
log_errors=On log_errors=On
error_log=/var/www/html/var/log/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
View File
@@ -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
View File
@@ -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)
+26
View File
@@ -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');
}
}
+38
View File
@@ -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');
}
}
+30
View File
@@ -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');
}
}
+26
View File
@@ -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');
}
}
+494
View File
@@ -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",
+7
View File
@@ -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

+41
View File
@@ -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;
}
}
+134
View File
@@ -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
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Mime\Email;
#[AsCommand( #[AsCommand(
name: 'app:mail:test', name: 'app:mail:test',
description: 'Sends a simple test email using the configured mail transport (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
{ {
+5 -4
View File
@@ -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;
+117 -8
View File
@@ -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 = [
+88
View File
@@ -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;
}
}
+15
View File
@@ -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);
}
} }
+15
View File
@@ -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>
*/ */
+3 -3
View File
@@ -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';
} }
+11
View File
@@ -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';
} }
+11
View File
@@ -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();
}
}
+173 -28
View File
@@ -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) {
+584 -16
View File
@@ -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(str_starts_with($message, '/')) { if ($data === null) {
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'];
} }
+17
View File
@@ -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();
}
}
} }
+105
View File
@@ -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,
]);
}
}
+1 -1
View File
@@ -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)]
+1 -1
View File
@@ -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)
+50
View File
@@ -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();
}
}
+9 -1
View File
@@ -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