Compare commits
34
Commits
7dbb738da8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba45e06972 | ||
|
|
2913b8d2a2 | ||
|
|
335697e520 | ||
|
|
daa37390d0 | ||
|
|
a9cb0fa57f | ||
|
|
2bfc2aca1a | ||
|
|
846cfbc44a | ||
|
|
207346d571 | ||
|
|
dfa75719d0 | ||
|
|
444f54b6c6 | ||
|
|
98cf48b29d | ||
|
|
f6df9f7ba6 | ||
|
|
fc93486367 | ||
|
|
98066118a6 | ||
|
|
1be07440e3 | ||
|
|
c28abef5b7 | ||
|
|
886208c5c0 | ||
|
|
2941cfca49 | ||
|
|
3c58e153dc | ||
|
|
3a7bc3ed49 | ||
|
|
aa667df889 | ||
|
|
c06c45cb52 | ||
|
|
51679d9a6a | ||
|
|
e8be7919ef | ||
|
|
cb2e945419 | ||
|
|
3984a33282 | ||
|
|
eee6c3a369 | ||
|
|
e6ba469ef9 | ||
|
|
f6a0d62017 | ||
|
|
6fd0b5d993 | ||
|
|
6db9d42852 | ||
|
|
1e644eb13b | ||
|
|
8554f04735 | ||
|
|
839113c356 |
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
+205
-20
@@ -3,8 +3,14 @@ import './styles/game1.css';
|
||||
|
||||
let sequenceFinished = false;
|
||||
let stillPlayingSound = true;
|
||||
let navigatingAway = false;
|
||||
|
||||
function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
|
||||
function goTo(url) {
|
||||
navigatingAway = true;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl) {
|
||||
try {
|
||||
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
|
||||
const es = new EventSource(url);
|
||||
@@ -14,7 +20,15 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[Mercure][game1] Update:', data);
|
||||
|
||||
// data is [sendTo, message]
|
||||
if (data && !Array.isArray(data) && data.type === 'game_finished') {
|
||||
const destination = data.status === 'won' ? wonUrl : lostUrl;
|
||||
if (destination) {
|
||||
goTo(destination);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// data is [sendTo, message, messageType?] - messageType defaults to 'mainframe' (green)
|
||||
if (Array.isArray(data) && data.length >= 2) {
|
||||
const sendTo = parseInt(data[0]);
|
||||
// Filter: 0 means everyone, otherwise must match myScreen
|
||||
@@ -25,12 +39,7 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
|
||||
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
if (messageContainer) {
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'message';
|
||||
msgEl.textContent = data[1];
|
||||
msgEl.style.color = '#0F0'; // Green for incoming messages
|
||||
msgEl.style.marginBottom = '10px';
|
||||
messageContainer.appendChild(msgEl);
|
||||
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe');
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
if(stillPlayingSound)
|
||||
playSound();
|
||||
@@ -77,6 +86,148 @@ function flashRed() {
|
||||
}, 150);
|
||||
}
|
||||
|
||||
let lockRevealTimer = null;
|
||||
let lockExpireTimer = null;
|
||||
let lockCountdownTimer = null;
|
||||
let currentLockedAt = null;
|
||||
|
||||
function lockMessageClass(messageType) {
|
||||
if (messageType === 'virus') return 'message-virus';
|
||||
if (messageType === 'mainframe') return 'message-mainframe';
|
||||
if (messageType === 'hint') return 'message-hint';
|
||||
return '';
|
||||
}
|
||||
|
||||
function appendResultMessage(container, text, messageType) {
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = ('message ' + lockMessageClass(messageType)).trim();
|
||||
msgEl.textContent = text;
|
||||
container.appendChild(msgEl);
|
||||
}
|
||||
|
||||
function setInputDisabled(disabled) {
|
||||
const inputField = document.getElementById('input-message');
|
||||
if (inputField) {
|
||||
inputField.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLockTimers() {
|
||||
if (lockRevealTimer) { clearTimeout(lockRevealTimer); lockRevealTimer = null; }
|
||||
if (lockExpireTimer) { clearTimeout(lockExpireTimer); lockExpireTimer = null; }
|
||||
if (lockCountdownTimer) { clearInterval(lockCountdownTimer); lockCountdownTimer = null; }
|
||||
}
|
||||
|
||||
function clearLock() {
|
||||
clearLockTimers();
|
||||
currentLockedAt = null;
|
||||
document.body.classList.remove('locked');
|
||||
const banner = document.getElementById('lock-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
setInputDisabled(false);
|
||||
}
|
||||
|
||||
function updateLockCountdown(unlockAtMs) {
|
||||
const countdownEl = document.getElementById('lock-countdown');
|
||||
if (!countdownEl) return;
|
||||
const remaining = Math.max(0, Math.ceil((unlockAtMs - Date.now()) / 1000));
|
||||
countdownEl.textContent = remaining + 's';
|
||||
}
|
||||
|
||||
async function fetchLockReveal(apiEchoUrl, messageContainer) {
|
||||
if (!apiEchoUrl) return;
|
||||
try {
|
||||
const response = await fetchJson(apiEchoUrl, {
|
||||
method: 'POST',
|
||||
body: { message: '', ts: new Date().toISOString() },
|
||||
});
|
||||
const result = response && response.result;
|
||||
if (result && Array.isArray(result.result)) {
|
||||
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
if (result && result.locked === false) {
|
||||
clearLock();
|
||||
return;
|
||||
}
|
||||
// Code has been revealed (or already was), let the player try /unlock
|
||||
setInputDisabled(false);
|
||||
} catch (e) {
|
||||
console.error('[Game1] Failed to fetch lock reveal:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLock(lockData, apiEchoUrl, messageContainer) {
|
||||
if (currentLockedAt === lockData.lockedAt) {
|
||||
return; // already tracking this lock, avoid re-fetching/duplicating messages
|
||||
}
|
||||
currentLockedAt = lockData.lockedAt;
|
||||
|
||||
clearLockTimers();
|
||||
|
||||
const banner = document.getElementById('lock-banner');
|
||||
if (banner) banner.style.display = 'flex';
|
||||
document.body.classList.add('locked');
|
||||
|
||||
const revealAtMs = lockData.revealAt * 1000;
|
||||
const unlockAtMs = lockData.unlockAt * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
if (now < revealAtMs) {
|
||||
setInputDisabled(true);
|
||||
lockRevealTimer = setTimeout(() => fetchLockReveal(apiEchoUrl, messageContainer), revealAtMs - now);
|
||||
} else {
|
||||
fetchLockReveal(apiEchoUrl, messageContainer);
|
||||
}
|
||||
|
||||
lockExpireTimer = setTimeout(() => clearLock(), Math.max(0, unlockAtMs - now));
|
||||
|
||||
updateLockCountdown(unlockAtMs);
|
||||
lockCountdownTimer = setInterval(() => {
|
||||
updateLockCountdown(unlockAtMs);
|
||||
if (Date.now() >= unlockAtMs) {
|
||||
clearInterval(lockCountdownTimer);
|
||||
lockCountdownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
let filesRemovalTimer = null;
|
||||
let scheduledFilesRemovalDeadline = null;
|
||||
|
||||
async function pingFilesRemovalDeadline(apiEchoUrl) {
|
||||
if (!apiEchoUrl) return;
|
||||
try {
|
||||
// A no-op message is enough to make the server evaluate the deadline server-side;
|
||||
// the actual restore notice (if any) arrives for everyone via the Mercure broadcast.
|
||||
await fetchJson(apiEchoUrl, {
|
||||
method: 'POST',
|
||||
body: { message: '', ts: new Date().toISOString() },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Game1] Failed to ping files-removal deadline:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFilesRemovalCheck(deadline, apiEchoUrl) {
|
||||
if (!deadline || scheduledFilesRemovalDeadline === deadline) {
|
||||
return; // nothing to (re)schedule
|
||||
}
|
||||
scheduledFilesRemovalDeadline = deadline;
|
||||
|
||||
if (filesRemovalTimer) {
|
||||
clearTimeout(filesRemovalTimer);
|
||||
filesRemovalTimer = null;
|
||||
}
|
||||
|
||||
const delay = Math.max(0, deadline * 1000 - Date.now());
|
||||
filesRemovalTimer = setTimeout(() => {
|
||||
filesRemovalTimer = null;
|
||||
scheduledFilesRemovalDeadline = null;
|
||||
pingFilesRemovalDeadline(apiEchoUrl);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const opts = { ...options };
|
||||
const headers = new Headers(opts.headers || {});
|
||||
@@ -113,8 +264,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Look for config injected by Twig in the page
|
||||
const cfgEl = document.getElementById('mercure-config');
|
||||
|
||||
// Prevent/warn on page reload
|
||||
// Prevent/warn on page reload, except for our own win/lose redirects
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (navigatingAway) {
|
||||
return;
|
||||
}
|
||||
// Standard way to trigger the browser's confirmation dialog
|
||||
event.preventDefault();
|
||||
// Included for compatibility with older browsers
|
||||
@@ -133,9 +287,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
||||
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
|
||||
const lostUrl = cfgEl.dataset.lostUrl;
|
||||
const wonUrl = cfgEl.dataset.wonUrl;
|
||||
const lockLockedAt = cfgEl.dataset.lockLockedAt;
|
||||
const lockRevealAt = cfgEl.dataset.lockRevealAt;
|
||||
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
|
||||
const filesRemovalDeadline = cfgEl.dataset.filesRemovalDeadline;
|
||||
|
||||
// Resume the auto-restore timer after a page refresh, if a window is already running
|
||||
if (filesRemovalDeadline) {
|
||||
scheduleFilesRemovalCheck(parseInt(filesRemovalDeadline, 10), apiEchoUrl);
|
||||
}
|
||||
|
||||
if (mercurePublicUrl && topic) {
|
||||
subscribeToMercure(mercurePublicUrl, topic, screen);
|
||||
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl);
|
||||
} else {
|
||||
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
||||
}
|
||||
@@ -157,7 +321,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
|
||||
if (response && response.finished) {
|
||||
window.location.href = lostUrl;
|
||||
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl);
|
||||
return; // Stop the timer loop
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -236,7 +400,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
msgEl.className = 'message ' + extraClass;
|
||||
msgEl.textContent = msg[0];
|
||||
msgEl.style.marginBottom = '10px';
|
||||
messageContainer.appendChild(msgEl);
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
|
||||
@@ -265,7 +428,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'message';
|
||||
msgEl.textContent = message;
|
||||
msgEl.style.marginBottom = '10px';
|
||||
messageContainer.appendChild(msgEl);
|
||||
|
||||
if (message && apiEchoUrl) {
|
||||
@@ -277,15 +439,29 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
console.log('[API][game1] message sent →', response);
|
||||
if (response && response.result && Array.isArray(response.result.result)) {
|
||||
response.result.result.forEach(text => {
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'message';
|
||||
msgEl.textContent = text;
|
||||
msgEl.style.marginBottom = '10px';
|
||||
messageContainer.appendChild(msgEl);
|
||||
});
|
||||
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType));
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
if (response && response.result) {
|
||||
if (response.result.gameWon === true && wonUrl) {
|
||||
goTo(wonUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.result.locked === true) {
|
||||
applyLock({
|
||||
lockedAt: response.result.lockedAt,
|
||||
revealAt: response.result.revealAt,
|
||||
unlockAt: response.result.unlockAt,
|
||||
}, apiEchoUrl, messageContainer);
|
||||
} else if (response.result.locked === false) {
|
||||
clearLock();
|
||||
}
|
||||
|
||||
if (response.result.filesRemovalDeadline) {
|
||||
scheduleFilesRemovalCheck(response.result.filesRemovalDeadline, apiEchoUrl);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[API][game1] Failed to send message:', err);
|
||||
}
|
||||
@@ -296,6 +472,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('[Game1] message-container height changed to 400vh and input enabled');
|
||||
sequenceFinished = true;
|
||||
console.log('[Game1] sequenceFinished is now TRUE');
|
||||
|
||||
// Restore an in-progress lock after a page refresh
|
||||
if (lockUnlockAt && parseInt(lockUnlockAt, 10) * 1000 > Date.now()) {
|
||||
applyLock({
|
||||
lockedAt: parseInt(lockLockedAt, 10),
|
||||
revealAt: parseInt(lockRevealAt, 10),
|
||||
unlockAt: parseInt(lockUnlockAt, 10),
|
||||
}, apiEchoUrl, messageContainer);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ServerRoot "/etc/apache2"
|
||||
Listen 80
|
||||
User www-data
|
||||
Group www-data
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
LogLevel warn
|
||||
IncludeOptional mods-enabled/*.load
|
||||
IncludeOptional sites-enabled/*.conf
|
||||
@@ -0,0 +1,3 @@
|
||||
deb http://deb.debian.org/debian bookworm main contrib non-free-firmware
|
||||
deb http://deb.debian.org/debian bookworm-updates main contrib non-free-firmware
|
||||
deb http://security.debian.org/debian-security bookworm-security main contrib non-free-firmware
|
||||
@@ -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.
|
||||
+52
-3
@@ -47,12 +47,61 @@ div#message-container {
|
||||
justify-content: flex-end;
|
||||
min-height: calc(100vh - 100px); /* Fill most of the viewport initially */
|
||||
box-sizing: border-box;
|
||||
font-size: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
div.message {
|
||||
color: #C0C0C0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
div.message-virus {
|
||||
color: #F00;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.message-mainframe {
|
||||
color: #0F0;
|
||||
}
|
||||
|
||||
div.message-hint {
|
||||
color: #FF0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div#lock-banner {
|
||||
position: fixed;
|
||||
top: 68px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
background-color: #200;
|
||||
border-top: 1px solid #F00;
|
||||
border-bottom: 1px solid #F00;
|
||||
color: #F00;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: lock-banner-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes lock-banner-pulse {
|
||||
0%, 100% {
|
||||
background-color: #200;
|
||||
}
|
||||
50% {
|
||||
background-color: #400;
|
||||
}
|
||||
}
|
||||
|
||||
body.locked div#message-container {
|
||||
padding-top: 130px;
|
||||
}
|
||||
|
||||
div#input {
|
||||
@@ -61,11 +110,11 @@ div#input {
|
||||
|
||||
input#input-message {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding: 6px 10px;
|
||||
background: #111;
|
||||
border: 1px solid #A00000;
|
||||
color: #C0C0C0;
|
||||
font-size: 18px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -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,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260711210000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add deleted_at and last_login_at to user table; cascade-delete email_log and reset_password_request rows';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE `user` ADD deleted_at DATETIME DEFAULT NULL, ADD last_login_at DATETIME DEFAULT NULL');
|
||||
|
||||
$this->addSql('ALTER TABLE email_log DROP FOREIGN KEY FK_6FB4883A76ED395');
|
||||
$this->addSql('ALTER TABLE email_log ADD CONSTRAINT FK_6FB4883A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE');
|
||||
|
||||
$this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395');
|
||||
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE email_log DROP FOREIGN KEY FK_6FB4883A76ED395');
|
||||
$this->addSql('ALTER TABLE email_log ADD CONSTRAINT FK_6FB4883A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
|
||||
|
||||
$this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395');
|
||||
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
|
||||
|
||||
$this->addSql('ALTER TABLE `user` DROP deleted_at, DROP last_login_at');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Tech\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:users:purge-deleted',
|
||||
description: 'Permanently removes soft-deleted users who never played a game, 3 months after deletion.'
|
||||
)]
|
||||
final class PurgeDeletedUsersCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$deletedBefore = new \DateTimeImmutable('-3 months');
|
||||
$users = $this->userRepository->findPurgeableDeletedUsers($deletedBefore);
|
||||
|
||||
foreach ($users as $user) {
|
||||
$this->entityManager->remove($user);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$output->writeln(sprintf('<info>Purged %d deleted user(s).</info>', count($users)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -66,11 +66,12 @@ final class GameAdminUserController extends AbstractController
|
||||
return $this->redirectToRoute('game_admin_users');
|
||||
}
|
||||
|
||||
$username = $user->getUsername();
|
||||
$em->remove($user);
|
||||
$em->flush();
|
||||
if (!$user->isDeleted()) {
|
||||
$user->setDeletedAt(new \DateTimeImmutable());
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
$this->addFlash('success', sprintf('User "%s" deleted.', $username));
|
||||
$this->addFlash('success', sprintf('User "%s" deleted.', $user->getUsername()));
|
||||
|
||||
return $this->redirectToRoute('game_admin_users');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,8 +12,8 @@ use App\Game\Repository\GameRepository;
|
||||
use App\Game\Repository\PlayerRepository;
|
||||
use App\Game\Repository\SessionRepository;
|
||||
use App\Game\Service\GameDashboardService;
|
||||
use App\Game\Service\GameResponseService;
|
||||
use App\Tech\Entity\User;
|
||||
use App\Game\Service\PlayerService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -22,6 +22,8 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\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 {
|
||||
@@ -115,7 +150,8 @@ final class GameController extends AbstractController
|
||||
Request $request,
|
||||
Security $security,
|
||||
PlayerRepository $playerRepository,
|
||||
GameDashboardService $dashboardService
|
||||
GameDashboardService $dashboardService,
|
||||
GameResponseService $gameResponseService
|
||||
): Response
|
||||
{
|
||||
$user = $security->getUser();
|
||||
@@ -126,13 +162,51 @@ final class GameController extends AbstractController
|
||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||
|
||||
if ($request->isMethod('POST') && $request->request->has('toggle_ready')) {
|
||||
$dashboardService->toggleReady($session, $user);
|
||||
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);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
||||
}
|
||||
|
||||
// Periodically check readiness timeout
|
||||
if ($request->isMethod('POST') && $request->request->has('expire_ready')) {
|
||||
if ($this->isCsrfTokenValid('expire_ready_' . $session->getId(), $request->request->get('_token'))) {
|
||||
$dashboardService->expireOwnReadyIfDue($session, $user);
|
||||
}
|
||||
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST') && $request->request->has('send_message')) {
|
||||
if ($this->isCsrfTokenValid('send_message_' . $session->getId(), $request->request->get('_token'))) {
|
||||
$dashboardService->postLobbyMessage($session, $user, (string) $request->request->get('content', ''));
|
||||
}
|
||||
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
||||
}
|
||||
|
||||
// Lazily pick up readiness changes from other players since our last request
|
||||
$dashboardService->checkAllPlayersReady($session);
|
||||
|
||||
if ($dashboardService->isLobbyChatOpen($session)) {
|
||||
return $this->render('game/lobby.html.twig', [
|
||||
'session' => $session,
|
||||
'messages' => $dashboardService->getLobbyMessages($session),
|
||||
'player' => $player,
|
||||
'mercure_public_url' => $this->mercurePublicUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($session->getStatus() === SessionStatus::WON) {
|
||||
return $this->redirectToRoute('game_won', ['session' => $session->getId()]);
|
||||
}
|
||||
|
||||
if ($session->getStatus() === SessionStatus::LOST) {
|
||||
return $this->redirectToRoute('game_lost', ['session' => $session->getId()]);
|
||||
}
|
||||
|
||||
if ($session->getStatus() === SessionStatus::READY) {
|
||||
$isReady = false;
|
||||
$readyAt = null;
|
||||
@@ -157,11 +231,15 @@ final class GameController extends AbstractController
|
||||
|
||||
$screen = $player ? $player->getScreen() : 0;
|
||||
$session_id = $session->getId();
|
||||
$lock = $player ? $gameResponseService->getPublicLockState($player) : null;
|
||||
$filesRemovalDeadline = $gameResponseService->getPublicLockedFilesDeadline($session);
|
||||
|
||||
return $this->render('game/index.html.twig', [
|
||||
'session' => $session,
|
||||
'screen' => $screen,
|
||||
'session_id' => $session_id,
|
||||
'lock' => $lock,
|
||||
'filesRemovalDeadline' => $filesRemovalDeadline,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -172,14 +250,13 @@ final class GameController extends AbstractController
|
||||
Session $session,
|
||||
Request $request,
|
||||
Security $security,
|
||||
PlayerService $playerService,
|
||||
GameDashboardService $dashboardService
|
||||
PlayerRepository $playerRepository
|
||||
): Response {
|
||||
/** @var User $user */
|
||||
$user = $security->getUser();
|
||||
$player = $playerService->GetCurrentlyActiveAsPlayer($user);
|
||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
|
||||
$difficulty = $request->request->get('difficulty');
|
||||
$entertaining = $request->request->get('entertaining');
|
||||
$theme = $request->request->get('theme');
|
||||
@@ -198,6 +275,38 @@ final class GameController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/won/{session}', name: 'game_won', methods: ['GET', 'POST'])]
|
||||
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
|
||||
#[IsGranted('SESSION_VIEW', subject: 'session')]
|
||||
public function won(
|
||||
Session $session,
|
||||
Request $request,
|
||||
Security $security,
|
||||
PlayerRepository $playerRepository
|
||||
): Response {
|
||||
/** @var User $user */
|
||||
$user = $security->getUser();
|
||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||
|
||||
if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
|
||||
$difficulty = $request->request->get('difficulty');
|
||||
$entertaining = $request->request->get('entertaining');
|
||||
$theme = $request->request->get('theme');
|
||||
$feedback = $request->request->get('feedback');
|
||||
|
||||
// Save feedback
|
||||
if ($player) {
|
||||
$this->saveFeedback($session, $player, $difficulty, $entertaining, $theme, $feedback);
|
||||
$this->addFlash('success', 'Thank you for your feedback!');
|
||||
return $this->redirectToRoute('game_dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('game/won.html.twig', [
|
||||
'session' => $session,
|
||||
]);
|
||||
}
|
||||
|
||||
private function saveFeedback(Session $session, Player $player, $difficulty, $entertaining, $theme, $feedback): void
|
||||
{
|
||||
$settings = [
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Game\Enum;
|
||||
|
||||
enum DecodeMessage: string
|
||||
{
|
||||
case TEST = 'This is a test decoding message.';
|
||||
case SECRET = 'The secret code is 42.';
|
||||
case WELCOME = 'Welcome to the system, agent.';
|
||||
case PLAYER_1 = 'Sudo is now available';
|
||||
case PLAYER_2 = 'AI virus protects its own files by replacing them';
|
||||
case PLAYER_3 = 'The locked up bash files should be removed to lock it up';
|
||||
}
|
||||
|
||||
@@ -74,4 +74,15 @@ enum SessionSettingType: string
|
||||
case FEEDBACK_ENTERTAINING = 'FeedbackEntertaining';
|
||||
case FEEDBACK_THEME = 'FeedbackTheme';
|
||||
case FEEDBACK_TEXT = 'FeedbackText';
|
||||
case LOCK_FOR_PLAYER1 = 'LockForPlayer1';
|
||||
case LOCK_FOR_PLAYER2 = 'LockForPlayer2';
|
||||
case LOCK_FOR_PLAYER3 = 'LockForPlayer3';
|
||||
case LOCK_FOR_PLAYER4 = 'LockForPlayer4';
|
||||
case LOCK_FOR_PLAYER5 = 'LockForPlayer5';
|
||||
case LOCK_FOR_PLAYER6 = 'LockForPlayer6';
|
||||
case LOCK_FOR_PLAYER7 = 'LockForPlayer7';
|
||||
case LOCK_FOR_PLAYER8 = 'LockForPlayer8';
|
||||
case LOCK_FOR_PLAYER9 = 'LockForPlayer9';
|
||||
case LOCK_FOR_PLAYER10 = 'LockForPlayer10';
|
||||
case LOCKED_FILES_REMOVAL_DEADLINE = 'LockedFilesRemovalDeadline';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -20,9 +22,14 @@ 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,
|
||||
) {
|
||||
@@ -120,6 +127,8 @@ final class GameDashboardService
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->publishLobbyEvent($session, 'player_joined');
|
||||
|
||||
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
|
||||
$this->startSession($session);
|
||||
}
|
||||
@@ -281,15 +290,93 @@ 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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$user->isVerified()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$player = null;
|
||||
foreach ($session->getPlayers() as $p) {
|
||||
if ($p->getUser() === $user) {
|
||||
@@ -309,11 +396,12 @@ final class GameDashboardService
|
||||
|
||||
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
||||
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
||||
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
||||
$existingSetting = $settingRepo->getSetting($session, $settingName, $player);
|
||||
$nowReady = $existingSetting === null;
|
||||
|
||||
if ($setting) {
|
||||
$session->removeSetting($setting);
|
||||
$this->entityManager->remove($setting);
|
||||
if ($existingSetting) {
|
||||
$session->removeSetting($existingSetting);
|
||||
$this->entityManager->remove($existingSetting);
|
||||
} else {
|
||||
$setting = new SessionSetting();
|
||||
$setting->setSession($session);
|
||||
@@ -330,17 +418,76 @@ final class GameDashboardService
|
||||
// transitioned the session out of READY and published 'all_ready' —
|
||||
// don't also publish a redundant 'player_ready'.
|
||||
if ($session->getStatus() === SessionStatus::READY) {
|
||||
try {
|
||||
$topic = '/game/hub/' . $session->getId();
|
||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting])));
|
||||
} catch (\Exception $e) {
|
||||
// Mercure might be down, but we don't want to crash the game
|
||||
}
|
||||
$this->publishPlayerReady($session, $player->getScreen(), $nowReady);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly (and idempotently) expires the current user's own ready status once
|
||||
* their 60-second window is genuinely up. Called by the ready player's own browser
|
||||
* on a timer, so the "not ready" broadcast goes out as close to the deadline as
|
||||
* possible instead of waiting for someone else's request to lazily discover it.
|
||||
*/
|
||||
public function expireOwnReadyIfDue(Session $session, User $user): void
|
||||
{
|
||||
if ($session->getStatus() !== SessionStatus::READY) {
|
||||
return;
|
||||
}
|
||||
|
||||
$player = null;
|
||||
foreach ($session->getPlayers() as $p) {
|
||||
if ($p->getUser() === $user) {
|
||||
$player = $p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$player) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen());
|
||||
if (!$settingName) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
||||
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
||||
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
||||
|
||||
if (!$setting) {
|
||||
return; // Already not ready
|
||||
}
|
||||
|
||||
$readyAtTimestamp = (int)$setting->getValue();
|
||||
if ((new \DateTime())->getTimestamp() - $readyAtTimestamp < self::READY_TIMEOUT_SECONDS) {
|
||||
return; // Not actually due yet
|
||||
}
|
||||
|
||||
$session->removeSetting($setting);
|
||||
$this->entityManager->remove($setting);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->publishPlayerReady($session, $player->getScreen(), false);
|
||||
}
|
||||
|
||||
private function publishPlayerReady(Session $session, int $screen, bool $ready): void
|
||||
{
|
||||
$this->publishLobbyEvent($session, 'player_ready', ['player' => $screen, 'ready' => $ready]);
|
||||
}
|
||||
|
||||
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
|
||||
{
|
||||
try {
|
||||
$topic = '/game/hub/' . $session->getId();
|
||||
$this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra))));
|
||||
} catch (\Exception $e) {
|
||||
// Mercure might be down, but we don't want to crash the game
|
||||
}
|
||||
}
|
||||
|
||||
public function checkAllPlayersReady(Session $session): void
|
||||
{
|
||||
if ($session->getStatus() !== SessionStatus::READY) {
|
||||
@@ -356,7 +503,6 @@ final class GameDashboardService
|
||||
|
||||
$readyPlayersCount = 0;
|
||||
$now = new \DateTime();
|
||||
$anyReset = false;
|
||||
|
||||
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
||||
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
||||
@@ -368,27 +514,21 @@ final class GameDashboardService
|
||||
}
|
||||
|
||||
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
||||
if ($setting) {
|
||||
$readyAtTimestamp = (int)$setting->getValue();
|
||||
// Check timeout: 1 minute = 60 seconds
|
||||
if (($now->getTimestamp() - $readyAtTimestamp) > 60) {
|
||||
$session->removeSetting($setting);
|
||||
$this->entityManager->remove($setting);
|
||||
$anyReset = true;
|
||||
} else {
|
||||
$readyPlayersCount++;
|
||||
}
|
||||
if (!$setting) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($anyReset) {
|
||||
$this->entityManager->flush();
|
||||
try {
|
||||
$topic = '/game/hub/' . $session->getId();
|
||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready'])));
|
||||
} catch (\Exception $e) {
|
||||
// Mercure might be down
|
||||
$readyAtTimestamp = (int)$setting->getValue();
|
||||
if (($now->getTimestamp() - $readyAtTimestamp) >= self::READY_TIMEOUT_SECONDS) {
|
||||
$session->removeSetting($setting);
|
||||
$this->entityManager->remove($setting);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->publishPlayerReady($session, $player->getScreen(), false);
|
||||
continue;
|
||||
}
|
||||
|
||||
$readyPlayersCount++;
|
||||
}
|
||||
|
||||
if ($readyPlayersCount === $numPlayers) {
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Game\Service;
|
||||
|
||||
use App\Game\Enum\DecodeMessage;
|
||||
use App\Game\Enum\SessionSettingType;
|
||||
use App\Game\Enum\SessionStatus;
|
||||
use App\Game\Entity\Player;
|
||||
use App\Game\Entity\Session;
|
||||
use App\Game\Entity\SessionSetting;
|
||||
use App\Game\Repository\SessionSettingRepository;
|
||||
use App\Tech\Entity\User;
|
||||
@@ -15,6 +17,10 @@ use Symfony\Component\Mercure\Update;
|
||||
|
||||
class GameResponseService
|
||||
{
|
||||
private const LOCK_REVEAL_AFTER_SECONDS = 30;
|
||||
private const LOCK_DURATION_SECONDS = 45;
|
||||
private const LOCK_PASSCODE_LENGTH = 12;
|
||||
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private PlayerService $playerService,
|
||||
@@ -25,6 +31,25 @@ class GameResponseService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, code-free view of a player's current lock state, safe to expose on page load
|
||||
* (e.g. so the UI can restore the lock banner/countdown after a refresh).
|
||||
*/
|
||||
public function getPublicLockState(Player $player): ?array
|
||||
{
|
||||
$lock = $this->getLockState($player);
|
||||
|
||||
if ($lock === null || time() >= $lock['unlockAt']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'lockedAt' => $lock['lockedAt'],
|
||||
'revealAt' => $lock['revealAt'],
|
||||
'unlockAt' => $lock['unlockAt'],
|
||||
];
|
||||
}
|
||||
|
||||
public function getGameResponse(string $raw) : array
|
||||
{
|
||||
$info = json_decode($raw, true);
|
||||
@@ -45,14 +70,18 @@ class GameResponseService
|
||||
if(!$player)
|
||||
return ['error' => 'You are not in a game.'];
|
||||
|
||||
$this->enforceLockedFilesRemovalDeadline($player->getSession());
|
||||
|
||||
$this->logSessionActivity($player, 'PLAYER: ' . $message);
|
||||
|
||||
$data = [];
|
||||
$data = $this->handleLockedPlayer($message, $player);
|
||||
|
||||
if(str_starts_with($message, '/')) {
|
||||
$data = $this->checkGameCommando($message, $player);
|
||||
} else {
|
||||
$data = $this->checkConsoleCommando($message, $player);
|
||||
if ($data === null) {
|
||||
if (str_starts_with($message, '/')) {
|
||||
$data = $this->checkGameCommando($message, $player);
|
||||
} else {
|
||||
$data = $this->checkConsoleCommando($message, $player);
|
||||
}
|
||||
}
|
||||
|
||||
$responseLog = '';
|
||||
@@ -78,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);
|
||||
|
||||
@@ -202,7 +230,33 @@ class GameResponseService
|
||||
return ['result' => ['You are not allowed to remove this file.']];
|
||||
|
||||
$this->playerService->addDeletedFileToSession($player, $fullPath);
|
||||
return ['result' => ['File removed: ' . $filename]];
|
||||
|
||||
if ($this->checkFilesRemovalWin($player)) {
|
||||
return [
|
||||
'result' => [
|
||||
'File removed: ' . $filename,
|
||||
'MAINFRAME: All protected files purged. The AI virus has been contained. Well done, agents.',
|
||||
],
|
||||
'messageType' => 'mainframe',
|
||||
'gameWon' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$filesRemovalDeadline = $this->startLockedFilesRemovalDeadline($player->getSession(), $fullPath);
|
||||
$lock = $this->triggerLock($player);
|
||||
|
||||
return [
|
||||
'result' => [
|
||||
'File removed: ' . $filename,
|
||||
'AI VIRUS: Intrusion detected. Locking down your terminal...',
|
||||
],
|
||||
'messageType' => 'virus',
|
||||
'locked' => true,
|
||||
'lockedAt' => $lock['lockedAt'],
|
||||
'revealAt' => $lock['revealAt'],
|
||||
'unlockAt' => $lock['unlockAt'],
|
||||
'filesRemovalDeadline' => $filesRemovalDeadline,
|
||||
];
|
||||
case 'sudo':
|
||||
if(!in_array('sudo', $rechten))
|
||||
return ['result' => ['Unknown command']];
|
||||
@@ -211,6 +265,17 @@ class GameResponseService
|
||||
$message = implode(' ', $messagePart);
|
||||
|
||||
return $this->checkConsoleCommando($message, $player, true);
|
||||
case 'scan':
|
||||
if(!in_array('scan', $rechten))
|
||||
return ['result' => ['Unknown command']];
|
||||
|
||||
$result = ['Running quarantine scan...', 'Locked files detected:'];
|
||||
foreach ($this->getLockedFiles() as $lockedFile) {
|
||||
$result[] = ' ' . $lockedFile;
|
||||
}
|
||||
$result[] = 'These files are protected by the AI virus. Use sudo rm {file} to remove them.';
|
||||
|
||||
return ['result' => $result];
|
||||
default:
|
||||
return ['result' => ['Unknown command']];
|
||||
}
|
||||
@@ -287,6 +352,12 @@ class GameResponseService
|
||||
$messages[] = ' USAGE: sudo {command}';
|
||||
$messages[] = '';
|
||||
break;
|
||||
case 'scan':
|
||||
$messages[] = 'scan';
|
||||
$messages[] = ' Runs a quarantine scan that reveals which files are locked by the AI virus.';
|
||||
$messages[] = ' USAGE: scan';
|
||||
$messages[] = '';
|
||||
break;
|
||||
case 'verify':
|
||||
$messages[] = '/verify';
|
||||
$messages[] = ' You can verify yourself by using this command.';
|
||||
@@ -470,24 +541,213 @@ class GameResponseService
|
||||
}
|
||||
}
|
||||
|
||||
private function handleDecodeMessage(string $message, Player $player)
|
||||
private function handleDecodeMessage(string $message, Player $player): string
|
||||
{
|
||||
$userNumber = $player->getScreen();
|
||||
|
||||
preg_match('/\d+/', $message, $matches);
|
||||
preg_match('/\d/', $message, $matches);
|
||||
$num = $matches[0] ?? null;
|
||||
$randomString = $this->generateRandomString(250, 500);
|
||||
|
||||
if(is_null($num) || $num != $userNumber)
|
||||
if (is_null($num) || (int)$num !== $userNumber) {
|
||||
return $randomString;
|
||||
}
|
||||
|
||||
foreach (DecodeMessage::cases() as $decodeMessage) {
|
||||
if ($decodeMessage->name === $message) {
|
||||
return $decodeMessage->value;
|
||||
$decodeMessage = match ($userNumber) {
|
||||
1 => DecodeMessage::PLAYER_1,
|
||||
2 => DecodeMessage::PLAYER_2,
|
||||
3 => DecodeMessage::PLAYER_3,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($decodeMessage === null) {
|
||||
return $randomString;
|
||||
}
|
||||
|
||||
if ($decodeMessage === DecodeMessage::PLAYER_1) {
|
||||
$this->grantRightToAllPlayers($player->getSession(), 'sudo');
|
||||
$this->grantRightToAllPlayers($player->getSession(), 'rm');
|
||||
}
|
||||
|
||||
if ($decodeMessage === DecodeMessage::PLAYER_2) {
|
||||
$this->grantRightToAllPlayers($player->getSession(), 'scan');
|
||||
}
|
||||
|
||||
return $decodeMessage->value;
|
||||
}
|
||||
|
||||
private function grantRightToAllPlayers(Session $session, string $right): void
|
||||
{
|
||||
$updated = false;
|
||||
|
||||
foreach ($session->getPlayers() as $sessionPlayer) {
|
||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $sessionPlayer->getScreen());
|
||||
if (!$rightsSettingName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($session, $rightsSettingName, $sessionPlayer);
|
||||
if (!$setting) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
||||
if (!in_array($right, $rights)) {
|
||||
$rights[] = $right;
|
||||
$setting->setValue(json_encode($rights));
|
||||
$this->entityManager->persist($setting);
|
||||
$updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $randomString;
|
||||
if ($updated) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null if the player isn't locked (or their lock already expired), otherwise
|
||||
* a result array to short-circuit normal command handling.
|
||||
*/
|
||||
private function handleLockedPlayer(string $message, Player $player): ?array
|
||||
{
|
||||
$lock = $this->getLockState($player);
|
||||
|
||||
if ($lock === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
if ($now >= $lock['unlockAt']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($message);
|
||||
|
||||
if (stripos($trimmed, '/unlock ') === 0) {
|
||||
$attempt = trim(substr($trimmed, 8));
|
||||
|
||||
if ($now >= $lock['revealAt'] && hash_equals($lock['code'], $attempt)) {
|
||||
$this->setLockUnlockAt($player, $now);
|
||||
|
||||
return [
|
||||
'result' => ['ACCESS RESTORED. Welcome back, agent.'],
|
||||
'messageType' => 'mainframe',
|
||||
'locked' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->lockStatusResponse('ACCESS DENIED: Incorrect passcode.', 'virus', $lock);
|
||||
}
|
||||
|
||||
if ($now >= $lock['revealAt']) {
|
||||
return $this->lockStatusResponse(
|
||||
'MAINFRAME: Recovery code acquired: ' . $lock['code'] . '. Use /unlock ' . $lock['code'] . ' to restore access.',
|
||||
'mainframe',
|
||||
$lock
|
||||
);
|
||||
}
|
||||
|
||||
return $this->lockStatusResponse(
|
||||
'AI VIRUS: SYSTEM LOCKED. Countermeasures engaged. Stand by for mainframe recovery protocol.',
|
||||
'virus',
|
||||
$lock
|
||||
);
|
||||
}
|
||||
|
||||
private function lockStatusResponse(string $text, string $messageType, array $lock): array
|
||||
{
|
||||
return [
|
||||
'result' => [$text],
|
||||
'messageType' => $messageType,
|
||||
'locked' => true,
|
||||
'lockedAt' => $lock['lockedAt'],
|
||||
'revealAt' => $lock['revealAt'],
|
||||
'unlockAt' => $lock['unlockAt'],
|
||||
];
|
||||
}
|
||||
|
||||
private function getLockState(Player $player): ?array
|
||||
{
|
||||
$settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen());
|
||||
if (!$settingName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player);
|
||||
if (!$setting || !$setting->getValue()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = json_decode($setting->getValue(), true);
|
||||
if (!is_array($state) || !isset($state['lockedAt'], $state['revealAt'], $state['unlockAt'], $state['code'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function triggerLock(Player $player): array
|
||||
{
|
||||
$now = time();
|
||||
$state = [
|
||||
'lockedAt' => $now,
|
||||
'revealAt' => $now + self::LOCK_REVEAL_AFTER_SECONDS,
|
||||
'unlockAt' => $now + self::LOCK_DURATION_SECONDS,
|
||||
'code' => $this->generatePasscode(),
|
||||
];
|
||||
|
||||
$settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen());
|
||||
if ($settingName) {
|
||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player);
|
||||
if (!$setting) {
|
||||
$setting = new SessionSetting();
|
||||
$setting->setSession($player->getSession());
|
||||
$setting->setPlayer($player);
|
||||
$setting->setName($settingName);
|
||||
}
|
||||
|
||||
$setting->setValue(json_encode($state));
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function setLockUnlockAt(Player $player, int $unlockAt): void
|
||||
{
|
||||
$settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen());
|
||||
if (!$settingName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player);
|
||||
if (!$setting || !$setting->getValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = json_decode($setting->getValue(), true);
|
||||
if (!is_array($state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state['unlockAt'] = $unlockAt;
|
||||
$setting->setValue(json_encode($state));
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function generatePasscode(): string
|
||||
{
|
||||
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
$charactersLength = strlen($characters);
|
||||
$code = '';
|
||||
for ($i = 0; $i < self::LOCK_PASSCODE_LENGTH; $i++) {
|
||||
$code .= $characters[random_int(0, $charactersLength - 1)];
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function generateRandomString(int $min, int $max): string
|
||||
@@ -737,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';
|
||||
|
||||
@@ -770,13 +1085,146 @@ class GameResponseService
|
||||
if(in_array('sudo', $rights) || $sudo)
|
||||
return true;
|
||||
|
||||
$sudoFiles = [
|
||||
return !in_array($file, $this->getLockedFiles());
|
||||
}
|
||||
|
||||
private function getLockedFiles() : array
|
||||
{
|
||||
return [
|
||||
'/var/arrest/handle.sh',
|
||||
'/var/arrest/cell.sh',
|
||||
'/var/marriage/divorce.sh',
|
||||
];
|
||||
}
|
||||
|
||||
return !in_array($file, $sudoFiles);
|
||||
/**
|
||||
* Checks whether all locked files are currently removed and, if so, marks the session
|
||||
* as won (once) and broadcasts a "game finished" signal to every connected player.
|
||||
*/
|
||||
private function checkFilesRemovalWin(Player $player): bool
|
||||
{
|
||||
$session = $player->getSession();
|
||||
|
||||
if ($session->getStatus() !== SessionStatus::PLAYING) {
|
||||
return $session->getStatus() === SessionStatus::WON;
|
||||
}
|
||||
|
||||
$deletedFiles = $this->playerService->getDeletedFilesOfSession($player);
|
||||
$lockedFiles = $this->getLockedFiles();
|
||||
|
||||
if (count(array_intersect($lockedFiles, $deletedFiles)) < count($lockedFiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$session->setStatus(SessionStatus::WON);
|
||||
$session->setFinishedAt(new \DateTime());
|
||||
$this->entityManager->persist($session);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$topic = '/game/hub/' . $session->getId();
|
||||
try {
|
||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'won'])));
|
||||
} catch (\Exception $e) {
|
||||
// Mercure might be down
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (if not already running) the 60-second window within which all locked files
|
||||
* must be removed, or the ones already removed get restored by the virus. Returns the
|
||||
* (new or already-running) deadline as a unix timestamp, or null if the removed file
|
||||
* wasn't a locked one.
|
||||
*/
|
||||
private function startLockedFilesRemovalDeadline(Session $session, string $removedFile): ?int
|
||||
{
|
||||
if (!in_array($removedFile, $this->getLockedFiles())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
|
||||
if ($setting && $setting->getValue()) {
|
||||
return (int)$setting->getValue(); // Window already running
|
||||
}
|
||||
|
||||
if (!$setting) {
|
||||
$setting = new SessionSetting();
|
||||
$setting->setSession($session);
|
||||
$setting->setName(SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
|
||||
}
|
||||
|
||||
$deadline = time() + 60;
|
||||
$setting->setValue((string)$deadline);
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $deadline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, session-wide view of the active locked-files removal deadline (if any), safe
|
||||
* to expose on page load so the UI can schedule its auto-check timer after a refresh.
|
||||
*/
|
||||
public function getPublicLockedFilesDeadline(Session $session): ?int
|
||||
{
|
||||
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
|
||||
if (!$setting || !$setting->getValue()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$deadline = (int)$setting->getValue();
|
||||
|
||||
return $deadline > time() ? $deadline : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily checked on every player interaction: if the 60-second window to remove all
|
||||
* locked files has expired without all of them being removed, restore whichever ones
|
||||
* were removed and clear the window.
|
||||
*/
|
||||
private function enforceLockedFilesRemovalDeadline(Session $session): void
|
||||
{
|
||||
if ($session->getStatus() !== SessionStatus::PLAYING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
|
||||
if (!$setting || !$setting->getValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (time() < (int)$setting->getValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$player = $session->getPlayers()->first() ?: null;
|
||||
$lockedFiles = $this->getLockedFiles();
|
||||
|
||||
if ($player) {
|
||||
$deletedFiles = $this->playerService->getDeletedFilesOfSession($player);
|
||||
$stillLocked = array_intersect($lockedFiles, $deletedFiles);
|
||||
|
||||
if (count($stillLocked) < count($lockedFiles)) {
|
||||
foreach ($stillLocked as $file) {
|
||||
$this->playerService->removeDeletedFileFromSession($player, $file);
|
||||
}
|
||||
|
||||
if (!empty($stillLocked)) {
|
||||
$topic = '/game/hub/' . $session->getId();
|
||||
$message = 'AI VIRUS: Integrity check complete. Restored files that were not fully purged in time.';
|
||||
try {
|
||||
$this->hub->publish(new Update($topic, json_encode([0, $message, 'virus'])));
|
||||
} catch (\Exception $e) {
|
||||
// Mercure might be down
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$setting->setValue(null);
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function fileExists(string $file, Player $player) : bool
|
||||
@@ -820,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;
|
||||
}
|
||||
|
||||
@@ -93,4 +93,21 @@ class PlayerService
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDeletedFileFromSession(Player $player, string $filename): void
|
||||
{
|
||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), SessionSettingType::SET_OF_DELETED_FILES);
|
||||
if (!$setting || !$setting->getValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deletedFiles = json_decode($setting->getValue(), true) ?? [];
|
||||
$newDeletedFiles = array_values(array_diff($deletedFiles, [$filename]));
|
||||
|
||||
if (count($newDeletedFiles) !== count($deletedFiles)) {
|
||||
$setting->setValue(json_encode($newDeletedFiles));
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tech\Controller;
|
||||
|
||||
use App\Tech\Entity\User;
|
||||
use App\Tech\Form\ProfileChangeEmailFormType;
|
||||
use App\Tech\Form\ProfileChangePasswordFormType;
|
||||
use App\Tech\Repository\UserRepository;
|
||||
use App\Tech\Service\EmailVerifier;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
final class ProfileController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EmailVerifier $emailVerifier,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/profile', name: 'app_profile', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$passwordForm = $this->createForm(ProfileChangePasswordFormType::class);
|
||||
$passwordForm->handleRequest($request);
|
||||
|
||||
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
|
||||
$currentPassword = (string) $passwordForm->get('currentPassword')->getData();
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
|
||||
$passwordForm->get('currentPassword')->addError(new FormError('Incorrect password.'));
|
||||
} else {
|
||||
$plainPassword = (string) $passwordForm->get('plainPassword')->getData();
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Your password has been updated.');
|
||||
|
||||
return $this->redirectToRoute('app_profile');
|
||||
}
|
||||
}
|
||||
|
||||
$emailForm = $this->createForm(ProfileChangeEmailFormType::class, ['email' => $user->getEmail()]);
|
||||
$emailForm->handleRequest($request);
|
||||
|
||||
if ($emailForm->isSubmitted() && $emailForm->isValid()) {
|
||||
$currentPassword = (string) $emailForm->get('currentPassword')->getData();
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
|
||||
$emailForm->get('currentPassword')->addError(new FormError('Incorrect password.'));
|
||||
} else {
|
||||
$newEmail = (string) $emailForm->get('email')->getData();
|
||||
|
||||
if ($newEmail === $user->getEmail()) {
|
||||
$this->addFlash('info', 'That is already your email address.');
|
||||
return $this->redirectToRoute('app_profile');
|
||||
}
|
||||
|
||||
$existing = $this->userRepository->findOneBy(['email' => $newEmail]);
|
||||
if ($existing) {
|
||||
$emailForm->get('email')->addError(new FormError('This email address is already in use.'));
|
||||
} else {
|
||||
$user->setEmail($newEmail);
|
||||
$user->setIsVerified(false);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
|
||||
(new TemplatedEmail())
|
||||
->from($this->getParameter('mailer_from'))
|
||||
->to($newEmail)
|
||||
->subject('Please confirm your new email address')
|
||||
->htmlTemplate('tech/registration/confirmation_email.html.twig')
|
||||
);
|
||||
|
||||
$this->addFlash('success', 'Your email address has been updated. You must verify it (check your inbox) before you can log in again.');
|
||||
|
||||
return $this->redirectToRoute('app_profile');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('tech/profile/index.html.twig', [
|
||||
'user' => $user,
|
||||
'passwordForm' => $passwordForm,
|
||||
'emailForm' => $emailForm,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class EmailLog
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
|
||||
@@ -19,7 +19,7 @@ class ResetPasswordRequest implements ResetPasswordRequestInterface
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?User $user = null;
|
||||
|
||||
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
||||
|
||||
@@ -42,6 +42,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $marketingOptIn = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $lastLoginAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -152,4 +158,33 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deletedAt !== null;
|
||||
}
|
||||
|
||||
public function getLastLoginAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->lastLoginAt;
|
||||
}
|
||||
|
||||
public function setLastLoginAt(?\DateTimeImmutable $lastLoginAt): static
|
||||
{
|
||||
$this->lastLoginAt = $lastLoginAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ class EmailLoggerListener
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($user->isDeleted()) {
|
||||
$event->reject();
|
||||
return;
|
||||
}
|
||||
|
||||
$emailLog = new EmailLog();
|
||||
$emailLog->setUser($user);
|
||||
$emailLog->setSentAt(new \DateTimeImmutable());
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tech\EventListener;
|
||||
|
||||
use App\Tech\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||
|
||||
#[AsEventListener(event: LoginSuccessEvent::class, method: 'onLoginSuccess')]
|
||||
class LastLoginListener
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onLoginSuccess(LoginSuccessEvent $event): void
|
||||
{
|
||||
$user = $event->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->setLastLoginAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tech\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Email;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
|
||||
class ProfileChangeEmailFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('email', EmailType::class, [
|
||||
'label' => 'New email address',
|
||||
'constraints' => [new NotBlank(), new Email()],
|
||||
])
|
||||
->add('currentPassword', PasswordType::class, [
|
||||
'label' => 'Current password',
|
||||
'attr' => ['autocomplete' => 'current-password'],
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter your current password'),
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tech\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
|
||||
use Symfony\Component\Validator\Constraints\PasswordStrength;
|
||||
|
||||
class ProfileChangePasswordFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('currentPassword', PasswordType::class, [
|
||||
'label' => 'Current password',
|
||||
'attr' => ['autocomplete' => 'current-password'],
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter your current password'),
|
||||
],
|
||||
])
|
||||
->add('plainPassword', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'options' => [
|
||||
'attr' => [
|
||||
'autocomplete' => 'new-password',
|
||||
],
|
||||
],
|
||||
'first_options' => [
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter a password'),
|
||||
new Length(
|
||||
min: 12,
|
||||
minMessage: 'Your password should be at least {{ limit }} characters',
|
||||
max: 4096,
|
||||
),
|
||||
new PasswordStrength(),
|
||||
new NotCompromisedPassword(),
|
||||
],
|
||||
'label' => 'New password',
|
||||
],
|
||||
'second_options' => [
|
||||
'label' => 'Repeat new password',
|
||||
],
|
||||
'invalid_message' => 'The password fields must match.',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -44,4 +44,20 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deleted users, deleted before the given date, who never played a game.
|
||||
*
|
||||
* @return User[]
|
||||
*/
|
||||
public function findPurgeableDeletedUsers(\DateTimeImmutable $deletedBefore): array
|
||||
{
|
||||
return $this->createQueryBuilder('u')
|
||||
->andWhere('u.deletedAt IS NOT NULL')
|
||||
->andWhere('u.deletedAt <= :deletedBefore')
|
||||
->andWhere('NOT EXISTS (SELECT 1 FROM App\Game\Entity\Player p WHERE p.user = u)')
|
||||
->setParameter('deletedBefore', $deletedBefore)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ class UserChecker implements UserCheckerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->isDeleted()) {
|
||||
throw new CustomUserMessageAuthenticationException('This account no longer exists.');
|
||||
}
|
||||
|
||||
if (!$user->isVerified()) {
|
||||
throw new CustomUserMessageAuthenticationException('Your email address is not verified.', ['%resend_link%' => '/verify/resend']);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,34 @@ final class HomeController extends AbstractController
|
||||
return $this->render(
|
||||
'website/home/index.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/briefing', name: 'website_intro')]
|
||||
public function intro(): Response
|
||||
{
|
||||
return $this->render(
|
||||
'website/home/intro.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/looking-for-help', name: 'website_help_wanted')]
|
||||
public function helpWanted(): Response
|
||||
{
|
||||
return $this->render(
|
||||
'website/home/help_wanted.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/donation/cancel', name: 'website_donation_cancel')]
|
||||
public function donationCancel(): Response
|
||||
{
|
||||
$this->addFlash('info', 'Donation failed, but thank you for trying anyway.');
|
||||
|
||||
return $this->redirectToRoute('game_dashboard');
|
||||
}
|
||||
|
||||
#[Route(path: '/donation/success', name: 'website_donation_success')]
|
||||
public function donationSuccess(): Response
|
||||
{
|
||||
$this->addFlash('success', 'Thank you for the donation!');
|
||||
|
||||
return $this->redirectToRoute('game_dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -18,6 +18,8 @@
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Roles</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Verified</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Marketing</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Last Login</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Status</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -57,6 +59,16 @@
|
||||
<span style="color: #dc2626;">✗ No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569;">
|
||||
{{ user.lastLoginAt ? user.lastLoginAt|date('Y-m-d H:i') : 'Never' }}
|
||||
</td>
|
||||
<td style="padding: 0.75rem 1rem;">
|
||||
{% if user.deleted %}
|
||||
<span style="color: #dc2626; font-weight: 500;">Deleted</span>
|
||||
{% else %}
|
||||
<span style="color: #16a34a; font-weight: 500;">Active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 0.75rem 1rem;">
|
||||
<a href="{{ path('game_admin_user_edit', {id: user.id}) }}" style="
|
||||
color: #3b82f6;
|
||||
@@ -65,7 +77,7 @@
|
||||
margin-right: 0.75rem;
|
||||
">Edit</a>
|
||||
|
||||
{% if user != app.user %}
|
||||
{% if user != app.user and not user.deleted %}
|
||||
<form method="post" action="{{ path('game_admin_user_delete', {id: user.id}) }}" style="display: inline;" onsubmit="return confirm('Delete user {{ user.username }}?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_user_' ~ user.id) }}">
|
||||
<button type="submit" style="
|
||||
@@ -82,7 +94,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No users found.</td>
|
||||
<td colspan="9" style="padding: 2rem; text-align: center; color: #94a3b8;">No users found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,13 +20,26 @@
|
||||
data-api-echo-url="{{ path('game_api_message')|e('html_attr') }}"
|
||||
data-api-check-finished-url="{{ path('game_api_check_finished', {session: session.id})|e('html_attr') }}"
|
||||
data-lost-url="{{ path('game_lost', {session: session.id})|e('html_attr') }}"
|
||||
data-won-url="{{ path('game_won', {session: session.id})|e('html_attr') }}"
|
||||
data-screen="{{ screen|e('html_attr') }}"
|
||||
{% if lock %}
|
||||
data-lock-locked-at="{{ lock.lockedAt }}"
|
||||
data-lock-reveal-at="{{ lock.revealAt }}"
|
||||
data-lock-unlock-at="{{ lock.unlockAt }}"
|
||||
{% endif %}
|
||||
{% if filesRemovalDeadline %}
|
||||
data-files-removal-deadline="{{ filesRemovalDeadline }}"
|
||||
{% endif %}
|
||||
style="display:none">
|
||||
</div>
|
||||
|
||||
<div id="game-timer" data-end-time="{{ session.timer }}">
|
||||
--:--:--
|
||||
</div>
|
||||
<div id="lock-banner" style="display:none">
|
||||
<div id="lock-banner-text">AI VIRUS: SYSTEM LOCKED</div>
|
||||
<div id="lock-countdown">--</div>
|
||||
</div>
|
||||
<div id="message-container">
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user