[Server] Add deck share links and public deck visibility

This commit is contained in:
Lukas Brübach 2026-09-04 22:41:44 +02:00 committed by GitHub
parent b3c426cd43
commit 5d0a23dffb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 899 additions and 19 deletions

View file

@ -15,9 +15,15 @@ set(PROTO_FILES
command_deck_del.proto
command_deck_del_dir.proto
command_deck_download.proto
command_deck_download_public.proto
command_deck_list.proto
command_deck_list_other_user.proto
command_deck_new_dir.proto
command_deck_select.proto
command_deck_set_visibility.proto
command_deck_share_create.proto
command_deck_share_download.proto
command_deck_share_list.proto
command_deck_upload.proto
command_del_counter.proto
command_delete_arrow.proto
@ -137,6 +143,9 @@ set(PROTO_FILES
response_card_art_rule_entry.proto
response_deck_download.proto
response_deck_list.proto
response_deck_share_create.proto
response_deck_share_download.proto
response_deck_share_list.proto
response_deck_upload.proto
response_dump_zone.proto
response_forgotpasswordrequest.proto
@ -175,6 +184,7 @@ set(PROTO_FILES
serverinfo_cardcounter.proto
serverinfo_chat_message.proto
serverinfo_counter.proto
serverinfo_deck_share_item.proto
serverinfo_deckstorage.proto
serverinfo_game.proto
serverinfo_gametype.proto

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckDownloadPublic {
extend SessionCommand {
optional Command_DeckDownloadPublic ext = 1031;
}
optional uint32 deck_id = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckListOtherUser {
extend SessionCommand {
optional Command_DeckListOtherUser ext = 1029;
}
optional string user_name = 1;
}

View file

@ -0,0 +1,13 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckSetVisibility {
extend SessionCommand {
optional Command_DeckSetVisibility ext = 1030;
}
// Set the public visibility of a single deck (mutually exclusive with folder_path).
optional uint32 deck_id = 1;
// Set the public visibility of a folder (all decks under it inherit).
optional string folder_path = 2;
optional bool is_public = 3;
}

View file

@ -0,0 +1,24 @@
syntax = "proto2";
import "session_commands.proto";
message DeckShareItem {
// Reference an existing deck in the sharer's personal deck storage.
// Mutually exclusive with deck_list.
optional uint32 deck_id = 1;
// Inline deck content in the native format.
// Mutually exclusive with deck_id.
optional string deck_list = 2;
// Color identity of the deck (e.g. "WUBRG"), computed by the sharing client.
optional string color_identity = 3;
}
message Command_DeckShareCreate {
extend SessionCommand {
optional Command_DeckShareCreate ext = 1026;
}
optional string name = 1;
repeated DeckShareItem items = 2;
// Path of a folder in the sharer's personal deck storage. When set, all
// decks in that folder are shared (resolved by the server).
optional string folder_path = 3;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckShareDownload {
extend SessionCommand {
optional Command_DeckShareDownload ext = 1028;
}
optional string token = 1;
optional uint32 item_id = 2;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckShareList {
extend SessionCommand {
optional Command_DeckShareList ext = 1027;
}
optional string token = 1;
}

View file

@ -8,4 +8,12 @@ message Command_DeckUpload {
optional string path = 1; // to upload a new deck
optional uint32 deck_id = 2; // to replace an existing deck
optional string deck_list = 3;
optional bool is_public = 4; // mark the deck public on upload (publish)
// Preview metadata computed by the uploading client (see ServerInfo_DeckStorage_File).
optional string banner_card_name = 5;
optional string banner_card_provider = 6;
optional string color_identity = 7;
// Comma-separated list of tag names associated with the deck, used to render
// and filter another user's public decks on the client.
optional string tags = 8;
}

View file

@ -81,6 +81,9 @@ message Response {
REPLAY_LIST = 1100; // Response listing replays
REPLAY_DOWNLOAD = 1101; // Response for replay download
REPLAY_GET_CODE = 1102; // Response containing replay code
DECK_SHARE_CREATE = 1103; // Response to deck share creation
DECK_SHARE_LIST = 1104; // Response listing shared decks
DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download
CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "response.proto";
message Response_DeckShareCreate {
extend Response {
optional Response_DeckShareCreate ext = 1103;
}
optional string token = 1;
optional uint64 expires_at = 2;
optional uint32 item_count = 3;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "response.proto";
message Response_DeckShareDownload {
extend Response {
optional Response_DeckShareDownload ext = 1105;
}
optional string deck = 1;
}

View file

@ -0,0 +1,12 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_deck_share_item.proto";
message Response_DeckShareList {
extend Response {
optional Response_DeckShareList ext = 1104;
}
optional string name = 1;
optional uint64 expires_at = 2;
repeated ServerInfo_DeckShareItem items = 3;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
message ServerInfo_DeckShareItem {
optional uint32 id = 1;
optional string name = 2;
repeated string tags = 3;
optional string banner_card = 4;
optional string game_format = 5;
optional string color_identity = 6;
}

View file

@ -1,10 +1,22 @@
syntax = "proto2";
message ServerInfo_DeckStorage_File {
optional uint32 creation_time = 1;
optional bool is_public = 2;
// Preview metadata computed by the uploading client, so other clients can
// render this deck (e.g. in a visual storage grid) without downloading the
// full deck list. Empty for decks uploaded before the metadata columns.
optional string banner_card_name = 3;
optional string banner_card_provider = 4;
optional string color_identity = 5;
// Comma-separated list of tag names, matching the corresponding
// ServerInfo_DeckStorage_File upload metadata. Empty for decks uploaded
// before the tags column existed.
optional string tags = 6;
}
message ServerInfo_DeckStorage_Folder {
repeated ServerInfo_DeckStorage_TreeItem items = 1;
optional bool is_public = 2;
}
message ServerInfo_DeckStorage_TreeItem {

View file

@ -28,6 +28,12 @@ message SessionCommand {
FORGOT_PASSWORD_CHALLENGE = 1023;
REQUEST_PASSWORD_SALT = 1024;
SET_CARD_ART_PARAMS = 1025;
DECK_SHARE_CREATE = 1026;
DECK_SHARE_LIST = 1027;
DECK_SHARE_DOWNLOAD = 1028;
DECK_LIST_OTHER_USER = 1029;
DECK_SET_VISIBILITY = 1030;
DECK_DOWNLOAD_PUBLIC = 1031;
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102;

View file

@ -0,0 +1,71 @@
-- Servatrice db migration from version 36 to version 37
-- Deck sharing (temporary share links + permanent public decks).
--
-- This feature was developed behind several intermediate migrations that have
-- never shipped, so they are folded into this single 36 -> 37 migration:
-- temporary share links, permanent public-deck visibility, preview metadata,
-- and per-deck tags.
-- 1. Temporary deck shares: a named bundle of decks that can be fetched by
-- anyone who knows the (unguessable) token, until the share expires.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`token` varchar(64) NOT NULL,
`name` varchar(64) NOT NULL,
`created_by` int(7) unsigned NULL,
`created_at` datetime NOT NULL,
`expires_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `expires_at` (`expires_at`),
FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Individual decks inside a share bundle. Content is materialized at share
-- time so expiring/deleting a share can cascade cleanly. The metadata columns
-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when
-- using prepared statements.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`share_id` int(7) unsigned zerofill NOT NULL,
`name` varchar(50) NOT NULL,
`tags` text NULL,
`banner_card` varchar(255) NULL,
`game_format` varchar(50) NULL,
`color_identity` varchar(5) NULL,
`content` text NOT NULL,
`position` int(7) NOT NULL,
PRIMARY KEY (`id`),
KEY `share_id` (`share_id`),
FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- 2. Permanent deck sharing: add public visibility flags to the deck storage
-- tables. A deck is visible to other users if it is marked public, or if any
-- ancestor folder is marked public (inherited). Existing decks default to
-- private, so the upgrade does not expose any data.
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `content`;
ALTER TABLE `cockatrice_decklist_folders`
ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `name`;
-- 3. Per-deck preview metadata so clients can render another user's public
-- decks (e.g. in a visual deck storage grid) without downloading each deck
-- list. The metadata is computed by the uploading client; decks uploaded
-- before this migration have empty values until they are re-uploaded.
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `banner_card_name` varchar(255) NULL AFTER `content`,
ADD COLUMN `banner_card_provider` varchar(32) NULL AFTER `banner_card_name`,
ADD COLUMN `color_identity` varchar(5) NULL AFTER `banner_card_provider`;
-- 4. Per-deck tags for public decks. The uploading client sends a
-- comma-separated tag string (matching the deck's own tags), so another user's
-- public decks can render and filter by tag without downloading each deck list.
-- Decks uploaded before this migration have NULL tags until they are
-- re-uploaded.
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `tags` text NULL AFTER `color_identity`;
UPDATE cockatrice_schema_version SET version=37 WHERE version=36;

View file

@ -452,3 +452,19 @@ ssl_cert=ssl_cert.pem
; Filename of the private key for the server-to-server certificate
ssl_key=ssl_key.pem
[deck_share]
; How many days a created deck share link remains valid before it expires.
; Default: 7
expiry_days=7
; How often (in minutes) the server checks for and removes expired deck shares.
; A value of 0 disables the automatic cleanup.
; Default: 60
cleanup_interval=60
; Maximum number of decks a single share link can contain.
; Default: 50
max_decks_per_share=50

View file

@ -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(36);
INSERT INTO cockatrice_schema_version VALUES(37);
-- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
@ -66,16 +66,56 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` (
`name` varchar(50) NOT NULL,
`upload_time` datetime NOT NULL,
`content` text NOT NULL,
`is_public` tinyint(1) NOT NULL DEFAULT 0,
`banner_card_name` varchar(255) NULL,
`banner_card_provider` varchar(32) NULL,
`color_identity` varchar(5) NULL,
`tags` text NULL,
PRIMARY KEY (`id`),
KEY `FolderPlusUser` (`id_folder`,`id_user`),
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Temporary deck shares: a named bundle of decks that can be fetched by
-- anyone who knows the (unguessable) token, until the share expires.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`token` varchar(64) NOT NULL,
`name` varchar(64) NOT NULL,
`created_by` int(7) unsigned NULL,
`created_at` datetime NOT NULL,
`expires_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `expires_at` (`expires_at`),
FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Individual decks inside a share bundle. Content is materialized at share
-- time so expiring/deleting a share can cascade cleanly. The metadata columns
-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when
-- using prepared statements.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`share_id` int(7) unsigned zerofill NOT NULL,
`name` varchar(50) NOT NULL,
`tags` text NULL,
`banner_card` varchar(255) NULL,
`game_format` varchar(50) NULL,
`color_identity` varchar(5) NULL,
`content` text NOT NULL,
`position` int(7) NOT NULL,
PRIMARY KEY (`id`),
KEY `share_id` (`share_id`),
FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_decklist_folders` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`id_parent` int(7) unsigned zerofill NOT NULL,
`id_user` int(7) unsigned NULL,
`name` varchar(30) NOT NULL,
`is_public` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `ParentPlusUser` (`id_parent`,`id_user`),
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE

View file

@ -437,6 +437,14 @@ bool Servatrice::initServer()
statusUpdateClock->start(getServerStatusUpdateTime());
}
deckShareCleanupClock = new QTimer(this);
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares()));
const int deckShareCleanupInterval = getDeckShareCleanupInterval();
if (deckShareCleanupInterval > 0) {
qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms";
deckShareCleanupClock->start(deckShareCleanupInterval);
}
// SOCKET SERVER
if (getNumberOfTCPPools() > 0) {
gameServer =
@ -655,6 +663,11 @@ void Servatrice::setRequiredFeatures(const QString &featureList)
qDebug() << "Set required client features to:" << serverRequiredFeatureList;
}
void Servatrice::cleanupExpiredDeckShares()
{
servatriceDatabaseInterface->cleanupExpiredDeckShares();
}
void Servatrice::statusUpdate()
{
if (!servatriceDatabaseInterface->checkSql()) {
@ -1067,6 +1080,22 @@ int Servatrice::getServerStatusUpdateTime() const
return settingsCache->value("server/statusupdate", 15000).toInt();
}
int Servatrice::getDeckShareExpiryDays() const
{
return settingsCache->value("deck_share/expiry_days", 7).toInt();
}
int Servatrice::getDeckShareCleanupInterval() const
{
// default: every 60 minutes
return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000;
}
int Servatrice::getDeckShareMaxDecksPerShare() const
{
return settingsCache->value("deck_share/max_decks_per_share", 50).toInt();
}
int Servatrice::getNumberOfTCPPools() const
{
return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -146,6 +146,7 @@ public:
private slots:
void statusUpdate();
void shutdownTimeout();
void cleanupExpiredDeckShares();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -159,6 +160,7 @@ private:
AuthenticationMethod authenticationMethod;
DatabaseType databaseType;
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
@ -276,6 +278,9 @@ public:
int getMaxGameInactivityTime() const override;
int getMaxPlayerInactivityTime() const override;
int getClientKeepAlive() const override;
int getDeckShareExpiryDays() const;
int getDeckShareCleanupInterval() const;
int getDeckShareMaxDecksPerShare() const;
int getMaxUsersPerAddress() const;
int getMessageCountingInterval() const override;
int getMaxMessageCountPerInterval() const override;

View file

@ -7,6 +7,7 @@
#include <QChar>
#include <QDateTime>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
@ -1079,6 +1080,127 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
return deck;
}
bool Servatrice_DatabaseInterface::createDeckShare(const QString &token,
const QString &name,
int userId,
const QList<DeckShareItemRecord> &items,
int expiryDays)
{
checkSql();
if (items.isEmpty()) {
return false;
}
sqlDatabase.transaction();
QSqlQuery *query = prepareQuery("insert into {prefix}_deck_share (token, name, created_by, created_at, expires_at) "
"values (:token, :name, :created_by, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))");
query->bindValue(":token", token);
query->bindValue(":name", name);
query->bindValue(":created_by", userId < 1 ? QVariant() : userId);
query->bindValue(":days", expiryDays);
if (!execSqlQuery(query)) {
sqlDatabase.rollback();
return false;
}
const int shareId = query->lastInsertId().toInt();
for (int i = 0; i < items.size(); ++i) {
const DeckShareItemRecord &item = items.at(i);
QSqlQuery *itemQuery = prepareQuery("insert into {prefix}_deck_share_item (share_id, name, tags, banner_card, "
"game_format, color_identity, content, position) values (:share_id, :name, "
":tags, :banner_card, :game_format, :color_identity, :content, :position)");
itemQuery->bindValue(":share_id", shareId);
itemQuery->bindValue(":name", item.name);
QJsonArray tagArray;
for (const QString &tag : item.tags) {
tagArray.append(tag);
}
itemQuery->bindValue(":tags", QString::fromUtf8(QJsonDocument(tagArray).toJson(QJsonDocument::Compact)));
itemQuery->bindValue(":banner_card", item.bannerCard);
itemQuery->bindValue(":game_format", item.gameFormat);
itemQuery->bindValue(":color_identity", item.colorIdentity);
itemQuery->bindValue(":content", item.content);
itemQuery->bindValue(":position", i);
if (!execSqlQuery(itemQuery)) {
sqlDatabase.rollback();
return false;
}
}
sqlDatabase.commit();
return true;
}
bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token,
QString &name,
qint64 &expiresAt,
QList<DeckShareItemRecord> &items)
{
checkSql();
QSqlQuery *query =
prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = "
":token and expires_at > now()");
query->bindValue(":token", token);
execSqlQuery(query);
if (!query->next()) {
return false;
}
const int shareId = query->value(0).toInt();
name = query->value(1).toString();
expiresAt = query->value(2).toLongLong();
items.clear();
QSqlQuery *itemQuery =
prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item "
"where share_id = :share_id order by position");
itemQuery->bindValue(":share_id", shareId);
execSqlQuery(itemQuery);
while (itemQuery->next()) {
DeckShareItemRecord item;
item.id = itemQuery->value(0).toInt();
item.name = itemQuery->value(1).toString();
const QJsonArray tagArray = QJsonDocument::fromJson(itemQuery->value(2).toString().toUtf8()).array();
for (const QJsonValue &tag : tagArray) {
item.tags.append(tag.toString());
}
item.bannerCard = itemQuery->value(3).toString();
item.gameFormat = itemQuery->value(4).toString();
item.colorIdentity = itemQuery->value(5).toString();
items.append(item);
}
return true;
}
bool Servatrice_DatabaseInterface::getDeckShareItem(const QString &token, int itemId, QString &content)
{
checkSql();
QSqlQuery *query = prepareQuery("select i.content from {prefix}_deck_share_item i join {prefix}_deck_share s on "
"s.id = i.share_id where s.token = :token and s.expires_at > now() and i.id = :id");
query->bindValue(":token", token);
query->bindValue(":id", itemId);
execSqlQuery(query);
if (!query->next()) {
return false;
}
content = query->value(0).toString();
return true;
}
void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
{
checkSql();
QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where expires_at < now()");
execSqlQuery(query);
}
void Servatrice_DatabaseInterface::logMessage(const int senderId,
const QString &senderName,
const QString &senderIp,

View file

@ -13,10 +13,22 @@
#include <server.h>
#include <server_database_interface.h>
#define DATABASE_SCHEMA_VERSION 36
#define DATABASE_SCHEMA_VERSION 37
class Servatrice;
/** @brief Metadata of a single deck inside a temporary deck share bundle. */
struct DeckShareItemRecord
{
int id = -1; ///< Database id, used for downloads.
QString name; ///< Deck name.
QStringList tags; ///< Deck tags.
QString bannerCard; ///< Banner card name (deck image).
QString gameFormat; ///< Game format the deck was built for.
QString colorIdentity; ///< Color identity, e.g. "WUBRG".
QString content; ///< Deck content (native format); empty in list queries.
};
class Servatrice_DatabaseInterface : public Server_DatabaseInterface
{
Q_OBJECT
@ -80,6 +92,25 @@ public:
const QList<GameReplay *> &replayList) override;
DeckList *getDeckFromDatabase(int deckId, int userId) override;
/** @brief Creates a new temporary deck share bundle. Returns false on failure. */
bool createDeckShare(const QString &token,
const QString &name,
int userId,
const QList<DeckShareItemRecord> &items,
int expiryDays);
/**
* @brief Looks up a valid (non-expired) share bundle by token.
* @return false if the token is unknown or expired.
*/
bool getDeckShareList(const QString &token, QString &name, qint64 &expiresAt, QList<DeckShareItemRecord> &items);
/**
* @brief Fetches the content of one item of a valid share bundle.
* @return false if the token is unknown/expired or the item does not belong to the bundle.
*/
bool getDeckShareItem(const QString &token, int itemId, QString &content);
/** @brief Deletes all expired share bundles (cascades to their items). */
void cleanupExpiredDeckShares();
int getNextGameId() override;
int getNextReplayId() override;
int getActiveUserCount(QString connectionType = QString()) override;

View file

@ -35,10 +35,12 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <QSqlError>
#include <QSqlQuery>
#include <QString>
#include <algorithm>
#include <game/server_player.h>
#include <google/protobuf/descriptor.h>
#include <iostream>
@ -47,8 +49,14 @@
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download_public.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list_other_user.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_set_visibility.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
@ -81,6 +89,9 @@
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response_forgotpasswordrequest.pb.h>
#include <libcockatrice/protocol/pb/response_get_admin_notes.pb.h>
@ -277,6 +288,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdRemoveFromList(cmd.GetExtension(Command_RemoveFromList::ext), rc);
case SessionCommand::DECK_LIST:
return cmdDeckList(cmd.GetExtension(Command_DeckList::ext), rc);
case SessionCommand::DECK_LIST_OTHER_USER:
return cmdDeckListOtherUser(cmd.GetExtension(Command_DeckListOtherUser::ext), rc);
case SessionCommand::DECK_SET_VISIBILITY:
return cmdDeckSetVisibility(cmd.GetExtension(Command_DeckSetVisibility::ext), rc);
case SessionCommand::DECK_DOWNLOAD_PUBLIC:
return cmdDeckDownloadPublic(cmd.GetExtension(Command_DeckDownloadPublic::ext), rc);
case SessionCommand::DECK_NEW_DIR:
return cmdDeckNewDir(cmd.GetExtension(Command_DeckNewDir::ext), rc);
case SessionCommand::DECK_DEL_DIR:
@ -320,6 +337,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc);
case SessionCommand::SET_CARD_ART_PARAMS:
return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc);
case SessionCommand::DECK_SHARE_CREATE:
return cmdDeckShareCreate(cmd.GetExtension(Command_DeckShareCreate::ext), rc);
case SessionCommand::DECK_SHARE_LIST:
return cmdDeckShareList(cmd.GetExtension(Command_DeckShareList::ext), rc);
case SessionCommand::DECK_SHARE_DOWNLOAD:
return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc);
case SessionCommand::ACCOUNT_PASSWORD:
return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc);
case SessionCommand::REQUEST_PASSWORD_SALT:
@ -566,46 +589,70 @@ int AbstractServerSocketInterface::getDeckPathId(const QString &path)
return getDeckPathId(0, path.split("/"));
}
bool AbstractServerSocketInterface::deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder)
bool AbstractServerSocketInterface::deckListHelper(int folderId,
ServerInfo_DeckStorage_Folder *folder,
int userId,
bool inheritedPublic,
bool publicOnly)
{
QSqlQuery *query = sqlInterface->prepareQuery(
"select id, name from {prefix}_decklist_folders where id_parent = :id_parent and id_user = :id_user");
QSqlQuery *query = sqlInterface->prepareQuery("select id, name, is_public from {prefix}_decklist_folders where "
"id_parent = :id_parent and id_user = :id_user");
query->bindValue(":id_parent", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
QMap<int, QString> results;
QList<std::pair<int, std::pair<QString, bool>>> folderRows;
while (query->next()) {
results[query->value(0).toInt()] = query->value(1).toString();
folderRows.append({query->value(0).toInt(), {query->value(1).toString(), query->value(2).toBool()}});
}
std::sort(folderRows.begin(), folderRows.end(), [](const auto &a, const auto &b) { return a.first < b.first; });
for (const auto &[folderIdValue, folderInfo] : folderRows) {
const QString name = folderInfo.first;
const bool ownPublic = folderInfo.second;
const bool effectivePublic = inheritedPublic || ownPublic;
if (publicOnly && !effectivePublic) {
continue;
}
for (int key : results.keys()) {
ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items();
newItem->set_id(key);
newItem->set_name(results.value(key).toStdString());
newItem->set_id(folderIdValue);
newItem->set_name(name.toStdString());
newItem->mutable_folder()->set_is_public(ownPublic);
if (!deckListHelper(newItem->id(), newItem->mutable_folder())) {
if (!deckListHelper(newItem->id(), newItem->mutable_folder(), userId, effectivePublic, publicOnly)) {
return false;
}
}
query = sqlInterface->prepareQuery("select id, name, upload_time from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags from {prefix}_decklist_files where "
"id_folder = :id_folder and id_user = :id_user");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
while (query->next()) {
const bool ownPublic = query->value(3).toBool();
if (publicOnly && !(inheritedPublic || ownPublic)) {
continue;
}
ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items();
newItem->set_id(query->value(0).toInt());
newItem->set_name(query->value(1).toString().toStdString());
ServerInfo_DeckStorage_File *newFile = newItem->mutable_file();
newFile->set_creation_time(query->value(2).toDateTime().toSecsSinceEpoch());
newFile->set_is_public(ownPublic);
newFile->set_banner_card_name(query->value(4).toString().toStdString());
newFile->set_banner_card_provider(query->value(5).toString().toStdString());
newFile->set_color_identity(query->value(6).toString().toStdString());
newFile->set_tags(query->value(7).toString().toStdString());
}
return true;
@ -626,7 +673,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_
Response_DeckList *re = new Response_DeckList;
ServerInfo_DeckStorage_Folder *root = re->mutable_root();
if (!deckListHelper(0, root)) {
if (!deckListHelper(0, root, userInfo->id(), false, false)) {
return Response::RespContextError;
}
@ -634,6 +681,156 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
const QString userName = nameFromStdString(cmd.user_name());
const int userId = sqlInterface->getUserIdInDB(userName);
if (userId == -1) {
return Response::RespNameNotFound;
}
Response_DeckList *re = new Response_DeckList;
ServerInfo_DeckStorage_Folder *root = re->mutable_root();
if (!deckListHelper(0, root, userId, false, true)) {
return Response::RespContextError;
}
rc.setResponseExtension(re);
return Response::RespOk;
}
int AbstractServerSocketInterface::getDeckOwnerId(int deckId)
{
QSqlQuery *query = sqlInterface->prepareQuery("select id_user from {prefix}_decklist_files where id = :id");
query->bindValue(":id", deckId);
if (!sqlInterface->execSqlQuery(query)) {
return -1;
}
if (!query->next()) {
return -1;
}
return query->value(0).toInt();
}
bool AbstractServerSocketInterface::isDeckEffectivelyPublic(int deckId)
{
QSqlQuery *query =
sqlInterface->prepareQuery("select is_public, id_folder from {prefix}_decklist_files where id = :id");
query->bindValue(":id", deckId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
if (!query->next()) {
return false;
}
if (query->value(0).toBool()) {
return true;
}
int folderId = query->value(1).toInt();
int guard = 0;
while (folderId != 0 && guard < 100) {
QSqlQuery *folderQuery =
sqlInterface->prepareQuery("select is_public, id_parent from {prefix}_decklist_folders where id = :id");
folderQuery->bindValue(":id", folderId);
if (!sqlInterface->execSqlQuery(folderQuery)) {
return false;
}
if (!folderQuery->next()) {
return false;
}
if (folderQuery->value(0).toBool()) {
return true;
}
folderId = folderQuery->value(1).toInt();
++guard;
}
return false;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd,
ResponseContainer & /*rc*/)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
if (!query->next()) {
return Response::RespNameNotFound;
}
query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id");
query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
query->bindValue(":id", cmd.deck_id());
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
} else if (cmd.has_folder_path()) {
const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path()));
if (folderId == -1 || folderId == 0) {
return Response::RespNameNotFound;
}
QSqlQuery *query =
sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id");
query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
query->bindValue(":id", folderId);
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
} else {
return Response::RespInvalidData;
}
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
const int deckId = cmd.deck_id();
const int ownerId = getDeckOwnerId(deckId);
if (ownerId == -1 || !isDeckEffectivelyPublic(deckId)) {
return Response::RespNameNotFound;
}
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(deckId, ownerId);
} catch (Response::ResponseCode &r) {
return r;
}
Response_DeckDownload *re = new Response_DeckDownload;
re->set_deck(deck->writeToString_Native().toStdString());
rc.setResponseExtension(re);
delete deck;
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckNewDir(const Command_DeckNewDir &cmd,
ResponseContainer & /*rc*/)
{
@ -774,11 +971,18 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
QSqlQuery *query =
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content) values(:id_folder, :id_user, :name, NOW(), :content)");
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, "
":banner_card_name, :banner_card_provider, :color_identity, :tags)");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", nameFromStdString(cmd.banner_card_name()));
query->bindValue(":banner_card_provider", nameFromStdString(cmd.banner_card_provider()));
query->bindValue(":color_identity", nameFromStdString(cmd.color_identity()));
query->bindValue(":tags", nameFromStdString(cmd.tags()));
sqlInterface->execSqlQuery(query);
Response_DeckUpload *re = new Response_DeckUpload;
@ -786,26 +990,42 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
fileInfo->set_id(query->lastInsertId().toInt());
fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public());
rc.setResponseExtension(re);
} else if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("update {prefix}_decklist_files set name=:name, upload_time=NOW(), "
"content=:content where id = :id_deck and id_user = :id_user");
"content=:content, banner_card_name=:banner_card_name, "
"banner_card_provider=:banner_card_provider, color_identity=:color_identity, "
"tags=:tags where id = :id_deck and id_user = :id_user");
query->bindValue(":id_deck", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":banner_card_name", nameFromStdString(cmd.banner_card_name()));
query->bindValue(":banner_card_provider", nameFromStdString(cmd.banner_card_provider()));
query->bindValue(":color_identity", nameFromStdString(cmd.color_identity()));
query->bindValue(":tags", nameFromStdString(cmd.tags()));
sqlInterface->execSqlQuery(query);
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
QSqlQuery *visibilityQuery =
sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and "
"id_user = :id_user");
visibilityQuery->bindValue(":id", cmd.deck_id());
visibilityQuery->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(visibilityQuery);
const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool();
Response_DeckUpload *re = new Response_DeckUpload;
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
fileInfo->set_id(cmd.deck_id());
fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
fileInfo->mutable_file()->set_is_public(isPublic);
rc.setResponseExtension(re);
} else {
return Response::RespInvalidData;
@ -836,6 +1056,179 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm
return Response::RespOk;
}
namespace
{
/** @brief Builds a cryptographically random, URL-safe share token. */
QString generateShareToken()
{
QByteArray bytes(32, Qt::Uninitialized);
QRandomGenerator::system()->fillRange(reinterpret_cast<quint32 *>(bytes.data()), bytes.size() / sizeof(quint32));
return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
/** @brief Extracts the share metadata for a deck, materializing its content. */
DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &colorIdentity)
{
DeckShareItemRecord item;
item.name = deck.getName();
if (item.name.isEmpty()) {
item.name = "Unnamed deck";
}
item.tags = deck.getTags();
item.bannerCard = deck.getBannerCard().name;
item.gameFormat = deck.getGameFormat();
QString sanitizedColorIdentity;
for (const QChar &color : colorIdentity) {
const QChar upper = color.toUpper();
if (QStringLiteral("WUBRG").contains(upper) && !sanitizedColorIdentity.contains(upper)) {
sanitizedColorIdentity.append(upper);
}
}
item.colorIdentity = sanitizedColorIdentity;
item.content = deck.writeToString_Native();
return item;
}
} // namespace
Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const Command_DeckShareCreate &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QList<DeckShareItemRecord> items;
if (cmd.items_size() > 0) {
for (const DeckShareItem &shareItem : cmd.items()) {
if (shareItem.has_deck_list()) {
DeckList deck;
if (!deck.loadFromString_Native(fileFromStdString(shareItem.deck_list()))) {
return Response::RespContextError;
}
items.append(makeShareItemFromDeck(deck, nameFromStdString(shareItem.color_identity())));
} else if (shareItem.has_deck_id()) {
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(shareItem.deck_id(), userInfo->id());
} catch (Response::ResponseCode &r) {
return r;
}
items.append(makeShareItemFromDeck(*deck, nameFromStdString(shareItem.color_identity())));
delete deck;
} else {
return Response::RespInvalidData;
}
}
} else if (cmd.has_folder_path()) {
const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path()));
if (folderId == -1) {
return Response::RespNameNotFound;
}
QSqlQuery *query = sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
while (query->next()) {
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(query->value(0).toInt(), userInfo->id());
} catch (Response::ResponseCode &r) {
return r;
}
items.append(makeShareItemFromDeck(*deck, QString()));
delete deck;
}
} else {
return Response::RespInvalidData;
}
if (items.isEmpty()) {
return Response::RespInvalidData;
}
const int maxItems = servatrice->getDeckShareMaxDecksPerShare();
if (items.size() > maxItems) {
return Response::RespTooManyRequests;
}
QString shareName = nameFromStdString(cmd.name());
if (shareName.isEmpty()) {
shareName = "Shared decks";
}
const QString token = generateShareToken();
if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays())) {
return Response::RespInvalidData;
}
Response_DeckShareCreate *re = new Response_DeckShareCreate;
re->set_token(token.toStdString());
re->set_expires_at(
QDateTime::currentDateTimeUtc().addDays(servatrice->getDeckShareExpiryDays()).toSecsSinceEpoch());
re->set_item_count(items.size());
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareList(const Command_DeckShareList &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QString name;
qint64 expiresAt = 0;
QList<DeckShareItemRecord> items;
if (!sqlInterface->getDeckShareList(nameFromStdString(cmd.token()), name, expiresAt, items)) {
return Response::RespNameNotFound;
}
Response_DeckShareList *re = new Response_DeckShareList;
re->set_name(name.toStdString());
re->set_expires_at(expiresAt);
for (const DeckShareItemRecord &item : items) {
ServerInfo_DeckShareItem *itemInfo = re->add_items();
itemInfo->set_id(item.id);
itemInfo->set_name(item.name.toStdString());
for (const QString &tag : item.tags) {
itemInfo->add_tags(tag.toStdString());
}
itemInfo->set_banner_card(item.bannerCard.toStdString());
itemInfo->set_game_format(item.gameFormat.toStdString());
itemInfo->set_color_identity(item.colorIdentity.toStdString());
}
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareDownload(const Command_DeckShareDownload &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QString content;
if (!sqlInterface->getDeckShareItem(nameFromStdString(cmd.token()), cmd.item_id(), content)) {
return Response::RespNameNotFound;
}
Response_DeckShareDownload *re = new Response_DeckShareDownload;
re->set_deck(content.toStdString());
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdReplayList(const Command_ReplayList & /*cmd*/,
ResponseContainer &rc)
{

View file

@ -45,11 +45,17 @@ class ServerInfo_DeckStorage_Folder;
class Command_AddToList;
class Command_RemoveFromList;
class Command_DeckList;
class Command_DeckListOtherUser;
class Command_DeckNewDir;
class Command_DeckDelDir;
class Command_DeckDel;
class Command_DeckDownload;
class Command_DeckDownloadPublic;
class Command_DeckUpload;
class Command_DeckSetVisibility;
class Command_DeckShareCreate;
class Command_DeckShareList;
class Command_DeckShareDownload;
class Command_ReplayList;
class Command_ReplayDownload;
class Command_ReplayModifyMatch;
@ -97,8 +103,16 @@ private:
Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc);
int getDeckPathId(int basePathId, QStringList path);
int getDeckPathId(const QString &path);
bool deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder);
bool deckListHelper(int folderId,
ServerInfo_DeckStorage_Folder *folder,
int userId,
bool inheritedPublic,
bool publicOnly);
int getDeckOwnerId(int deckId);
bool isDeckEffectivelyPublic(int deckId);
Response::ResponseCode cmdDeckList(const Command_DeckList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer &rc);
void deckDelDirHelper(int basePathId);
void sendServerMessage(const QString userName, const QString message);
@ -107,6 +121,10 @@ private:
Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc);
DeckList *getDeckFromDatabase(int deckId);
Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareDownload(const Command_DeckShareDownload &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayList(const Command_ReplayList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayDownload(const Command_ReplayDownload &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc);