From d18e28a65ba5d7cf19a193d0da8b52bfcc9979eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Fri, 4 Sep 2026 22:41:44 +0200 Subject: [PATCH] [Server] Add deck share links and public deck visibility --- .../libcockatrice/protocol/pb/CMakeLists.txt | 10 + .../pb/command_deck_download_public.proto | 9 + .../pb/command_deck_list_other_user.proto | 9 + .../pb/command_deck_set_visibility.proto | 13 + .../pb/command_deck_share_create.proto | 24 + .../pb/command_deck_share_download.proto | 10 + .../protocol/pb/command_deck_share_list.proto | 9 + .../protocol/pb/command_deck_upload.proto | 8 + .../libcockatrice/protocol/pb/response.proto | 3 + .../pb/response_deck_share_create.proto | 11 + .../pb/response_deck_share_download.proto | 9 + .../pb/response_deck_share_list.proto | 12 + .../pb/serverinfo_deck_share_item.proto | 10 + .../protocol/pb/serverinfo_deckstorage.proto | 12 + .../protocol/pb/session_commands.proto | 6 + .../migrations/servatrice_0036_to_0037.sql | 71 +++ servatrice/servatrice.ini.example | 16 + servatrice/servatrice.sql | 42 +- servatrice/src/servatrice.cpp | 29 ++ servatrice/src/servatrice.h | 5 + .../src/servatrice_database_interface.cpp | 122 +++++ .../src/servatrice_database_interface.h | 33 +- servatrice/src/serversocketinterface.cpp | 425 +++++++++++++++++- servatrice/src/serversocketinterface.h | 20 +- 24 files changed, 899 insertions(+), 19 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto create mode 100644 servatrice/migrations/servatrice_0036_to_0037.sql diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index f22828f46..9df7291a8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -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 @@ -135,6 +141,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 @@ -172,6 +181,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 diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto new file mode 100644 index 000000000..a5592ef51 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto new file mode 100644 index 000000000..2459608e2 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto new file mode 100644 index 000000000..3ada87973 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto new file mode 100644 index 000000000..9cebf8eae --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto new file mode 100644 index 000000000..251a662b3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto new file mode 100644 index 000000000..b75fd65f9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareList { + extend SessionCommand { + optional Command_DeckShareList ext = 1027; + } + optional string token = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto index 63d9c80ef..a79ac35c2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -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; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index 14ba737b5..09f6e5c7b 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -80,6 +80,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 } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto new file mode 100644 index 000000000..574c02eb6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto new file mode 100644 index 000000000..def0ccbe6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareDownload { + extend Response { + optional Response_DeckShareDownload ext = 1105; + } + optional string deck = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto new file mode 100644 index 000000000..3edffa8ac --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto new file mode 100644 index 000000000..bac025419 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index 16e3f28e3..a77dc9484 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -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 { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index fee8c36a8..c4b47f10f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -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; diff --git a/servatrice/migrations/servatrice_0036_to_0037.sql b/servatrice/migrations/servatrice_0036_to_0037.sql new file mode 100644 index 000000000..3abc14fed --- /dev/null +++ b/servatrice/migrations/servatrice_0036_to_0037.sql @@ -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; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index c1940c22f..3e94e5790 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -439,3 +439,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 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 5dbf69cbc..8baabf3bd 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(36); +INSERT INTO cockatrice_schema_version VALUES(37); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -63,16 +63,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 diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index db8751658..0c1d6f18c 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -428,6 +428,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 = @@ -600,6 +608,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()) { @@ -1012,6 +1025,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(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 8b0f5ad60..f4b4146a4 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -143,6 +143,7 @@ public: private slots: void statusUpdate(); void shutdownTimeout(); + void cleanupExpiredDeckShares(); protected: void doSendIslMessage(const IslMessage &msg, int _serverId) override; @@ -156,6 +157,7 @@ private: AuthenticationMethod authenticationMethod; DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; + QTimer *deckShareCleanupClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -267,6 +269,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; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 847be61da..28c7ff2aa 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -1026,6 +1027,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 &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 &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, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index cd76ae288..c33669f0d 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -13,10 +13,22 @@ #include #include -#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 @@ -79,6 +91,25 @@ public: const QList &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 &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 &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; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 2a8b5f0a4..99f632776 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -34,18 +34,26 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include +#include +#include +#include +#include #include #include #include @@ -77,6 +85,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -201,6 +212,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: @@ -244,6 +261,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: @@ -470,46 +493,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 results; + QList>> 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; @@ -530,7 +577,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; } @@ -538,6 +585,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*/) { @@ -678,11 +875,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; @@ -690,26 +894,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; @@ -740,6 +960,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(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 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 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) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 600796b5f..47657f5ec 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -44,11 +44,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; @@ -95,8 +101,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); @@ -105,6 +119,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);