Compare commits
14
Commits
1be07440e3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba45e06972 | ||
|
|
2913b8d2a2 | ||
|
|
335697e520 | ||
|
|
daa37390d0 | ||
|
|
a9cb0fa57f | ||
|
|
2bfc2aca1a | ||
|
|
846cfbc44a | ||
|
|
207346d571 | ||
|
|
dfa75719d0 | ||
|
|
444f54b6c6 | ||
|
|
98cf48b29d | ||
|
|
f6df9f7ba6 | ||
|
|
fc93486367 | ||
|
|
98066118a6 |
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,6 @@ import './styles/app.scss';
|
||||
import 'bootstrap/js/dist/collapse';
|
||||
import 'bootstrap/js/dist/alert';
|
||||
import 'bootstrap/js/dist/dropdown';
|
||||
import './game-waiting';
|
||||
import './game-lobby';
|
||||
import './admin-session-tabs';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const config = document.getElementById('game-lobby-config');
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicUrl = config.dataset.mercurePublicUrl;
|
||||
const topic = config.dataset.topic;
|
||||
const chatLog = document.getElementById('lobby-chat-log');
|
||||
|
||||
function appendLobbyMessage(username, content, createdAt) {
|
||||
if (!chatLog) {
|
||||
return;
|
||||
}
|
||||
const emptyNotice = document.getElementById('lobby-chat-empty');
|
||||
if (emptyNotice) {
|
||||
emptyNotice.remove();
|
||||
}
|
||||
|
||||
const time = createdAt
|
||||
? new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'lobby-message';
|
||||
|
||||
const author = document.createElement('strong');
|
||||
author.textContent = username;
|
||||
|
||||
const timestamp = document.createElement('span');
|
||||
timestamp.className = 'text-muted small';
|
||||
timestamp.textContent = ' ' + time;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.textContent = content;
|
||||
|
||||
wrapper.appendChild(author);
|
||||
wrapper.appendChild(timestamp);
|
||||
wrapper.appendChild(body);
|
||||
chatLog.appendChild(wrapper);
|
||||
chatLog.scrollTop = chatLog.scrollHeight;
|
||||
}
|
||||
|
||||
if (publicUrl && topic) {
|
||||
const url = new URL(publicUrl);
|
||||
url.searchParams.append('topic', topic);
|
||||
|
||||
const eventSource = new EventSource(url);
|
||||
eventSource.onmessage = event => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'lobby_message') {
|
||||
appendLobbyMessage(data.username, data.content, data.createdAt);
|
||||
} else if (data.type === 'player_joined' || data.type === 'session_started') {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (chatLog) {
|
||||
chatLog.scrollTop = chatLog.scrollHeight;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const config = document.getElementById('game-waiting-config');
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicUrl = config.dataset.mercurePublicUrl;
|
||||
const topic = config.dataset.topic;
|
||||
const readyAt = config.dataset.readyAt;
|
||||
|
||||
let reloading = false;
|
||||
const reloadOnce = (eventSource) => {
|
||||
if (reloading) {
|
||||
return;
|
||||
}
|
||||
reloading = true;
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (publicUrl && topic) {
|
||||
const url = new URL(publicUrl);
|
||||
url.searchParams.append('topic', topic);
|
||||
|
||||
const eventSource = new EventSource(url);
|
||||
eventSource.onmessage = event => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'all_ready' || data.type === 'player_ready') {
|
||||
reloadOnce(eventSource);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Our own ready status expires 60s after we set it - proactively tell the
|
||||
// server as close to that deadline as possible, so the other players find
|
||||
// out live instead of only whenever someone else's request happens to
|
||||
// trigger the lazy check.
|
||||
if (readyAt) {
|
||||
const timeoutMs = 61000; // slightly more than the server-side 60s
|
||||
const readyAtMs = readyAt * 1000;
|
||||
const countdownEl = document.getElementById('ready-countdown');
|
||||
const expireForm = document.getElementById('expire-ready-form');
|
||||
|
||||
const updateCountdown = () => {
|
||||
const remaining = Math.max(0, Math.ceil((readyAtMs + timeoutMs - Date.now()) / 1000));
|
||||
if (countdownEl) {
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = remaining % 60;
|
||||
countdownEl.textContent = m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
return remaining;
|
||||
};
|
||||
|
||||
const remaining = updateCountdown();
|
||||
if (remaining <= 0) {
|
||||
expireForm?.submit();
|
||||
} else {
|
||||
const countdownInterval = setInterval(() => {
|
||||
if (updateCountdown() <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
expireForm?.submit();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -94,6 +94,7 @@ let currentLockedAt = null;
|
||||
function lockMessageClass(messageType) {
|
||||
if (messageType === 'virus') return 'message-virus';
|
||||
if (messageType === 'mainframe') return 'message-mainframe';
|
||||
if (messageType === 'hint') return 'message-hint';
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ServerRoot "/etc/apache2"
|
||||
Listen 80
|
||||
User www-data
|
||||
Group www-data
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
LogLevel warn
|
||||
IncludeOptional mods-enabled/*.load
|
||||
IncludeOptional sites-enabled/*.conf
|
||||
@@ -0,0 +1,3 @@
|
||||
deb http://deb.debian.org/debian bookworm main contrib non-free-firmware
|
||||
deb http://deb.debian.org/debian bookworm-updates main contrib non-free-firmware
|
||||
deb http://security.debian.org/debian-security bookworm-security main contrib non-free-firmware
|
||||
@@ -0,0 +1,8 @@
|
||||
# /etc/crontab: system-wide crontab
|
||||
SHELL=/bin/sh
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
|
||||
|
||||
17 * * * * root cd / && run-parts --report /etc/cron.hourly
|
||||
25 6 * * * root test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily
|
||||
47 6 * * 7 root test -x /usr/sbin/anacron || run-parts --report /etc/cron.weekly
|
||||
52 6 1 * * root test -x /usr/sbin/anacron || run-parts --report /etc/cron.monthly
|
||||
@@ -0,0 +1 @@
|
||||
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
@@ -0,0 +1,4 @@
|
||||
# /etc/fstab: static file system information.
|
||||
UUID=8f14e45f-ceea-4a63-9b3f-1a2b3c4d5e6f / ext4 errors=remount-ro 0 1
|
||||
UUID=1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d /boot ext4 defaults 0 2
|
||||
/swapfile none swap sw 0 0
|
||||
@@ -0,0 +1 @@
|
||||
archive-node-04
|
||||
@@ -0,0 +1,6 @@
|
||||
127.0.0.1 localhost
|
||||
127.0.1.1 archive-node-04
|
||||
::1 localhost ip6-localhost ip6-loopback
|
||||
ff02::1 ip6-allnodes
|
||||
ff02::2 ip6-allrouters
|
||||
10.0.0.4 archive-node-04.internal
|
||||
@@ -0,0 +1,2 @@
|
||||
Debian GNU/Linux 12 \n \l
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Welcome to archive-node-04.
|
||||
All connections are logged and monitored for internal review purposes.
|
||||
@@ -0,0 +1,10 @@
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
auto eth0
|
||||
iface eth0 inet static
|
||||
address 10.0.0.4
|
||||
netmask 255.255.255.0
|
||||
gateway 10.0.0.1
|
||||
@@ -0,0 +1,14 @@
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
include /etc/nginx/mime.types;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
passwd: files
|
||||
group: files
|
||||
shadow: files
|
||||
hosts: files dns
|
||||
networks: files
|
||||
protocols: db files
|
||||
services: db files
|
||||
ethers: db files
|
||||
rpc: db files
|
||||
@@ -0,0 +1,8 @@
|
||||
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||
NAME="Debian GNU/Linux"
|
||||
VERSION_ID="12"
|
||||
VERSION="12 (bookworm)"
|
||||
VERSION_CODENAME=bookworm
|
||||
ID=debian
|
||||
HOME_URL="https://www.debian.org/"
|
||||
SUPPORT_URL="https://www.debian.org/support"
|
||||
@@ -0,0 +1,11 @@
|
||||
root:x:0:0:root:/root:/bin/bash
|
||||
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
|
||||
bin:x:2:2:bin:/bin:/usr/sbin/nologin
|
||||
sys:x:3:3:sys:/dev:/usr/sbin/nologin
|
||||
sync:x:4:65534:sync:/bin:/bin/sync
|
||||
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
|
||||
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
|
||||
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
|
||||
sshd:x:105:65534::/run/sshd:/usr/sbin/nologin
|
||||
admin:x:1000:1000:admin,,,:/home/admin:/bin/bash
|
||||
guest:x:1001:1001:guest,,,:/home/guest:/bin/bash
|
||||
@@ -0,0 +1,3 @@
|
||||
nameserver 1.1.1.1
|
||||
nameserver 9.9.9.9
|
||||
options edns0
|
||||
@@ -0,0 +1,7 @@
|
||||
root:$6$rounds=656000$xJ2kLQmZ$aFq9zN3vQwErTyUiOpAsDfGhJkLzXcVbNm1234567890abcdefgh:19700:0:99999:7:::
|
||||
daemon:*:19700:0:99999:7:::
|
||||
bin:*:19700:0:99999:7:::
|
||||
sys:*:19700:0:99999:7:::
|
||||
sshd:*:19700:0:99999:7:::
|
||||
admin:$6$rounds=656000$k3PqR8tW$bGr0oPqLmNbVcXzAsDfGhJkLqWeRtYuIoP0987654321zyxwvu:19700:0:99999:7:::
|
||||
guest:*:19700:0:99999:7:::
|
||||
@@ -0,0 +1,4 @@
|
||||
Host *
|
||||
SendEnv LANG LC_*
|
||||
HashKnownHosts yes
|
||||
GSSAPIAuthentication yes
|
||||
@@ -0,0 +1,7 @@
|
||||
Port 22
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication yes
|
||||
PubkeyAuthentication yes
|
||||
X11Forwarding no
|
||||
PrintMotd no
|
||||
Subsystem sftp /usr/lib/openssh/sftp-server
|
||||
@@ -0,0 +1 @@
|
||||
Europe/Amsterdam
|
||||
@@ -0,0 +1,8 @@
|
||||
app:
|
||||
name: internal-archive-sync
|
||||
version: 2.3.1
|
||||
log_level: info
|
||||
port: 8080
|
||||
database:
|
||||
driver: sqlite
|
||||
path: /opt/app/data.db
|
||||
@@ -0,0 +1,5 @@
|
||||
apt update
|
||||
apt upgrade -y
|
||||
systemctl restart nginx
|
||||
df -h
|
||||
journalctl -xe
|
||||
@@ -0,0 +1,10 @@
|
||||
# ~/.bashrc: executed by bash for non-login shells
|
||||
|
||||
case $- in
|
||||
*i*) ;;
|
||||
*) return;;
|
||||
esac
|
||||
|
||||
export PS1='\u@\h:\w\$ '
|
||||
alias ll='ls -alF'
|
||||
alias la='ls -A'
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZ8pQxT2mN0vRkLwYb6cJhU3sEoAeKdVmXpZq7tRnBs admin@archive-node-04
|
||||
@@ -0,0 +1,2 @@
|
||||
update-alternatives 2026-05-30 03:10:04: link group editor updated to point to /usr/bin/vim.basic
|
||||
update-alternatives 2026-05-30 03:10:04: link group pager updated to point to /usr/bin/less
|
||||
@@ -0,0 +1,6 @@
|
||||
Jun 12 09:41:55 archive-node-04 sshd[10233]: Accepted publickey for admin from 10.0.0.7 port 51422 ssh2
|
||||
Jun 12 09:41:55 archive-node-04 sshd[10233]: pam_unix(sshd:session): session opened for user admin by (uid=0)
|
||||
Jun 12 09:55:02 archive-node-04 sudo: admin : TTY=pts/0 ; PWD=/home/admin ; USER=root ; COMMAND=/usr/bin/apt update
|
||||
Jun 12 10:12:40 archive-node-04 sshd[10233]: pam_unix(sshd:session): session closed for user admin
|
||||
Jun 12 22:03:11 archive-node-04 sshd[15092]: Failed password for invalid user test from 203.0.113.44 port 39102 ssh2
|
||||
Jun 12 22:03:14 archive-node-04 sshd[15092]: Connection closed by 203.0.113.44 port 39102 [preauth]
|
||||
@@ -0,0 +1,4 @@
|
||||
[ OK ] Started Network Manager.
|
||||
[ OK ] Started OpenSSH server daemon.
|
||||
[ OK ] Started Nginx HTTP server.
|
||||
[ OK ] Reached target Multi-User System.
|
||||
@@ -0,0 +1,2 @@
|
||||
Jun 12 04:00:11 archive-node-04 CRON[9021]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily)
|
||||
Jun 12 18:30:02 archive-node-04 CRON[15544]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.hourly)
|
||||
@@ -0,0 +1,2 @@
|
||||
Jun 12 03:00:05 archive-node-04 systemd-udevd[512]: Using default interface naming scheme 'v252'.
|
||||
Jun 12 03:00:11 archive-node-04 dbus-daemon[601]: [system] Successfully activated service 'org.freedesktop.hostname1'
|
||||
@@ -0,0 +1,4 @@
|
||||
[ 0.000000] Linux version 6.1.0-21-amd64 (debian-kernel@lists.debian.org)
|
||||
[ 0.004211] Command line: BOOT_IMAGE=/boot/vmlinuz-6.1.0-21-amd64 root=UUID=8f14e45f
|
||||
[ 0.512033] ACPI: Core revision 20221020
|
||||
[ 1.221004] usb 1-1: new high-speed USB device number 2
|
||||
@@ -0,0 +1,4 @@
|
||||
2026-05-30 03:10:02 startup archives unpack
|
||||
2026-05-30 03:10:04 install curl:amd64 <none> 8.4.0-2
|
||||
2026-05-30 03:10:05 status installed curl:amd64 8.4.0-2
|
||||
2026-06-02 09:44:11 upgrade openssh-server:amd64 1:9.2p1-2 1:9.2p1-2+deb12u2
|
||||
@@ -0,0 +1,4 @@
|
||||
Jun 12 03:00:02 archive-node-04 kernel: [ 0.000000] Linux version 6.1.0-21-amd64
|
||||
Jun 12 03:00:02 archive-node-04 kernel: [ 0.004211] Command line: BOOT_IMAGE=/boot/vmlinuz-6.1.0-21-amd64 root=UUID=8f14e45f
|
||||
Jun 12 03:00:03 archive-node-04 kernel: [ 1.221004] usb 1-1: new high-speed USB device number 2
|
||||
Jun 12 03:00:03 archive-node-04 kernel: [ 1.552210] eth0: link up, 1000Mbps, full-duplex
|
||||
@@ -0,0 +1,2 @@
|
||||
Jun 12 05:11:02 archive-node-04 postfix/qmgr[812]: 3F2A1C0021: removed
|
||||
Jun 12 05:11:02 archive-node-04 postfix/smtp[9944]: 3F2A1C0021: to=<root@localhost>, status=sent
|
||||
@@ -0,0 +1,8 @@
|
||||
Jun 12 03:12:01 archive-node-04 systemd[1]: Starting Daily apt download activities...
|
||||
Jun 12 03:12:04 archive-node-04 systemd[1]: apt-daily.service: Deactivated successfully.
|
||||
Jun 12 04:00:11 archive-node-04 CRON[9021]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily)
|
||||
Jun 12 06:25:00 archive-node-04 anacron[1122]: Job `cron.daily' terminated
|
||||
Jun 12 09:41:55 archive-node-04 sshd[10233]: Accepted publickey for admin from 10.0.0.7 port 51422 ssh2
|
||||
Jun 12 09:41:55 archive-node-04 sshd[10233]: pam_unix(sshd:session): session opened for user admin
|
||||
Jun 12 12:03:19 archive-node-04 systemd[1]: Reloading nginx.service
|
||||
Jun 12 18:30:02 archive-node-04 CRON[15544]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.hourly)
|
||||
@@ -0,0 +1,4 @@
|
||||
From cron@archive-node-04 Wed Jun 10 06:25:01 2026
|
||||
Subject: Cron <root@archive-node-04> run-parts --report /etc/cron.daily
|
||||
|
||||
Daily housekeeping completed without errors.
|
||||
@@ -66,6 +66,11 @@ div.message-mainframe {
|
||||
color: #0F0;
|
||||
}
|
||||
|
||||
div.message-hint {
|
||||
color: #FF0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div#lock-banner {
|
||||
position: fixed;
|
||||
top: 68px;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
|
||||
Generated
+376
-317
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ framework:
|
||||
storage_factory_id: session.storage.factory.native
|
||||
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
|
||||
|
||||
rate_limiter:
|
||||
invite_code_join:
|
||||
policy: 'sliding_window'
|
||||
limit: 10
|
||||
interval: '1 minute'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
session:
|
||||
|
||||
@@ -47,6 +47,14 @@ when@prod:
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
nested:
|
||||
type: group
|
||||
members: [nested_file, nested_stderr]
|
||||
nested_file:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/php/prod.log"
|
||||
level: debug
|
||||
formatter: monolog.formatter.json
|
||||
nested_stderr:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
level: debug
|
||||
@@ -56,7 +64,14 @@ when@prod:
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
type: group
|
||||
channels: [deprecation]
|
||||
members: [deprecation_file, deprecation_stderr]
|
||||
deprecation_file:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/php/deprecation.log"
|
||||
formatter: monolog.formatter.json
|
||||
deprecation_stderr:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
framework:
|
||||
notifier:
|
||||
chatter_transports:
|
||||
texter_transports:
|
||||
sendgrid: '%env(MAILER_DSN)%'
|
||||
channel_policy:
|
||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
||||
urgent: ['email']
|
||||
high: ['email']
|
||||
medium: ['email']
|
||||
low: ['email']
|
||||
admin_recipients:
|
||||
- { email: admin@example.com }
|
||||
framework:
|
||||
notifier:
|
||||
chatter_transports:
|
||||
texter_transports:␍
|
||||
channel_policy:
|
||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
||||
urgent: ['email']
|
||||
high: ['email']
|
||||
medium: ['email']
|
||||
low: ['email']
|
||||
admin_recipients:
|
||||
- { email: admin@example.com }
|
||||
|
||||
@@ -22,6 +22,9 @@ security:
|
||||
enable_csrf: true
|
||||
username_parameter: username
|
||||
password_parameter: password
|
||||
login_throttling:
|
||||
max_attempts: 5
|
||||
interval: '15 minutes'
|
||||
logout:
|
||||
path: app_logout
|
||||
# where to redirect after logout
|
||||
|
||||
@@ -66,6 +66,39 @@ services:
|
||||
# ipv4_address: 172.23.0.11
|
||||
restart: unless-stopped
|
||||
|
||||
php-cron:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/php/Dockerfile
|
||||
args:
|
||||
USER_ID: ${USER_ID}
|
||||
GROUP_ID: ${GROUP_ID}
|
||||
container_name: escapepage-php-cron
|
||||
volumes:
|
||||
- ../:/var/www/html:delegated
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV}
|
||||
SITE_BASE_URL: ${SITE_BASE_URL}
|
||||
MAILER_DSN: ${MAILER_DSN}
|
||||
MAILER_FROM: ${MAILER_FROM}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
MERCURE_URL: ${MERCURE_URL}
|
||||
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
|
||||
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
|
||||
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
|
||||
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
|
||||
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
|
||||
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
|
||||
depends_on:
|
||||
- database
|
||||
- mercure
|
||||
command: ["crond", "-f", "-l", "2"]
|
||||
# networks:
|
||||
# backend:
|
||||
# ipv4_address: 172.23.0.16
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29.4-alpine
|
||||
container_name: escapepage-nginx
|
||||
|
||||
+12
-2
@@ -12,7 +12,8 @@ RUN apk add --no-cache \
|
||||
make \
|
||||
nodejs \
|
||||
npm \
|
||||
shadow
|
||||
shadow \
|
||||
logrotate
|
||||
|
||||
# Install PHP extension installer
|
||||
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
|
||||
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)
|
||||
ARG USER_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
|
||||
|
||||
# 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
|
||||
|
||||
# Default command
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
* * * * * php /var/www/html/bin/console app:hints:check >> /var/www/html/var/log/cron/cron.log 2>&1
|
||||
5 3 * * * logrotate -s /var/www/html/var/log/.logrotate.state /etc/logrotate.d/cron-hints /etc/logrotate.d/php-logs
|
||||
@@ -0,0 +1,11 @@
|
||||
/var/www/html/var/log/cron/cron.log {
|
||||
size 25M
|
||||
rotate 100
|
||||
maxage 90
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
dateext
|
||||
dateformat -%Y%m%d-%s
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/var/www/html/var/log/php/*.log {
|
||||
size 25M
|
||||
rotate 100
|
||||
maxage 90
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
dateext
|
||||
dateformat -%Y%m%d-%s
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ opcache.validate_timestamps=1
|
||||
opcache.revalidate_freq=0
|
||||
|
||||
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.cookie_lifetime=0
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ echo "Stopping and removing containers..."
|
||||
docker network rm escapepage_network || true
|
||||
docker network rm $(docker network ls -q --filter name=escapepage) || 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
|
||||
|
||||
echo "Clearing Docker build cache..."
|
||||
@@ -21,7 +21,7 @@ docker builder prune -af
|
||||
echo "Setting permissions for var/volumes/db and var directories..."
|
||||
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
||||
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
|
||||
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$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 chmod -R 777 "$ROOT_DIR/var" || true
|
||||
|
||||
|
||||
@@ -143,6 +143,10 @@ Common commands:
|
||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
|
||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
|
||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
|
||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-cron) # crond scheduler activity
|
||||
tail -f "$ROOT_DIR/var/log/cron/cron.log" # hint-check command output
|
||||
tail -f "$ROOT_DIR/var/log/php/error.log" # raw PHP errors
|
||||
tail -f "$ROOT_DIR/var/log/php/prod.log" # Symfony app errors (prod only)
|
||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
|
||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
|
||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260810120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add lobby_message table for pre-game lobby chat';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE lobby_message (id INT AUTO_INCREMENT NOT NULL, session_id INT NOT NULL, player_id INT NOT NULL, content VARCHAR(500) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_LOBBY_MESSAGE_SESSION (session_id), INDEX IDX_LOBBY_MESSAGE_PLAYER (player_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_SESSION FOREIGN KEY (session_id) REFERENCES session (id)');
|
||||
$this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_PLAYER FOREIGN KEY (player_id) REFERENCES player (id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_SESSION');
|
||||
$this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_PLAYER');
|
||||
$this->addSql('DROP TABLE lobby_message');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260810130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add finished_at column to session table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE session ADD finished_at DATETIME DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE session DROP finished_at');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Game\Controller;
|
||||
use App\Game\Entity\Session;
|
||||
use App\Game\Enum\SessionStatus;
|
||||
use App\Game\Repository\GameRepository;
|
||||
use App\Game\Repository\LobbyMessageRepository;
|
||||
use App\Game\Repository\SessionRepository;
|
||||
use App\Tech\Repository\UserRepository;
|
||||
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'])]
|
||||
public function viewSession(Session $session): Response
|
||||
public function viewSession(Session $session, LobbyMessageRepository $lobbyMessageRepository): Response
|
||||
{
|
||||
$playersLogs = [];
|
||||
foreach ($session->getPlayers() as $player) {
|
||||
$username = $player->getUser()->getUsername();
|
||||
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
|
||||
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $player->getLogFileBasename() . '.txt';
|
||||
|
||||
$playersLogs[] = [
|
||||
'username' => $username,
|
||||
'username' => $player->getUser()->getUsername(),
|
||||
'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', [
|
||||
'session' => $session,
|
||||
'playersLogs' => $playersLogs,
|
||||
'lobbyMessages' => $lobbyMessageRepository->findForSession($session),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ final class GameAdminSessionController extends AbstractController
|
||||
}
|
||||
|
||||
$session->setStatus(SessionStatus::LOST);
|
||||
$session->setFinishedAt(new \DateTime());
|
||||
$em->flush();
|
||||
|
||||
$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->getTimer() !== null && $now >= $session->getTimer()) {
|
||||
$session->setStatus(SessionStatus::LOST);
|
||||
$session->setFinishedAt(new \DateTime());
|
||||
$this->entityManager->persist($session);
|
||||
$this->entityManager->flush();
|
||||
$isFinished = true;
|
||||
|
||||
@@ -22,6 +22,8 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
|
||||
final class GameController extends AbstractController
|
||||
{
|
||||
@@ -39,13 +41,20 @@ final class GameController extends AbstractController
|
||||
GameRepository $gameRepository,
|
||||
SessionRepository $sessionRepository,
|
||||
GameDashboardService $dashboardService,
|
||||
Security $security
|
||||
Security $security,
|
||||
#[Target('invite_code_join')]
|
||||
RateLimiterFactoryInterface $inviteCodeJoinLimiter
|
||||
): Response {
|
||||
$user = $security->getUser();
|
||||
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
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');
|
||||
$game = $gameRepository->find($gameId);
|
||||
|
||||
@@ -55,6 +64,17 @@ final class GameController extends AbstractController
|
||||
}
|
||||
}
|
||||
} 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');
|
||||
if ($dashboardService->joinSession($inviteCode, $user)) {
|
||||
$this->addFlash('success', 'Joined session successfully!');
|
||||
@@ -70,6 +90,11 @@ final class GameController extends AbstractController
|
||||
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);
|
||||
if ($inviteCode) {
|
||||
$this->addFlash('success', 'Invite link created: ' . $inviteCode);
|
||||
@@ -79,6 +104,11 @@ final class GameController extends AbstractController
|
||||
$session = $sessionRepository->find($sessionId);
|
||||
|
||||
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)) {
|
||||
$this->addFlash('success', 'Left session successfully.');
|
||||
} else {
|
||||
@@ -90,6 +120,11 @@ final class GameController extends AbstractController
|
||||
$session = $sessionRepository->find($sessionId);
|
||||
|
||||
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)) {
|
||||
$this->addFlash('success', 'Session started! Screens have been assigned.');
|
||||
} else {
|
||||
@@ -127,7 +162,9 @@ final class GameController extends AbstractController
|
||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||
|
||||
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.');
|
||||
} else {
|
||||
$dashboardService->toggleReady($session, $user);
|
||||
@@ -137,16 +174,29 @@ final class GameController extends AbstractController
|
||||
}
|
||||
|
||||
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()]);
|
||||
}
|
||||
|
||||
// Lazily pick up readiness changes from other players since our last request
|
||||
$dashboardService->checkAllPlayersReady($session);
|
||||
|
||||
if ($session->getStatus() === SessionStatus::CREATED) {
|
||||
$this->addFlash('info', 'This session is still waiting for more players to join.');
|
||||
return $this->redirectToRoute('game_dashboard');
|
||||
if ($dashboardService->isLobbyChatOpen($session)) {
|
||||
return $this->render('game/lobby.html.twig', [
|
||||
'session' => $session,
|
||||
'messages' => $dashboardService->getLobbyMessages($session),
|
||||
'player' => $player,
|
||||
'mercure_public_url' => $this->mercurePublicUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($session->getStatus() === SessionStatus::WON) {
|
||||
@@ -206,7 +256,7 @@ final class GameController extends AbstractController
|
||||
$user = $security->getUser();
|
||||
$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');
|
||||
$entertaining = $request->request->get('entertaining');
|
||||
$theme = $request->request->get('theme');
|
||||
@@ -238,7 +288,7 @@ final class GameController extends AbstractController
|
||||
$user = $security->getUser();
|
||||
$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');
|
||||
$entertaining = $request->request->get('entertaining');
|
||||
$theme = $request->request->get('theme');
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Game\Entity;
|
||||
|
||||
use App\Game\Repository\LobbyMessageRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: LobbyMessageRepository::class)]
|
||||
#[ORM\Table(name: 'lobby_message')]
|
||||
class LobbyMessage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Session::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Session $session = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Player::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Player $player = null;
|
||||
|
||||
#[ORM\Column(length: 500)]
|
||||
private ?string $content = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private ?\DateTimeInterface $createdAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getSession(): ?Session
|
||||
{
|
||||
return $this->session;
|
||||
}
|
||||
|
||||
public function setSession(?Session $session): static
|
||||
{
|
||||
$this->session = $session;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPlayer(): ?Player
|
||||
{
|
||||
return $this->player;
|
||||
}
|
||||
|
||||
public function setPlayer(?Player $player): static
|
||||
{
|
||||
$this->player = $player;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContent(): ?string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function setContent(string $content): static
|
||||
{
|
||||
$this->content = $content;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeInterface $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -66,4 +66,19 @@ class Player
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A filesystem-safe basename derived from the player's username, for use when
|
||||
* building per-player log file paths. Usernames are validated to only contain
|
||||
* safe characters at registration time, but this sanitizes defensively too, so
|
||||
* a path segment can never traverse outside its intended directory regardless
|
||||
* of what ends up stored on the user.
|
||||
*/
|
||||
public function getLogFileBasename(): string
|
||||
{
|
||||
$username = $this->user?->getUsername() ?? '';
|
||||
$safe = preg_replace('/[^A-Za-z0-9_-]/', '_', $username);
|
||||
|
||||
return $safe !== null && $safe !== '' ? $safe : ('player-' . $this->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ class Session
|
||||
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||
private ?\DateTimeInterface $created = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTimeInterface $finishedAt = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
|
||||
private Collection $players;
|
||||
|
||||
@@ -97,6 +100,18 @@ class Session
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFinishedAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->finishedAt;
|
||||
}
|
||||
|
||||
public function setFinishedAt(?\DateTimeInterface $finishedAt): static
|
||||
{
|
||||
$this->finishedAt = $finishedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Player>
|
||||
*/
|
||||
|
||||
@@ -9,4 +9,15 @@ enum SessionStatus: string
|
||||
case PLAYING = 'playing';
|
||||
case WON = 'won';
|
||||
case LOST = 'lost';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CREATED => 'Waiting for players',
|
||||
self::READY => 'Waiting for players to be ready',
|
||||
self::PLAYING => 'In progress',
|
||||
self::WON => 'Completed - Won',
|
||||
self::LOST => 'Completed - Lost',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Game\Repository;
|
||||
|
||||
use App\Game\Entity\LobbyMessage;
|
||||
use App\Game\Entity\Session;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<LobbyMessage>
|
||||
*/
|
||||
class LobbyMessageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, LobbyMessage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LobbyMessage[]
|
||||
*/
|
||||
public function findForSession(Session $session, int $limit = 100): array
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->andWhere('m.session = :session')
|
||||
->setParameter('session', $session)
|
||||
->orderBy('m.createdAt', 'ASC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace App\Game\Service;
|
||||
|
||||
use App\Game\Entity\Game;
|
||||
use App\Game\Entity\LobbyMessage;
|
||||
use App\Game\Entity\Player;
|
||||
use App\Game\Entity\Session;
|
||||
use App\Game\Entity\SessionSetting;
|
||||
@@ -11,6 +12,7 @@ use App\Game\Enum\GameStatus;
|
||||
use App\Game\Enum\SessionSettingType;
|
||||
use App\Game\Enum\SessionStatus;
|
||||
use App\Game\Repository\GameRepository;
|
||||
use App\Game\Repository\LobbyMessageRepository;
|
||||
use App\Game\Repository\SessionRepository;
|
||||
use App\Tech\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -21,10 +23,13 @@ use Symfony\Component\Mercure\Update;
|
||||
final class GameDashboardService
|
||||
{
|
||||
private const READY_TIMEOUT_SECONDS = 60;
|
||||
private const LOBBY_MESSAGE_MAX_LENGTH = 500;
|
||||
private const LOBBY_CHAT_GRACE_PERIOD_SECONDS = 3600;
|
||||
|
||||
public function __construct(
|
||||
private readonly GameRepository $gameRepository,
|
||||
private readonly SessionRepository $sessionRepository,
|
||||
private readonly LobbyMessageRepository $lobbyMessageRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly HubInterface $hub,
|
||||
) {
|
||||
@@ -122,6 +127,8 @@ final class GameDashboardService
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->publishLobbyEvent($session, 'player_joined');
|
||||
|
||||
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
|
||||
$this->startSession($session);
|
||||
}
|
||||
@@ -283,9 +290,83 @@ final class GameDashboardService
|
||||
$this->entityManager->persist($session);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->publishLobbyEvent($session, 'session_started');
|
||||
|
||||
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
|
||||
{
|
||||
if ($session->getStatus() !== SessionStatus::READY) {
|
||||
@@ -393,10 +474,15 @@ final class GameDashboardService
|
||||
}
|
||||
|
||||
private function publishPlayerReady(Session $session, int $screen, bool $ready): void
|
||||
{
|
||||
$this->publishLobbyEvent($session, 'player_ready', ['player' => $screen, 'ready' => $ready]);
|
||||
}
|
||||
|
||||
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
|
||||
{
|
||||
try {
|
||||
$topic = '/game/hub/' . $session->getId();
|
||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $screen, 'ready' => $ready])));
|
||||
$this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra))));
|
||||
} catch (\Exception $e) {
|
||||
// Mercure might be down, but we don't want to crash the game
|
||||
}
|
||||
|
||||
@@ -107,14 +107,13 @@ class GameResponseService
|
||||
private function logSessionActivity(Player $player, string $content): void
|
||||
{
|
||||
$sessionId = $player->getSession()->getId();
|
||||
$username = $player->getUser()->getUsername();
|
||||
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0777, true);
|
||||
}
|
||||
|
||||
$logFile = $logDir . '/' . $username . '.txt';
|
||||
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
|
||||
|
||||
@@ -998,6 +997,61 @@ class GameResponseService
|
||||
$paths[] = '/etc/handle';
|
||||
$paths[] = '/etc/freak';
|
||||
$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';
|
||||
|
||||
@@ -1063,6 +1117,7 @@ class GameResponseService
|
||||
}
|
||||
|
||||
$session->setStatus(SessionStatus::WON);
|
||||
$session->setFinishedAt(new \DateTime());
|
||||
$this->entityManager->persist($session);
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -1213,6 +1268,127 @@ class GameResponseService
|
||||
$files[] = '/var/rapports/011_130-62.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) {
|
||||
return $files;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Email;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\Regex;
|
||||
|
||||
class AdminUserType extends AbstractType
|
||||
{
|
||||
@@ -26,7 +27,14 @@ class AdminUserType extends AbstractType
|
||||
'constraints' => [new NotBlank(), new Email()],
|
||||
])
|
||||
->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, [
|
||||
'mapped' => false,
|
||||
|
||||
@@ -16,6 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\Regex;
|
||||
|
||||
class RegistrationFormType extends AbstractType
|
||||
{
|
||||
@@ -26,6 +27,11 @@ class RegistrationFormType extends AbstractType
|
||||
->add('username', TextType::class, [
|
||||
'constraints' => [
|
||||
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, [
|
||||
|
||||
@@ -262,15 +262,6 @@
|
||||
"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": {
|
||||
"version": "2.30",
|
||||
"recipe": {
|
||||
|
||||
@@ -1,23 +1,99 @@
|
||||
{% 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 %}
|
||||
<div style="display: flex; min-height: calc(100vh - 60px);">
|
||||
<div class="admin-shell">
|
||||
|
||||
{# ── Sidebar ──────────────────────────────────────────────────────── #}
|
||||
<nav style="
|
||||
width: 220px;
|
||||
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>
|
||||
<nav class="admin-sidebar">
|
||||
<div class="admin-sidebar-title">Game Admin</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 navItems = [
|
||||
@@ -31,15 +107,9 @@
|
||||
{% for item in navItems %}
|
||||
{% set isActive = current starts with item.route %}
|
||||
<li>
|
||||
<a href="{{ path(item.route) }}" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
<a href="{{ path(item.route) }}" class="admin-nav-link" style="
|
||||
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
|
||||
background: {{ isActive ? '#334155' : 'transparent' }};
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
|
||||
">
|
||||
<span>{{ item.icon }}</span>
|
||||
@@ -49,7 +119,7 @@
|
||||
{% endfor %}
|
||||
</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;">
|
||||
← Back to game
|
||||
</a>
|
||||
@@ -57,7 +127,7 @@
|
||||
</nav>
|
||||
|
||||
{# ── Content ──────────────────────────────────────────────────────── #}
|
||||
<main style="flex: 1; background: #f8fafc; padding: 2rem; overflow-x: auto;">
|
||||
<main class="admin-main">
|
||||
{% for label, messages in app.flashes %}
|
||||
{% for message in messages %}
|
||||
<div style="
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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 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>
|
||||
<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>
|
||||
|
||||
@@ -10,74 +10,95 @@
|
||||
<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>
|
||||
|
||||
{% if playersLogs is empty %}
|
||||
<div style="background: #fff; border-radius: 8px; padding: 2rem; text-align: center; color: #94a3b8; box-shadow: 0 1px 3px rgba(0,0,0,.07);">
|
||||
No players in this session.
|
||||
</div>
|
||||
{% else %}
|
||||
{# Tab buttons #}
|
||||
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0;">
|
||||
{% for playerLog in playersLogs %}
|
||||
<button
|
||||
onclick="openTab('player-{{ loop.index }}')"
|
||||
id="tab-{{ loop.index }}"
|
||||
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' }};
|
||||
{# Tab buttons #}
|
||||
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||
<button
|
||||
data-tab-target="lobby-chat-tab"
|
||||
id="tab-lobby-chat"
|
||||
style="
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: 1px solid #3b82f6;
|
||||
border-bottom: 1px solid #fff;
|
||||
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);
|
||||
">
|
||||
<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>
|
||||
color: #1e40af;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: 6px 6px 0 0;
|
||||
margin-bottom: -1px;
|
||||
"
|
||||
>Lobby Chat</button>
|
||||
{% for playerLog in playersLogs %}
|
||||
<button
|
||||
data-tab-target="player-{{ loop.index }}"
|
||||
id="tab-{{ loop.index }}"
|
||||
style="
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
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 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openTab(id) {
|
||||
document.querySelectorAll('[id^="player-"]').forEach(el => el.style.display = 'none');
|
||||
document.getElementById(id).style.display = 'block';
|
||||
{# Tab content #}
|
||||
<div id="lobby-chat-tab" class="admin-tab-panel" 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];
|
||||
document.querySelectorAll('[id^="tab-"]').forEach((btn, i) => {
|
||||
const active = String(i + 1) === idx;
|
||||
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';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% for playerLog in playersLogs %}
|
||||
<div id="player-{{ loop.index }}" class="admin-tab-panel" style="
|
||||
display: none;
|
||||
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);
|
||||
">
|
||||
<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 %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<div class="card-body">
|
||||
{% if availableGames is not empty %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('create_session') }}">
|
||||
<select name="game_id" class="form-select mb-3">
|
||||
{% for game in availableGames %}
|
||||
<option value="{{ game.id }}">
|
||||
@@ -40,6 +41,7 @@
|
||||
<div class="card-header bg-secondary text-white">Join Session</div>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<button type="submit" name="join_session" class="btn btn-primary text-nowrap">Join Session</button>
|
||||
</form>
|
||||
@@ -67,7 +69,7 @@
|
||||
<tr>
|
||||
<td>{{ session.id }}</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>
|
||||
{% set inviteCode = '' %}
|
||||
@@ -81,6 +83,7 @@
|
||||
<code>{{ inviteCode }}</code>
|
||||
{% else %}
|
||||
<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 }}">
|
||||
<button type="submit" name="create_invite" class="btn btn-sm btn-outline-secondary">Generate Invite</button>
|
||||
</form>
|
||||
@@ -91,12 +94,14 @@
|
||||
{% if session.status.value == 'created' %}
|
||||
{% if session.players|length >= session.game.numberOfPlayers %}
|
||||
<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 }}">
|
||||
<button type="submit" name="start_session" class="btn btn-sm btn-success">Start Session</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if session.timer == 0 %}
|
||||
<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 }}">
|
||||
<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>
|
||||
|
||||
@@ -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 — 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 — 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…" 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 %}
|
||||
@@ -78,6 +78,7 @@
|
||||
<div class="feedback-form mt-4">
|
||||
<h5>Feedback</h5>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('game_feedback_' ~ session.id) }}">
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<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">
|
||||
<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' : '' }}>
|
||||
@@ -87,77 +88,14 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
</form>
|
||||
|
||||
<div id="mercure-config"
|
||||
<div id="game-waiting-config"
|
||||
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
|
||||
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
|
||||
data-ready-at="{{ readyAt|e('html_attr') }}"
|
||||
style="display:none">
|
||||
</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 %}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
<div class="feedback-form mt-4">
|
||||
<h5>Feedback</h5>
|
||||
<form method="post">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('game_feedback_' ~ session.id) }}">
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<form method="post">
|
||||
{% if error %}
|
||||
<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 %}
|
||||
<a href="{{ error.messageData['%resend_link%'] }}">Resend activation link</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Game\Entity\SessionSetting;
|
||||
use App\Game\Enum\GameStatus;
|
||||
use App\Game\Enum\SessionStatus;
|
||||
use App\Game\Enum\SessionSettingType;
|
||||
use App\Game\Repository\LobbyMessageRepository;
|
||||
use App\Game\Service\GameDashboardService;
|
||||
use App\Tech\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -23,6 +24,7 @@ class GameDashboardServiceTest extends TestCase
|
||||
private $entityManager;
|
||||
private $gameRepository;
|
||||
private $sessionRepository;
|
||||
private $lobbyMessageRepository;
|
||||
private $hub;
|
||||
private $service;
|
||||
|
||||
@@ -31,14 +33,15 @@ class GameDashboardServiceTest extends TestCase
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->gameRepository = $this->createMock(GameRepository::class);
|
||||
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
||||
$this->lobbyMessageRepository = $this->createMock(LobbyMessageRepository::class);
|
||||
$this->hub = $this->createMock(HubInterface::class);
|
||||
|
||||
$this->service = new GameDashboardService(
|
||||
$this->gameRepository,
|
||||
$this->sessionRepository,
|
||||
$this->lobbyMessageRepository,
|
||||
$this->entityManager,
|
||||
$this->hub,
|
||||
'http://localhost/topic'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ class SessionLoggingTest extends TestCase
|
||||
$player->method('getUser')->willReturn($user);
|
||||
$player->method('getSession')->willReturn($session);
|
||||
$player->method('getScreen')->willReturn(1);
|
||||
$player->method('getLogFileBasename')->willReturn('player1');
|
||||
|
||||
$this->security->method('getUser')->willReturn($user);
|
||||
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);
|
||||
|
||||
Reference in New Issue
Block a user