diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 0a91bbe77..8b4ade900 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -769,7 +769,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetGamesOfUser(const Command_G while (roomIterator.hasNext()) { Server_Room *room = roomIterator.next().value(); room->gamesLock.lockForRead(); - room->getInfo(*re->add_room_list(), false, true); + room->getInfo(*re->add_room_list(), false, true, true, userInfo); QListIterator gameIterator(room->getGamesOfUser(nameFromStdString(cmd.user_name()))); while (gameIterator.hasNext()) { re->add_game_list()->CopyFrom(gameIterator.next()); @@ -827,7 +827,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdListRooms(const Command_ListRo Event_ListRooms event; QMapIterator roomIterator(server->getRooms()); while (roomIterator.hasNext()) { - roomIterator.next().value()->getInfo(*event.add_room_list(), false); + roomIterator.next().value()->getInfo(*event.add_room_list(), false, false, true, userInfo); } rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); @@ -871,9 +871,13 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory)); } - // Replay the history of every catalog channel. Privilege filtering for - // restricted channels is applied on top of this in the moderator stack. + // Replay the history of every catalog channel the joining user may see. + // Public channels are replayed to everyone; restricted channels only to + // members whose privilege level meets the channel's access level. for (const ServerInfo_RoomChannel &channel : room->getChannels()) { + if (!room->mayReadChannel(*userInfo, channel)) { + continue; + } const QList channelHistory = room->getChatHistory(QString::fromStdString(channel.id())); for (const ServerInfo_ChatMessage &chatMessage : channelHistory) { Event_RoomChannelSay channelChatHistory; @@ -891,7 +895,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(joinMessageEvent)); auto *re = new Response_JoinRoom; - room->getInfo(*re->mutable_room_info(), true); + room->getInfo(*re->mutable_room_info(), true, false, true, userInfo); rc.setResponseExtension(re); return Response::RespOk; @@ -986,6 +990,9 @@ Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_R // The client only ever offers catalog ids; a crafted or stale id is rejected. return Response::RespInvalidCommand; } + if (!room->mayWriteChannel(*userInfo, *channel)) { + return Response::RespFunctionNotAllowed; + } if (!addSaidMessageSize(static_cast(cmd.message().size()))) { return Response::RespChatFlood; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 7a8c59536..68b137cd4 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -107,8 +107,11 @@ Server *Server_Room::getServer() const return static_cast(parent()); } -const ServerInfo_Room & -Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, bool includeExternalData) const +const ServerInfo_Room &Server_Room::getInfo(ServerInfo_Room &result, + bool complete, + bool showGameTypes, + bool includeExternalData, + const ServerInfo_User *forUser) const { result.set_room_id(id); result.set_name(name.toStdString()); @@ -158,6 +161,9 @@ Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, } for (const ServerInfo_RoomChannel &channel : channels) { + if (forUser != nullptr && !mayReadChannel(*forUser, channel)) { + continue; + } result.add_channel_list()->CopyFrom(channel); } @@ -179,6 +185,43 @@ const ServerInfo_RoomChannel *Server_Room::findChannel(const QString &channelId) return nullptr; } +bool Server_Room::hasChannelPrivilege(const ServerInfo_User &userInfo, + ServerInfo_RoomChannel::AccessLevel requiredLevel) +{ + // User flags are independent bits, not a ladder: holding IsDeveloper does + // not imply IsModerator or IsAdmin. The database grants admins the admin + // and the moderator bit together, so admins still reach moderator (and + // judge) channels; moderators likewise reach judge channels. + const uint32_t userLevel = userInfo.user_level(); + switch (requiredLevel) { + case ServerInfo_RoomChannel::Public: + return true; + case ServerInfo_RoomChannel::Registered: + return userLevel & ServerInfo_User::IsRegistered; + case ServerInfo_RoomChannel::Judge: + return userLevel & (ServerInfo_User::IsJudge | ServerInfo_User::IsModerator); + case ServerInfo_RoomChannel::Moderator: + return userLevel & ServerInfo_User::IsModerator; + case ServerInfo_RoomChannel::Admin: + return userLevel & ServerInfo_User::IsAdmin; + case ServerInfo_RoomChannel::Developer: + return userLevel & (ServerInfo_User::IsDeveloper | ServerInfo_User::IsAdmin); + default: + return false; + } +} + +bool Server_Room::mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel) +{ + return hasChannelPrivilege(userInfo, channel.access_level()); +} + +bool Server_Room::mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel) +{ + const auto writeLevel = channel.has_write_access_level() ? channel.write_access_level() : channel.access_level(); + return mayReadChannel(userInfo, channel) && hasChannelPrivilege(userInfo, writeLevel); +} + QList Server_Room::getChatHistory(const QString &channelId) const { return channelChatHistory.value(isMainChannel(channelId) ? MAIN_CHANNEL_KEY : channelId); @@ -342,18 +385,31 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl) { const bool isMain = isMainChannel(channelId); + ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public; + if (!isMain) { + const ServerInfo_RoomChannel *channel = findChannel(channelId); + if (channel == nullptr) { + // The channel was not whitelisted against the catalog; drop the message. + return; + } + accessLevel = channel->access_level(); + // V1 does not relay restricted channels across ISL (plan N5). + if (accessLevel != ServerInfo_RoomChannel::Public) { + sendToIsl = false; + } + } if (isMain) { Event_RoomSay event; event.set_name(userName.toStdString()); event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel); } else { Event_RoomChannelSay event; event.set_channel_id(channelId.toStdString()); event.set_name(userName.toStdString()); event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel); } if (chatHistorySize != 0) { @@ -405,13 +461,17 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s } } -void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl) +void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl, ServerInfo_RoomChannel::AccessLevel accessLevel) { usersLock.lockForRead(); { QMapIterator userIterator(users); while (userIterator.hasNext()) { - userIterator.next().value()->sendProtocolItem(*event); + Server_ProtocolHandler *user = userIterator.next().value(); + if (!hasChannelPrivilege(*user->getUserInfo(), accessLevel)) { + continue; + } + user->sendProtocolItem(*event); } } usersLock.unlock(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h index e9227862f..b6e6f269a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -119,6 +119,24 @@ public: const ServerInfo_RoomChannel *findChannel(const QString &channelId) const; /** @brief True when @p channelId refers to the implicit Main channel. */ static bool isMainChannel(const QString &channelId); + /** + * @brief True when the flags in @p userInfo satisfy @p requiredLevel. + * + * User flags are independent bits, not a ladder: IsDeveloper does not + * imply IsModerator or IsAdmin, so developer-only users cannot read or + * post in moderator/admin channels. Admins hold the moderator bit + * alongside the admin bit and moderators are admitted to judge channels. + */ + static bool hasChannelPrivilege(const ServerInfo_User &userInfo, ServerInfo_RoomChannel::AccessLevel requiredLevel); + /** @brief True when @p userInfo may read @p channel. */ + static bool mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel); + /** + * @brief True when @p userInfo may write to @p channel. + * + * Writing requires the channel's write level, or — when no explicit write + * level is configured — the same level as reading. + */ + static bool mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel); const QMap &getGames() const { return games; @@ -128,8 +146,11 @@ public: return externalGames; } Server *getServer() const; - const ServerInfo_Room & - getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const; + const ServerInfo_Room &getInfo(ServerInfo_Room &result, + bool complete, + bool showGameTypes = false, + bool includeExternalData = true, + const ServerInfo_User *forUser = nullptr) const; int getGamesCreatedByUser(const QString &name) const; QList getGamesOfUser(const QString &name) const; QList getChatHistory(const QString &channelId) const; @@ -155,7 +176,9 @@ public: void addGame(Server_Game *game); void removeGame(Server_Game *game); - void sendRoomEvent(RoomEvent *event, bool sendToIsl = true); + void sendRoomEvent(RoomEvent *event, + bool sendToIsl = true, + ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public); RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent); }; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto index d38b1f651..0f3a9ccad 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto @@ -2,8 +2,10 @@ syntax = "proto2"; message ServerInfo_RoomChannel { enum AccessLevel { - // Minimum privilege level, evaluated against the privilege ladder - // Public < Registered < Judge < Moderator < Admin < Developer. + // Required user flag set, evaluated per level (not as a ladder): + // Registered needs IsRegistered; Judge needs IsJudge or IsModerator; + // Moderator needs IsModerator (admins hold it alongside IsAdmin); + // Admin needs IsAdmin; Developer needs IsDeveloper or IsAdmin. Public = 0; Registered = 1; Judge = 2; diff --git a/servatrice/migrations/servatrice_0038_to_0039.sql b/servatrice/migrations/servatrice_0038_to_0039.sql new file mode 100644 index 000000000..0cad5f494 --- /dev/null +++ b/servatrice/migrations/servatrice_0038_to_0039.sql @@ -0,0 +1,19 @@ +-- Servatrice db migration from version 38 to version 39 + +-- Per-channel read/write split and banner messages. +-- +-- write_access_level gates sending: NULL (the default) means writing requires +-- the same level as reading (access_level), preserving the previous +-- single-level behavior. Set it higher than access_level for read-mostly +-- channels (e.g. a developer news feed readable by everyone but writable only +-- by developers). +-- +-- banner_message is a static notice shown at the top of the channel, +-- analogous to the room join message on Main. Empty = none. + +ALTER TABLE `cockatrice_rooms_channels` + MODIFY `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + ADD COLUMN `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL, + ADD COLUMN `banner_message` varchar(255) NOT NULL DEFAULT ''; + +UPDATE cockatrice_schema_version SET version=39 WHERE version=38; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index e45b38804..90207aa70 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -336,17 +336,35 @@ roomlist\1\game_types\3\name="GameType3" ; channel every room has. Each channel needs a stable ASCII id, a display name ; (server data, like the room name) and an access level. Supported levels, from ; least to most restrictive, are: public, registered, judge, moderator, admin -; and developer. A channel is readable and writable by every member whose own -; privilege level is at least the channel's level -- so a moderator channel is -; also reachable by admins and developers, and an admin channel by developers. +; and developer. Access is evaluated against the member's user flags, not as a +; ladder: a moderator channel admits moderators (admins hold the moderator +; flag alongside the admin flag, so they are admitted too), an admin channel +; admits only admins, and a developer channel admits only developers and +; admins. In particular, developers cannot read moderator or admin channels. +; A judge channel additionally admits moderators. ; Public channels are visible to every room member. -roomlist\1\channels\size=2 +; +; Write access defaults to the read level, so by default a channel is readable +; and writable by the same members. Set writeaccess higher for read-mostly +; channels (e.g. a developer news feed readable by everyone but writable only +; by developers). Members who can read but not write see the channel with a +; disabled message input. +; +; banner is an optional static notice shown at the top of the channel, similar +; to the room joinmessage on Main. Empty or unset = none. +roomlist\1\channels\size=3 roomlist\1\channels\1\id=looking-for-players roomlist\1\channels\1\displayname="Looking for players" roomlist\1\channels\1\access=public +roomlist\1\channels\1\banner="Find opponents here. Be friendly!" roomlist\1\channels\2\id=moderators roomlist\1\channels\2\displayname="Moderators" roomlist\1\channels\2\access=moderator +roomlist\1\channels\3\id=developer-news +roomlist\1\channels\3\displayname="Developer news" +roomlist\1\channels\3\access=public +roomlist\1\channels\3\writeaccess=developer +roomlist\1\channels\3\banner="Official announcements from the developers." [game] diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index c83f5bb06..839dbf572 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(38); +INSERT INTO cockatrice_schema_version VALUES(39); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -164,6 +164,8 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( `channel_id` varchar(50) NOT NULL, `display_name` varchar(50) NOT NULL, `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL, + `banner_message` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id_room`, `id_server`, `channel_id`), FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 9dd5a987c..040030320 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -68,6 +68,7 @@ #include #include #include +#include #include #include #include @@ -255,10 +256,10 @@ Servatrice::~Servatrice() * @brief Maps a configured channel access level (database column or ini value) * onto the ServerInfo_RoomChannel privilege ladder. * - * Unknown keywords fall back to Public so a typo never hides a channel that was - * meant to be broadly readable. + * Returns nullopt for unknown keywords so callers can fail closed instead of + * silently downgrading a restricted channel to Public. */ -static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QString &key) +static std::optional roomChannelAccessFromKey(const QString &key) { const QString level = key.trimmed().toLower(); if (level == "developer") { @@ -276,7 +277,10 @@ static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QStrin if (level == "registered") { return ServerInfo_RoomChannel::Registered; } - return ServerInfo_RoomChannel::Public; + if (level == "public") { + return ServerInfo_RoomChannel::Public; + } + return std::nullopt; } bool Servatrice::initServer() @@ -392,7 +396,8 @@ bool Servatrice::initServer() } QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery( - "select channel_id, display_name, access_level from {prefix}_rooms_channels where id_room = :id_room " + "select channel_id, display_name, access_level, write_access_level, banner_message from " + "{prefix}_rooms_channels where id_room = :id_room " "AND id_server = :id_server order by channel_id asc"); query3->bindValue(":id_server", serverId); query3->bindValue(":id_room", query->value(0).toInt()); @@ -414,10 +419,33 @@ bool Servatrice::initServer() continue; } seenChannelIds.insert(channelId); + const QString accessKey = query3->value(2).toString(); + auto accessLevel = + accessKey.trimmed().isEmpty() + ? std::optional(ServerInfo_RoomChannel::Public) + : roomChannelAccessFromKey(accessKey); + if (!accessLevel.has_value()) { + qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id" + << channelId; + continue; + } ServerInfo_RoomChannel channel; channel.set_id(channelId.toStdString()); channel.set_display_name(query3->value(1).toString().toStdString()); - channel.set_access_level(roomChannelAccessFromKey(query3->value(2).toString())); + channel.set_access_level(accessLevel.value()); + if (!query3->isNull(3)) { + const QString writeKey = query3->value(3).toString(); + if (!writeKey.trimmed().isEmpty()) { + auto writeLevel = roomChannelAccessFromKey(writeKey); + if (!writeLevel.has_value()) { + qWarning() << "Skipping room channel with unknown write access level" << writeKey + << "for id" << channelId; + continue; + } + channel.set_write_access_level(writeLevel.value()); + } + } + channel.set_banner_message(query3->value(4).toString().toStdString()); channels.append(channel); } @@ -456,10 +484,33 @@ bool Servatrice::initServer() continue; } seenChannelIds.insert(channelId); + const QString accessKey = settingsCache->value("access").toString(); + auto accessLevel = + accessKey.trimmed().isEmpty() + ? std::optional(ServerInfo_RoomChannel::Public) + : roomChannelAccessFromKey(accessKey); + if (!accessLevel.has_value()) { + qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id" + << channelId; + continue; + } ServerInfo_RoomChannel channel; channel.set_id(channelId.toStdString()); channel.set_display_name(settingsCache->value("displayname").toString().toStdString()); - channel.set_access_level(roomChannelAccessFromKey(settingsCache->value("access").toString())); + channel.set_access_level(accessLevel.value()); + if (settingsCache->contains("writeaccess")) { + const QString writeKey = settingsCache->value("writeaccess").toString(); + if (!writeKey.trimmed().isEmpty()) { + auto writeLevel = roomChannelAccessFromKey(writeKey); + if (!writeLevel.has_value()) { + qWarning() << "Skipping room channel with unknown write access level" << writeKey + << "for id" << channelId; + continue; + } + channel.set_write_access_level(writeLevel.value()); + } + } + channel.set_banner_message(settingsCache->value("banner").toString().toStdString()); channels.append(channel); } settingsCache->endArray(); diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0bae67e6c..2e0d69113 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -21,7 +21,7 @@ class QSqlQuery; -#define DATABASE_SCHEMA_VERSION 38 +#define DATABASE_SCHEMA_VERSION 39 class Servatrice;