Клиент: ники других игроков рендерились через innerHTML без экранирования — XSS. Добавлен escapeHtml() для nickname и в списке игроков комнаты. Сервер: - Санитайзинг ника и на бэкенде (обрезка длины, вырезание control/HTML символов) — защита не только для этого клиента, но и для любых будущих потребителей (логи, другие рендеры). - onMessage обёрнут в try/catch: payload.value<T>() кидает json::type_error на несовпадении типа поля — одно кривое сообщение могло уронить процесс со всеми активными играми. - Лимит на общее число комнат (200) — иначе клиент, спамящий lobby.create_room, разгонял rooms_ до исчерпания памяти. - Fix: одна из раскладок coop-карты клала укрытие поверх стартовой позиции игрока — танк рождался внутри кирпича и не мог выехать. Тайл под каждым стартовым танком теперь расчищается после построения карты. Docker: gameserver теперь работает от непривилегированного пользователя, а не от root. nginx: rate-limiting на /ws (5 подключений/с на IP, до 20 одновременных) — защита от флуда соединениями/комнатами; CSP и security-заголовки (X-Content-Type-Options, X-Frame-Options, Referrer-Policy).
366 lines
11 KiB
C++
366 lines
11 KiB
C++
#include "Lobby.hpp"
|
|
#include "Game.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <iomanip>
|
|
#include <sstream>
|
|
|
|
Lobby::Lobby() = default;
|
|
Lobby::~Lobby() = default;
|
|
|
|
void Lobby::send(WS *ws, const std::string &type, json payload) {
|
|
json msg = {{"type", type}, {"payload", std::move(payload)}};
|
|
ws->send(msg.dump(), uWS::OpCode::TEXT);
|
|
}
|
|
|
|
void Lobby::sendError(WS *ws, const std::string &code, const std::string &message) {
|
|
send(ws, "error", {{"code", code}, {"message", message}});
|
|
}
|
|
|
|
std::string Lobby::generateRoomId() {
|
|
std::ostringstream oss;
|
|
oss << "cactus-room-" << std::setw(2) << std::setfill('0') << next_room_id_++;
|
|
return oss.str();
|
|
}
|
|
|
|
json Lobby::roomsSummary() const {
|
|
json rooms = json::array();
|
|
for (const auto &[id, room] : rooms_) {
|
|
rooms.push_back({
|
|
{"id", room.id},
|
|
{"name", room.id},
|
|
{"mode", room.mode},
|
|
{"players", room.players.size()},
|
|
{"max_players", kMaxPlayersPerRoom},
|
|
{"state", room.state},
|
|
});
|
|
}
|
|
return rooms;
|
|
}
|
|
|
|
json Lobby::roomPlayers(const Room &room) const {
|
|
json players = json::array();
|
|
for (size_t i = 0; i < room.players.size(); i++) {
|
|
players.push_back({
|
|
{"id", room.players[i].player_id},
|
|
{"nickname", room.players[i].nickname},
|
|
{"slot", i},
|
|
{"ready", room.players[i].ready},
|
|
});
|
|
}
|
|
return players;
|
|
}
|
|
|
|
void Lobby::broadcastRoomsToLobby() {
|
|
json rooms = roomsSummary();
|
|
for (WS *client : sockets_) {
|
|
if (client->getUserData()->room_id.empty()) {
|
|
send(client, "lobby.rooms", {{"rooms", rooms}});
|
|
}
|
|
}
|
|
}
|
|
|
|
void Lobby::broadcastRoomUpdated(const Room &room, WS *exclude) {
|
|
json payload = {{"players", roomPlayers(room)}};
|
|
for (const auto &p : room.players) {
|
|
if (p.ws != exclude) {
|
|
send(p.ws, "lobby.room_updated", payload);
|
|
}
|
|
}
|
|
}
|
|
|
|
void Lobby::onOpen(WS *ws) {
|
|
sockets_.insert(ws);
|
|
}
|
|
|
|
void Lobby::onClose(WS *ws) {
|
|
endActiveGameIfAny(ws->getUserData()->room_id, ws);
|
|
removePlayerFromRoom(ws);
|
|
sockets_.erase(ws);
|
|
}
|
|
|
|
void Lobby::onMessage(WS *ws, std::string_view message) {
|
|
json request;
|
|
std::string type;
|
|
try {
|
|
request = json::parse(message);
|
|
type = request.value("type", "");
|
|
} catch (const std::exception &e) {
|
|
sendError(ws, "bad_json", e.what());
|
|
return;
|
|
}
|
|
|
|
json payload = request.value("payload", json::object());
|
|
|
|
try {
|
|
if (type == "hello") {
|
|
handleHello(ws, payload);
|
|
} else if (type == "lobby.list_rooms") {
|
|
handleListRooms(ws);
|
|
} else if (type == "lobby.create_room") {
|
|
handleCreateRoom(ws, payload);
|
|
} else if (type == "lobby.join_room") {
|
|
handleJoinRoom(ws, payload);
|
|
} else if (type == "lobby.leave_room") {
|
|
handleLeaveRoom(ws);
|
|
} else if (type == "game.ready") {
|
|
handleGameReady(ws);
|
|
} else if (type == "game.input") {
|
|
handleGameInput(ws, payload);
|
|
} else {
|
|
sendError(ws, "unknown_type", "unknown message type: " + type);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
// payload может быть не тем типом, что ожидает хендлер (например,
|
|
// payload.value<T>() на не-объекте/не том типе поля) — это кидает
|
|
// json::type_error. Не даём одному кривому сообщению уронить процесс
|
|
// со всеми активными играми.
|
|
sendError(ws, "bad_payload", e.what());
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
// Ник рассылается другим игрокам и рендерится на клиенте как текст — режем
|
|
// длину и выкидываем управляющие/разметочные символы, чтобы кривой или
|
|
// специально вредоносный ник от одного игрока не ломал UI остальных.
|
|
std::string sanitizeNickname(std::string nickname) {
|
|
constexpr size_t kMaxLen = 20;
|
|
if (nickname.empty()) {
|
|
return "anon";
|
|
}
|
|
std::string out;
|
|
out.reserve(std::min(nickname.size(), kMaxLen));
|
|
for (unsigned char c : nickname) {
|
|
if (out.size() >= kMaxLen) {
|
|
break;
|
|
}
|
|
if (c == '<' || c == '>' || c == '&' || c == '"' || c == '\'' || c < 0x20) {
|
|
continue;
|
|
}
|
|
out.push_back(static_cast<char>(c));
|
|
}
|
|
return out.empty() ? "anon" : out;
|
|
}
|
|
} // namespace
|
|
|
|
void Lobby::handleHello(WS *ws, const json &payload) {
|
|
auto *data = ws->getUserData();
|
|
if (data->player_id.empty()) {
|
|
data->player_id = "player-" + std::to_string(next_player_id_++);
|
|
}
|
|
data->nickname = sanitizeNickname(payload.value("nickname", "anon"));
|
|
send(ws, "hello.ack", {{"player_id", data->player_id}});
|
|
}
|
|
|
|
void Lobby::handleListRooms(WS *ws) {
|
|
send(ws, "lobby.rooms", {{"rooms", roomsSummary()}});
|
|
}
|
|
|
|
void Lobby::handleCreateRoom(WS *ws, const json &payload) {
|
|
auto *data = ws->getUserData();
|
|
if (data->player_id.empty()) {
|
|
sendError(ws, "not_authenticated", "send hello first");
|
|
return;
|
|
}
|
|
if (!data->room_id.empty()) {
|
|
sendError(ws, "already_in_room", "leave the current room first");
|
|
return;
|
|
}
|
|
|
|
std::string mode = payload.value("mode", "");
|
|
if (mode != "coop" && mode != "pvp") {
|
|
sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\"");
|
|
return;
|
|
}
|
|
if (rooms_.size() >= kMaxRooms) {
|
|
sendError(ws, "too_many_rooms", "server is at capacity, try again later");
|
|
return;
|
|
}
|
|
|
|
Room room;
|
|
room.id = generateRoomId();
|
|
room.mode = mode;
|
|
room.players.push_back({data->player_id, data->nickname, ws});
|
|
|
|
data->room_id = room.id;
|
|
std::string room_id = room.id;
|
|
rooms_[room_id] = std::move(room);
|
|
|
|
send(ws, "lobby.room_joined", {
|
|
{"room_id", room_id},
|
|
{"name", room_id},
|
|
{"mode", mode},
|
|
{"players", roomPlayers(rooms_[room_id])},
|
|
});
|
|
|
|
broadcastRoomsToLobby();
|
|
}
|
|
|
|
void Lobby::handleJoinRoom(WS *ws, const json &payload) {
|
|
auto *data = ws->getUserData();
|
|
if (data->player_id.empty()) {
|
|
sendError(ws, "not_authenticated", "send hello first");
|
|
return;
|
|
}
|
|
if (!data->room_id.empty()) {
|
|
sendError(ws, "already_in_room", "leave the current room first");
|
|
return;
|
|
}
|
|
|
|
std::string room_id = payload.value("room_id", "");
|
|
auto it = rooms_.find(room_id);
|
|
if (it == rooms_.end()) {
|
|
sendError(ws, "room_not_found", "no such room: " + room_id);
|
|
return;
|
|
}
|
|
|
|
Room &room = it->second;
|
|
if (room.state != "waiting") {
|
|
sendError(ws, "room_in_progress", "room is not accepting new players");
|
|
return;
|
|
}
|
|
if (room.players.size() >= kMaxPlayersPerRoom) {
|
|
sendError(ws, "room_full", "room is full");
|
|
return;
|
|
}
|
|
|
|
room.players.push_back({data->player_id, data->nickname, ws});
|
|
data->room_id = room_id;
|
|
|
|
send(ws, "lobby.room_joined", {
|
|
{"room_id", room_id},
|
|
{"name", room_id},
|
|
{"mode", room.mode},
|
|
{"players", roomPlayers(room)},
|
|
});
|
|
|
|
broadcastRoomUpdated(room, ws);
|
|
broadcastRoomsToLobby();
|
|
}
|
|
|
|
void Lobby::handleLeaveRoom(WS *ws) {
|
|
if (ws->getUserData()->room_id.empty()) {
|
|
sendError(ws, "not_in_room", "you are not in a room");
|
|
return;
|
|
}
|
|
endActiveGameIfAny(ws->getUserData()->room_id, ws);
|
|
removePlayerFromRoom(ws);
|
|
broadcastRoomsToLobby();
|
|
}
|
|
|
|
void Lobby::handleGameReady(WS *ws) {
|
|
auto *data = ws->getUserData();
|
|
if (data->room_id.empty()) {
|
|
sendError(ws, "not_in_room", "you are not in a room");
|
|
return;
|
|
}
|
|
|
|
auto it = rooms_.find(data->room_id);
|
|
if (it == rooms_.end()) {
|
|
return;
|
|
}
|
|
for (auto &p : it->second.players) {
|
|
if (p.ws == ws) {
|
|
p.ready = true;
|
|
}
|
|
}
|
|
broadcastRoomUpdated(it->second);
|
|
maybeStartGame(data->room_id);
|
|
}
|
|
|
|
void Lobby::handleGameInput(WS *ws, const json &payload) {
|
|
auto *data = ws->getUserData();
|
|
if (data->room_id.empty()) {
|
|
return;
|
|
}
|
|
auto it = games_.find(data->room_id);
|
|
if (it == games_.end()) {
|
|
return;
|
|
}
|
|
it->second->handleInput(data->player_id, payload);
|
|
}
|
|
|
|
void Lobby::maybeStartGame(const std::string &room_id) {
|
|
auto it = rooms_.find(room_id);
|
|
if (it == rooms_.end()) {
|
|
return;
|
|
}
|
|
Room &room = it->second;
|
|
if (room.state != "waiting" || room.players.empty()) {
|
|
return;
|
|
}
|
|
// pvp — строго вдвоём; coop — можно соло, второй подключается позже.
|
|
if (room.mode == "pvp" && room.players.size() != kMaxPlayersPerRoom) {
|
|
return;
|
|
}
|
|
bool allReady = std::all_of(room.players.begin(), room.players.end(),
|
|
[](const RoomPlayer &p) { return p.ready; });
|
|
if (!allReady) {
|
|
return;
|
|
}
|
|
|
|
room.state = "playing";
|
|
|
|
auto game = std::make_unique<Game>(room, [this, room_id]() {
|
|
auto rit = rooms_.find(room_id);
|
|
if (rit != rooms_.end()) {
|
|
rit->second.state = "waiting";
|
|
for (auto &p : rit->second.players) {
|
|
p.ready = false;
|
|
}
|
|
broadcastRoomUpdated(rit->second);
|
|
}
|
|
broadcastRoomsToLobby();
|
|
// Мы всё ещё внутри вызова метода Game (tick/handleDisconnect), который
|
|
// и вызвал этот колбэк — удалять объект прямо сейчас было бы use-after-free
|
|
// на возврате из этого вызова. Откладываем erase на следующий тик луп'а.
|
|
uWS::Loop::get()->defer([this, room_id]() { games_.erase(room_id); });
|
|
});
|
|
|
|
json startPayload = game->buildStartPayload(room.mode);
|
|
for (const auto &p : room.players) {
|
|
send(p.ws, "game.start", startPayload);
|
|
}
|
|
game->start();
|
|
games_[room_id] = std::move(game);
|
|
|
|
broadcastRoomsToLobby();
|
|
}
|
|
|
|
void Lobby::endActiveGameIfAny(const std::string &room_id, WS *leavingWs) {
|
|
if (room_id.empty()) {
|
|
return;
|
|
}
|
|
auto it = games_.find(room_id);
|
|
if (it == games_.end()) {
|
|
return;
|
|
}
|
|
it->second->handleDisconnect(leavingWs);
|
|
games_.erase(it);
|
|
}
|
|
|
|
void Lobby::removePlayerFromRoom(WS *ws) {
|
|
auto *data = ws->getUserData();
|
|
if (data->room_id.empty()) {
|
|
return;
|
|
}
|
|
|
|
auto it = rooms_.find(data->room_id);
|
|
data->room_id.clear();
|
|
if (it == rooms_.end()) {
|
|
return;
|
|
}
|
|
|
|
Room &room = it->second;
|
|
room.players.erase(
|
|
std::remove_if(room.players.begin(), room.players.end(),
|
|
[ws](const RoomPlayer &p) { return p.ws == ws; }),
|
|
room.players.end());
|
|
|
|
if (room.players.empty()) {
|
|
rooms_.erase(it);
|
|
} else {
|
|
broadcastRoomUpdated(room);
|
|
}
|
|
}
|