Chat im Match: Match-Chat und Team-Chat in Tabs, Zuschauer ausgeschlossen, nach Matchende nur lesbar

This commit is contained in:
mregosar committed 2026-10-29 22:44:17 +01:00
1 parent e26c736f2e
commit c820ba9f12
8 files changed
+267

No files matched your search

+9
View File
@@ -101,6 +101,15 @@ Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich au
- **Server-Zugangsdaten:** RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert. - **Server-Zugangsdaten:** RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert.
- **Befehls-Injection:** Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, `;` und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch. - **Befehls-Injection:** Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, `;` und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch.
### Chat im Match
Auf der Match-Seite gibt es einen Chat mit zwei Reitern über der Chatbox:
- **Match-Chat:** beide Teams und der Organisator.
- **Team-Chat:** nur die Spieler des eigenen Teams, z. B. für Absprachen im Veto. Der Organisator sieht ihn nur, wenn er selbst im Team mitspielt.
Zuschauer und Gäste sehen keinen Chat. Er gilt das ganze Match über, von der Lobby bis zum Ende. Danach ist er nur noch lesbar. Nachrichten haben höchstens 300 Zeichen, pro Person gehen höchstens 5 Nachrichten in 10 Sekunden. Ein roter Punkt am Reiter zeigt ungelesene Nachrichten.
### Wiederherstellung nach Neuladen oder Verbindungsabbruch ### Wiederherstellung nach Neuladen oder Verbindungsabbruch
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`). Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../lib/api.js';
const time = (ms) => new Date(ms).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const SEEN_KEY = (matchId) => `cs2mv:chat-seen:${matchId}`;
function readSeen(matchId) {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY(matchId))) ?? {};
} catch {
return {};
}
}
/** Chat im Match: Tabs für Match-Chat und Team-Chat über der Nachrichtenliste. */
export default function ChatBox({ chat, matchId, token, playerToken }) {
const [active, setActive] = useState(chat.channels[0].key);
const [text, setText] = useState('');
const [error, setError] = useState(null);
const [sending, setSending] = useState(false);
const [seen, setSeen] = useState(() => readSeen(matchId));
const listRef = useRef(null);
const channel = chat.channels.some((c) => c.key === active) ? active : chat.channels[0].key;
const messages = chat.messages[channel] ?? [];
const lastId = (key) => chat.messages[key]?.at(-1)?.id ?? 0;
// Aktiven Kanal als gelesen merken und ans Ende scrollen, wenn neue Nachrichten kommen
useEffect(() => {
const id = lastId(channel);
if (id && seen[channel] !== id) {
const next = { ...seen, [channel]: id };
setSeen(next);
try {
localStorage.setItem(SEEN_KEY(matchId), JSON.stringify(next));
} catch {
/* ignorieren */
}
}
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [channel, messages.length]); // eslint-disable-line react-hooks/exhaustive-deps
const send = async (e) => {
e.preventDefault();
if (!text.trim() || sending) return;
setSending(true);
setError(null);
try {
await api(`/matches/${matchId}/chat`, { method: 'POST', body: { channel, text }, token, playerToken });
setText('');
} catch (err) {
setError(err.message);
} finally {
setSending(false);
}
};
return (
<section className="panel chat-box" aria-label="Chat">
<div className="chat-tabs" role="tablist">
{chat.channels.map((c) => {
const unread = c.key !== channel && lastId(c.key) > (seen[c.key] ?? 0);
return (
<button key={c.key} role="tab" aria-selected={c.key === channel}
className={`chat-tab chat-tab-${c.key.toLowerCase()} ${c.key === channel ? 'is-active' : ''}`}
onClick={() => setActive(c.key)}>
{c.label}
{unread && <span className="chat-unread" aria-label="neue Nachrichten" />}
</button>
);
})}
</div>
<ol className="chat-list" ref={listRef} aria-live="polite">
{messages.length === 0 && (
<li className="chat-empty muted small">
{channel === 'match' ? 'Noch keine Nachricht. Hier schreiben beide Teams und der Organisator.' : 'Noch keine Nachricht. Diesen Chat sieht nur dein Team.'}
</li>
)}
{messages.map((m) => (
<li key={m.id} className={`chat-msg ${m.mine ? 'is-mine' : ''}`}>
<span className="chat-meta">
<strong className={m.team ? `team-${m.team.toLowerCase()}` : 'chat-org'}>{m.author}</strong>
{m.organizer && m.team && <span className="chat-role">Organisator</span>}
<time className="muted">{time(m.at)}</time>
</span>
<span className="chat-text">{m.text}</span>
</li>
))}
</ol>
{chat.canWrite ? (
<form className="chat-form" onSubmit={send}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={chat.maxLength}
placeholder={channel === 'match' ? 'Nachricht an alle im Match' : 'Nachricht an dein Team'}
aria-label="Nachricht"
/>
<button className="btn btn-primary" disabled={!text.trim() || sending}>Senden</button>
</form>
) : (
<p className="small muted chat-closed">Das Match ist beendet, der Chat ist nur noch lesbar.</p>
)}
{error && <p className="field-error">{error}</p>}
</section>
);
}
+2
View File
@@ -12,6 +12,7 @@ import SeriesList from '../components/SeriesList.jsx';
import EventLog from '../components/EventLog.jsx'; import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx'; import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.jsx'; import SelfJoin from '../components/SelfJoin.jsx';
import ChatBox from '../components/ChatBox.jsx';
export default function MatchPage() { export default function MatchPage() {
const { id } = useParams(); const { id } = useParams();
@@ -137,6 +138,7 @@ export default function MatchPage() {
{viewer.role !== 'public' && ( {viewer.role !== 'public' && (
<div className="match-lower"> <div className="match-lower">
{state.chat && <ChatBox chat={state.chat} matchId={id} token={token} playerToken={playerToken} />}
<ServerPanel {...ctx} /> <ServerPanel {...ctx} />
{isOrg && <OrganizerPanel {...ctx} token={token} />} {isOrg && <OrganizerPanel {...ctx} token={token} />}
<EventLog events={state.events} /> <EventLog events={state.events} />
+23
View File
@@ -729,3 +729,26 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.knife-decide .row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } .knife-decide .row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.knife-team-a { border-color: var(--team-a); color: var(--team-a); } .knife-team-a { border-color: var(--team-a); color: var(--team-a); }
.knife-team-b { border-color: var(--team-b); color: var(--team-b); } .knife-team-b { border-color: var(--team-b); color: var(--team-b); }
/* Chat im Match */
.chat-box { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
.chat-tabs { display: flex; border-bottom: 1px solid var(--line); }
.chat-tab { position: relative; flex: 1; font: 700 1rem var(--display); color: var(--muted); background: transparent; border: 0; border-bottom: 3px solid transparent; padding: 12px 14px 10px; cursor: pointer; }
.chat-tab:hover { color: var(--text); }
.chat-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
.chat-tab-a.is-active { border-bottom-color: var(--team-a); }
.chat-tab-b.is-active { border-bottom-color: var(--team-b); }
.chat-unread { display: inline-block; width: 8px; height: 8px; margin-left: 6px; border-radius: 50%; background: var(--brand-red); vertical-align: middle; }
.chat-list { list-style: none; margin: 0; padding: 10px 16px; height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
.chat-empty { margin: auto 0; text-align: center; }
.chat-msg { display: flex; flex-direction: column; gap: 1px; max-width: 92%; }
.chat-msg.is-mine { align-self: flex-end; text-align: right; }
.chat-meta { display: flex; gap: 6px; align-items: baseline; font-size: .8rem; }
.chat-msg.is-mine .chat-meta { justify-content: flex-end; }
.chat-org { color: var(--text); }
.chat-role { font-size: .7rem; padding: 0 6px; border: 1px solid var(--line-strong); border-radius: 999px; color: var(--muted); }
.chat-text { display: inline-block; padding: 6px 10px; border-radius: 10px; background: var(--surface-2); overflow-wrap: anywhere; }
.chat-msg.is-mine .chat-text { background: color-mix(in srgb, var(--primary) 30%, var(--surface-2)); }
.chat-form { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); }
.chat-form input { flex: 1; min-width: 0; }
.chat-closed { margin: 0; padding: 10px 16px; border-top: 1px solid var(--line); }
.chat-box .field-error { margin: 0 16px 10px; }
+14
View File
@@ -118,6 +118,20 @@ CREATE TABLE IF NOT EXISTS knife_servers (
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
-- Chat im Match: channel = 'match' (beide Teams + Organisator) oder 'A' / 'B' (nur das eigene Team)
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
channel TEXT NOT NULL,
player_id TEXT,
author TEXT NOT NULL,
team TEXT,
organizer INTEGER NOT NULL DEFAULT 0,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS chat_by_match ON chat_messages (match_id, channel, id);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
+1
View File
@@ -113,6 +113,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {}))); const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {})));
action('ready', (v, b) => service.setReady(v, Boolean(b.ready))); action('ready', (v, b) => service.setReady(v, Boolean(b.ready)));
action('chat', (v, b) => service.postChat(v, { channel: b.channel, text: b.text }));
action('leave', (v) => service.leave(v)); action('leave', (v) => service.leave(v));
action('kick', (v, b) => service.kick(v, b.playerId)); action('kick', (v, b) => service.kick(v, b.playerId));
action('captain', (v, b) => service.setCaptain(v, b.playerId)); action('captain', (v, b) => service.setCaptain(v, b.playerId));
+69
View File
@@ -41,9 +41,13 @@ function cleanSteamId(value) {
return raw; return raw;
} }
const CHAT_MAX_LENGTH = 300;
const CHAT_HISTORY = 200;
export class MatchService extends EventEmitter { export class MatchService extends EventEmitter {
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) { constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
super(); super();
this.chatRate = new Map();
this.db = db; this.db = db;
this.pools = pools; this.pools = pools;
this.config = config; this.config = config;
@@ -635,6 +639,70 @@ export class MatchService extends EventEmitter {
.run(matchId, match.current_map_number); .run(matchId, match.current_map_number);
} }
// ---------- Chat ----------
/**
* Chat-Kanäle, die dieser Zugang sieht: der Match-Chat für Spieler beider Teams und den Organisator,
* dazu der Team-Chat des eigenen Teams. Zuschauer und Gäste sehen keinen Chat.
*/
chatChannels(match, viewer) {
const channels = [];
if (viewer.isOrganizer || viewer.playerId) channels.push({ key: 'match', label: 'Match-Chat' });
if (viewer.playerId && (viewer.team === 'A' || viewer.team === 'B')) {
channels.push({ key: viewer.team, label: `Team ${this.teamName(match, viewer.team)}` });
}
return channels;
}
chatView(match, viewer) {
const channels = this.chatChannels(match, viewer);
if (!channels.length) return null;
const stmt = this.db.prepare(`
SELECT * FROM (SELECT * FROM chat_messages WHERE match_id = ? AND channel = ? ORDER BY id DESC LIMIT ${CHAT_HISTORY})
ORDER BY id
`);
const messages = {};
for (const c of channels) {
messages[c.key] = stmt.all(match.id, c.key).map((m) => ({
id: m.id,
author: m.author,
team: m.team,
organizer: !!m.organizer,
text: m.text,
at: m.created_at,
mine: viewer.playerId ? m.player_id === viewer.playerId : !!m.organizer && !m.player_id,
}));
}
return { channels, messages, canWrite: !CLOSED_STATUSES.includes(match.status), maxLength: CHAT_MAX_LENGTH };
}
/** Nachricht in einen Kanal schreiben. Nach Matchende ist der Chat nur noch lesbar. */
postChat(viewer, { channel, text } = {}) {
const match = this.getMatch(viewer.matchId);
if (!this.chatChannels(match, viewer).some((c) => c.key === channel)) {
throw new HttpError(403, 'In diesem Chat darfst du nicht schreiben', 'forbidden');
}
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist beendet, der Chat ist nur noch lesbar', 'match_closed');
const clean = String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
if (!clean) throw new HttpError(400, 'Die Nachricht ist leer', 'invalid_input');
if (clean.length > CHAT_MAX_LENGTH) throw new HttpError(400, `Höchstens ${CHAT_MAX_LENGTH} Zeichen`, 'invalid_input');
// Gegen Spam: höchstens 5 Nachrichten in 10 Sekunden pro Person
const who = `${match.id}:${viewer.playerId ?? 'org'}`;
const now = this.now();
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
this.chatRate.set(who, [...recent, now]);
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
this.db.prepare(`
INSERT INTO chat_messages (match_id, channel, player_id, author, team, organizer, text, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(match.id, channel, viewer.playerId ?? null, player?.nickname ?? 'Organisator', player?.team ?? null,
viewer.isOrganizer ? 1 : 0, clean, now);
this.changed(match.id);
}
/** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */ /** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */
setArchived(matchId, archived) { setArchived(matchId, archived) {
const match = this.getMatch(matchId); const match = this.getMatch(matchId);
@@ -957,6 +1025,7 @@ export class MatchService extends EventEmitter {
seriesScore: seriesScore(seriesRows), seriesScore: seriesScore(seriesRows),
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive, maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
server: this.serverView(match, viewer), server: this.serverView(match, viewer),
chat: this.chatView(match, viewer),
events: publicView events: publicView
? [] ? []
: this.db : this.db
+42
View File
@@ -648,3 +648,45 @@ test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => {
await close(); await close();
} }
}); });
test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eigene Team, Zuschauer sehen nichts', async () => {
const { call, close } = await setup();
try {
const { id, org, codes, tokens } = await readyWingmanMatch(call, 'bo1');
const spectator = codes.spectator;
const post = (token, channel, text) => call('POST', `/matches/${id}/chat`, { token, body: { channel, text } });
assert.equal((await post(tokens.a1, 'match', 'Hallo zusammen')).status, 200);
assert.equal((await post(tokens.a1, 'A', 'Wir bannen Nuke')).status, 200);
assert.equal((await post(org, 'match', 'Veto startet gleich')).status, 200);
// Fremder Team-Chat und Zuschauer: verboten
assert.equal((await post(tokens.b1, 'A', 'Spioniere')).status, 403);
assert.equal((await post(org, 'A', 'Organisator im Team-Chat')).status, 403);
assert.equal((await post(spectator, 'match', 'Zuschauer')).status, 403);
assert.equal((await post(tokens.a1, 'match', ' ')).status, 400);
assert.equal((await post(tokens.a1, 'match', 'x'.repeat(301))).status, 400);
const a = (await call('GET', `/matches/${id}`, { token: tokens.a2 })).body.chat;
assert.deepEqual(a.channels.map((c) => c.key), ['match', 'A']);
assert.deepEqual(a.messages.match.map((m) => m.text), ['Hallo zusammen', 'Veto startet gleich']);
assert.equal(a.messages.match[1].organizer, true);
assert.deepEqual(a.messages.A.map((m) => m.text), ['Wir bannen Nuke']);
const b = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.chat;
assert.deepEqual(b.channels.map((c) => c.key), ['match', 'B']);
assert.ok(!JSON.stringify(b).includes('Wir bannen Nuke'), 'Team B sieht den Team-Chat von A nicht');
assert.deepEqual((await call('GET', `/matches/${id}`, { token: org })).body.chat.channels.map((c) => c.key), ['match']);
assert.equal((await call('GET', `/matches/${id}`, { token: spectator })).body.chat, null);
assert.equal((await call('GET', `/matches/${id}`)).body.chat, null);
// Spam-Schutz und nach Matchende nur lesbar
for (let i = 0; i < 5; i++) await post(tokens.b1, 'match', `Nachricht ${i}`);
assert.equal((await post(tokens.b1, 'match', 'eine zu viel')).status, 429);
await call('POST', `/matches/${id}/finish`, { token: org });
assert.equal((await post(tokens.a1, 'match', 'Nach dem Ende')).status, 409);
const closed = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.chat;
assert.equal(closed.canWrite, false);
assert.equal(closed.messages.match.length, 7);
} finally {
await close();
}
});