Author SHA1 Message Date
Frank 0edaa9d8cc Merge branch 'docker-test-environment': multi-instance Docker stack + test.escapepage.com scripts 2026-08-31 00:23:05 +02:00
FrankandClaude Sonnet 5 97d35f8fcb Add dedicated setup.test.sh / restart.test.sh for the staging stack
Both refuse to run unless docker/.env names a non-prod COMPOSE_PROJECT_NAME and
scope every compose call to that project.

setup.test.sh: like setup.sh but runs the DB/cache/console steps as www-data and
repairs var/cache|log|sessions ownership at the end, so php-fpm can read its own
compiled cache (bare `docker exec` runs as root -> silent 500s). Never touches
var/volumes/db. Test-appropriate final message (NPM upstream, local curl check).

restart.test.sh: keeps the database by default (--fresh-db to wipe + re-init),
never runs a host-wide docker prune, and only chowns var/cache|log|sessions
(chowning var/volumes/db is what corrupted the MySQL data dir).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:19:13 +02:00
FrankandClaude Sonnet 5 b565825cd9 docker: make the stack multi-instance so a test.escapepage.com copy can run alongside prod
compose.yaml / compose.override.yaml:
- container_name is now ${STACK_NAME:-escapepage}-* (STACK_NAME is a plain var,
  not COMPOSE_PROJECT_NAME, so prod keeps its escapepage-* names with no config change)
- every published host port is ${*_PORT:-<current default>}, so prod is unchanged
  and a second stack can bind its own (localhost-only) ports
- nginx joins the external nginx_default network so Nginx Proxy Manager can
  forward to <stack>-nginx by name

restart.sh:
- scoped to STACK_NAME / COMPOSE_PROJECT_NAME read from docker/.env, so running it
  from the test checkout can't touch the prod stack
- host-wide `docker system prune` / `docker builder prune` moved behind --prune-all

Adds docker/.env.test.example and doc/test-environment.md (separate checkout,
env layers, NPM proxy host + Access List IP allowlist, Mercure on test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:52:53 +02:00
Frank 07e8e68742 Ignore auto-generated config/reference.php
Symfony regenerates this config-builder reference file locally (e.g.
on composer install); it doesn't need to be tracked.
2026-08-29 23:02:27 +02:00
Frank e470a9848d Add read-tracking and soft-delete to contact messages
Admin can now open a message via a per-row "View" page (which stamps
read_at the first time it's opened), see a sortable Read column on the
list instead of the raw message text, and soft-delete messages via a
Delete button on both the list and detail view. Deleted messages are
excluded from the default list so it doesn't pile up over time.
2026-08-29 11:19:54 +02:00
Frank 6fb3ed597b Add contact form with reCAPTCHA, saved to DB, viewable in admin
Adds a public /contact page (name/email/message, protected by the same
reCAPTCHA v3 setup used on registration) that persists submissions to
a new contact_message table, plus a Contact section in the admin panel
to read them. Linked from the site footer.
2026-08-29 11:11:06 +02:00
Frank fd8d1730e8 Add Terms and Conditions page and link it from registration/footer
Adds a /terms page and links it from the site footer and from the
"I agree to the Terms and Conditions" checkbox on registration, which
previously didn't point anywhere.
2026-08-29 10:57:48 +02:00
FrankandClaude Sonnet 5 cafa60b0f2 Update Composer and npm dependencies to latest
Composer: ~40 Symfony 7.4.x packages patched to 7.4.17, plus four
majors - doctrine/dbal 3->4, phpdocumentor/reflection-docblock 5->6,
symfony/mercure-bundle 0.3->0.5, symfony/monolog-bundle 3->4. Removed
two doctrine.yaml keys (use_savepoints, report_fields_where_declared)
that DBAL 4 deprecated in favor of fixed defaults.

npm: @symfony/webpack-encore 4->6, which required bumping its peers
webpack-cli 5->6 and sass-loader 14->16 together, plus babel-loader
9->10. Fixes the one high-severity audit finding (RCE in
serialize-javascript, via the old css-minimizer-webpack-plugin). One
moderate finding remains in webpack-notifier's dev-only notification
chain - audit's suggested fix would downgrade it, so left alone; it's
build tooling only, never shipped to users.

Verified: full test suite green, lint:container clean, both `encore
dev` and `npm run build` compile without errors, and the production
CSS output was inspected byte-for-byte to confirm the new SVG-minifier
warnings (on Bootstrap's pre-encoded icon data-URIs) don't corrupt
anything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 21:02:08 +02:00
FrankandClaude Sonnet 5 5dc01a358a Add pagination/search/sort to the admin tables that can grow large
Uses simple-datatables (vanilla JS, no jQuery needed) on the Users,
Sessions, Email Log, and Feedback tables, since those grow with real
usage. Left Games (small, admin-curated) and Hints (row order is
meaningful - "checked in order" - a sortable column would mislead)
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:39:14 +02:00
FrankandClaude Sonnet 5 4daaa8d102 Hide the "In Development" banner once a game is open
The badge on the homepage and briefing page was static markup, wired
to nothing - flipping a game's status to open in the admin panel had
no effect on it. It's now driven by GameRepository::hasOpenGame() and
only shows while every game is still in development/locked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:19:00 +02:00
Frank 996087ec4e Allow console commands to also be typed with a leading slash
pwd/ls/scan/sudo/rm/cd/cat now work as /pwd, /ls, /scan, /sudo, /rm,
/cd, /cat too, delegating to checkConsoleCommando the same way the
bare chat/verify/decode aliasing already delegates the other
direction.
2026-08-23 21:39:57 +02:00
Frank c63af7becc Add a per-game hints CRUD in the admin panel
Hints used to be 11 hardcoded PHP strings/thresholds in
SendMainframeHintsCommand, walked through a fixed if/else chain. They
are now Hint rows (game, condition, thresholdSeconds, message,
sortOrder) manageable at /admin/games/{game}/hints - freely
addable, removable, and reorderable per game, which sets up exactly
what's needed for difficulty-dependent hint timing later (Easy/Medium/
Hard are separate games, so each gets its own hint set).

The condition a hint fires behind (e.g. "hasn't used help yet",
"isn't verified yet") is still a fixed, code-defined check
(HintCondition enum) tied to real game state - a true free-form
condition editor would need a full rules engine, which is out of
scope. What's fully free-form is which hints exist, in what order,
gated behind which of those conditions, with what wording and timing.

SendMainframeHintsCommand now reads hints from the DB: walking them in
sortOrder, it finds the first distinct condition still unresolved for
a player, fires the most-escalated hint sharing that condition whose
threshold has passed, and stops - matching the original "stop at the
first unresolved, dependent step" behavior.

Migration seeds every existing game with the previous hardcoded
hints so behavior doesn't regress on deploy. Two minor fidelity
simplifications from the original hardcoded logic, called out here
since they're easy to miss: the "go to rapports directory" hint no
longer additionally checks whether the player is already in that
folder, and the two chat hints are now independent conditions
(broadcast done / contacted everyone privately) instead of one
combined check - both are edge-case-only behavior changes, not
regressions in the common path.
2026-08-23 21:35:05 +02:00
Frank 552c71b718 Bold the win condition on the waiting-room page
Makes it clearer up front how the game is actually solved. Also fixed
an unclosed <strong> tag (was <strong>...<strong> instead of </strong>).
2026-08-23 21:21:42 +02:00
Frank c6227ea8c1 Add admin "create game" form; show total time on session-creation dropdown
New game_admin_game_new route/form (reuses AdminGameType, same pattern
as editing a game's total time) so games - including the 3 upcoming
Easy/Medium/Hard difficulty options - can be created through the admin
panel instead of needing a direct DB insert.

Also show each game's total time (in minutes) next to the player
count in the "Create New Session" dropdown, since that's the actual
difficulty signal players are choosing between - number of players
alone didn't convey it.

Difficulty itself needs no new session-level concept: each difficulty
is just a separate Game row with its own TOTAL_TIME setting, which
checkAllPlayersReady() already reads when starting the session's
timer. Hint timing depending on difficulty is separate, upcoming work.
2026-08-23 21:18:03 +02:00
Frank 1c0ab4780b Consolidate rights-granting through a single function
grantRights() is now the only place a player's RightsForPlayer{N}
setting ever gets written. All three previous call sites (verify+cat
after chat tracking, cd+decode after /verify, and the all-players
grant from decoding) now delegate to it instead of duplicating the
same read-modify-persist logic.

The main point: since every grant now flows through one place, it can
notify the specific player over Mercure the moment they receive new
rights, instead of them only finding out by trying a command that
used to be "Unknown command".

Also includes an already-present (uncommitted) tweak allowing bare
chat/verify/decode console input without the leading slash - unrelated
to this change but sitting in the same file, so it's coming along.
2026-08-23 21:07:36 +02:00
FrankandClaude Sonnet 5 7f389fbbc2 Reword two confusing mainframe hints
- DecodeMessage::PLAYER_3 ("locked up bash files should be removed to
  lock it up") was awkward and unclear about what it meant.
- The private-communication hint said "contact each other privately",
  which read as any one private message rather than the actual
  requirement of messaging every other agent privately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:15:13 +02:00
FrankandClaude Sonnet 5 ffa2b12786 Fix rm not accepting absolute paths
rm always glued its argument onto the player's current directory, so
typing an absolute path (e.g. sudo rm /var/arrest/handle.sh) built a
garbage path that matched no file and was rejected as "not allowed" -
even with full rm/sudo rights. cd already special-cased a leading '/'
as absolute; rm now does the same.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:15:05 +02:00
FrankandClaude Sonnet 5 51c542c7bb Explain the win condition on the ready-up screen
Players had no in-game indication of what actually ends the game
before reaching the terminal itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:14:55 +02:00
FrankandClaude Sonnet 5 a290b75238 Stop auto-scrolling the game terminal when a new message arrives
Every message append (mainframe broadcasts, lock reveals, the boot
sequence, and responses to your own commands) forced the page to jump
to the bottom. Removed all four window.scrollTo calls so the view
stays put.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:16:48 +02:00
FrankandClaude Sonnet 5 2b3c90d3fc Show feedback in the admin panel and as aggregate stats on the briefing page
Feedback was only ever written to session_setting rows with no way to
view it short of querying the database directly. Adds a FeedbackService
that pulls per-player feedback entries and aggregate averages, backing
two new views:

- /admin/feedback: a full table of every submission (game, session,
  player, ratings, comment) plus summary tiles, linked from the admin
  sidebar.
- /briefing: a "Field Reports" block with the average difficulty/
  entertainment/theme ratings, shown to prospective players. Free-text
  comments are deliberately left off this public page since they're
  unmoderated player input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:41:47 +02:00
FrankandClaude Sonnet 5 5d9c113eea Recall the last command with ArrowUp in the game terminal
Pressing ArrowUp while the input field is focused now repopulates it
with the last submitted command, cursor placed at the end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:32:00 +02:00
FrankandClaude Sonnet 5 779ddcbccb Fix 3 pre-existing failing tests
- testToggleReady: user was never marked verified, so toggleReady()
  returned false before doing anything (an isVerified() gate was
  added to the service after this test was written).
- testCheckAllPlayersReadyTransitionsStatus: entityManager mock
  returned the same SessionSettingRepository for every getRepository()
  call, but the service now also fetches a GameSettingRepository for
  the session's total-time setting, causing a TypeError. Route the
  mock by requested class instead.
- testChatRegeneratesVerifyCodesIfShared: two competing
  method('getSetting') stubs were registered without with()
  constraints; PHPUnit keeps the first one it sees active for every
  call, so the (correct, more complete) willReturnCallback stub was
  silently dead code and the regeneration path never actually ran.
  Dropped the redundant first stub, and updated the flush() count now
  that the real flow (chat tracking + code regeneration) executes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:18:36 +02:00
FrankandClaude Sonnet 5 6b1436dfe7 Randomize decoy usernames and which rapports carry coded messages
Decoy names in /var/home and verifyCodes.txt were a hardcoded 4-name
list (Luke, Charles, William, Peter), so a real player registering
under one of those names would collide with it. Now each session
picks 6 decoys from a pool of 10, excluding any name already taken by
a real player, and stores the pick as a session setting.

Likewise the 3 "special report" rapports that get coded messages were
always Doyle, Vega and Lennox. Each session now randomly assigns 3 of
the 20 rapports to that role, and the win screen / mainframe-help
message reference whichever agents were actually picked instead of
the hardcoded names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:09:52 +02:00
FrankandClaude Sonnet 5 02780d8f04 Add a favicon
Crops the key/globe mark out of the existing logo and wires it into
base.html.twig as favicon.ico plus PNG/apple-touch-icon variants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:09:44 +02:00
Frank 9a52e6b32c Turn the tutorial link into a real button with clearer copy
Replaces the inline "New here?" link with a proper button and a heading
that more directly asks whether the player is unfamiliar with running
commands on a command line.
2026-08-18 22:42:45 +02:00
Frank 7c14ab3331 Add a back button to the tutorial that docks into the input row on completion
Lets players leave the tutorial at any time via a top-left link back to the
game session. Once the tutorial finishes and the input field is disabled,
the button relocates into the input row so it's the obvious next action.
2026-08-18 22:35:52 +02:00
Frank 6a04a0274a Add command-line tutorial (PreGameController) and link it from the lobby
Adds a black-terminal tutorial page at /game/pregame/{session} that walks
players through help, pwd, cd, ls, cat, rm and sudo before the real game
starts. Links to it are shown on both the lobby (waiting for players) and
ready-up screens so players can practice, repeatedly, before starting.
2026-08-18 22:23:26 +02:00
Frank 367ded6441 Only regenerate verify codes on broadcast, not any targeted /chat
Any /chat {agent-id} {code} was previously exempted only if aimed at
the code's rightful owner; sending to the wrong player still burned
it. Now only an untargeted, open-chat broadcast counts as a leak -
mistargeting via /chat is harmless.
2026-08-18 21:49:45 +02:00
Frank 1f35784c52 Don't regenerate a verify code when sent privately to its rightful owner
checkAndRegenerateVerifyCodes() regenerated a code on any message
containing it, regardless of who the message was sent to - so sharing
a code exactly as intended, via /chat {agent-id} {code} to the
correct recipient, still burned it immediately, making the puzzle
unsolvable. Now only broadcasts and messages sent to the wrong player
count as a leak.
2026-08-18 21:47:21 +02:00
Frank b0b357f99b Strip spaces from generated decode report codes
generateSpecialCode() reused generateRandomString(), whose charset
includes a space. Across a 75-100 character code that made a space
almost certain, and /decode splits its argument on spaces, silently
truncating the code the player typed back in - breaking decoding.

Fixed at generation time rather than at display time, since the
stored value itself was the problem, not just how it's shown.
generateRandomString() is left untouched for its other use (the
"garbage" filler text shown to players who fail to decode), where
spaces are harmless.
2026-08-18 21:32:06 +02:00
FrankandClaude Sonnet 5 a00899e444 Route every Mercure publish through an event, log pushes per-player
Every $hub->publish() call site (chat, hints, security alerts, virus
alerts, game_finished, and the pre-game lobby events) now dispatches
a PushMercureMessageEvent instead of publishing directly. Two
listeners handle it: PublishMercureMessageListener does the actual
Mercure publish exactly as before (same wire format, no client
changes needed), and LogMercureMessageListener appends it to the
activity log of whichever player(s) it was actually delivered to.

A private /chat to one agent only gets logged for that agent, never
broadcast into everyone's transcript - the event carries an explicit
targetScreen (null = everyone) rather than leaving listeners to guess
from the payload shape.

The per-player log format moved from flat text to JSON Lines (one
timestamped, structured entry per line) via a new shared
SessionActivityLogger service, since a flat string can't carry an
event's type or exact payload - both needed for a future feature to
replay a player's full session history, not just their own commands,
on page reload. The admin session log viewer now parses and
formats these entries back into readable lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 19:47:53 +02:00
FrankandClaude Sonnet 5 aba79251e0 Broadcast game_finished when a player's own timeout check catches it
Previously only the cron's timeout sweep broadcast to everyone;
check-finished (called by a player's own client the moment their
local countdown hits zero) just updated the database silently. Now
whichever path notices the timeout first broadcasts - both only act
while the session is still PLAYING, so there's no double broadcast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 17:56:24 +02:00
FrankandClaude Sonnet 5 2951a39204 Add mainframe hint system and cron-driven timeout handling
The cron (app:hints:check, every minute) now walks each playing
player through a strict dependency chain - help, communication,
verification, navigation, rapports/decode, endgame urgency - and
sends at most one hint per player per run: the first step that's
both unresolved and old enough to nudge about. Later steps genuinely
depend on earlier ones (e.g. /verify isn't usable until a player has
finished contacting everyone), so a player never gets a hint for
something they can't act on yet.

Two bits of new tracking were needed:
- Help command usage wasn't recorded anywhere, so it's now saved to
  a new HelpUsedForPlayer{N} session setting.
- "All messages decoded" needed a reliable session-wide signal, so
  the rm right (previously granted alongside sudo when player 1
  decodes) now comes from player 3's decode instead, making
  sudo+scan+rm together mean "the whole team has decoded".

The cron also now finishes sessions whose countdown has run out:
sets the session to LOST and broadcasts the same game_finished
Mercure event the win path already uses, so every connected player
gets pushed to the lost page within a minute of the game actually
ending - not just whichever player's own client-side timer happens
to notice first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 17:56:16 +02:00
FrankandClaude Sonnet 5 2bda70b32b Fix missing player number in mainframe welcome message
Welcome agent ' +  + ' to the mainframe.' had a stray += with nothing
between them, evaluating to NaN. Plugs in the screen variable that
was already sitting in scope right above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 17:56:04 +02:00
Frank ebd2f68df6 Sync PhpStorm project index with updated vendor packages
Reflects the sendgrid-mailer removal and mailgun-mailer/rate-limiter/
polyfill-php85 additions from the recent composer update.
2026-08-16 16:04:46 +02:00
FrankandClaude Sonnet 5 2c8e568255 Fix sessions never persisting as PLAYING, and dangerous-mode logrotate configs
checkAllPlayersReady() set the session to PLAYING and cleared/updated its
timer in memory but never flushed, relying on callers to do so. The
toggleReady() caller flushed right after, but GameController::index()'s
lazy catch-up call did not - so if the "everyone ready" transition was
only detected on a page load (e.g. players didn't click ready within the
same 60s window), the terminal would render for that one request from the
in-memory state, letting the game be played entirely through the
unguarded message API, while the database silently kept the session on
'ready' with timer 0 forever. This made sessions invisible to the mainframe
hint cron and the admin "running sessions" count. Moved the flush inside
checkAllPlayersReady() itself so both callers persist reliably.

Also chmod the logrotate configs after COPY in the Dockerfile, since
their on-disk mode ended up group-writable (0664) depending on the
build host's umask, which made logrotate refuse to use them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 00:14:00 +02:00
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
218 changed files with 12997 additions and 5828 deletions
+25
View File
@@ -0,0 +1,25 @@
# Git
.git
.gitignore
# Symfony
var/cache/*
var/log/*
var/sessions/*
!var/cache/.gitkeep
!var/log/.gitkeep
!var/sessions/.gitkeep
# Node
node_modules
npm-debug.log
# Other
.env.local
.env.local.php
.env.dev.local
.env.test.local
.env.prod.local
vendor
public/build
+88
View File
@@ -0,0 +1,88 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Copy this file to .env (and .env.dev / .env.prod / .env.test as needed) and
# fill in real values. Those files are gitignored and never committed.
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=CHANGEME_APP_SECRET
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 ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DB_DRIVER=pdo_mysql
DB_SERVER_VERSION=8.0.32
DB_CHARSET=utf8mb4
DB_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD
DB_HOST=database
DB_PORT=3306
DB_NAME=escapepage
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}"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> symfony/mailer ###
# Development: use Mailpit (docker compose override provides service `mailer` on port 1025)
MAILGUN_API_KEY=REPLACE_WITH_MAILGUN_API_KEY
MAILGUN_DOMAIN=REPLACE_WITH_MAILGUN_SENDING_DOMAIN
MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu
MAILER_FROM=mailer@escapepage.nl
# Optional default sender (used by test command if --from not passed):
###< symfony/mailer ###
###> mercure ###
# Internal hub URL used by the PHP app (reachable from the php container)
MERCURE_URL=http://mercure/.well-known/mercure
# Public hub URL used by browsers
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
# Shared secret for signing JWTs (dev only). In prod, set via real env/secrets.
MERCURE_JWT_SECRET=!ChangeThisMercureJWTSignedBySymfonySecretKey!
# Pre-generated JWT tokens for convenience (signed with the dev secret above)
MERCURE_PUBLISHER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.E5b7ma4k-kA7lVGOQtICh7r2sspwX4G1iOhwtbxHQck
MERCURE_SUBSCRIBER_JWT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.mwSAjvbm6vOnjMoRSHMdcqapNCwyGZs1s57uLK4T3UM
# CORS allowed origins (default)
MERCURE_CORS_ALLOWED_ORIGINS="https://www.escapepage.com https://escapepage.com"
# Base URL for Mercure topics.
MERCURE_TOPIC_BASE=https://escapepage.com
###< mercure ###
###> docker ###
USER_ID=1000
GROUP_ID=1000
###< docker ###
###> karser/karser-recaptcha3-bundle ###
# Get your API key and secret from https://g.co/recaptcha/v3
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
###< karser/karser-recaptcha3-bundle ###
+12
View File
@@ -1,11 +1,19 @@
###> symfony/framework-bundle ###
/.env
/.env.dev
/.env.prod
/.env.test
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/config/reference.php
/public/bundles/
/var/
!/var/volumes/
/var/volumes/*
!/var/volumes/.gitignore
/vendor/
###< symfony/framework-bundle ###
@@ -27,3 +35,7 @@ yarn-error.log
###< symfony/webpack-encore-bundle ###
/.idea
###> docker env ###
/docker/.env
###< docker env ###
+4 -1
View File
@@ -136,10 +136,13 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/lcobucci/jwt" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/sendgrid-mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webpack-encore-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/verify-email-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/karser/karser-recaptcha3-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailgun-mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php85" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
Generated
+5 -2
View File
@@ -146,13 +146,16 @@
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/sendgrid-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/webpack-encore-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
<path value="$PROJECT_DIR$/vendor/karser/karser-recaptcha3-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mailgun-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/rate-limiter" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
@@ -177,4 +180,4 @@
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>
</project>
+13 -17
View File
@@ -16,7 +16,7 @@ This repository contains a Symfony 7.3 (PHP >= 8.5.1) application for a collabor
6. Run tests: `vendor/bin/phpunit`
- With Docker:
1. From `docker/`: `docker compose up -d`
1. `cd docker && docker compose up -d`
2. Install vendors inside the PHP container:
- `docker compose exec php bash`
- `composer install`
@@ -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`
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.
- SMTP DSN in `.env`: `MAILER_DSN=smtp://mailer:1025`
- Mailpit UI: http://localhost:8025
- Mailpit UI: http://localhost:8025 (or mapped port 8025)
- Send a test mail: `php bin/console app:mail:test you@example.com`
- Staging/Prod: use SendGrid.
- Require package (already in composer): `symfony/sendgrid-mailer`.
- Staging/Prod: use Mailgun.
- Require package (already in composer): `symfony/mailgun-mailer`.
- Set environment variables (do NOT commit secrets):
- `MAILER_DSN=sendgrid+api://%env(SENDGRID_API_KEY)%`
- `SENDGRID_API_KEY=YOUR_REAL_KEY`
- `MAILER_DSN=mailgun+api://${MAILGUN_API_KEY}:${MAILGUN_DOMAIN}@default?region=eu`
- `MAILGUN_API_KEY=YOUR_REAL_KEY`
- `MAILGUN_DOMAIN=YOUR_SENDING_DOMAIN` (e.g. `mg.escapepage.nl`)
- Optional: `MAILER_FROM=no-reply@your-domain.tld`
- Drop `region=eu` (or use `region=us`) depending on which region your Mailgun domain was created in.
- Alternatively via SMTP (no extra package):
- `MAILER_DSN="smtp://apikey:%env(SENDGRID_API_KEY)%@smtp.sendgrid.net:587?encryption=tls"`
- `MAILER_DSN="mailgun+smtp://USERNAME:PASSWORD@default?region=eu"`
Troubleshooting:
- If emails don’t appear in dev, open Mailpit at http://localhost:8025 and verify messages.
- In prod, check logs for HTTP 2xx responses from SendGrid and verify sender domain is verified in SendGrid.
- In prod, check logs for HTTP 2xx responses from Mailgun and verify sender domain is verified (SPF/DKIM) in Mailgun.
## Frontend assets with Webpack Encore
We use Webpack Encore to build and minify JS/CSS from the `assets/` directory into `public/build/`.
@@ -81,9 +83,9 @@ See doc/CONTRIBUTING.md for code style and more details.
We use a Mercure hub (Docker service) to push server updates to browsers via Server‑Sent Events (SSE).
Quick start (dev):
1. Start Docker stack from `docker/`:
1. Start Docker stack:
```
docker compose up -d
cd docker && docker compose up -d
```
This starts `mercure` at http://localhost:8090 and the app at http://localhost:8080.
2. Install PHP deps inside the PHP container if you haven't yet:
@@ -91,12 +93,6 @@ Quick start (dev):
docker compose exec php bash
composer install
```
3. Open the Game Hub page in your browser: http://localhost:8080/game
- The page subscribes to a demo topic and logs messages in the console.
4. Publish a test update (in the PHP container):
```
php bin/console app:mercure:publish
```
You should see a console log like `[Mercure] Update received: { ... }` on the Game Hub page.
Configuration:
+22
View File
@@ -0,0 +1,22 @@
import { DataTable } from 'simple-datatables';
import 'simple-datatables/dist/style.css';
// Paginate/search/sort any admin table opted in via class="admin-datatable".
// Kept off tables that are admin-curated and stay small (games) or where row
// order is meaningful and must not be disturbed by sorting (hints).
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('table.admin-datatable').forEach((table) => {
new DataTable(table, {
perPage: 25,
perPageSelect: [10, 25, 50, 100],
searchable: true,
sortable: true,
labels: {
placeholder: 'Search...',
perPage: '{select} entries per page',
noRows: 'No matching entries found',
info: 'Showing {start} to {end} of {rows} entries',
},
});
});
});
+27
View File
@@ -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));
});
});
+8 -3
View File
@@ -1,6 +1,11 @@
/*
* Welcome to your app's main JavaScript file!
*/
import './styles/app.css';
console.log('This log comes from assets/app.js built by Webpack Encore! 🎉');
import './styles/app.scss';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/alert';
import 'bootstrap/js/dist/dropdown';
import './game-waiting';
import './game-lobby';
import './admin-session-tabs';
import './admin-datatables';
+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);
}
}
});
+264 -23
View File
@@ -3,8 +3,14 @@ import './styles/game1.css';
let sequenceFinished = false;
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 {
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
const es = new EventSource(url);
@@ -14,7 +20,15 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
const data = JSON.parse(event.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) {
const sendTo = parseInt(data[0]);
// Filter: 0 means everyone, otherwise must match myScreen
@@ -25,13 +39,7 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = data[1];
msgEl.style.color = '#0F0'; // Green for incoming messages
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe');
if(stillPlayingSound)
playSound();
console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished);
@@ -77,6 +85,147 @@ function flashRed() {
}, 150);
}
let lockRevealTimer = null;
let lockExpireTimer = null;
let lockCountdownTimer = null;
let currentLockedAt = null;
function lockMessageClass(messageType) {
if (messageType === 'virus') return 'message-virus';
if (messageType === 'mainframe') return 'message-mainframe';
if (messageType === 'hint') return 'message-hint';
return '';
}
function appendResultMessage(container, text, messageType) {
const msgEl = document.createElement('div');
msgEl.className = ('message ' + lockMessageClass(messageType)).trim();
msgEl.textContent = text;
container.appendChild(msgEl);
}
function setInputDisabled(disabled) {
const inputField = document.getElementById('input-message');
if (inputField) {
inputField.disabled = disabled;
}
}
function clearLockTimers() {
if (lockRevealTimer) { clearTimeout(lockRevealTimer); lockRevealTimer = null; }
if (lockExpireTimer) { clearTimeout(lockExpireTimer); lockExpireTimer = null; }
if (lockCountdownTimer) { clearInterval(lockCountdownTimer); lockCountdownTimer = null; }
}
function clearLock() {
clearLockTimers();
currentLockedAt = null;
document.body.classList.remove('locked');
const banner = document.getElementById('lock-banner');
if (banner) banner.style.display = 'none';
setInputDisabled(false);
}
function updateLockCountdown(unlockAtMs) {
const countdownEl = document.getElementById('lock-countdown');
if (!countdownEl) return;
const remaining = Math.max(0, Math.ceil((unlockAtMs - Date.now()) / 1000));
countdownEl.textContent = remaining + 's';
}
async function fetchLockReveal(apiEchoUrl, messageContainer) {
if (!apiEchoUrl) return;
try {
const response = await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message: '', ts: new Date().toISOString() },
});
const result = response && response.result;
if (result && Array.isArray(result.result)) {
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
}
if (result && result.locked === false) {
clearLock();
return;
}
// Code has been revealed (or already was), let the player try /unlock
setInputDisabled(false);
} catch (e) {
console.error('[Game1] Failed to fetch lock reveal:', e);
}
}
function applyLock(lockData, apiEchoUrl, messageContainer) {
if (currentLockedAt === lockData.lockedAt) {
return; // already tracking this lock, avoid re-fetching/duplicating messages
}
currentLockedAt = lockData.lockedAt;
clearLockTimers();
const banner = document.getElementById('lock-banner');
if (banner) banner.style.display = 'flex';
document.body.classList.add('locked');
const revealAtMs = lockData.revealAt * 1000;
const unlockAtMs = lockData.unlockAt * 1000;
const now = Date.now();
if (now < revealAtMs) {
setInputDisabled(true);
lockRevealTimer = setTimeout(() => fetchLockReveal(apiEchoUrl, messageContainer), revealAtMs - now);
} else {
fetchLockReveal(apiEchoUrl, messageContainer);
}
lockExpireTimer = setTimeout(() => clearLock(), Math.max(0, unlockAtMs - now));
updateLockCountdown(unlockAtMs);
lockCountdownTimer = setInterval(() => {
updateLockCountdown(unlockAtMs);
if (Date.now() >= unlockAtMs) {
clearInterval(lockCountdownTimer);
lockCountdownTimer = null;
}
}, 1000);
}
let filesRemovalTimer = null;
let scheduledFilesRemovalDeadline = null;
async function pingFilesRemovalDeadline(apiEchoUrl) {
if (!apiEchoUrl) return;
try {
// A no-op message is enough to make the server evaluate the deadline server-side;
// the actual restore notice (if any) arrives for everyone via the Mercure broadcast.
await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message: '', ts: new Date().toISOString() },
});
} catch (e) {
console.error('[Game1] Failed to ping files-removal deadline:', e);
}
}
function scheduleFilesRemovalCheck(deadline, apiEchoUrl) {
if (!deadline || scheduledFilesRemovalDeadline === deadline) {
return; // nothing to (re)schedule
}
scheduledFilesRemovalDeadline = deadline;
if (filesRemovalTimer) {
clearTimeout(filesRemovalTimer);
filesRemovalTimer = null;
}
const delay = Math.max(0, deadline * 1000 - Date.now());
filesRemovalTimer = setTimeout(() => {
filesRemovalTimer = null;
scheduledFilesRemovalDeadline = null;
pingFilesRemovalDeadline(apiEchoUrl);
}, delay);
}
async function fetchJson(url, options = {}) {
const opts = { ...options };
const headers = new Headers(opts.headers || {});
@@ -113,8 +262,11 @@ document.addEventListener('DOMContentLoaded', async () => {
// Look for config injected by Twig in the page
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) => {
if (navigatingAway) {
return;
}
// Standard way to trigger the browser's confirmation dialog
event.preventDefault();
// Included for compatibility with older browsers
@@ -131,13 +283,71 @@ document.addEventListener('DOMContentLoaded', async () => {
const screen = cfgEl.dataset.screen;
const apiPingUrl = cfgEl.dataset.apiPingUrl;
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
const lostUrl = cfgEl.dataset.lostUrl;
const wonUrl = cfgEl.dataset.wonUrl;
const lockLockedAt = cfgEl.dataset.lockLockedAt;
const lockRevealAt = cfgEl.dataset.lockRevealAt;
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
const filesRemovalDeadline = cfgEl.dataset.filesRemovalDeadline;
// Resume the auto-restore timer after a page refresh, if a window is already running
if (filesRemovalDeadline) {
scheduleFilesRemovalCheck(parseInt(filesRemovalDeadline, 10), apiEchoUrl);
}
if (mercurePublicUrl && topic) {
subscribeToMercure(mercurePublicUrl, topic, screen);
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl);
} else {
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
}
// Timer logic
const timerEl = document.getElementById('game-timer');
if (timerEl && timerEl.dataset.endTime) {
const endTime = parseInt(timerEl.dataset.endTime) * 1000;
const updateTimer = async () => {
const now = Date.now();
const diff = endTime - now;
if (diff <= 0) {
timerEl.textContent = '00:00:00';
// Timer reached zero, check with server
if (apiCheckFinishedUrl && lostUrl) {
try {
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
if (response && response.finished) {
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl);
return; // Stop the timer loop
}
} catch (e) {
console.error('[API][game1] Failed to check finished status:', e);
}
}
// Even if check failed or not finished, stop the loop if diff <= 0
// (though technically if the server says not finished, we might want to keep checking,
// but 00:00:00 usually means it's over).
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
const hDisplay = hours.toString().padStart(2, '0');
const mDisplay = minutes.toString().padStart(2, '0');
const sDisplay = seconds.toString().padStart(2, '0');
timerEl.textContent = `${hDisplay}:${mDisplay}:${sDisplay}`;
setTimeout(updateTimer, 1000);
};
updateTimer();
}
// Demo API calls
try {
if (apiPingUrl) {
@@ -166,7 +376,7 @@ document.addEventListener('DOMContentLoaded', async () => {
let messages = [
['System initializing...', 500],
['Connection established.', 200],
['Welcome agent to the mainframe.', 1000],
['Welcome agent ' + screen + ' to the mainframe.', 1000],
['Scanning...', 3000],
['Virus detected.', 500],
['Starting Mainframe help modus...', 2000],
@@ -188,9 +398,7 @@ document.addEventListener('DOMContentLoaded', async () => {
msgEl.className = 'message ' + extraClass;
msgEl.textContent = msg[0];
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
playSound();
@@ -207,6 +415,17 @@ document.addEventListener('DOMContentLoaded', async () => {
const inputField = document.getElementById('input-message');
inputField.disabled = false;
let lastCommand = '';
// Recall the last submitted command on ArrowUp, cursor at the end
inputField.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
inputField.value = lastCommand;
inputField.setSelectionRange(inputField.value.length, inputField.value.length);
}
});
// Add event listener for Enter key
inputField.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') {
@@ -217,10 +436,10 @@ document.addEventListener('DOMContentLoaded', async () => {
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = message;
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
if (message && apiEchoUrl) {
lastCommand = message;
inputField.value = '';
try {
const response = await fetchJson(apiEchoUrl, {
@@ -229,14 +448,27 @@ document.addEventListener('DOMContentLoaded', async () => {
});
console.log('[API][game1] message sent →', response);
if (response && response.result && Array.isArray(response.result.result)) {
response.result.result.forEach(text => {
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = text;
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
});
window.scrollTo(0, document.body.scrollHeight);
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType));
}
if (response && response.result) {
if (response.result.gameWon === true && wonUrl) {
goTo(wonUrl);
return;
}
if (response.result.locked === true) {
applyLock({
lockedAt: response.result.lockedAt,
revealAt: response.result.revealAt,
unlockAt: response.result.unlockAt,
}, apiEchoUrl, messageContainer);
} else if (response.result.locked === false) {
clearLock();
}
if (response.result.filesRemovalDeadline) {
scheduleFilesRemovalCheck(response.result.filesRemovalDeadline, apiEchoUrl);
}
}
} catch (err) {
console.error('[API][game1] Failed to send message:', err);
@@ -248,6 +480,15 @@ document.addEventListener('DOMContentLoaded', async () => {
console.log('[Game1] message-container height changed to 400vh and input enabled');
sequenceFinished = true;
console.log('[Game1] sequenceFinished is now TRUE');
// Restore an in-progress lock after a page refresh
if (lockUnlockAt && parseInt(lockUnlockAt, 10) * 1000 > Date.now()) {
applyLock({
lockedAt: parseInt(lockLockedAt, 10),
revealAt: parseInt(lockRevealAt, 10),
unlockAt: parseInt(lockUnlockAt, 10),
}, apiEchoUrl, messageContainer);
}
}, 2000);
}
};
@@ -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.
+362
View File
@@ -0,0 +1,362 @@
/* Pregame (commandline tutorial) entry point built with Webpack Encore */
import './styles/pregame.css';
function appendMessage(container, text, extraClass = '') {
const el = document.createElement('div');
el.className = ('message ' + extraClass).trim();
el.textContent = text;
container.appendChild(el);
window.scrollTo(0, document.body.scrollHeight);
}
function dockBackButton(inputField) {
const backButton = document.getElementById('back-button');
const inputContainer = document.getElementById('input');
if (!backButton || !inputContainer) {
return;
}
inputField.style.display = 'none';
backButton.classList.add('docked');
inputContainer.appendChild(backButton);
}
document.addEventListener('DOMContentLoaded', () => {
console.log('Pregame bundle loaded');
const messageContainer = document.getElementById('message-container');
const inputField = document.getElementById('input-message');
if (!messageContainer || !inputField) {
return;
}
const bootMessages = [
['Booting tutorial terminal...', 500],
['Loading command line basics...', 800],
['"Welcome agent. Lets go through the basics together.', 2000],
['Let us start with the help command. Type help in the terminal (the input field at the bottom of the screen and press enter.', 500],
];
// Same command help text as GameResponseService::getHelpCommand(), minus "scan".
const helpMessages = [
'Help function:',
'',
'pwd',
' This message will let you know what your current location is.',
' It will show you the folder you are in so you can continue navigating the server.',
' USAGE: pwd',
'',
'cd',
' Use cd to move to a different directory.',
' You can go into a folder by using cd {foldername}, or a folder up by using "cd ..".',
' Using cd / moves you to the root directory.',
' USAGE: cd {directory}',
'',
'ls',
' To show all the files in the current directory, use ls.',
' This will print the full list of directories and files of the current location on your screen.',
' USAGE: ls',
'',
'cat',
' To read a file, use cat {filename}.',
' This will print the full content of the file on the screen.',
' USAGE: cat {filename}',
'',
'rm',
' Use rm to delete a file.',
' Be careful with this command. It can not be undone and we do not want to lose any valuable data.',
' USAGE: rm {filename}',
'',
'sudo',
' If you do not have enough rights to execute a command, you can use sudo to execute it as root.',
' This is only possible for verified users. To verify yourself, use the /verify command.',
' USAGE: sudo {command}',
'',
];
// Narrative shown after the help output finishes, introducing the next step.
// Each entry is [delayBeforeShowing, text, cssClass].
const helpFollowUp = [
[2000, 'Well done, you now have a complete overview of the terminal commands this game uses.', 'message-mainframe'],
[500, 'Not all commands will be available immediately. But lets go through them one by one.', 'message-mainframe'],
[1000, 'Lets start with the command pwd. Enter this in the terminal (input field) now and press enter.', 'message-mainframe'],
];
const pwdFollowUp = [
[1000, 'You now know the location you are in on the terminal. /var/home/agent.', 'message-mainframe'],
[500, 'You can change directories with the cd command.', 'message-mainframe'],
[500, "If you type 'cd ..' in the terminal, you will go one directory up to /var/home", 'message-mainframe'],
[500, 'Try this now.', 'message-mainframe'],
];
const cdFollowUp = [
[1000, 'Good. Please check the directory you are in now.', 'message-mainframe'],
];
const pwd2FollowUp = [
[1000, 'As you can see, you are now in the /var/home directory', 'message-mainframe'],
[500, 'You can move between directories like this.', 'message-mainframe'],
];
const wrongPwdMessage = "That is not the command which gives you the location you are in. You can check your current folder with the pwd command.";
const lsIntro = [
[1000, 'Now lets see what is actually in this directory. The ls command lists all files and folders in your current location.', 'message-mainframe'],
[500, 'Type ls now.', 'message-mainframe'],
];
const lsListing = [
'agent,dir',
'peter,dir',
'james,dir',
'william,dir',
'system,dir',
];
const lsFollowUp = [
[1000, 'You can see several folders here, including your own: agent.', 'message-mainframe'],
[500, 'Lets go back to your own directory. Use cd agent to enter it.', 'message-mainframe'],
[250, 'Remember, cd /var/home/agent also works to get there directly from anywhere on the server.', 'message-mainframe'],
];
const cdAgentFollowUp = [
[1000, 'Lets check if you now really are in your own home folder.', 'message-mainframe'],
];
const verifyAgentFollowUp = [
[1000, "Well done, you're right back where you started.", 'message-mainframe'],
[500, 'Now lets move to the next command: cat.', 'message-mainframe'],
[500, 'First, lets see what is inside this folder.', 'message-mainframe'],
[500, 'Type ls to check the content of /var/home/agent.', 'message-mainframe'],
];
const lsAgentListing = [
'HelloWorld.txt,file',
'notes.txt,file',
'schedule.txt,file',
'contacts.db,file',
'keycard.dat,file',
'backup.zip,file',
];
const lsAgentFinalListing = [
'notes.txt,file',
'schedule.txt,file',
'contacts.db,file',
'keycard.dat,file',
'backup.zip,file',
];
const lsAgentFollowUp = [
[1000, 'There it is: HelloWorld.txt, right there in the list.', 'message-mainframe'],
[500, 'Lets read what is inside. Type cat HelloWorld.txt to open the file.', 'message-mainframe'],
];
const helloWorldContent = [
'Day 1 at the agency. My mentor says curiosity keeps you sharp -- and alive.',
"- I keep a spare set of shoelaces in my desk. You'd be surprised how handy that is.",
'- The coffee machine on this floor is cursed. Never trust it before 9am.',
'- Jared Williams owes me a favor after the incident with the elevator. He should not have forgotten that.',
'',
'If you are reading this, agent: welcome to the team. Stay sharp out there.',
];
const catFollowUp = [
[1000, 'Nice work. cat is perfect for quickly reading any file on the server.', 'message-mainframe'],
];
const rmIntro = [
[1000, "There's one more command left in the basics: rm.", 'message-mainframe'],
[500, 'This command permanently deletes a file, so use it with caution.', 'message-mainframe'],
[500, 'Try removing HelloWorld.txt now. Type rm HelloWorld.txt.', 'message-mainframe'],
];
const rmDeniedMessages = [
'Permission denied: HelloWorld.txt is protected against removal.',
'You need sudo rights to remove this file.',
];
const rmFollowUp = [
[1000, 'sudo can technically be used for any command, at any moment.', 'message-mainframe'],
[500, 'But from a security standpoint, it is better not to use it unless it is truly necessary.', 'message-mainframe'],
[500, 'Only reach for sudo in extreme cases -- like this one, where a file is actively protected.', 'message-mainframe'],
[500, 'Try it now. Type sudo rm HelloWorld.txt.', 'message-mainframe'],
];
const sudoRmFollowUp = [
[1000, 'Well done. Since you used sudo, the protection was bypassed and the file was deleted.', 'message-mainframe'],
[500, 'Lets check the folder once more to confirm it is really gone. Type ls now.', 'message-mainframe'],
];
const tutorialEndFollowUp = [
[1000, 'As you can see, HelloWorld.txt is no longer in the list. It has been permanently removed.', 'message-mainframe'],
[500, 'That concludes the basics of the terminal, agent. You are ready for the real mission.', 'message-mainframe'],
[1000, 'Please be aware more commands can exist to help this mission succeed. These commands will show up in the help command. Good luck!', 'message-mainframe']
];
function playSequence(items, container, onDone) {
let i = 0;
const step = () => {
if (i >= items.length) {
if (onDone) onDone();
return;
}
const [delay, text, cls] = items[i];
i++;
setTimeout(() => {
appendMessage(container, text, cls);
step();
}, delay);
};
step();
}
// Tracks which command the tutorial is currently guiding the player towards.
let tutorialStage = 'help';
let currentMessageIndex = 0;
const printNextMessage = () => {
if (currentMessageIndex < bootMessages.length) {
const [text, delay] = bootMessages[currentMessageIndex];
appendMessage(messageContainer, text, 'message-mainframe');
currentMessageIndex++;
setTimeout(printNextMessage, delay);
return;
}
inputField.disabled = false;
inputField.focus();
inputField.addEventListener('keypress', (e) => {
if (e.key !== 'Enter') {
return;
}
const value = inputField.value.trim();
inputField.value = '';
if (!value) {
return;
}
appendMessage(messageContainer, value);
if (value.toLowerCase() === 'help' || value.toLowerCase() === '/help') {
// No extraClass -> default "message" color, same as the real game's help output.
helpMessages.forEach((line) => appendMessage(messageContainer, line));
if (tutorialStage === 'help') {
playSequence(helpFollowUp, messageContainer, () => {
tutorialStage = 'pwd';
});
}
return;
}
if (tutorialStage === 'pwd' && value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home/agent');
playSequence(pwdFollowUp, messageContainer, () => {
tutorialStage = 'cd';
});
return;
}
if (tutorialStage === 'cd' && value.toLowerCase() === 'cd ..') {
playSequence(cdFollowUp, messageContainer, () => {
tutorialStage = 'pwd2';
});
return;
}
if (tutorialStage === 'pwd2') {
if (value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home');
playSequence(pwd2FollowUp, messageContainer, () => {
playSequence(lsIntro, messageContainer, () => {
tutorialStage = 'ls';
});
});
} else {
appendMessage(messageContainer, wrongPwdMessage, 'message-hint');
}
return;
}
if (tutorialStage === 'ls' && value.toLowerCase() === 'ls') {
lsListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(lsFollowUp, messageContainer, () => {
tutorialStage = 'cd-agent';
});
return;
}
if (
tutorialStage === 'cd-agent' &&
(value.toLowerCase() === 'cd agent' || value.toLowerCase() === 'cd /var/home/agent')
) {
playSequence(cdAgentFollowUp, messageContainer, () => {
tutorialStage = 'verify-agent';
});
return;
}
if (tutorialStage === 'verify-agent' && value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home/agent');
playSequence(verifyAgentFollowUp, messageContainer, () => {
tutorialStage = 'ls-agent';
});
return;
}
if (tutorialStage === 'ls-agent' && value.toLowerCase() === 'ls') {
lsAgentListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(lsAgentFollowUp, messageContainer, () => {
tutorialStage = 'cat-file';
});
return;
}
if (tutorialStage === 'cat-file' && value.toLowerCase() === 'cat helloworld.txt') {
helloWorldContent.forEach((line) => appendMessage(messageContainer, line));
playSequence(catFollowUp, messageContainer, () => {
playSequence(rmIntro, messageContainer, () => {
tutorialStage = 'rm';
});
});
return;
}
if (tutorialStage === 'rm' && value.toLowerCase() === 'rm helloworld.txt') {
rmDeniedMessages.forEach((line) => appendMessage(messageContainer, line, 'message-hint'));
playSequence(rmFollowUp, messageContainer, () => {
tutorialStage = 'sudo-rm';
});
return;
}
if (tutorialStage === 'sudo-rm' && value.toLowerCase() === 'sudo rm helloworld.txt') {
appendMessage(messageContainer, 'File removed: HelloWorld.txt');
playSequence(sudoRmFollowUp, messageContainer, () => {
tutorialStage = 'ls-confirm';
});
return;
}
if (tutorialStage === 'ls-confirm' && value.toLowerCase() === 'ls') {
lsAgentFinalListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(tutorialEndFollowUp, messageContainer, () => {
tutorialStage = 'complete';
inputField.disabled = true;
dockBackButton(inputField);
});
return;
}
// Placeholder response until the rest of the tutorial content is defined.
appendMessage(messageContainer, 'Command not recognized yet.', 'message-hint');
});
};
printNextMessage();
});
-3
View File
@@ -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;
min-height: calc(100vh - 100px); /* Fill most of the viewport initially */
box-sizing: border-box;
font-size: 20px;
font-size: 14px;
}
div.message {
color: #C0C0C0;
white-space: pre-wrap;
line-height: 1.35;
margin-bottom: 2px;
}
div.message-virus {
color: #F00;
font-weight: bold;
}
div.message-mainframe {
color: #0F0;
}
div.message-hint {
color: #FF0;
font-weight: bold;
}
div#lock-banner {
position: fixed;
top: 68px;
left: 0;
width: 100%;
padding: 12px 20px;
background-color: #200;
border-top: 1px solid #F00;
border-bottom: 1px solid #F00;
color: #F00;
font-size: 18px;
font-weight: bold;
letter-spacing: 1px;
z-index: 99;
display: flex;
justify-content: space-between;
align-items: center;
animation: lock-banner-pulse 1s ease-in-out infinite;
}
@keyframes lock-banner-pulse {
0%, 100% {
background-color: #200;
}
50% {
background-color: #400;
}
}
body.locked div#message-container {
padding-top: 130px;
}
div#input {
@@ -61,11 +110,11 @@ div#input {
input#input-message {
width: 100%;
padding: 10px;
padding: 6px 10px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 18px;
font-size: 14px;
box-sizing: border-box;
font-family: monospace;
}
+100
View File
@@ -0,0 +1,100 @@
/* Styles for the pregame (commandline tutorial) terminal, mirroring game1's look */
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 1px;
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track {
background: #000;
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb {
background: #F00;
}
body {
background-color: #000;
min-height: 100vh;
font-family: monospace;
margin: 0;
scrollbar-width: thin;
scrollbar-color: #F00 #000;
}
div#message-container {
padding: 20px;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: calc(100vh - 80px);
box-sizing: border-box;
font-size: 14px;
}
div.message {
color: #C0C0C0;
white-space: pre-wrap;
line-height: 1.35;
margin-bottom: 2px;
}
div.message-mainframe {
color: #0F0;
}
div.message-virus {
color: #F00;
font-weight: bold;
}
div.message-hint {
color: #FF0;
font-weight: bold;
}
div#input {
padding: 20px;
}
input#input-message {
width: 100%;
padding: 6px 10px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 14px;
box-sizing: border-box;
font-family: monospace;
}
a#back-button {
position: fixed;
top: 16px;
left: 16px;
padding: 6px 14px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 13px;
font-family: monospace;
text-decoration: none;
z-index: 200;
}
a#back-button:hover {
background: #1a1a1a;
color: #FFF;
}
a#back-button.docked {
position: static;
display: block;
width: 100%;
padding: 6px 10px;
font-size: 14px;
text-align: center;
box-sizing: border-box;
}
-7
View File
@@ -1,7 +0,0 @@
services:
###> symfony/mercure-bundle ###
mercure:
ports:
- "80"
###< symfony/mercure-bundle ###
-31
View File
@@ -1,31 +0,0 @@
services:
###> symfony/mercure-bundle ###
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
# Uncomment the following line to disable HTTPS,
#SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
# Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://localhost:8080
# Comment the following line to disable the development mode
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
healthcheck:
test: ["CMD", "curl", "-f", "https://localhost/healthz"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- mercure_data:/data
- mercure_config:/config
###< symfony/mercure-bundle ###
volumes:
###> symfony/mercure-bundle ###
mercure_data:
mercure_config:
###< symfony/mercure-bundle ###
+51 -49
View File
@@ -7,48 +7,50 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.16",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.5",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3",
"symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.3.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/sendgrid-mailer": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.30",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-turbo": "^2.30",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.3.*",
"symfonycasts/reset-password-bundle": "^1.24",
"doctrine/dbal": "^4.4.4",
"doctrine/doctrine-bundle": "^2.18.3",
"doctrine/doctrine-migrations-bundle": "^3.7",
"doctrine/orm": "^3.6.7",
"karser/karser-recaptcha3-bundle": "^0.3.0",
"phpdocumentor/reflection-docblock": "^6.0.3",
"phpstan/phpdoc-parser": "^2.3.2",
"symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*",
"symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2.11",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mailgun-mailer": "7.4.*",
"symfony/mercure-bundle": "^0.5.0",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^4.0.2",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.36",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/ux-turbo": "^2.36",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.4.1",
"symfony/yaml": "7.4.*",
"symfonycasts/reset-password-bundle": "^1.25",
"symfonycasts/verify-email-bundle": "^1.18",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
"twig/extra-bundle": "^2.12|^3.24",
"twig/twig": "^2.12|^3.28.0"
},
"config": {
"allow-plugins": {
@@ -98,16 +100,16 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*"
"require": "7.4.*"
}
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
"phpunit/phpunit": "^11.5.55",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.67",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
}
}
Generated
+1547 -1243
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,4 +17,5 @@ return [
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Karser\Recaptcha3Bundle\KarserRecaptcha3Bundle::class => ['all' => true],
];
+8 -9
View File
@@ -1,11 +1,10 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout
# form:
# csrf_protection:
# token_id: submit
# csrf_protection:
# stateless_token_ids:
# - submit
# - authenticate
# - logout
+9 -3
View File
@@ -1,17 +1,23 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# url: '%env(resolve:DATABASE_URL)%'
driver: '%env(DB_DRIVER)%'
server_version: '%env(DB_SERVER_VERSION)%'
host: '%env(DB_HOST)%'
port: '%env(DB_PORT)%'
user: '%env(DB_USER)%'
password: '%env(DB_PASSWORD)%'
dbname: '%env(DB_NAME)%'
charset: '%env(DB_CHARSET)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
+21 -1
View File
@@ -8,7 +8,27 @@ framework:
fallbacks: ['en', 'nl']
# Note that the session will be started ONLY if you read or write from it.
session: true
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
rate_limiter:
invite_code_join:
policy: 'sliding_window'
limit: 10
interval: '1 minute'
when@prod:
framework:
session:
handler_id: null
cookie_secure: true
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
#esi: true
#fragments: true
+5
View File
@@ -0,0 +1,5 @@
karser_recaptcha3:
site_key: '%env(RECAPTCHA3_KEY)%'
secret_key: '%env(RECAPTCHA3_SECRET)%'
score_threshold: 0.5
enabled: true
+16 -1
View File
@@ -47,6 +47,14 @@ when@prod:
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: group
members: [nested_file, nested_stderr]
nested_file:
type: stream
path: "%kernel.logs_dir%/php/prod.log"
level: debug
formatter: monolog.formatter.json
nested_stderr:
type: stream
path: php://stderr
level: debug
@@ -56,7 +64,14 @@ when@prod:
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
type: group
channels: [deprecation]
members: [deprecation_file, deprecation_stderr]
deprecation_file:
type: stream
path: "%kernel.logs_dir%/php/deprecation.log"
formatter: monolog.formatter.json
deprecation_stderr:
type: stream
path: php://stderr
formatter: monolog.formatter.json
+12 -13
View File
@@ -1,13 +1,12 @@
framework:
notifier:
chatter_transports:
texter_transports:
sendgrid: '%env(MAILER_DSN)%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }
framework:
notifier:
chatter_transports:
texter_transports:␍
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }
+1 -1
View File
@@ -2,7 +2,7 @@ framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
default_uri: '%env(SITE_BASE_URL)%'
when@prod:
framework:
+4
View File
@@ -22,6 +22,9 @@ security:
enable_csrf: true
username_parameter: username
password_parameter: password
login_throttling:
max_attempts: 5
interval: '15 minutes'
logout:
path: app_logout
# where to redirect after logout
@@ -30,6 +33,7 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
@@ -0,0 +1,2 @@
karser_recaptcha3:
enabled: false
+1
View File
@@ -1,4 +1,5 @@
twig:
form_themes: ['bootstrap_5_layout.html.twig']
globals:
mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'
mercure_topic_base: '%env(MERCURE_TOPIC_BASE)%'
+5
View File
@@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
mailer_from: '%env(MAILER_FROM)%'
services:
# default configuration for services in *this* file
@@ -16,5 +17,9 @@ services:
App\:
resource: '../src/'
App\Game\Service\GameResponseService:
arguments:
$projectDir: '%kernel.project_dir%'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
+1 -1
View File
@@ -4,7 +4,7 @@ Use this index to quickly locate files and directories during development and in
## Top-Level
- docker/compose.yaml / docker/compose.override.yaml — Docker services.
- docker/ — Docker build contexts and configs (php Dockerfile, nginx vhost, compose files).
- docker/ — Docker build contexts and configs (php Dockerfile, nginx vhost).
- composer.json / composer.lock — Dependencies and scripts.
- importmap.php — Importmap configuration for JS dependencies.
- phpunit.dist.xml — PHPUnit configuration.
+15 -9
View File
@@ -9,7 +9,7 @@ This app can run fully in Docker using docker compose with PHP-FPM, Nginx and My
- mailer (dev only via compose.override.yaml): Mailpit (SMTP/UI)
## Prerequisites
- Docker and Docker Compose (v2)
- Docker and Docker Compose (docker compose)
## Usage
@@ -21,36 +21,42 @@ App will be served at http://localhost:8080
Alternatively (manual):
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
cd docker
docker compose up -d --build
```
### 2) Install dependencies
The setup script already runs composer install. To run manually:
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php composer install
cd docker
docker compose exec php composer install
```
### 3) Prepare DB
The setup script already prepares the DB. To run manually:
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php php bin/console doctrine:database:create --if-not-exists
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php php bin/console doctrine:migrations:migrate -n
cd docker
docker compose exec php php bin/console doctrine:database:create --if-not-exists
docker compose exec php php bin/console doctrine:migrations:migrate -n
```
### 4) Run tests
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml exec php vendor/bin/phpunit
cd docker
docker compose exec php vendor/bin/phpunit
```
### 5) Logs
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml logs -f nginx
docker compose -f docker/compose.yaml -f docker/compose.override.yaml logs -f php
cd docker
docker compose logs -f nginx
docker compose logs -f php
```
### 6) Stop
```
docker compose -f docker/compose.yaml -f docker/compose.override.yaml down
cd docker
docker compose down
```
## Notes
+14 -11
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:
- Development: Mailpit (mailcatcher) via SMTP in Docker.
- Production: SendGrid via API transport.
- Production: Mailgun via API transport.
## Development (Mailpit)
@@ -18,28 +18,31 @@ MAILER_DSN=smtp://mailer:1025
```
- Usage:
1. Start stack: `docker compose up -d`
1. Start stack: `docker-compose up -d`
2. Send an email from the app.
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`:
```
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:
- Real environment variable on the server/container, or
- Symfony secrets: `php bin/console secrets:set SENDGRID_API_KEY` (and dump for prod), or
- `region=eu` is only needed if the Mailgun account/domain was created in Mailgun's EU region (common for `.nl`/EU-based senders). Drop it (or use `region=us`) if the domain lives in the US region.
- Provide `MAILGUN_API_KEY` and `MAILGUN_DOMAIN` via:
- Real environment variables on the server/container, or
- Symfony secrets: `php bin/console secrets:set MAILGUN_API_KEY` (and dump for prod), or
- Orchestration secret stores (e.g., Docker/K8s).
- The sending domain must be added and DNS-verified (SPF/DKIM/tracking CNAME) in the Mailgun dashboard before production sending will work reliably; unverified domains are rate-limited/sandboxed.
### Notes
- No Mailpit container is defined in the base `compose.yaml`, only in `compose.override.yaml`. This ensures it is used in development only.
- To test email locally without Docker, you can:
- Run Mailpit on your host (ports 1025/8025) and set `MAILER_DSN=smtp://127.0.0.1:1025` in `.env.local`.
- If you need to use SendGrid SMTP instead of API, a DSN example:
`smtp://apikey:YOUR_SENDGRID_API_KEY@smtp.sendgrid.net:587`.
- If you need to use Mailgun SMTP instead of API, a DSN example:
`mailgun+smtp://USERNAME:PASSWORD@default?region=eu` (username/password come from the Mailgun domain's SMTP credentials).
- Use `php bin/console app:mail:test you@example.com` to send a quick test email against whatever `MAILER_DSN` is currently configured.
+152
View File
@@ -0,0 +1,152 @@
# Test / staging environment (`test.escapepage.com`)
Runs a second copy of the full Docker stack on the same server as production,
behind the same Nginx Proxy Manager (NPM). Production is untouched: with no
`docker/.env` overrides, `compose.yaml` behaves exactly as before.
## How isolation works
`docker/compose.yaml` derives everything instance-specific from `docker/.env`:
| Concern | Mechanism | prod (no overrides) | test |
|---|---|---|---|
| Container names | `${STACK_NAME:-escapepage}-*` | `escapepage-php` … | `escapepage-test-php` … |
| Compose project (networks, labels) | `COMPOSE_PROJECT_NAME` | `docker` (unchanged) | `escapepage-test` |
| Published ports | `${NGINX_HTTP_PORT:-8080}` etc. | `0.0.0.0:8080/8443/3306/8090/8025` | `127.0.0.1:8081/8444/3307/8091/8026` |
| Database files | bind mount `../var/volumes/db` | per checkout | per checkout |
| Web reachability | `nginx` joined to external `nginx_default` | via NPM | via NPM |
`STACK_NAME` is a plain variable (not the Compose-managed `COMPOSE_PROJECT_NAME`),
so its `:-escapepage` default is reliable and **production needs no `docker/.env`
change** to keep its `escapepage-*` container names.
`nginx`, `mercure` and `mailer` all sit on the external `nginx_default` network,
so NPM forwards straight to `escapepage-test-nginx` / `-mercure` by name — no
public host port needed.
## Production checkout — optional
Nothing is required. Two optional tidy-ups, each needing a `docker compose up -d`
to recreate the affected container:
- The `nginx` service now also attaches to `nginx_default`. If you'd rather have
NPM forward to `escapepage-nginx` by name instead of `host:8080`, recreate it
and repoint the NPM proxy host. Otherwise the published `8080/8443` still work
as before and you can ignore this.
- Add `STACK_NAME=escapepage` and `COMPOSE_PROJECT_NAME=escapepage` to the
production `docker/.env` to move it off the implicit `docker` project name.
Cosmetic; do it only if you want the two stacks named symmetrically.
## Standing up the test stack
### 1. DNS
`test.escapepage.com` → server IP. (`mercure-test.escapepage.com` too if you want
live game features.) On Cloudflare, DNS-only ("grey cloud") keeps it low-profile.
### 2. Separate checkout
```bash
git clone <repo> /opt/escapepage-test
cd /opt/escapepage-test
git checkout <branch-to-test>
```
Use a **separate clone**, not `git worktree` — the Docker build context is the
checkout directory and full isolation avoids surprises.
### 3. Compose env
```bash
cp docker/.env.test.example docker/.env
# edit: real passwords, fresh MERCURE_JWT_SECRET (openssl rand -hex 32), reCAPTCHA keys
```
### 4. Symfony env overrides
Create `/opt/escapepage-test/.env.local` in the checkout (git-ignored):
```
APP_SECRET=<openssl rand -hex 16>
# Behind NPM the forwarded client IP lands from a Docker-private range; trust them
# all on staging so URL generation / HTTPS detection work.
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
```
`APP_ENV=prod`, `DATABASE_URL`, `SITE_BASE_URL`, `MERCURE_*` and `MAILER_DSN` are
already supplied to the containers by `docker/.env`.
### 5. Build & start
```bash
./docker/setup.test.sh
```
The test-specific script (not `setup.sh`): it refuses to run unless
`COMPOSE_PROJECT_NAME` in `docker/.env` is a non-prod value, scopes every compose
call to that project, runs the DB/cache/console steps as `www-data`, and repairs
`var/cache` / `var/log` / `var/sessions` ownership at the end (bare `docker exec`
runs as root, which otherwise leaves php-fpm unable to read its own cache — a
silent 500). It never touches `var/volumes/db`.
Builds `escapepage-test-*` images, starts the containers, creates + migrates
`escapepage_test`, builds assets. Re-run any time; `--no-build` skips the rebuild.
### 6. Nginx Proxy Manager — proxy host
- Domain: `test.escapepage.com`
- Forward to: `escapepage-test-nginx`, port **443**, scheme **https**
(the app nginx force-redirects 80→443 and serves a self-signed cert; leave
"Verify SSL" off — same arrangement as production)
- SSL: request a Let's Encrypt cert, Force SSL, HTTP/2
- Optional: add response header `X-Robots-Tag: noindex`
If you want live game screens, add a second proxy host
`mercure-test.escapepage.com` → `escapepage-test-mercure` port `80` (http).
### 7. Restrict access to your IP — NPM Access List
A host firewall on 80/443 can't help: prod and test share those ports on NPM.
Restrict at the proxy instead.
- **NPM → Access Lists → Add Access List**
- Name: e.g. `staging-allowlist`
- Authorisation: leave empty
- Access: `Allow <your.public.ip>` then a final `Deny all`
- Satisfy: **Any**
- Edit the `test.escapepage.com` proxy host → **Access List** → select it.
- Do the same on `mercure-test.escapepage.com` if you added it.
Everyone else gets `403` at the proxy. Update the IP in one place when it changes.
Defence in depth (optional): in `/opt/escapepage-test/docker/nginx/default.conf`,
inside the `server { listen 443 ... }` block:
```nginx
set_real_ip_from 172.16.0.0/12; # NPM's docker network
real_ip_header X-Forwarded-For;
allow <your.public.ip>;
deny all;
```
## Deploying a new version to test
```bash
cd /var/sites/escapepage-test
git fetch && git checkout <branch> && git pull
./docker/setup.test.sh --no-build # composer install, migrate, build assets, fix perms
```
`--no-build` skips the image rebuild; drop it if the Dockerfile changed.
## Restarting
```bash
./docker/restart.test.sh # down + up, keeps the DB and images
./docker/restart.test.sh --build # also rebuild images
./docker/restart.test.sh --fresh-db # also wipe var/volumes/db and re-init MySQL
```
## Safety notes
- Use the **`*.test.sh`** scripts on this checkout, not `setup.sh` / `restart.sh`.
Both refuse to run unless `docker/.env` names a non-prod
`COMPOSE_PROJECT_NAME`, and both scope every action to that project — they
can't reach the production stack.
- `restart.test.sh` **keeps the database** by default (you loaded prod data into
it); `--fresh-db` is the only thing that wipes it. It never runs a host-wide
`docker system prune` / `docker builder prune`, and it only repairs ownership
of `var/cache` / `var/log` / `var/sessions` — **never `var/volumes/db`**
(chowning the MySQL data dir is what corrupted it earlier this build).
- The test `docker/.env` uses its own `DB_NAME` and passwords so a config slip
can't reach the production database.
- `MAILER_DSN=smtp://mailer:1026` keeps staging mail inside Mailpit instead of
sending through Mailgun.
+31
View File
@@ -0,0 +1,31 @@
# User and Group IDs
USER_ID=1000
GROUP_ID=1000
# Application
APP_ENV=prod
SITE_BASE_URL=https://escapepage.com
# Mailer
MAILGUN_API_KEY=CHANGEME_MAILGUN_API_KEY
MAILGUN_DOMAIN=CHANGEME_MAILGUN_DOMAIN
MAILER_DSN=mailgun+api://CHANGEME_MAILGUN_API_KEY:CHANGEME_MAILGUN_DOMAIN@default?region=eu
MAILER_FROM=mailer@escapepage.nl
# Database
DATABASE_URL=mysql://escapepage:CHANGEME_DB_PASSWORD@database:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4
DB_NAME=escapepage
DB_USER=escapepage
DB_PASSWORD=CHANGEME_DB_PASSWORD
MYSQL_ROOT_PASSWORD=CHANGEME_MYSQL_ROOT_PASSWORD
# Mercure
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=CHANGEME_MERCURE_JWT_SECRET
MERCURE_CORS_ALLOWED_ORIGINS=https://www.escapepage.com https://escapepage.com
MERCURE_TOPIC_BASE=https://escapepage.com
# Recaptcha
RECAPTCHA3_KEY=CHANGEME_RECAPTCHA3_KEY
RECAPTCHA3_SECRET=CHANGEME_RECAPTCHA3_SECRET
+77
View File
@@ -0,0 +1,77 @@
# =============================================================================
# docker/.env for a TEST / STAGING stack (e.g. test.escapepage.com)
# =============================================================================
# Copy this to docker/.env inside the *test* checkout and fill in the blanks.
# docker/.env is git-ignored, so it never leaves the server.
#
# Compose reads this file automatically. Anything unique per stack is derived
# from COMPOSE_PROJECT_NAME + the *_PORT vars below, so the same compose.yaml
# serves both production and this copy.
#
# Two config layers, don't mix them up:
# - THIS file -> consumed by `docker compose` (container names, ports, and the
# runtime env it injects into the php containers).
# - <checkout>/.env(.local) -> consumed by Symfony itself (APP_SECRET,
# TRUSTED_PROXIES, messenger DSN, CLI database access, ...).
# =============================================================================
## --- Instance identity -------------------------------------------------------
# Both must be unique on the host.
# STACK_NAME -> prefix for every container_name (escapepage-test-php …)
# COMPOSE_PROJECT_NAME -> compose project: isolates networks, volumes and the
# labels that `docker compose down` / *.test.sh act on.
# Must be a non-prod value or setup.test.sh / restart.test.sh
# refuse to run.
STACK_NAME=escapepage-test
COMPOSE_PROJECT_NAME=escapepage-test
## --- Published host ports ---------------------------------------------------
# Bound to localhost only: the sole public entrypoint is Nginx Proxy Manager,
# which reaches the containers over the shared `nginx_default` network by name.
# Pick ports that don't clash with the production stack (8080/8443/3306/8090/8025/1025).
NGINX_HTTP_PORT=127.0.0.1:8081
NGINX_HTTPS_PORT=127.0.0.1:8444
DB_HOST_PORT=127.0.0.1:3307
MERCURE_HTTP_PORT=127.0.0.1:8091
MAILPIT_UI_PORT=127.0.0.1:8026
MAILPIT_SMTP_PORT=127.0.0.1:1026
## --- PHP image build ------------------------------------------------------
USER_ID=1000
GROUP_ID=1000
## --- Symfony runtime (injected into php / php-worker / php-cron) ------------
APP_ENV=prod
SITE_BASE_URL=https://test.escapepage.com
# Staging should NOT send real mail. Point at the bundled Mailpit and read it
# at http://127.0.0.1:8026 on the server (or via an NPM host if you expose it).
MAILER_DSN=smtp://mailer:1026
MAILER_FROM=mailer@test.escapepage.com
## --- Database -------------------------------------------------------------
# `database` is the compose *service* name and resolves inside this stack's
# own network - keep it as-is. Use its own name + fresh credentials so a mistake
# here can never point at the production database.
DB_NAME=escapepage_test
DB_USER=escapepage
DB_PASSWORD=CHANGE_ME_test_db_password
MYSQL_ROOT_PASSWORD=CHANGE_ME_test_root_password
DATABASE_URL=pdo_mysql://escapepage:CHANGE_ME_test_db_password@database:3306/escapepage_test?serverVersion=8.0.32&charset=utf8mb4
## --- Mercure -----------------------------------------------------------------
# Internal hub URL (service name, stays the same). Public URL + CORS must be the
# test domain. Add a `mercure-test.escapepage.com` proxy host in NPM ->
# <project>-mercure:80.
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure-test.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32
MERCURE_CORS_ALLOWED_ORIGINS="https://test.escapepage.com"
MERCURE_TOPIC_BASE=https://test.escapepage.com
## --- reCAPTCHA v3 ----------------------------------------------------------
# Register test.escapepage.com in the reCAPTCHA admin console and paste its keys,
# or leave the placeholders and expect the contact / "suggest a room" forms to
# fail captcha validation on staging.
RECAPTCHA3_KEY=CHANGE_ME_or_reuse_prod_if_domain_added
RECAPTCHA3_SECRET=CHANGE_ME_or_reuse_prod_if_domain_added
+22 -10
View File
@@ -1,26 +1,38 @@
services:
php:
environment:
XDEBUG_MODE: off
XDEBUG_MODE: "off"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- mailer
# networks:
# backend:
# ipv4_address: 172.23.0.10
###> doctrine/doctrine-bundle ###
database:
ports:
- "3306"
###< doctrine/doctrine-bundle ###
###> doctrine/doctrine-bundle ###
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
###> symfony/mailer ###
mailer:
image: axllent/mailpit
ports:
- "1025:1025"
- "8025:8025"
- "${MAILPIT_SMTP_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
# networks:
# backend:
# ipv4_address: 172.23.0.13
# networks:
# backend:
# name: escapepage_network
# driver: bridge
# ipam:
# config:
# - subnet: 172.23.0.0/16
# gateway: 172.23.0.1
# attachable: true
###< symfony/mailer ###
+129 -42
View File
@@ -1,113 +1,200 @@
version: '3.7'
# This stack can run more than once on the same host (e.g. production + a
# test.escapepage.com staging copy). Everything that must be unique per instance
# comes from docker/.env:
# STACK_NAME -> container name prefix (default: escapepage)
# COMPOSE_PROJECT_NAME -> compose project / network namespace
# NGINX_HTTP_PORT etc. -> published host ports
# With no docker/.env overrides it behaves exactly as before: containers
# escapepage-*, ports 8080/8443/3306/8090/8025.
services:
php:
build:
context: ..
dockerfile: docker/php/Dockerfile
container_name: escapepage-php
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: ${STACK_NAME:-escapepage}-php
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment:
APP_ENV: dev
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
networks:
- backend
# networks:
# backend:
# ipv4_address: 172.23.0.10
restart: unless-stopped
php-worker:
build:
context: ..
dockerfile: docker/php/Dockerfile
container_name: escapepage-php-worker
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: ${STACK_NAME:-escapepage}-php-worker
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment:
APP_ENV: dev
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: ["php", "bin/console", "messenger:consume", "async", "-vv"]
networks:
- backend
# networks:
# backend:
# ipv4_address: 172.23.0.11
restart: unless-stopped
php-cron:
build:
context: ..
dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: ${STACK_NAME:-escapepage}-php-cron
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment:
APP_ENV: ${APP_ENV}
SITE_BASE_URL: ${SITE_BASE_URL}
MAILER_DSN: ${MAILER_DSN}
MAILER_FROM: ${MAILER_FROM}
DATABASE_URL: ${DATABASE_URL}
MERCURE_URL: ${MERCURE_URL}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
depends_on:
- database
- mercure
command: ["crond", "-f", "-l", "2"]
# networks:
# backend:
# ipv4_address: 172.23.0.16
restart: unless-stopped
nginx:
image: nginx:1.29.4-alpine
container_name: escapepage-nginx
container_name: ${STACK_NAME:-escapepage}-nginx
ports:
- "8080:80"
- "${NGINX_HTTP_PORT:-8080}:80"
- "${NGINX_HTTPS_PORT:-8443}:443"
volumes:
- ../:/var/www/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- /etc/hosts:/etc/hosts:ro
depends_on:
- php
# Joined to the Nginx Proxy Manager network so NPM can forward straight to
# "<project>-nginx" without going back out to a published host port.
networks:
- backend
- default
- nginx_proxy
restart: unless-stopped
mailer:
image: axllent/mailpit:latest
container_name: escapepage-mailer
container_name: ${STACK_NAME:-escapepage}-mailer
ports:
- "8025:8025"
- "${MAILPIT_UI_PORT:-8025}:8025"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
- backend
- default
- nginx_proxy
restart: unless-stopped
mercure:
image: dunglas/mercure:v0.21
container_name: escapepage-mercure
container_name: ${STACK_NAME:-escapepage}-mercure
environment:
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureJWTSignedBySymfonySecretKey!'
MERCURE_CORS_ALLOWED_ORIGINS: http://localhost:8080
MERCURE_PUBLISH_ALLOWED_ORIGINS: http://localhost:8080
SERVER_NAME: "http://:80"
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_PUBLISH_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://localhost:8080
# Allow anonymous subscribers in dev only
cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
anonymous
ports:
- "8090:80"
- "${MERCURE_HTTP_PORT:-8090}:80"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
- backend
- default
- nginx_proxy
restart: unless-stopped
###> doctrine/doctrine-bundle ###
###> doctrine/doctrine-bundle ###
database:
image: mysql:8.0
container_name: escapepage-db
container_name: ${STACK_NAME:-escapepage}-db
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-app}
MYSQL_USER: ${MYSQL_USER:-app}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-!ChangeMe!}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}"]
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
command: ["--default-authentication-plugin=mysql_native_password", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
command: ["--default-authentication-plugin=mysql_native_password", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--lower-case-table-names=1", "--innodb-use-native-aio=0"]
volumes:
- database_data:/var/lib/mysql:rw
- ../var/volumes/db:/var/lib/mysql:rw
- ./mysql/init:/docker-entrypoint-initdb.d:ro
- /etc/hosts:/etc/hosts:ro
# Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
# ports:
# - "3306:3306"
networks:
- backend
ports:
- "${DB_HOST_PORT:-3306}:3306"
# networks:
# backend:
# ipv4_address: 172.23.0.15
restart: unless-stopped
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###
networks:
backend:
driver: bridge
nginx_proxy:
external: true
name: nginx_default
+5
View File
@@ -0,0 +1,5 @@
-- This script ensures the user has correct privileges.
-- The user is actually created by the official MySQL image using environment variables.
GRANT ALL PRIVILEGES ON *.* TO 'escapepage'@'%';
FLUSH PRIVILEGES;
+20
View File
@@ -1,6 +1,18 @@
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/html/public;
index index.php index.html;
@@ -18,6 +30,14 @@ server {
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass php:9000;
fastcgi_read_timeout 120;
# Ensure HTTPS is correctly detected by Symfony if Nginx is behind a TLS termination proxy
fastcgi_param HTTPS $https if_not_empty;
# Standard forwarded headers
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_PROTO $scheme;
fastcgi_param HTTP_X_FORWARDED_HOST $host;
fastcgi_param HTTP_X_FORWARDED_PORT $server_port;
}
location ~ /\.ht {
+22
View File
@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDrTCCApWgAwIBAgIURXHwywjcTFR43Q8+qtMAMuhHmW0wDQYJKoZIhvcNAQEL
BQAwZjELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5
MRUwEwYDVQQKDAxPcmdhbml6YXRpb24xDTALBgNVBAsMBFVuaXQxEjAQBgNVBAMM
CWxvY2FsaG9zdDAeFw0yNjAxMTAxMjMzNTNaFw0yNzAxMTAxMjMzNTNaMGYxCzAJ
BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
CgwMT3JnYW5pemF0aW9uMQ0wCwYDVQQLDARVbml0MRIwEAYDVQQDDAlsb2NhbGhv
c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0dQIpm6SeY/Qt1zTr
GDfQuRAqowde6vzlNDwwC5hNQUaA4MCsDcmqmxj/YPUA8qG4MWQzYsj3HEn8l863
a7BELIYy2kvHTO7mgZMsBiH6HzHilIOsZkMJEV3QLlFn7VRb7i6WSw48pbRJk77l
sOX/e3vzE2pemnx4ggSORzNorrQ7UwyBpK374yisKSFzs6KKPnkVDbfBNX2k+fUT
8Ncjq5WkllA93ztPzh1iHNcFThx+MiH5fcs9obdMbfNkcQy22J9Nbi0OT9Tf8R7k
OaBEVPxFkT+moj6bCwetLkdQDGaoGA6AXTR1lrN812eU1TJ6KA4TAOj4ZAuygWa0
kqi3AgMBAAGjUzBRMB0GA1UdDgQWBBSayyPInKCPbaliYycRx9GEK2tTFjAfBgNV
HSMEGDAWgBSayyPInKCPbaliYycRx9GEK2tTFjAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQCT3r5wZd8fN/ognHFopJRKxjw3ZBBYl54ELb32OSVS
NcKR63/2kZc7KQY5LjPbBMpDutLUPsVtJ97OSYY/JQDm/VVkJy0jIUtPD/bLnjEI
bhMoIGKwUDtnSaYF3oXhwMX3XchDCLmpsk+E17LTTq+tHUzkhXZu+sHoHrE70Wls
XfziM0O/zpApJQSeCLi8UDGffLVChFQd4uU//YW+4OMyk/mbu7dV4ckJXQVIvqTr
7UuC7SgRChcYkaQpkDUnaoX+miKbr9SHUmBSbCsXDyPDth5TOUSZWbP6ewDKVWW7
37OURA5UqT2RvnX75+FdLnBtqJrt/3X8wafOOLXILwmA
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0dQIpm6SeY/Qt
1zTrGDfQuRAqowde6vzlNDwwC5hNQUaA4MCsDcmqmxj/YPUA8qG4MWQzYsj3HEn8
l863a7BELIYy2kvHTO7mgZMsBiH6HzHilIOsZkMJEV3QLlFn7VRb7i6WSw48pbRJ
k77lsOX/e3vzE2pemnx4ggSORzNorrQ7UwyBpK374yisKSFzs6KKPnkVDbfBNX2k
+fUT8Ncjq5WkllA93ztPzh1iHNcFThx+MiH5fcs9obdMbfNkcQy22J9Nbi0OT9Tf
8R7kOaBEVPxFkT+moj6bCwetLkdQDGaoGA6AXTR1lrN812eU1TJ6KA4TAOj4ZAuy
gWa0kqi3AgMBAAECggEAfwOccgzK4XEY/OrspEx3fMHFTz1Qgs6DEhCiDG8c08OO
DEglVPSfbSWdgqKL0A73JN4e2Mw/By8yJEf1h8SUXGe6TTC5BZ5wyG2LWQE4CQTL
598AjuerZ0aB8XWodq3lIo+S2tYZPzainucPBjxsplYT+BNCWzQBSBC7hCk5VgPx
6BvzlzBEWJYizpnT55Ta7zDV1tofP2RUt5Q6GT27Qm5fMlAj3a3LsmgeDLIPHhQd
RCo0kEc56X4vZyojaNUrmTzh6+Ljoj7ahEsW9fr8kfQvIlvuR1qjkuuCEUDU7kS/
iblwVkY1Lfrfm9mI82EYI287m28LBTP99ULk9KRhAQKBgQDpEjK0/OmsHSQfjiG7
PHQXrmIdMzaz+BYttiGV9Fx5hsdVPvihdjzzwZck2MkSg5ODMtEthb7uBareS3Nl
CG7a7brY8a/x5ZdnUPNXGykfix/oz557EENembKaWpsV8qiHM8vuADOWEvmqBTVt
C0iXrwvyxgy/GuNz9A9Tfyya3wKBgQDGNb9Pr903/JzJKFkT+4dGpAgE0a3eQsDm
HEJimbhNoOw79AyOHWbpV2f74kz0GdG2MjU3988lZ/VJ7FM0eyDkuBvv3c2YdKCm
A/5tprB/8PefdNJD0HuVm4BE2XDLV74DbOCgoqsFMC1BdeUVBAhSqmRNrYFQYRqj
DvqtDQiFKQKBgB5p6YQEnNmA0/3qJiywrtWIQ/VbgX/ql7pPUgKnaInTNJ/DH96x
9zI3yOleAJ8R3GX6c6FlGo0k4C8x2VUNzKl07DTzFOqT8zXgMmDjgnJDTV6r+RpF
/QSTOeM6f5JVn/hEog/kptamkz3EgDxChK6GgSClB3TIpXW0G2vh5IgxAoGBAIIl
WHDicMcKP4h1zcepKLHhksJXS2rdOfveIljLxpByUassG/JUq/YbRlPFy/Gb4m9X
mEoflQxirlTTr+6NypNjsDRX1197dOCNTsqA4POhLXauJkIQ6pTZfee3PrDF9CYb
n4LaTKEjeRO6bajW9QASkbnPa1Fz8SGP/FkUbbvBAoGAKIuvVLwht1A8C0BXaFrb
znZu3u90SB9TEcm2V9pU1ptiU6Q/CGlxm8UYvx1ahmxNYL6Ip/QNIFyb+HCqvIUf
Id3C+4LlLeXVBP0uBCX828zREhuQutq3kju2iOQfsOkwc1McS4WXk6tExXoVwkzl
2WYMu+GpSZLcti71L58tOf4=
-----END PRIVATE KEY-----
+47 -3
View File
@@ -6,15 +6,33 @@ RUN apk add --no-cache \
git \
icu-dev \
libzip-dev \
libxml2-dev \
oniguruma-dev \
g++ \
make \
nodejs \
npm
npm \
shadow \
logrotate
# Install PHP extension installer
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
# Install PHP extensions
RUN docker-php-ext-configure intl \
&& docker-php-ext-install -j$(nproc) intl pdo pdo_mysql opcache zip
RUN install-php-extensions \
intl \
pdo_mysql \
opcache \
zip \
tokenizer \
ctype \
iconv \
mbstring \
dom \
xml \
simplexml \
xmlreader \
xmlwriter
# Install composer
ENV COMPOSER_ALLOW_SUPERUSER=1 \
@@ -24,7 +42,33 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Configure PHP
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
# Cron daemon (BusyBox's built-in crond) for the php-cron container.
# Harmless for the php/php-worker containers too, since they never invoke crond.
COPY docker/php/crontab /etc/crontabs/root
RUN chmod 0600 /etc/crontabs/root
# Log rotation for the cron hint-check and PHP error logs: 25MB per file, kept for 3 months.
COPY docker/php/logrotate/cron-hints.conf /etc/logrotate.d/cron-hints
COPY docker/php/logrotate/php-logs.conf /etc/logrotate.d/php-logs
RUN chmod 0644 /etc/logrotate.d/cron-hints /etc/logrotate.d/php-logs
# Adjust www-data UID/GID to match host user (default 1000)
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN if [ ${USER_ID:-0} -ne 0 ] && [ ${GROUP_ID:-0} -ne 0 ]; then \
userdel -f www-data &&\
if getent group www-data ; then groupdel www-data; fi &&\
groupadd -g ${GROUP_ID} www-data &&\
useradd -l -u ${USER_ID} -g www-data www-data &&\
install -d -m 0755 -o www-data -g www-data /home/www-data \
;fi
WORKDIR /var/www/html
# Set permissions for Symfony directories
RUN mkdir -p var/cache var/log/cron var/log/php var/sessions && \
chown -R www-data:www-data var
# Default command
CMD ["php-fpm"]
+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
}
+5
View File
@@ -7,3 +7,8 @@ opcache.enable=1
opcache.enable_cli=1
opcache.validate_timestamps=1
opcache.revalidate_freq=0
log_errors=On
error_log=/var/www/html/var/log/php/error.log
session.gc_maxlifetime=1440
session.cookie_lifetime=0
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
# Completely restart ONE project stack (prod or a test/staging copy).
# Can be run from any directory. Everything is scoped to the Compose project
# name so running this from the test checkout never touches the prod stack.
#
# ./docker/restart.sh # rebuild-less restart of this checkout's stack
# ./docker/restart.sh --prune-all # also run host-wide `docker system/builder prune`
# # (old behaviour; skip it when another stack shares the host)
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
PRUNE_ALL=0
for arg in "$@"; do
case "$arg" in
--prune-all) PRUNE_ALL=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
# Read the identifiers from docker/.env (same file Compose uses). STACK_NAME is
# the container-name prefix; COMPOSE_PROJECT_NAME is the compose project.
read_env() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"'" || true; }
STACK="$(read_env STACK_NAME)"; STACK="${STACK:-escapepage}"
PROJECT="$(read_env COMPOSE_PROJECT_NAME)"; PROJECT="${PROJECT:-$STACK}"
echo "Restarting stack: $STACK (compose project: $PROJECT)"
echo "Stopping and removing containers..."
(cd "$DOCKER_DIR" && docker compose -p "$PROJECT" -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
# Belt-and-suspenders: drop anything still lingering for THIS stack only.
docker rm -f \
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
docker network rm "$net" || true
done
if [ "$PRUNE_ALL" -eq 1 ]; then
echo "Host-wide prune (containers, networks, build cache)..."
docker system prune -f || true
docker builder prune -af || true
else
echo "Skipping host-wide prune (pass --prune-all to force it)."
fi
echo "Setting permissions for var/volumes/db and var directories..."
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/sessions"
sudo chown -R 1000:1000 "$ROOT_DIR/var" || true
sudo chmod -R 777 "$ROOT_DIR/var" || true
echo "Running setup script..."
"$DOCKER_DIR/setup.sh" --no-build
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
# Restart the TEST (staging) stack. Scoped entirely to this checkout's compose
# project, so it can never touch the production stack. Unlike docker/restart.sh
# it:
# - keeps the database by default (you loaded prod data into it) — pass
# --fresh-db to wipe var/volumes/db and let MySQL re-initialise
# - never runs a host-wide `docker system/builder prune`
# - only repairs ownership of var/cache, var/log, var/sessions — NEVER
# var/volumes/db (that dir belongs to the mysql process; chowning it is
# what corrupts the data directory)
#
# Usage:
# ./docker/restart.test.sh # down + up (keeps DB and images)
# ./docker/restart.test.sh --build # also rebuild images
# ./docker/restart.test.sh --fresh-db # also wipe + re-initialise the database
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
if [ ! -f "$DOCKER_DIR/.env" ]; then
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env first." >&2
exit 1
fi
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
case "${PROJECT:-}" in
""|escapepage|docker)
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
echo "Refusing to run a test script against the production stack." >&2
exit 1 ;;
esac
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
exit 1
fi
FRESH_DB=0; BUILD=0
for arg in "$@"; do
case "$arg" in
--fresh-db) FRESH_DB=1 ;;
--build) BUILD=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
echo "Restarting test stack: $STACK (compose project: $PROJECT)"
echo "Stopping containers..."
dc down --remove-orphans || true
# scoped fallback — only this stack
docker rm -f \
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
docker network rm "$net" || true
done
if [ "$FRESH_DB" -eq 1 ]; then
echo "Wiping the test database (var/volumes/db)..."
sudo rm -rf "$ROOT_DIR/var/volumes/db"
fi
echo "Repairing var/ ownership (cache/log/sessions only)..."
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/sessions"
sudo chown -R 1000:1000 "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
sudo chmod -R u+rwX,g+rwX "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
echo "Bringing the stack back up..."
if [ "$BUILD" -eq 1 ]; then
"$DOCKER_DIR/setup.test.sh"
else
"$DOCKER_DIR/setup.test.sh" --no-build
fi
Regular → Executable
+19 -7
View File
@@ -17,18 +17,18 @@ set -euo pipefail
ROOT_DIR=$(cd "$(dirname "$0")"/.. && pwd)
DOCKER_DIR="$ROOT_DIR/docker"
# Determine the docker compose command (V2 'docker compose' or V1 'docker-compose')
# Determine the docker-compose command
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: Neither 'docker compose' nor 'docker-compose' was found. Please install Docker Compose." >&2
echo "Error: Neither 'docker-compose' nor 'docker compose' was found. Please install Docker Compose." >&2
exit 1
fi
# Helper to run docker compose from the docker/ directory
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -f compose.yaml "$@"); }
# Helper to run docker compose from the docker directory
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -f compose.yaml -f compose.override.yaml "$@"); }
REBUILD=1
RECREATE=0
@@ -61,7 +61,7 @@ if [ "$RECREATE" -eq 1 ]; then
fi
# Start stack
dc up "${BUILD_ARGS[@]}"
dc up -d "${BUILD_ARGS[@]}"
# Helper to run commands in php container
pexec() { dc exec -T php "$@"; }
@@ -104,9 +104,17 @@ fi
# Prepare DB
echo "Creating database if it doesn't exist..."
pexec php bin/console doctrine:database:create --if-not-exists
if ! pexec php bin/console doctrine:database:create --if-not-exists; then
echo "Error: Database creation failed. Check Docker logs for details." >&2
dc logs database
exit 1
fi
echo "Running migrations..."
pexec php bin/console doctrine:migrations:migrate -n
if ! pexec php bin/console doctrine:migrations:migrate -n; then
echo "Error: Migrations failed." >&2
exit 1
fi
# Import JS deps (Importmap/Asset Mapper)
if [ -f "$ROOT_DIR/importmap.php" ]; then
@@ -135,6 +143,10 @@ Common commands:
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-cron) # crond scheduler activity
tail -f "$ROOT_DIR/var/log/cron/cron.log" # hint-check command output
tail -f "$ROOT_DIR/var/log/php/error.log" # raw PHP errors
tail -f "$ROOT_DIR/var/log/php/prod.log" # Symfony app errors (prod only)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -euo pipefail
# Bootstrap / re-provision a TEST (staging) stack — e.g. test.escapepage.com.
# Same job as docker/setup.sh, but:
# - refuses to run unless docker/.env marks this as a non-prod stack
# - scopes every compose call to COMPOSE_PROJECT_NAME
# - runs DB / cache / console steps as www-data and repairs var/ ownership at
# the end, so php-fpm (www-data) can actually read the compiled container
# (bare `docker exec` runs as root and would leave var/cache root-owned)
# - NEVER touches var/volumes/db (MySQL owns that)
#
# Usage:
# ./docker/setup.test.sh # full setup (build images)
# ./docker/setup.test.sh --no-build # skip image rebuild
# ./docker/setup.test.sh --recreate # force-recreate containers
# ./docker/setup.test.sh --down # stop and remove this stack's containers
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
# --- safety gate: never let a *.test.sh script act on the production stack ----
if [ ! -f "$DOCKER_DIR/.env" ]; then
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env and fill it in." >&2
exit 1
fi
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
case "${PROJECT:-}" in
""|escapepage|docker)
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
echo "Refusing to run a test script against the production stack." >&2
echo "Set COMPOSE_PROJECT_NAME and STACK_NAME to e.g. 'escapepage-test' in docker/.env." >&2
exit 1 ;;
esac
# --- compose command -------------------------------------------------------
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
exit 1
fi
command -v docker >/dev/null 2>&1 || { echo "Error: docker is required." >&2; exit 1; }
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
pexec() { dc exec -T php "$@"; } # as root (composer / npm)
pexecwww() { dc exec -T -u www-data php "$@"; } # as www-data (console / DB / cache)
REBUILD=1; RECREATE=0; DOWN_ONLY=0
for arg in "$@"; do
case "$arg" in
--no-build) REBUILD=0 ;;
--recreate) RECREATE=1 ;;
--down) DOWN_ONLY=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
echo "Test stack: $STACK (compose project: $PROJECT)"
if [ "$DOWN_ONLY" -eq 1 ]; then
dc down --remove-orphans
exit 0
fi
BUILD_ARGS=()
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
[ "$RECREATE" -eq 1 ] && BUILD_ARGS+=("--force-recreate")
dc up -d "${BUILD_ARGS[@]}"
# --- wait for the database ------------------------------------------------
printf "Waiting for database to be healthy..."
for i in $(seq 1 60); do
DB_ID=$(dc ps -q database 2>/dev/null || true)
if [ -n "$DB_ID" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$DB_ID" 2>/dev/null || true)" = "healthy" ]; then
echo " OK"; break
fi
printf "."; sleep 2
[ "$i" -eq 60 ] && echo -e "\nWarning: database not healthy yet, continuing anyway."
done
# --- dependencies (root: writes vendor/ and node_modules/) --------------
pexec composer install --no-interaction
# --- APP_SECRET: generate into .env.local if it's set nowhere -----------
if ! grep -qsE '^APP_SECRET=.+' "$ROOT_DIR/.env" "$ROOT_DIR/.env.local"; then
echo "Generating APP_SECRET in .env.local..."
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
fi
# --- database (www-data: keeps var/ writable by php-fpm) ---------------
echo "Creating database if it doesn't exist..."
pexecwww php bin/console doctrine:database:create --if-not-exists || {
echo "Error: database creation failed." >&2; dc logs database | tail -n 40; exit 1;
}
echo "Running migrations..."
pexecwww php bin/console doctrine:migrations:migrate -n || { echo "Error: migrations failed." >&2; exit 1; }
[ -f "$ROOT_DIR/importmap.php" ] && pexec php bin/console importmap:install || true
if [ -f "$ROOT_DIR/package.json" ]; then
echo "Installing npm dependencies..."; pexec npm ci || pexec npm install
echo "Building assets..."; pexec npm run build
fi
# --- repair var/ ownership for php-fpm (www-data), never var/volumes ----
echo "Fixing var/ ownership for php-fpm..."
pexec sh -lc 'mkdir -p var/cache var/log/php var/log/cron var/sessions && chown -R www-data:www-data var/cache var/log var/sessions'
pexecwww php bin/console cache:clear
NGINX_HTTPS="$(env_val NGINX_HTTPS_PORT)"
MAILPIT_UI="$(env_val MAILPIT_UI_PORT)"
cat <<EOT
Test stack '$PROJECT' is up.
NPM upstream: ${STACK}-nginx (scheme https, port 443, "Verify SSL" off)
Local check: curl -k https://${NGINX_HTTPS:-127.0.0.1:18443}/
Mailpit UI: http://${MAILPIT_UI:-127.0.0.1:18026}
Logs: docker logs -f ${STACK}-php
Shell: docker exec -it -u www-data ${STACK}-php sh
Restart: ./docker/restart.test.sh (add --fresh-db to wipe + re-init the DB)
Re-run this script any time. Use --no-build to skip the image rebuild.
EOT
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260117143000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make player.screen nullable';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE player CHANGE screen screen INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE player CHANGE screen screen INT NOT NULL');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260627140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Seed initial game: AI Virus Deflection';
}
public function up(Schema $schema): void
{
$this->addSql("INSERT INTO game (name, number_of_players, status) VALUES ('AI Virus Deflection', 3, 'inDevelopment')");
$this->addSql("INSERT INTO game_setting (game_id, name, value) VALUES (LAST_INSERT_ID(), 'totalTime', '1800')");
}
public function down(Schema $schema): void
{
$this->addSql("DELETE gs FROM game_setting gs INNER JOIN game g ON gs.game_id = g.id WHERE g.name = 'AI Virus Deflection'");
$this->addSql("DELETE FROM game WHERE name = 'AI Virus Deflection'");
}
}
+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');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260810140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create hint table and seed it with the previously-hardcoded mainframe hints for every existing game';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE hint (id INT AUTO_INCREMENT NOT NULL, game_id INT NOT NULL, hint_condition VARCHAR(30) NOT NULL, threshold_seconds INT NOT NULL, message LONGTEXT NOT NULL, sort_order INT NOT NULL, INDEX IDX_HINT_GAME (game_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE hint ADD CONSTRAINT FK_HINT_GAME FOREIGN KEY (game_id) REFERENCES `game` (id) ON DELETE CASCADE');
// Seed every existing game with the hints that used to be hardcoded in
// SendMainframeHintsCommand, so behavior doesn't regress the moment the
// command switches to reading from this table.
$seed = [
['help_used', 120, 'Have you already checked which functions are available to you through the help command?', 10],
['broadcast_done', 300, 'You should establish communications with your fellow agents.', 20],
['contacted_all_privately', 420, 'The AI virus can see all communication if you are using open communication channels. Make sure you send private messages to all other agents as well, so I know you can.', 30],
['verified', 600, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', 40],
['verified', 780, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', 50],
['left_home_directory', 900, 'You can change the folder you are working in. Check the help command for more information', 60],
['all_decoded', 1020, 'You should go to the rapports directory to check out the rapports.', 70],
['all_decoded', 1140, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", 80],
['none', 1380, 'Have you checked out the help command to see what you can do?', 90],
['none', 1560, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', 100],
['none', 1680, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', 110],
];
foreach ($seed as [$condition, $threshold, $message, $sortOrder]) {
$this->addSql(
'INSERT INTO hint (game_id, hint_condition, threshold_seconds, message, sort_order) '
. 'SELECT id, ?, ?, ?, ? FROM `game`',
[$condition, $threshold, $message, $sortOrder]
);
}
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE hint DROP FOREIGN KEY FK_HINT_GAME');
$this->addSql('DROP TABLE hint');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create contact_message table for the public contact form';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contact_message (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE contact_message');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add read_at to contact_message so admin can track which messages have been read';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message ADD read_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message DROP read_at');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add deleted_at to contact_message for soft-deleting messages from the admin list';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message ADD deleted_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message DROP deleted_at');
}
}
+1800 -4043
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -12,14 +12,22 @@
"devDependencies": {
"@babel/core": "^7.25.0",
"@babel/preset-env": "^7.25.0",
"@symfony/webpack-encore": "^4.6.1",
"babel-loader": "^9.1.3",
"@symfony/webpack-encore": "^6.0.0",
"babel-loader": "^10.1.1",
"core-js": "^3.37.1",
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.2",
"regenerator-runtime": "^0.14.1",
"sass": "^1.101.0",
"sass-loader": "^16.0.1",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-cli": "^6.0.0",
"webpack-notifier": "^1.15.0"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"simple-datatables": "^10.3.0"
}
}

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