From 05dcde69dbd9d868abaa9196512da1c1528c43b5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:38 +0200 Subject: [PATCH] [Server] Gate room channels on privilege levels (#7477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Gate room channels on privilege levels * [Server] Filter channel catalog per user, split read/write gating, add banners - Server_Room::getInfo() now takes the requesting user and only lists channels that user may read. Previously every room member received the full catalog (e.g. the moderators channel) while message delivery was already gated, so restricted channels were visible but silent. - Split channel access into mayReadChannel/mayWriteChannel: history replay, catalog listing, and event fan-out use the read level (access_level); Command_RoomChannelSay uses the write level (write_access_level, falling back to the read level when unset). - Plumb write_access_level and banner_message through both room sources (SQL rooms_channels table and config-file channels via writeaccess and banner keys). - Schema 38 -> 39: add write_access_level (NULL means inherit the read level) and banner_message columns; also widen access_level to the full six-value privilege enum to match the 37 -> 38 migration. * Fixup: evaluate channel access per user flag instead of ladder User flags are independent bits: IsDeveloper does not imply IsModerator/IsAdmin, so developer-only users must not reach moderator/admin channels. Replace the userPrivilegeLevel ladder with hasChannelPrivilege: moderator channels need IsModerator (admins hold it alongside IsAdmin), admin channels need IsAdmin, developer channels need IsDeveloper or IsAdmin, judge channels admit IsJudge/IsModerator. Covers catalog filtering, history replay, write gating and fan-out, and corrects the ini example and proto docs. * [Server] Make channel privilege helpers static per review * [Server] Fail closed on unknown channel access levels per review - roomChannelAccessFromKey returns nullopt for unknown keywords instead of silently falling back to Public - skip channels with unknown read/write levels with qWarning, empty read defaults to Public, empty write means no override - mayWriteChannel now also requires read access so write-below-read configs cannot post into invisible channels * [Server] Remove stray conflict marker in channel replay comment * [Server] Fix servatrice.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../server/remote/server_protocolhandler.cpp | 17 +++-- .../network/server/remote/server_room.cpp | 72 +++++++++++++++++-- .../network/server/remote/server_room.h | 29 +++++++- .../protocol/pb/serverinfo_room_channel.proto | 6 +- .../migrations/servatrice_0038_to_0039.sql | 19 +++++ servatrice/servatrice.ini.example | 26 +++++-- servatrice/servatrice.sql | 4 +- servatrice/src/servatrice.cpp | 65 +++++++++++++++-- .../src/servatrice_database_interface.h | 2 +- 9 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 servatrice/migrations/servatrice_0038_to_0039.sql 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;