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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:34:36 +02:00
86 changed files with 1821 additions and 571 deletions
+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));
});
});
+3
View File
@@ -5,3 +5,6 @@ import './styles/app.scss';
import 'bootstrap/js/dist/collapse'; import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/alert'; import 'bootstrap/js/dist/alert';
import 'bootstrap/js/dist/dropdown'; import 'bootstrap/js/dist/dropdown';
import './game-waiting';
import './game-lobby';
import './admin-session-tabs';
+62
View File
@@ -0,0 +1,62 @@
document.addEventListener('DOMContentLoaded', () => {
const config = document.getElementById('game-lobby-config');
if (!config) {
return;
}
const publicUrl = config.dataset.mercurePublicUrl;
const topic = config.dataset.topic;
const chatLog = document.getElementById('lobby-chat-log');
function appendLobbyMessage(username, content, createdAt) {
if (!chatLog) {
return;
}
const emptyNotice = document.getElementById('lobby-chat-empty');
if (emptyNotice) {
emptyNotice.remove();
}
const time = createdAt
? new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '';
const wrapper = document.createElement('div');
wrapper.className = 'lobby-message';
const author = document.createElement('strong');
author.textContent = username;
const timestamp = document.createElement('span');
timestamp.className = 'text-muted small';
timestamp.textContent = ' ' + time;
const body = document.createElement('div');
body.textContent = content;
wrapper.appendChild(author);
wrapper.appendChild(timestamp);
wrapper.appendChild(body);
chatLog.appendChild(wrapper);
chatLog.scrollTop = chatLog.scrollHeight;
}
if (publicUrl && topic) {
const url = new URL(publicUrl);
url.searchParams.append('topic', topic);
const eventSource = new EventSource(url);
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'lobby_message') {
appendLobbyMessage(data.username, data.content, data.createdAt);
} else if (data.type === 'player_joined' || data.type === 'session_started') {
window.location.reload();
}
};
}
if (chatLog) {
chatLog.scrollTop = chatLog.scrollHeight;
}
});
+68
View File
@@ -0,0 +1,68 @@
document.addEventListener('DOMContentLoaded', () => {
const config = document.getElementById('game-waiting-config');
if (!config) {
return;
}
const publicUrl = config.dataset.mercurePublicUrl;
const topic = config.dataset.topic;
const readyAt = config.dataset.readyAt;
let reloading = false;
const reloadOnce = (eventSource) => {
if (reloading) {
return;
}
reloading = true;
if (eventSource) {
eventSource.close();
}
window.location.reload();
};
if (publicUrl && topic) {
const url = new URL(publicUrl);
url.searchParams.append('topic', topic);
const eventSource = new EventSource(url);
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'all_ready' || data.type === 'player_ready') {
reloadOnce(eventSource);
}
};
}
// Our own ready status expires 60s after we set it - proactively tell the
// server as close to that deadline as possible, so the other players find
// out live instead of only whenever someone else's request happens to
// trigger the lazy check.
if (readyAt) {
const timeoutMs = 61000; // slightly more than the server-side 60s
const readyAtMs = readyAt * 1000;
const countdownEl = document.getElementById('ready-countdown');
const expireForm = document.getElementById('expire-ready-form');
const updateCountdown = () => {
const remaining = Math.max(0, Math.ceil((readyAtMs + timeoutMs - Date.now()) / 1000));
if (countdownEl) {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdownEl.textContent = m + ':' + s.toString().padStart(2, '0');
}
return remaining;
};
const remaining = updateCountdown();
if (remaining <= 0) {
expireForm?.submit();
} else {
const countdownInterval = setInterval(() => {
if (updateCountdown() <= 0) {
clearInterval(countdownInterval);
expireForm?.submit();
}
}, 1000);
}
}
});
+1
View File
@@ -94,6 +94,7 @@ let currentLockedAt = null;
function lockMessageClass(messageType) { function lockMessageClass(messageType) {
if (messageType === 'virus') return 'message-virus'; if (messageType === 'virus') return 'message-virus';
if (messageType === 'mainframe') return 'message-mainframe'; if (messageType === 'mainframe') return 'message-mainframe';
if (messageType === 'hint') return 'message-hint';
return ''; return '';
} }
@@ -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.
+5
View File
@@ -66,6 +66,11 @@ div.message-mainframe {
color: #0F0; color: #0F0;
} }
div.message-hint {
color: #FF0;
font-weight: bold;
}
div#lock-banner { div#lock-banner {
position: fixed; position: fixed;
top: 68px; top: 68px;
+1
View File
@@ -34,6 +34,7 @@
"symfony/process": "7.4.*", "symfony/process": "7.4.*",
"symfony/property-access": "7.4.*", "symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*", "symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*", "symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*", "symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*", "symfony/serializer": "7.4.*",
Generated
+376 -317
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -15,6 +15,12 @@ framework:
storage_factory_id: session.storage.factory.native storage_factory_id: session.storage.factory.native
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%' save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
rate_limiter:
invite_code_join:
policy: 'sliding_window'
limit: 10
interval: '1 minute'
when@prod: when@prod:
framework: framework:
session: session:
+16 -1
View File
@@ -47,6 +47,14 @@ when@prod:
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested: nested:
type: group
members: [nested_file, nested_stderr]
nested_file:
type: stream
path: "%kernel.logs_dir%/php/prod.log"
level: debug
formatter: monolog.formatter.json
nested_stderr:
type: stream type: stream
path: php://stderr path: php://stderr
level: debug level: debug
@@ -56,7 +64,14 @@ when@prod:
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine"] channels: ["!event", "!doctrine"]
deprecation: deprecation:
type: stream type: group
channels: [deprecation] channels: [deprecation]
members: [deprecation_file, deprecation_stderr]
deprecation_file:
type: stream
path: "%kernel.logs_dir%/php/deprecation.log"
formatter: monolog.formatter.json
deprecation_stderr:
type: stream
path: php://stderr path: php://stderr
formatter: monolog.formatter.json formatter: monolog.formatter.json
+12 -13
View File
@@ -1,13 +1,12 @@
framework: framework:
notifier: notifier:
chatter_transports: chatter_transports:
texter_transports: texter_transports:␍
sendgrid: '%env(MAILER_DSN)%' channel_policy:
channel_policy: # use chat/slack, chat/telegram, sms/twilio or sms/nexmo
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo urgent: ['email']
urgent: ['email'] high: ['email']
high: ['email'] medium: ['email']
medium: ['email'] low: ['email']
low: ['email'] admin_recipients:
admin_recipients: - { email: admin@example.com }
- { email: admin@example.com }
+3
View File
@@ -22,6 +22,9 @@ security:
enable_csrf: true enable_csrf: true
username_parameter: username username_parameter: username
password_parameter: password password_parameter: password
login_throttling:
max_attempts: 5
interval: '15 minutes'
logout: logout:
path: app_logout path: app_logout
# where to redirect after logout # where to redirect after logout
+33
View File
@@ -66,6 +66,39 @@ services:
# ipv4_address: 172.23.0.11 # ipv4_address: 172.23.0.11
restart: unless-stopped restart: unless-stopped
php-cron:
build:
context: ..
dockerfile: docker/php/Dockerfile
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php-cron
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
environment:
APP_ENV: ${APP_ENV}
SITE_BASE_URL: ${SITE_BASE_URL}
MAILER_DSN: ${MAILER_DSN}
MAILER_FROM: ${MAILER_FROM}
DATABASE_URL: ${DATABASE_URL}
MERCURE_URL: ${MERCURE_URL}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
depends_on:
- database
- mercure
command: ["crond", "-f", "-l", "2"]
# networks:
# backend:
# ipv4_address: 172.23.0.16
restart: unless-stopped
nginx: nginx:
image: nginx:1.29.4-alpine image: nginx:1.29.4-alpine
container_name: escapepage-nginx container_name: escapepage-nginx
+12 -2
View File
@@ -12,7 +12,8 @@ RUN apk add --no-cache \
make \ make \
nodejs \ nodejs \
npm \ npm \
shadow shadow \
logrotate
# Install PHP extension installer # Install PHP extension installer
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
@@ -41,6 +42,15 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Configure PHP # Configure PHP
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
# Cron daemon (BusyBox's built-in crond) for the php-cron container.
# Harmless for the php/php-worker containers too, since they never invoke crond.
COPY docker/php/crontab /etc/crontabs/root
RUN chmod 0600 /etc/crontabs/root
# Log rotation for the cron hint-check and PHP error logs: 25MB per file, kept for 3 months.
COPY docker/php/logrotate/cron-hints.conf /etc/logrotate.d/cron-hints
COPY docker/php/logrotate/php-logs.conf /etc/logrotate.d/php-logs
# Adjust www-data UID/GID to match host user (default 1000) # Adjust www-data UID/GID to match host user (default 1000)
ARG USER_ID=1000 ARG USER_ID=1000
ARG GROUP_ID=1000 ARG GROUP_ID=1000
@@ -56,7 +66,7 @@ RUN if [ ${USER_ID:-0} -ne 0 ] && [ ${GROUP_ID:-0} -ne 0 ]; then \
WORKDIR /var/www/html WORKDIR /var/www/html
# Set permissions for Symfony directories # Set permissions for Symfony directories
RUN mkdir -p var/cache var/log var/sessions && \ RUN mkdir -p var/cache var/log/cron var/log/php var/sessions && \
chown -R www-data:www-data var chown -R www-data:www-data var
# Default command # Default command
+2
View File
@@ -0,0 +1,2 @@
* * * * * php /var/www/html/bin/console app:hints:check >> /var/www/html/var/log/cron/cron.log 2>&1
5 3 * * * logrotate -s /var/www/html/var/log/.logrotate.state /etc/logrotate.d/cron-hints /etc/logrotate.d/php-logs
+11
View File
@@ -0,0 +1,11 @@
/var/www/html/var/log/cron/cron.log {
size 25M
rotate 100
maxage 90
missingok
notifempty
compress
delaycompress
dateext
dateformat -%Y%m%d-%s
}
+11
View File
@@ -0,0 +1,11 @@
/var/www/html/var/log/php/*.log {
size 25M
rotate 100
maxage 90
missingok
notifempty
compress
delaycompress
dateext
dateformat -%Y%m%d-%s
}
+1 -1
View File
@@ -9,6 +9,6 @@ opcache.validate_timestamps=1
opcache.revalidate_freq=0 opcache.revalidate_freq=0
log_errors=On log_errors=On
error_log=/var/www/html/var/log/errorlog_php.log error_log=/var/www/html/var/log/php/error.log
session.gc_maxlifetime=1440 session.gc_maxlifetime=1440
session.cookie_lifetime=0 session.cookie_lifetime=0
+2 -2
View File
@@ -12,7 +12,7 @@ echo "Stopping and removing containers..."
docker network rm escapepage_network || true docker network rm escapepage_network || true
docker network rm $(docker network ls -q --filter name=escapepage) || true docker network rm $(docker network ls -q --filter name=escapepage) || true
docker network prune -f || true docker network prune -f || true
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker || true docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker escapepage-php-cron || true
docker system prune -f || true docker system prune -f || true
echo "Clearing Docker build cache..." echo "Clearing Docker build cache..."
@@ -21,7 +21,7 @@ docker builder prune -af
echo "Setting permissions for var/volumes/db and var directories..." echo "Setting permissions for var/volumes/db and var directories..."
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions" sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/sessions"
sudo chown -R 1000:1000 "$ROOT_DIR/var" || true sudo chown -R 1000:1000 "$ROOT_DIR/var" || true
sudo chmod -R 777 "$ROOT_DIR/var" || true sudo chmod -R 777 "$ROOT_DIR/var" || true
+4
View File
@@ -143,6 +143,10 @@ Common commands:
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-cron) # crond scheduler activity
tail -f "$ROOT_DIR/var/log/cron/cron.log" # hint-check command output
tail -f "$ROOT_DIR/var/log/php/error.log" # raw PHP errors
tail -f "$ROOT_DIR/var/log/php/prod.log" # Symfony app errors (prod only)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down) (cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
+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');
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Game\Entity\Session;
use App\Game\Enum\GameSettingType;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameSettingRepository;
use App\Game\Repository\SessionRepository;
use App\Game\Repository\SessionSettingRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
#[AsCommand(
name: 'app:hints:check',
description: 'Checks running game sessions and sends mainframe hints to players who are behind schedule.'
)]
final class SendMainframeHintsCommand extends Command
{
private const DEFAULT_TOTAL_TIME = 3600;
public function __construct(
private readonly SessionRepository $sessionRepository,
private readonly SessionSettingRepository $sessionSettingRepository,
private readonly GameSettingRepository $gameSettingRepository,
private readonly HubInterface $hub,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$sessions = $this->sessionRepository->findBy(['status' => SessionStatus::PLAYING]);
$hintsSent = 0;
foreach ($sessions as $session) {
if ($this->checkContactHint($session)) {
$hintsSent++;
}
}
$output->writeln(sprintf('<info>Checked %d running session(s), sent %d hint(s).</info>', count($sessions), $hintsSent));
return Command::SUCCESS;
}
/**
* "Get in touch" hint: if 5 minutes into the game the players haven't messaged
* everyone (a private message to each other player, plus one broadcast), nudge them.
* Repeats every run of this command for as long as the condition still holds.
*/
private function checkContactHint(Session $session): bool
{
$elapsed = $this->getElapsedPlayingSeconds($session);
if ($elapsed === null || $elapsed < 300) {
return false;
}
if ($this->allPlayersHaveContactedEveryone($session)) {
return false;
}
$this->publishHint($session, 'Get in contact with your fellow agents to work together on defeating this AI virus.');
return true;
}
private function getElapsedPlayingSeconds(Session $session): ?int
{
$timer = $session->getTimer();
if ($timer === null) {
return null;
}
$totalTimeSetting = $this->gameSettingRepository->getSetting($session->getGame(), GameSettingType::TOTAL_TIME);
$totalTime = $totalTimeSetting ? (int)$totalTimeSetting->getValue() : self::DEFAULT_TOTAL_TIME;
$startedAt = $timer - $totalTime;
return time() - $startedAt;
}
private function allPlayersHaveContactedEveryone(Session $session): bool
{
$players = $session->getPlayers();
$screens = [];
foreach ($players as $player) {
if ($player->getScreen() === null) {
return false;
}
$screens[] = $player->getScreen();
}
foreach ($players as $player) {
$screen = $player->getScreen();
$trackingSettingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $screen);
if (!$trackingSettingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $trackingSettingName, $player);
$tracking = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
if (!in_array(0, $tracking)) {
return false;
}
foreach ($screens as $otherScreen) {
if ($otherScreen !== $screen && !in_array($otherScreen, $tracking)) {
return false;
}
}
}
return true;
}
private function publishHint(Session $session, string $message): void
{
$topic = '/game/hub/' . $session->getId();
try {
$this->hub->publish(new Update($topic, json_encode([0, $message, 'hint'])));
} catch (\Exception $e) {
// Mercure might be down
}
}
}
+5 -4
View File
@@ -7,6 +7,7 @@ namespace App\Game\Controller;
use App\Game\Entity\Session; use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus; use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository; use App\Game\Repository\GameRepository;
use App\Game\Repository\LobbyMessageRepository;
use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionRepository;
use App\Tech\Repository\UserRepository; use App\Tech\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -49,15 +50,14 @@ final class GameAdminController extends AbstractController
} }
#[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])] #[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])]
public function viewSession(Session $session): Response public function viewSession(Session $session, LobbyMessageRepository $lobbyMessageRepository): Response
{ {
$playersLogs = []; $playersLogs = [];
foreach ($session->getPlayers() as $player) { foreach ($session->getPlayers() as $player) {
$username = $player->getUser()->getUsername(); $logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $player->getLogFileBasename() . '.txt';
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
$playersLogs[] = [ $playersLogs[] = [
'username' => $username, 'username' => $player->getUser()->getUsername(),
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '', 'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
]; ];
} }
@@ -65,6 +65,7 @@ final class GameAdminController extends AbstractController
return $this->render('game/admin/sessions/view.html.twig', [ return $this->render('game/admin/sessions/view.html.twig', [
'session' => $session, 'session' => $session,
'playersLogs' => $playersLogs, 'playersLogs' => $playersLogs,
'lobbyMessages' => $lobbyMessageRepository->findForSession($session),
]); ]);
} }
} }
@@ -35,6 +35,7 @@ final class GameAdminSessionController extends AbstractController
} }
$session->setStatus(SessionStatus::LOST); $session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$em->flush(); $em->flush();
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId())); $this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
@@ -43,6 +43,7 @@ final class GameApiController extends AbstractController
if ($session->getStatus() === SessionStatus::PLAYING) { if ($session->getStatus() === SessionStatus::PLAYING) {
if ($session->getTimer() !== null && $now >= $session->getTimer()) { if ($session->getTimer() !== null && $now >= $session->getTimer()) {
$session->setStatus(SessionStatus::LOST); $session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session); $this->entityManager->persist($session);
$this->entityManager->flush(); $this->entityManager->flush();
$isFinished = true; $isFinished = true;
+58 -8
View File
@@ -22,6 +22,8 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
final class GameController extends AbstractController final class GameController extends AbstractController
{ {
@@ -39,13 +41,20 @@ final class GameController extends AbstractController
GameRepository $gameRepository, GameRepository $gameRepository,
SessionRepository $sessionRepository, SessionRepository $sessionRepository,
GameDashboardService $dashboardService, GameDashboardService $dashboardService,
Security $security Security $security,
#[Target('invite_code_join')]
RateLimiterFactoryInterface $inviteCodeJoinLimiter
): Response { ): Response {
$user = $security->getUser(); $user = $security->getUser();
$isAdmin = $this->isGranted('ROLE_ADMIN'); $isAdmin = $this->isGranted('ROLE_ADMIN');
if ($request->isMethod('POST')) { if ($request->isMethod('POST')) {
if ($request->request->has('create_session')) { if ($request->request->has('create_session')) {
if (!$this->isCsrfTokenValid('create_session', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
$gameId = $request->request->get('game_id'); $gameId = $request->request->get('game_id');
$game = $gameRepository->find($gameId); $game = $gameRepository->find($gameId);
@@ -55,6 +64,17 @@ final class GameController extends AbstractController
} }
} }
} elseif ($request->request->has('join_session')) { } elseif ($request->request->has('join_session')) {
if (!$this->isCsrfTokenValid('join_session', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
$limiter = $inviteCodeJoinLimiter->create($user->getUserIdentifier());
if (!$limiter->consume(1)->isAccepted()) {
$this->addFlash('error', 'Too many attempts. Please wait a moment and try again.');
return $this->redirectToRoute('game_dashboard');
}
$inviteCode = $request->request->get('invite_code'); $inviteCode = $request->request->get('invite_code');
if ($dashboardService->joinSession($inviteCode, $user)) { if ($dashboardService->joinSession($inviteCode, $user)) {
$this->addFlash('success', 'Joined session successfully!'); $this->addFlash('success', 'Joined session successfully!');
@@ -70,6 +90,11 @@ final class GameController extends AbstractController
return $this->redirectToRoute('game_dashboard'); return $this->redirectToRoute('game_dashboard');
} }
if (!$this->isCsrfTokenValid('create_invite_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
$inviteCode = $dashboardService->generateInviteCode($session, $user, $isAdmin); $inviteCode = $dashboardService->generateInviteCode($session, $user, $isAdmin);
if ($inviteCode) { if ($inviteCode) {
$this->addFlash('success', 'Invite link created: ' . $inviteCode); $this->addFlash('success', 'Invite link created: ' . $inviteCode);
@@ -79,6 +104,11 @@ final class GameController extends AbstractController
$session = $sessionRepository->find($sessionId); $session = $sessionRepository->find($sessionId);
if ($session) { if ($session) {
if (!$this->isCsrfTokenValid('leave_session_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
if ($dashboardService->leaveSession($session, $user)) { if ($dashboardService->leaveSession($session, $user)) {
$this->addFlash('success', 'Left session successfully.'); $this->addFlash('success', 'Left session successfully.');
} else { } else {
@@ -90,6 +120,11 @@ final class GameController extends AbstractController
$session = $sessionRepository->find($sessionId); $session = $sessionRepository->find($sessionId);
if ($session) { if ($session) {
if (!$this->isCsrfTokenValid('start_session_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_dashboard');
}
if ($dashboardService->startSession($session)) { if ($dashboardService->startSession($session)) {
$this->addFlash('success', 'Session started! Screens have been assigned.'); $this->addFlash('success', 'Session started! Screens have been assigned.');
} else { } else {
@@ -127,7 +162,9 @@ final class GameController extends AbstractController
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]); $player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
if ($request->isMethod('POST') && $request->request->has('toggle_ready')) { if ($request->isMethod('POST') && $request->request->has('toggle_ready')) {
if (!$user->isVerified()) { if (!$this->isCsrfTokenValid('toggle_ready_' . $session->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
} elseif (!$user->isVerified()) {
$this->addFlash('error', 'You must verify your email address before you can mark yourself as ready.'); $this->addFlash('error', 'You must verify your email address before you can mark yourself as ready.');
} else { } else {
$dashboardService->toggleReady($session, $user); $dashboardService->toggleReady($session, $user);
@@ -137,16 +174,29 @@ final class GameController extends AbstractController
} }
if ($request->isMethod('POST') && $request->request->has('expire_ready')) { if ($request->isMethod('POST') && $request->request->has('expire_ready')) {
$dashboardService->expireOwnReadyIfDue($session, $user); if ($this->isCsrfTokenValid('expire_ready_' . $session->getId(), $request->request->get('_token'))) {
$dashboardService->expireOwnReadyIfDue($session, $user);
}
return $this->redirectToRoute('game', ['session' => $session->getId()]);
}
if ($request->isMethod('POST') && $request->request->has('send_message')) {
if ($this->isCsrfTokenValid('send_message_' . $session->getId(), $request->request->get('_token'))) {
$dashboardService->postLobbyMessage($session, $user, (string) $request->request->get('content', ''));
}
return $this->redirectToRoute('game', ['session' => $session->getId()]); return $this->redirectToRoute('game', ['session' => $session->getId()]);
} }
// Lazily pick up readiness changes from other players since our last request // Lazily pick up readiness changes from other players since our last request
$dashboardService->checkAllPlayersReady($session); $dashboardService->checkAllPlayersReady($session);
if ($session->getStatus() === SessionStatus::CREATED) { if ($dashboardService->isLobbyChatOpen($session)) {
$this->addFlash('info', 'This session is still waiting for more players to join.'); return $this->render('game/lobby.html.twig', [
return $this->redirectToRoute('game_dashboard'); 'session' => $session,
'messages' => $dashboardService->getLobbyMessages($session),
'player' => $player,
'mercure_public_url' => $this->mercurePublicUrl,
]);
} }
if ($session->getStatus() === SessionStatus::WON) { if ($session->getStatus() === SessionStatus::WON) {
@@ -206,7 +256,7 @@ final class GameController extends AbstractController
$user = $security->getUser(); $user = $security->getUser();
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]); $player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
if ($request->isMethod('POST')) { if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
$difficulty = $request->request->get('difficulty'); $difficulty = $request->request->get('difficulty');
$entertaining = $request->request->get('entertaining'); $entertaining = $request->request->get('entertaining');
$theme = $request->request->get('theme'); $theme = $request->request->get('theme');
@@ -238,7 +288,7 @@ final class GameController extends AbstractController
$user = $security->getUser(); $user = $security->getUser();
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]); $player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
if ($request->isMethod('POST')) { if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
$difficulty = $request->request->get('difficulty'); $difficulty = $request->request->get('difficulty');
$entertaining = $request->request->get('entertaining'); $entertaining = $request->request->get('entertaining');
$theme = $request->request->get('theme'); $theme = $request->request->get('theme');
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Game\Entity;
use App\Game\Repository\LobbyMessageRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: LobbyMessageRepository::class)]
#[ORM\Table(name: 'lobby_message')]
class LobbyMessage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Session::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Session $session = null;
#[ORM\ManyToOne(targetEntity: Player::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Player $player = null;
#[ORM\Column(length: 500)]
private ?string $content = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getSession(): ?Session
{
return $this->session;
}
public function setSession(?Session $session): static
{
$this->session = $session;
return $this;
}
public function getPlayer(): ?Player
{
return $this->player;
}
public function setPlayer(?Player $player): static
{
$this->player = $player;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}
+15
View File
@@ -66,4 +66,19 @@ class Player
return $this; return $this;
} }
/**
* A filesystem-safe basename derived from the player's username, for use when
* building per-player log file paths. Usernames are validated to only contain
* safe characters at registration time, but this sanitizes defensively too, so
* a path segment can never traverse outside its intended directory regardless
* of what ends up stored on the user.
*/
public function getLogFileBasename(): string
{
$username = $this->user?->getUsername() ?? '';
$safe = preg_replace('/[^A-Za-z0-9_-]/', '_', $username);
return $safe !== null && $safe !== '' ? $safe : ('player-' . $this->id);
}
} }
+15
View File
@@ -31,6 +31,9 @@ class Session
#[ORM\Column(type: Types::DATETIME_MUTABLE)] #[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $created = null; private ?\DateTimeInterface $created = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $finishedAt = null;
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)] #[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
private Collection $players; private Collection $players;
@@ -97,6 +100,18 @@ class Session
return $this; return $this;
} }
public function getFinishedAt(): ?\DateTimeInterface
{
return $this->finishedAt;
}
public function setFinishedAt(?\DateTimeInterface $finishedAt): static
{
$this->finishedAt = $finishedAt;
return $this;
}
/** /**
* @return Collection<int, Player> * @return Collection<int, Player>
*/ */
+11
View File
@@ -9,4 +9,15 @@ enum SessionStatus: string
case PLAYING = 'playing'; case PLAYING = 'playing';
case WON = 'won'; case WON = 'won';
case LOST = 'lost'; case LOST = 'lost';
public function label(): string
{
return match ($this) {
self::CREATED => 'Waiting for players',
self::READY => 'Waiting for players to be ready',
self::PLAYING => 'In progress',
self::WON => 'Completed - Won',
self::LOST => 'Completed - Lost',
};
}
} }
@@ -0,0 +1,33 @@
<?php
namespace App\Game\Repository;
use App\Game\Entity\LobbyMessage;
use App\Game\Entity\Session;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<LobbyMessage>
*/
class LobbyMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, LobbyMessage::class);
}
/**
* @return LobbyMessage[]
*/
public function findForSession(Session $session, int $limit = 100): array
{
return $this->createQueryBuilder('m')
->andWhere('m.session = :session')
->setParameter('session', $session)
->orderBy('m.createdAt', 'ASC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}
+87 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Game\Service; namespace App\Game\Service;
use App\Game\Entity\Game; use App\Game\Entity\Game;
use App\Game\Entity\LobbyMessage;
use App\Game\Entity\Player; use App\Game\Entity\Player;
use App\Game\Entity\Session; use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting; use App\Game\Entity\SessionSetting;
@@ -11,6 +12,7 @@ use App\Game\Enum\GameStatus;
use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus; use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository; use App\Game\Repository\GameRepository;
use App\Game\Repository\LobbyMessageRepository;
use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionRepository;
use App\Tech\Entity\User; use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -21,10 +23,13 @@ use Symfony\Component\Mercure\Update;
final class GameDashboardService final class GameDashboardService
{ {
private const READY_TIMEOUT_SECONDS = 60; private const READY_TIMEOUT_SECONDS = 60;
private const LOBBY_MESSAGE_MAX_LENGTH = 500;
private const LOBBY_CHAT_GRACE_PERIOD_SECONDS = 3600;
public function __construct( public function __construct(
private readonly GameRepository $gameRepository, private readonly GameRepository $gameRepository,
private readonly SessionRepository $sessionRepository, private readonly SessionRepository $sessionRepository,
private readonly LobbyMessageRepository $lobbyMessageRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub, private readonly HubInterface $hub,
) { ) {
@@ -122,6 +127,8 @@ final class GameDashboardService
$this->entityManager->flush(); $this->entityManager->flush();
$this->publishLobbyEvent($session, 'player_joined');
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) { if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
$this->startSession($session); $this->startSession($session);
} }
@@ -283,9 +290,83 @@ final class GameDashboardService
$this->entityManager->persist($session); $this->entityManager->persist($session);
$this->entityManager->flush(); $this->entityManager->flush();
$this->publishLobbyEvent($session, 'session_started');
return true; return true;
} }
/**
* @return LobbyMessage[]
*/
public function getLobbyMessages(Session $session): array
{
return $this->lobbyMessageRepository->findForSession($session);
}
/**
* The lobby chat is open while a session is still gathering players, and stays
* open for a grace period after the game ends so players can wrap up the
* conversation before it disappears.
*/
public function isLobbyChatOpen(Session $session): bool
{
if ($session->getStatus() === SessionStatus::CREATED) {
return true;
}
if (!in_array($session->getStatus(), [SessionStatus::WON, SessionStatus::LOST], true)) {
return false;
}
$finishedAt = $session->getFinishedAt();
if ($finishedAt === null) {
return false;
}
return (new \DateTime())->getTimestamp() - $finishedAt->getTimestamp() < self::LOBBY_CHAT_GRACE_PERIOD_SECONDS;
}
public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage
{
if (!$this->isLobbyChatOpen($session)) {
return null;
}
$player = null;
foreach ($session->getPlayers() as $sessionPlayer) {
if ($sessionPlayer->getUser() === $user) {
$player = $sessionPlayer;
break;
}
}
if (!$player) {
return null;
}
$content = trim($content);
if ($content === '') {
return null;
}
$content = mb_substr($content, 0, self::LOBBY_MESSAGE_MAX_LENGTH);
$message = new LobbyMessage();
$message->setSession($session);
$message->setPlayer($player);
$message->setContent($content);
$this->entityManager->persist($message);
$this->entityManager->flush();
$this->publishLobbyEvent($session, 'lobby_message', [
'username' => $user->getUserIdentifier(),
'content' => $content,
'createdAt' => $message->getCreatedAt()->format(DATE_ATOM),
]);
return $message;
}
public function toggleReady(Session $session, User $user): bool public function toggleReady(Session $session, User $user): bool
{ {
if ($session->getStatus() !== SessionStatus::READY) { if ($session->getStatus() !== SessionStatus::READY) {
@@ -393,10 +474,15 @@ final class GameDashboardService
} }
private function publishPlayerReady(Session $session, int $screen, bool $ready): void private function publishPlayerReady(Session $session, int $screen, bool $ready): void
{
$this->publishLobbyEvent($session, 'player_ready', ['player' => $screen, 'ready' => $ready]);
}
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
{ {
try { try {
$topic = '/game/hub/' . $session->getId(); $topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $screen, 'ready' => $ready]))); $this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra))));
} catch (\Exception $e) { } catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game // Mercure might be down, but we don't want to crash the game
} }
+178 -2
View File
@@ -107,14 +107,13 @@ class GameResponseService
private function logSessionActivity(Player $player, string $content): void private function logSessionActivity(Player $player, string $content): void
{ {
$sessionId = $player->getSession()->getId(); $sessionId = $player->getSession()->getId();
$username = $player->getUser()->getUsername();
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId; $logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
if (!is_dir($logDir)) { if (!is_dir($logDir)) {
mkdir($logDir, 0777, true); mkdir($logDir, 0777, true);
} }
$logFile = $logDir . '/' . $username . '.txt'; $logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
$timestamp = date('Y-m-d H:i:s'); $timestamp = date('Y-m-d H:i:s');
$logMessage = sprintf("[%s] %s\n", $timestamp, $content); $logMessage = sprintf("[%s] %s\n", $timestamp, $content);
@@ -998,6 +997,61 @@ class GameResponseService
$paths[] = '/etc/handle'; $paths[] = '/etc/handle';
$paths[] = '/etc/freak'; $paths[] = '/etc/freak';
$paths[] = '/etc/host'; $paths[] = '/etc/host';
$paths[] = '/etc/ssh';
$paths[] = '/etc/nginx';
$paths[] = '/etc/apache2';
$paths[] = '/etc/systemd';
$paths[] = '/etc/cron.d';
$paths[] = '/etc/network';
$paths[] = '/etc/apt';
$paths[] = '/etc/default';
$paths[] = '/etc/init.d';
$paths[] = '/etc/security';
$paths[] = '/etc/skel';
$paths[] = '/etc/logrotate.d';
$paths[] = '/bin';
$paths[] = '/boot';
$paths[] = '/dev';
$paths[] = '/home';
$paths[] = '/home/admin';
$paths[] = '/home/guest';
$paths[] = '/home/backup';
$paths[] = '/lib';
$paths[] = '/lib64';
$paths[] = '/media';
$paths[] = '/mnt';
$paths[] = '/opt';
$paths[] = '/opt/app';
$paths[] = '/proc';
$paths[] = '/root';
$paths[] = '/root/.ssh';
$paths[] = '/run';
$paths[] = '/sbin';
$paths[] = '/srv';
$paths[] = '/sys';
$paths[] = '/tmp';
$paths[] = '/usr';
$paths[] = '/usr/bin';
$paths[] = '/usr/sbin';
$paths[] = '/usr/lib';
$paths[] = '/usr/local';
$paths[] = '/usr/local/bin';
$paths[] = '/usr/local/sbin';
$paths[] = '/usr/share';
$paths[] = '/usr/share/doc';
$paths[] = '/usr/share/man';
$paths[] = '/usr/include';
$paths[] = '/usr/src';
$paths[] = '/var/log';
$paths[] = '/var/lib';
$paths[] = '/var/cache';
$paths[] = '/var/spool';
$paths[] = '/var/backups';
$paths[] = '/var/tmp';
$paths[] = '/var/mail';
$paths[] = '/var/run';
$paths[] = '/var/home'; $paths[] = '/var/home';
@@ -1063,6 +1117,7 @@ class GameResponseService
} }
$session->setStatus(SessionStatus::WON); $session->setStatus(SessionStatus::WON);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session); $this->entityManager->persist($session);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -1213,6 +1268,127 @@ class GameResponseService
$files[] = '/var/rapports/011_130-62.txt'; $files[] = '/var/rapports/011_130-62.txt';
$files[] = '/var/rapports/index.txt'; $files[] = '/var/rapports/index.txt';
$files[] = '/etc/passwd';
$files[] = '/etc/shadow';
$files[] = '/etc/hostname';
$files[] = '/etc/hosts';
$files[] = '/etc/os-release';
$files[] = '/etc/motd';
$files[] = '/etc/issue';
$files[] = '/etc/timezone';
$files[] = '/etc/resolv.conf';
$files[] = '/etc/crontab';
$files[] = '/etc/nsswitch.conf';
$files[] = '/etc/environment';
$files[] = '/etc/fstab';
$files[] = '/etc/ssh/sshd_config';
$files[] = '/etc/ssh/ssh_config';
$files[] = '/etc/nginx/nginx.conf';
$files[] = '/etc/apache2/apache2.conf';
$files[] = '/etc/network/interfaces';
$files[] = '/etc/apt/sources.list';
$files[] = '/etc/cron.d/backup-cron.sh';
$files[] = '/etc/init.d/nginx.sh';
$files[] = '/etc/init.d/ssh.sh';
$files[] = '/etc/init.d/cron.sh';
$files[] = '/etc/logrotate.d/rsyslog.sh';
$files[] = '/etc/logrotate.d/apt.sh';
$files[] = '/etc/skel/bashrc.sh';
$files[] = '/etc/skel/profile.sh';
$files[] = '/var/log/syslog';
$files[] = '/var/log/auth.log';
$files[] = '/var/log/kern.log';
$files[] = '/var/log/boot.log';
$files[] = '/var/log/dmesg';
$files[] = '/var/log/dpkg.log';
$files[] = '/var/log/cron.log';
$files[] = '/var/log/mail.log';
$files[] = '/var/log/daemon.log';
$files[] = '/var/log/alternatives.log';
$files[] = '/var/lib/dpkg-status.sh';
$files[] = '/var/lib/apt-extended-states.sh';
$files[] = '/var/cache/apt-archives.sh';
$files[] = '/var/spool/cron-crontabs.sh';
$files[] = '/var/spool/mail-root.sh';
$files[] = '/var/backups/passwd.bak.sh';
$files[] = '/var/backups/group.bak.sh';
$files[] = '/var/mail/root';
$files[] = '/root/.bashrc';
$files[] = '/root/.bash_history';
$files[] = '/root/.ssh/authorized_keys';
$files[] = '/home/admin/notes.sh';
$files[] = '/home/admin/todo.sh';
$files[] = '/home/guest/readme.sh';
$files[] = '/home/backup/backup.sh';
$files[] = '/opt/app/config.yml';
$files[] = '/opt/app/app.sh';
$files[] = '/opt/app/start.sh';
$files[] = '/usr/bin/ls.sh';
$files[] = '/usr/bin/cat.sh';
$files[] = '/usr/bin/grep.sh';
$files[] = '/usr/bin/awk.sh';
$files[] = '/usr/bin/sed.sh';
$files[] = '/usr/bin/bash.sh';
$files[] = '/usr/bin/python3.sh';
$files[] = '/usr/bin/perl.sh';
$files[] = '/usr/bin/curl.sh';
$files[] = '/usr/bin/wget.sh';
$files[] = '/usr/bin/ssh.sh';
$files[] = '/usr/bin/scp.sh';
$files[] = '/usr/bin/rsync.sh';
$files[] = '/usr/bin/tar.sh';
$files[] = '/usr/bin/gzip.sh';
$files[] = '/usr/bin/vim.sh';
$files[] = '/usr/bin/nano.sh';
$files[] = '/usr/bin/top.sh';
$files[] = '/usr/bin/ps.sh';
$files[] = '/usr/bin/kill.sh';
$files[] = '/usr/bin/chmod.sh';
$files[] = '/usr/bin/chown.sh';
$files[] = '/usr/bin/systemctl.sh';
$files[] = '/usr/bin/docker.sh';
$files[] = '/usr/bin/git.sh';
$files[] = '/usr/bin/find.sh';
$files[] = '/usr/bin/sort.sh';
$files[] = '/usr/bin/uniq.sh';
$files[] = '/usr/bin/head.sh';
$files[] = '/usr/bin/tail.sh';
$files[] = '/bin/sh.sh';
$files[] = '/bin/mount.sh';
$files[] = '/bin/umount.sh';
$files[] = '/bin/ping.sh';
$files[] = '/bin/netstat.sh';
$files[] = '/bin/ifconfig.sh';
$files[] = '/bin/hostname.sh';
$files[] = '/bin/date.sh';
$files[] = '/bin/ln.sh';
$files[] = '/bin/cp.sh';
$files[] = '/bin/mv.sh';
$files[] = '/bin/rm.sh';
$files[] = '/bin/mkdir.sh';
$files[] = '/bin/rmdir.sh';
$files[] = '/bin/touch.sh';
$files[] = '/bin/echo.sh';
$files[] = '/sbin/init.sh';
$files[] = '/sbin/reboot.sh';
$files[] = '/sbin/shutdown.sh';
$files[] = '/sbin/fsck.sh';
$files[] = '/sbin/ifup.sh';
$files[] = '/sbin/ifdown.sh';
$files[] = '/sbin/iptables.sh';
$files[] = '/sbin/sysctl.sh';
$files[] = '/usr/local/bin/composer.sh';
$files[] = '/usr/local/bin/node.sh';
$files[] = '/usr/local/bin/npm.sh';
if ($player === null) { if ($player === null) {
return $files; return $files;
} }
+9 -1
View File
@@ -16,6 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Regex;
class AdminUserType extends AbstractType class AdminUserType extends AbstractType
{ {
@@ -26,7 +27,14 @@ class AdminUserType extends AbstractType
'constraints' => [new NotBlank(), new Email()], 'constraints' => [new NotBlank(), new Email()],
]) ])
->add('username', TextType::class, [ ->add('username', TextType::class, [
'constraints' => [new NotBlank(), new Length(min: 2, max: 180)], 'constraints' => [
new NotBlank(),
new Length(min: 2, max: 32),
new Regex(
pattern: '/^[A-Za-z0-9_-]+$/',
message: 'Username may only contain letters, numbers, underscores, and hyphens.',
),
],
]) ])
->add('plainPassword', PasswordType::class, [ ->add('plainPassword', PasswordType::class, [
'mapped' => false, 'mapped' => false,
+6
View File
@@ -16,6 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue; use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Regex;
class RegistrationFormType extends AbstractType class RegistrationFormType extends AbstractType
{ {
@@ -26,6 +27,11 @@ class RegistrationFormType extends AbstractType
->add('username', TextType::class, [ ->add('username', TextType::class, [
'constraints' => [ 'constraints' => [
new NotBlank(message: 'Please enter a username'), new NotBlank(message: 'Please enter a username'),
new Length(min: 3, max: 32, minMessage: 'Your username should be at least {{ limit }} characters', maxMessage: 'Your username cannot be longer than {{ limit }} characters'),
new Regex(
pattern: '/^[A-Za-z0-9_-]+$/',
message: 'Your username may only contain letters, numbers, underscores, and hyphens.',
),
], ],
]) ])
->add('plainPassword', RepeatedType::class, [ ->add('plainPassword', RepeatedType::class, [
-9
View File
@@ -262,15 +262,6 @@
"config/routes/security.yaml" "config/routes/security.yaml"
] ]
}, },
"symfony/sendgrid-mailer": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.4",
"ref": "224aedffb66812dc2b0965dabc14d5f800941da6"
}
},
"symfony/stimulus-bundle": { "symfony/stimulus-bundle": {
"version": "2.30", "version": "2.30",
"recipe": { "recipe": {
+93 -23
View File
@@ -1,23 +1,99 @@
{% extends 'layout/site.html.twig' %} {% extends 'layout/site.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<style>
.admin-shell {
display: flex;
min-height: calc(100vh - 60px);
}
.admin-sidebar {
width: 220px;
flex-shrink: 0;
background: #1e293b;
color: #cbd5e1;
display: flex;
flex-direction: column;
}
.admin-sidebar-title {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #334155;
font-weight: 700;
font-size: 1rem;
color: #f1f5f9;
}
.admin-nav-list {
list-style: none;
margin: 0;
padding: 0.5rem 0;
flex: 1;
}
.admin-nav-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1.5rem;
text-decoration: none;
font-size: 0.9rem;
}
.admin-sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #334155;
}
.admin-main {
flex: 1;
min-width: 0;
background: #f8fafc;
padding: 2rem;
overflow-x: auto;
}
@media (max-width: 767.98px) {
.admin-shell {
flex-direction: column;
min-height: auto;
}
.admin-sidebar {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
}
.admin-sidebar-title {
border-bottom: none;
padding: 0.75rem 1rem 0.25rem;
}
.admin-nav-list {
display: flex;
flex: 0 0 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 0.25rem 0.5rem 0.75rem;
gap: 0.25rem;
}
.admin-nav-link {
flex-shrink: 0;
white-space: nowrap;
padding: 0.5rem 0.9rem;
border-radius: 6px;
}
.admin-sidebar-footer {
display: none;
}
.admin-main {
padding: 1rem;
}
}
</style>
{% endblock %}
{% block main %} {% block main %}
<div style="display: flex; min-height: calc(100vh - 60px);"> <div class="admin-shell">
{# ── Sidebar ──────────────────────────────────────────────────────── #} {# ── Sidebar ──────────────────────────────────────────────────────── #}
<nav style=" <nav class="admin-sidebar">
width: 220px; <div class="admin-sidebar-title">Game Admin</div>
flex-shrink: 0;
background: #1e293b;
color: #cbd5e1;
display: flex;
flex-direction: column;
padding: 0;
">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid #334155;">
<span style="font-weight: 700; font-size: 1rem; color: #f1f5f9;">Game Admin</span>
</div>
<ul style="list-style: none; margin: 0; padding: 0.5rem 0; flex: 1;"> <ul class="admin-nav-list">
{% set current = app.request.attributes.get('_route') %} {% set current = app.request.attributes.get('_route') %}
{% set navItems = [ {% set navItems = [
@@ -31,15 +107,9 @@
{% for item in navItems %} {% for item in navItems %}
{% set isActive = current starts with item.route %} {% set isActive = current starts with item.route %}
<li> <li>
<a href="{{ path(item.route) }}" style=" <a href="{{ path(item.route) }}" class="admin-nav-link" style="
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1.5rem;
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }}; color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
background: {{ isActive ? '#334155' : 'transparent' }}; background: {{ isActive ? '#334155' : 'transparent' }};
text-decoration: none;
font-size: 0.9rem;
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }}; border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
"> ">
<span>{{ item.icon }}</span> <span>{{ item.icon }}</span>
@@ -49,7 +119,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
<div style="padding: 1rem 1.5rem; border-top: 1px solid #334155;"> <div class="admin-sidebar-footer">
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;"> <a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
← Back to game ← Back to game
</a> </a>
@@ -57,7 +127,7 @@
</nav> </nav>
{# ── Content ──────────────────────────────────────────────────────── #} {# ── Content ──────────────────────────────────────────────────────── #}
<main style="flex: 1; background: #f8fafc; padding: 2rem; overflow-x: auto;"> <main class="admin-main">
{% for label, messages in app.flashes %} {% for label, messages in app.flashes %}
{% for message in messages %} {% for message in messages %}
<div style=" <div style="
@@ -9,7 +9,7 @@
</div> </div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;"> <div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;"> <table style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
<thead> <thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;"> <tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th> <th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
+1 -1
View File
@@ -9,7 +9,7 @@
</div> </div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;"> <div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;"> <table style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
<thead> <thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;"> <tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th> <th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
-49
View File
@@ -1,49 +0,0 @@
{% extends 'base.html.twig' %}
{% block title %}View Session Logs - {{ session.id }}{% endblock %}
{% block body %}
<h1>Session: {{ session.game.name }} (#{{ session.id }})</h1>
<p><a href="{{ path('game_admin_dashboard') }}">Back to Dashboard</a></p>
<div class="tabs">
<ul style="display: flex; list-style: none; padding: 0; border-bottom: 1px solid #ccc;">
{% for playerLog in playersLogs %}
<li style="margin-right: 10px;">
<button
onclick="openTab(event, 'player-{{ loop.index }}')"
class="tablinks {{ loop.first ? 'active' : '' }}"
style="padding: 10px; cursor: pointer; border: 1px solid #ccc; border-bottom: none; background: {{ loop.first ? '#eee' : '#fff' }};"
>
{{ playerLog.username }}
</button>
</li>
{% endfor %}
</ul>
</div>
{% for playerLog in playersLogs %}
<div id="player-{{ loop.index }}" class="tabcontent" style="display: {{ loop.first ? 'block' : 'none' }}; border: 1px solid #ccc; border-top: none; padding: 20px;">
<h3>Logs for {{ playerLog.username }}</h3>
<pre style="background: #f4f4f4; padding: 15px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
</div>
{% endfor %}
<script>
function openTab(evt, playerName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
tablinks[i].style.background = "#fff";
}
document.getElementById(playerName).style.display = "block";
evt.currentTarget.className += " active";
evt.currentTarget.style.background = "#eee";
}
</script>
{% endblock %}
@@ -9,7 +9,7 @@
</div> </div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;"> <div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;"> <table style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
<thead> <thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;"> <tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th> <th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
+87 -66
View File
@@ -10,74 +10,95 @@
<h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1> <h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1>
<p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p> <p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p>
{% if playersLogs is empty %} {# Tab buttons #}
<div style="background: #fff; border-radius: 8px; padding: 2rem; text-align: center; color: #94a3b8; box-shadow: 0 1px 3px rgba(0,0,0,.07);"> <div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0; overflow-x: auto; -webkit-overflow-scrolling: touch;">
No players in this session. <button
</div> data-tab-target="lobby-chat-tab"
{% else %} id="tab-lobby-chat"
{# Tab buttons #} style="
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0;"> flex-shrink: 0;
{% for playerLog in playersLogs %} white-space: nowrap;
<button padding: 0.6rem 1.25rem;
onclick="openTab('player-{{ loop.index }}')" border: 1px solid #3b82f6;
id="tab-{{ loop.index }}" border-bottom: 1px solid #fff;
style="
padding: 0.6rem 1.25rem;
border: 1px solid {{ loop.first ? '#3b82f6' : '#e2e8f0' }};
border-bottom: {{ loop.first ? '1px solid #fff' : '1px solid #e2e8f0' }};
background: {{ loop.first ? '#fff' : '#f8fafc' }};
color: {{ loop.first ? '#1e40af' : '#64748b' }};
font-size: 0.9rem;
font-weight: {{ loop.first ? '600' : '400' }};
cursor: pointer;
border-radius: 6px 6px 0 0;
margin-bottom: -1px;
"
>{{ playerLog.username }}</button>
{% endfor %}
</div>
{# Tab content #}
{% for playerLog in playersLogs %}
<div id="player-{{ loop.index }}" style="
display: {{ loop.first ? 'block' : 'none' }};
background: #fff; background: #fff;
border: 1px solid #e2e8f0; color: #1e40af;
border-top: none; font-size: 0.9rem;
border-radius: 0 0 8px 8px; font-weight: 600;
padding: 1.25rem; cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,.07); border-radius: 6px 6px 0 0;
"> margin-bottom: -1px;
<pre style=" "
background: #1e293b; >Lobby Chat</button>
color: #e2e8f0; {% for playerLog in playersLogs %}
padding: 1rem; <button
border-radius: 6px; data-tab-target="player-{{ loop.index }}"
overflow-x: auto; id="tab-{{ loop.index }}"
white-space: pre-wrap; style="
word-break: break-word; flex-shrink: 0;
font-size: 0.85rem; white-space: nowrap;
line-height: 1.5; padding: 0.6rem 1.25rem;
margin: 0; border: 1px solid #e2e8f0;
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre> border-bottom: 1px solid #e2e8f0;
</div> background: #f8fafc;
color: #64748b;
font-size: 0.9rem;
font-weight: 400;
cursor: pointer;
border-radius: 6px 6px 0 0;
margin-bottom: -1px;
"
>{{ playerLog.username }}</button>
{% endfor %} {% endfor %}
{% endif %} </div>
<script> {# Tab content #}
function openTab(id) { <div id="lobby-chat-tab" class="admin-tab-panel" style="
document.querySelectorAll('[id^="player-"]').forEach(el => el.style.display = 'none'); display: block;
document.getElementById(id).style.display = 'block'; background: #fff;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
">
{% if lobbyMessages is empty %}
<p style="margin: 0; color: #94a3b8;">No lobby chat messages for this session.</p>
{% else %}
<div style="max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 0.6rem;">
{% for message in lobbyMessages %}
<div style="font-size: 0.9rem;">
<strong style="color: #0f172a;">{{ message.player.user.username }}</strong>
<span style="color: #94a3b8; font-size: 0.8rem;">{{ message.createdAt|date('Y-m-d H:i') }}</span>
<div style="color: #334155;">{{ message.content }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
const idx = id.split('-')[1]; {% for playerLog in playersLogs %}
document.querySelectorAll('[id^="tab-"]').forEach((btn, i) => { <div id="player-{{ loop.index }}" class="admin-tab-panel" style="
const active = String(i + 1) === idx; display: none;
btn.style.background = active ? '#fff' : '#f8fafc'; background: #fff;
btn.style.color = active ? '#1e40af' : '#64748b'; border: 1px solid #e2e8f0;
btn.style.fontWeight = active ? '600' : '400'; border-top: none;
btn.style.borderColor = active ? '#3b82f6' : '#e2e8f0'; border-radius: 0 0 8px 8px;
btn.style.borderBottom = active ? '1px solid #fff' : '1px solid #e2e8f0'; padding: 1.25rem;
}); box-shadow: 0 1px 3px rgba(0,0,0,.07);
} ">
</script> <pre style="
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
line-height: 1.5;
margin: 0;
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
</div>
{% endfor %}
{% endblock %} {% endblock %}
+1 -1
View File
@@ -9,7 +9,7 @@
</div> </div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;"> <div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;"> <table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
<thead> <thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;"> <tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th> <th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
+6 -1
View File
@@ -17,6 +17,7 @@
<div class="card-body"> <div class="card-body">
{% if availableGames is not empty %} {% if availableGames is not empty %}
<form method="post"> <form method="post">
<input type="hidden" name="_token" value="{{ csrf_token('create_session') }}">
<select name="game_id" class="form-select mb-3"> <select name="game_id" class="form-select mb-3">
{% for game in availableGames %} {% for game in availableGames %}
<option value="{{ game.id }}"> <option value="{{ game.id }}">
@@ -40,6 +41,7 @@
<div class="card-header bg-secondary text-white">Join Session</div> <div class="card-header bg-secondary text-white">Join Session</div>
<div class="card-body"> <div class="card-body">
<form method="post" class="d-flex gap-2"> <form method="post" class="d-flex gap-2">
<input type="hidden" name="_token" value="{{ csrf_token('join_session') }}">
<input type="text" name="invite_code" class="form-control" placeholder="Enter Invite Code" required> <input type="text" name="invite_code" class="form-control" placeholder="Enter Invite Code" required>
<button type="submit" name="join_session" class="btn btn-primary text-nowrap">Join Session</button> <button type="submit" name="join_session" class="btn btn-primary text-nowrap">Join Session</button>
</form> </form>
@@ -67,7 +69,7 @@
<tr> <tr>
<td>{{ session.id }}</td> <td>{{ session.id }}</td>
<td>{{ session.game.name }}</td> <td>{{ session.game.name }}</td>
<td><span class="badge bg-info text-dark">{{ session.status.value }}</span></td> <td><span class="badge bg-info text-dark">{{ session.status.label }}</span></td>
<td>{{ session.created|date('Y-m-d H:i') }}</td> <td>{{ session.created|date('Y-m-d H:i') }}</td>
<td> <td>
{% set inviteCode = '' %} {% set inviteCode = '' %}
@@ -81,6 +83,7 @@
<code>{{ inviteCode }}</code> <code>{{ inviteCode }}</code>
{% else %} {% else %}
<form method="post" class="d-inline"> <form method="post" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('create_invite_' ~ session.id) }}">
<input type="hidden" name="session_id" value="{{ session.id }}"> <input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="create_invite" class="btn btn-sm btn-outline-secondary">Generate Invite</button> <button type="submit" name="create_invite" class="btn btn-sm btn-outline-secondary">Generate Invite</button>
</form> </form>
@@ -91,12 +94,14 @@
{% if session.status.value == 'created' %} {% if session.status.value == 'created' %}
{% if session.players|length >= session.game.numberOfPlayers %} {% if session.players|length >= session.game.numberOfPlayers %}
<form method="post" class="d-inline"> <form method="post" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('start_session_' ~ session.id) }}">
<input type="hidden" name="session_id" value="{{ session.id }}"> <input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="start_session" class="btn btn-sm btn-success">Start Session</button> <button type="submit" name="start_session" class="btn btn-sm btn-success">Start Session</button>
</form> </form>
{% endif %} {% endif %}
{% if session.timer == 0 %} {% if session.timer == 0 %}
<form method="post" class="d-inline"> <form method="post" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('leave_session_' ~ session.id) }}">
<input type="hidden" name="session_id" value="{{ session.id }}"> <input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="leave_session" class="btn btn-sm btn-outline-danger" onclick="return confirm('Are you sure you want to leave this session?')">Leave Session</button> <button type="submit" name="leave_session" class="btn btn-sm btn-outline-danger" onclick="return confirm('Are you sure you want to leave this session?')">Leave Session</button>
</form> </form>
+92
View File
@@ -0,0 +1,92 @@
{% extends 'layout/site.html.twig' %}
{% set isCreated = session.status.value == 'created' %}
{% set isFinished = session.status.value in ['won', 'lost'] %}
{% block title %}{{ isCreated ? 'Waiting for players' : 'Post-game chat' }} - {{ session.game.name }}{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-md-8">
{% if isCreated %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">Waiting for more players to join</h3>
</div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>Share the invite code with your friends. Feel free to chat below while you wait &mdash; no need to reload the page.</p>
<div class="alert alert-info">
<strong>Players joined:</strong> {{ session.players|length }} / {{ session.game.numberOfPlayers }}
</div>
<ul class="list-group mb-3">
{% for sessionPlayer in session.players %}
<li class="list-group-item">{{ sessionPlayer.user.username }}</li>
{% endfor %}
</ul>
{% if session.players|length >= session.game.numberOfPlayers %}
<form method="post" action="{{ path('game_dashboard') }}" class="mb-3">
<input type="hidden" name="_token" value="{{ csrf_token('start_session_' ~ session.id) }}">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="start_session" class="btn btn-success">Start Session</button>
</form>
{% endif %}
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
</div>
</div>
{% elseif isFinished %}
<div class="card shadow-sm mb-4">
<div class="card-header {{ session.status.value == 'won' ? 'bg-success' : 'bg-secondary' }} text-white">
<h3 class="card-title mb-0">{{ session.status.value == 'won' ? 'You won!' : 'Game over' }}</h3>
</div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>The game has ended, but the chat is still open for a little while &mdash; feel free to keep talking.</p>
<a href="{{ path(session.status.value == 'won' ? 'game_won' : 'game_lost', {session: session.id}) }}" class="btn btn-primary btn-sm">View results</a>
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
</div>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-header">
<h5 class="mb-0">Lobby chat</h5>
</div>
<div class="card-body">
<div id="lobby-chat-log" class="mb-3 d-flex flex-column gap-2" style="max-height: 320px; overflow-y: auto;">
{% for message in messages %}
<div class="lobby-message">
<strong>{{ message.player.user.username }}</strong>
<span class="text-muted small">{{ message.createdAt|date('H:i') }}</span>
<div>{{ message.content }}</div>
</div>
{% else %}
<p class="text-muted mb-0" id="lobby-chat-empty">No messages yet. Say hi!</p>
{% endfor %}
</div>
{% if player %}
<form method="post" class="d-flex gap-2">
<input type="hidden" name="_token" value="{{ csrf_token('send_message_' ~ session.id) }}">
<input type="text" name="content" class="form-control" placeholder="Type a message&hellip;" maxlength="500" required autocomplete="off">
<button type="submit" name="send_message" class="btn btn-primary">Send</button>
</form>
{% else %}
<p class="text-muted mb-0">Only players in this session can chat.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div id="game-lobby-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
style="display:none">
</div>
{% endblock %}
+1
View File
@@ -78,6 +78,7 @@
<div class="feedback-form mt-4"> <div class="feedback-form mt-4">
<h5>Feedback</h5> <h5>Feedback</h5>
<form method="post"> <form method="post">
<input type="hidden" name="_token" value="{{ csrf_token('game_feedback_' ~ session.id) }}">
<div class="mb-3"> <div class="mb-3">
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label> <label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value"> <input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
+3 -65
View File
@@ -63,6 +63,7 @@
</div> </div>
{% endif %} {% endif %}
<form method="post" class="mt-4"> <form method="post" class="mt-4">
<input type="hidden" name="_token" value="{{ csrf_token('toggle_ready_' ~ session.id) }}">
<input type="hidden" name="toggle_ready" value="0"> <input type="hidden" name="toggle_ready" value="0">
<div class="form-check form-switch mb-3"> <div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" value="1" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }} {{ not app.user.verified ? 'disabled' : '' }}> <input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" value="1" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }} {{ not app.user.verified ? 'disabled' : '' }}>
@@ -87,77 +88,14 @@
</div> </div>
<form id="expire-ready-form" method="post" style="display:none"> <form id="expire-ready-form" method="post" style="display:none">
<input type="hidden" name="_token" value="{{ csrf_token('expire_ready_' ~ session.id) }}">
<input type="hidden" name="expire_ready" value="1"> <input type="hidden" name="expire_ready" value="1">
</form> </form>
<div id="mercure-config" <div id="game-waiting-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}" data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
data-topic="/game/hub/{{ session.id|e('html_attr') }}" data-topic="/game/hub/{{ session.id|e('html_attr') }}"
data-ready-at="{{ readyAt|e('html_attr') }}" data-ready-at="{{ readyAt|e('html_attr') }}"
style="display:none"> style="display:none">
</div> </div>
<script>
const config = document.getElementById('mercure-config');
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 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) {
document.getElementById('expire-ready-form').submit();
} else {
const countdownInterval = setInterval(() => {
if (updateCountdown() <= 0) {
clearInterval(countdownInterval);
document.getElementById('expire-ready-form').submit();
}
}, 1000);
}
}
</script>
{% endblock %} {% endblock %}
+1
View File
@@ -78,6 +78,7 @@
<div class="feedback-form mt-4"> <div class="feedback-form mt-4">
<h5>Feedback</h5> <h5>Feedback</h5>
<form method="post"> <form method="post">
<input type="hidden" name="_token" value="{{ csrf_token('game_feedback_' ~ session.id) }}">
<div class="mb-3"> <div class="mb-3">
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label> <label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value"> <input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
+1 -1
View File
@@ -6,7 +6,7 @@
<form method="post"> <form method="post">
{% if error %} {% if error %}
<div class="alert alert-danger"> <div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security')|raw }} {{ error.messageKey|trans(error.messageData, 'security') }}
{% if error.messageData['%resend_link%'] is defined %} {% if error.messageData['%resend_link%'] is defined %}
<a href="{{ error.messageData['%resend_link%'] }}">Resend activation link</a> <a href="{{ error.messageData['%resend_link%'] }}">Resend activation link</a>
{% endif %} {% endif %}
+4 -1
View File
@@ -10,6 +10,7 @@ use App\Game\Entity\SessionSetting;
use App\Game\Enum\GameStatus; use App\Game\Enum\GameStatus;
use App\Game\Enum\SessionStatus; use App\Game\Enum\SessionStatus;
use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionSettingType;
use App\Game\Repository\LobbyMessageRepository;
use App\Game\Service\GameDashboardService; use App\Game\Service\GameDashboardService;
use App\Tech\Entity\User; use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -23,6 +24,7 @@ class GameDashboardServiceTest extends TestCase
private $entityManager; private $entityManager;
private $gameRepository; private $gameRepository;
private $sessionRepository; private $sessionRepository;
private $lobbyMessageRepository;
private $hub; private $hub;
private $service; private $service;
@@ -31,14 +33,15 @@ class GameDashboardServiceTest extends TestCase
$this->entityManager = $this->createMock(EntityManagerInterface::class); $this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->gameRepository = $this->createMock(GameRepository::class); $this->gameRepository = $this->createMock(GameRepository::class);
$this->sessionRepository = $this->createMock(SessionRepository::class); $this->sessionRepository = $this->createMock(SessionRepository::class);
$this->lobbyMessageRepository = $this->createMock(LobbyMessageRepository::class);
$this->hub = $this->createMock(HubInterface::class); $this->hub = $this->createMock(HubInterface::class);
$this->service = new GameDashboardService( $this->service = new GameDashboardService(
$this->gameRepository, $this->gameRepository,
$this->sessionRepository, $this->sessionRepository,
$this->lobbyMessageRepository,
$this->entityManager, $this->entityManager,
$this->hub, $this->hub,
'http://localhost/topic'
); );
} }
+1
View File
@@ -74,6 +74,7 @@ class SessionLoggingTest extends TestCase
$player->method('getUser')->willReturn($user); $player->method('getUser')->willReturn($user);
$player->method('getSession')->willReturn($session); $player->method('getSession')->willReturn($session);
$player->method('getScreen')->willReturn(1); $player->method('getScreen')->willReturn(1);
$player->method('getLogFileBasename')->willReturn('player1');
$this->security->method('getUser')->willReturn($user); $this->security->method('getUser')->willReturn($user);
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player); $this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);