diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index d7952396f..bc4814d5e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -24,6 +24,8 @@ set(PROTO_FILES command_deck_share_create.proto command_deck_share_download.proto command_deck_share_list.proto + command_deck_share_list_mine.proto + command_deck_share_remove.proto command_deck_upload.proto command_del_counter.proto command_delete_arrow.proto @@ -146,6 +148,7 @@ set(PROTO_FILES response_deck_share_create.proto response_deck_share_download.proto response_deck_share_list.proto + response_deck_share_list_mine.proto response_deck_upload.proto response_dump_zone.proto response_forgotpasswordrequest.proto @@ -185,6 +188,7 @@ set(PROTO_FILES serverinfo_chat_message.proto serverinfo_counter.proto serverinfo_deck_share_item.proto + serverinfo_deck_share_summary.proto serverinfo_deckstorage.proto serverinfo_game.proto serverinfo_gametype.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto new file mode 100644 index 000000000..75bc30714 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Requests the list of share bundles created by the calling user, so they can +// be reviewed and revoked before they expire. +message Command_DeckShareListMine { + extend SessionCommand { + optional Command_DeckShareListMine ext = 1032; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto new file mode 100644 index 000000000..348996da9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Revokes one of the calling user's own share bundles. The referenced items +// are removed by cascade. +message Command_DeckShareRemove { + extend SessionCommand { + optional Command_DeckShareRemove ext = 1033; + } + optional uint32 share_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto index a79ac35c2..1a5d44e9e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -9,11 +9,9 @@ message Command_DeckUpload { 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; + // The server derives the banner card and tags from deck_list, so clients only + // need to send the color identity, which cannot be computed server-side. + reserved 5, 6, 8; + reserved "banner_card_name", "banner_card_provider", "tags"; 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 b0cdc61f3..caf9febde 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -84,6 +84,7 @@ message Response { 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 + DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto new file mode 100644 index 000000000..e3cbf83b9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_summary.proto"; + +message Response_DeckShareListMine { + extend Response { + optional Response_DeckShareListMine ext = 1106; + } + repeated ServerInfo_DeckShareSummary shares = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto new file mode 100644 index 000000000..43273a54f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +// A share bundle created by a user, as reported by a "list my shares" query. +message ServerInfo_DeckShareSummary { + optional uint32 id = 1; + optional string name = 2; + optional uint64 creation_time = 3; + optional uint64 expires_at = 4; + optional uint32 item_count = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index a77dc9484..b04d676d8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -8,10 +8,9 @@ message ServerInfo_DeckStorage_File { 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; + // Tag names associated with the deck. Empty for decks uploaded before the + // tags column existed. + repeated string tags = 6; } message ServerInfo_DeckStorage_Folder { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index c4b47f10f..4b7fe9c85 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -34,6 +34,8 @@ message SessionCommand { DECK_LIST_OTHER_USER = 1029; DECK_SET_VISIBILITY = 1030; DECK_DOWNLOAD_PUBLIC = 1031; + DECK_SHARE_LIST_MINE = 1032; + DECK_SHARE_REMOVE = 1033; 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 index 3abc14fed..576ab53b4 100644 --- a/servatrice/migrations/servatrice_0036_to_0037.sql +++ b/servatrice/migrations/servatrice_0036_to_0037.sql @@ -11,7 +11,7 @@ -- 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, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, `name` varchar(64) NOT NULL, `created_by` int(7) unsigned NULL, `created_at` datetime NOT NULL, @@ -53,18 +53,18 @@ ALTER TABLE `cockatrice_decklist_folders` -- 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. +-- list. The metadata is derived by the server from the deck content (the color +-- identity is supplied 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_name` varchar(255) NULL AFTER `is_public`, 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. +-- 4. Per-deck tags for public decks. The server renders the deck's own tags +-- into a JSON array, so another user's public decks can 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`; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 4e388603e..fc0ba11f2 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -468,3 +468,8 @@ cleanup_interval=60 ; Maximum number of decks a single share link can contain. ; Default: 50 max_decks_per_share=50 + +; Maximum number of share links a single user may create per day. +; A value of 0 disables the limit. +; Default: 0 +max_shares_per_day=0 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index e2bcf9226..4fbc1d8bd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -80,7 +80,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` ( -- 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, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, `name` varchar(64) NOT NULL, `created_by` int(7) unsigned NULL, `created_at` datetime NOT NULL, diff --git a/servatrice/src/deck_tag_serialization.h b/servatrice/src/deck_tag_serialization.h new file mode 100644 index 000000000..162b0604d --- /dev/null +++ b/servatrice/src/deck_tag_serialization.h @@ -0,0 +1,39 @@ +#ifndef DECK_TAG_SERIALIZATION_H +#define DECK_TAG_SERIALIZATION_H + +#include +#include +#include +#include +#include + +/** + * @brief Encodes deck tags as a compact JSON array for storage in a text column. + * + * Deck tags are stored as JSON (rather than a delimited string) so tag names may + * contain any character, and decoded uniformly everywhere they are read. + */ +inline QString serializeDeckTags(const QStringList &tags) +{ + QJsonArray array; + for (const QString &tag : tags) { + array.append(tag); + } + return QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)); +} + +/** @brief Decodes deck tags previously written by serializeDeckTags. */ +inline QStringList deserializeDeckTags(const QString &serialized) +{ + QStringList tags; + if (serialized.isEmpty()) { + return tags; + } + const QJsonArray array = QJsonDocument::fromJson(serialized.toUtf8()).array(); + for (const QJsonValue &tag : array) { + tags.append(tag.toString()); + } + return tags; +} + +#endif // DECK_TAG_SERIALIZATION_H diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index caf84c1e6..56aeedb9a 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -1096,6 +1096,12 @@ int Servatrice::getDeckShareMaxDecksPerShare() const return settingsCache->value("deck_share/max_decks_per_share", 50).toInt(); } +int Servatrice::getDeckShareMaxSharesPerDay() const +{ + // default: no limit + return settingsCache->value("deck_share/max_shares_per_day", 0).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 de33c505f..f39a44d51 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -281,6 +281,7 @@ public: int getDeckShareExpiryDays() const; int getDeckShareCleanupInterval() const; int getDeckShareMaxDecksPerShare() const; + int getDeckShareMaxSharesPerDay() 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 6362a01ed..5f1af68c4 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1,5 +1,6 @@ #include "servatrice_database_interface.h" +#include "deck_tag_serialization.h" #include "servatrice.h" #include "serversocketinterface.h" #include "settingscache.h" @@ -1084,7 +1085,8 @@ bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, const QString &name, int userId, const QList &items, - int expiryDays) + int expiryDays, + qint64 &expiresAt) { checkSql(); @@ -1092,7 +1094,9 @@ bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, return false; } - sqlDatabase.transaction(); + if (!sqlDatabase.transaction()) { + return false; + } 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))"); @@ -1106,6 +1110,17 @@ bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, } const int shareId = query->lastInsertId().toInt(); + + // Read the expiry back from the database so the value returned to the client + // matches the server clock rather than being approximated client-side. + QSqlQuery *expiryQuery = prepareQuery("select UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where id = :id"); + expiryQuery->bindValue(":id", shareId); + if (!execSqlQuery(expiryQuery) || !expiryQuery->next()) { + sqlDatabase.rollback(); + return false; + } + expiresAt = expiryQuery->value(0).toLongLong(); + 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, " @@ -1113,11 +1128,7 @@ bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, ":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(":tags", serializeDeckTags(item.tags)); itemQuery->bindValue(":banner_card", item.bannerCard); itemQuery->bindValue(":game_format", item.gameFormat); itemQuery->bindValue(":color_identity", item.colorIdentity); @@ -1129,7 +1140,10 @@ bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, } } - sqlDatabase.commit(); + if (!sqlDatabase.commit()) { + sqlDatabase.rollback(); + return false; + } return true; } @@ -1163,10 +1177,7 @@ bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token, 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.tags = deserializeDeckTags(itemQuery->value(2).toString()); item.bannerCard = itemQuery->value(3).toString(); item.gameFormat = itemQuery->value(4).toString(); item.colorIdentity = itemQuery->value(5).toString(); @@ -1201,6 +1212,45 @@ void Servatrice_DatabaseInterface::cleanupExpiredDeckShares() execSqlQuery(query); } +bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList &shares) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select s.id, s.name, UNIX_TIMESTAMP(s.created_at), " + "UNIX_TIMESTAMP(s.expires_at), count(i.id) from {prefix}_deck_share s left join " + "{prefix}_deck_share_item i on i.share_id = s.id where s.created_by = :created_by " + "group by s.id, s.name, s.created_at, s.expires_at order by s.created_at desc"); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + + shares.clear(); + while (query->next()) { + DeckShareSummaryRecord summary; + summary.id = query->value(0).toInt(); + summary.name = query->value(1).toString(); + summary.creationTime = query->value(2).toLongLong(); + summary.expiresAt = query->value(3).toLongLong(); + summary.itemCount = query->value(4).toInt(); + shares.append(summary); + } + return true; +} + +bool Servatrice_DatabaseInterface::deleteDeckShare(int shareId, int userId) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where id = :id and created_by = :created_by"); + query->bindValue(":id", shareId); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + 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 e3f64c623..ce51a2474 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -29,6 +29,16 @@ struct DeckShareItemRecord QString content; ///< Deck content (native format); empty in list queries. }; +/** @brief Summary of a share bundle owned by a user. */ +struct DeckShareSummaryRecord +{ + int id = -1; ///< Database id, used for revocation. + QString name; ///< Share name. + qint64 creationTime = 0; ///< Unix timestamp at which the share was created. + qint64 expiresAt = 0; ///< Unix timestamp at which the share expires. + int itemCount = 0; ///< Number of decks in the bundle. +}; + class Servatrice_DatabaseInterface : public Server_DatabaseInterface { Q_OBJECT @@ -92,12 +102,24 @@ public: const QList &replayList) override; DeckList *getDeckFromDatabase(int deckId, int userId) override; - /** @brief Creates a new temporary deck share bundle. Returns false on failure. */ + /** + * @brief Creates a new temporary deck share bundle. + * @param expiresAt Receives the actual expiry read back from the database. + * @return false on failure. + */ bool createDeckShare(const QString &token, const QString &name, int userId, const QList &items, - int expiryDays); + int expiryDays, + qint64 &expiresAt); + /** @brief Lists the share bundles created by a user, newest first. */ + bool getDeckSharesForUser(int userId, QList &shares); + /** + * @brief Deletes one of a user's own share bundles (cascades to its items). + * @return false if no such bundle belongs to the user. + */ + bool deleteDeckShare(int shareId, int userId); /** * @brief Looks up a valid (non-expired) share bundle by token. * @return false if the token is unknown or expired. diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 9bab597ee..07bc227f4 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -20,6 +20,7 @@ #include "serversocketinterface.h" +#include "deck_tag_serialization.h" #include "email_parser.h" #include "main.h" #include "servatrice.h" @@ -57,6 +58,8 @@ #include #include #include +#include +#include #include #include #include @@ -92,6 +95,7 @@ #include #include #include +#include #include #include #include @@ -341,6 +345,10 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm 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_LIST_MINE: + return cmdDeckShareListMine(cmd.GetExtension(Command_DeckShareListMine::ext), rc); + case SessionCommand::DECK_SHARE_REMOVE: + return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc); case SessionCommand::DECK_SHARE_DOWNLOAD: return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: @@ -652,7 +660,9 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId, 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()); + for (const QString &tag : deserializeDeckTags(query->value(7).toString())) { + newFile->add_tags(tag.toStdString()); + } } return true; @@ -775,9 +785,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const return Response::RespNameNotFound; } - query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id"); + query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id " + "and id_user = :id_user"); query->bindValue(":is_public", cmd.is_public() ? 1 : 0); query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); if (!sqlInterface->execSqlQuery(query)) { return Response::RespContextError; } @@ -788,9 +800,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const } QSqlQuery *query = - sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id"); + sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id " + "and id_user = :id_user"); query->bindValue(":is_public", cmd.is_public() ? 1 : 0); query->bindValue(":id", folderId); + query->bindValue(":id_user", userInfo->id()); if (!sqlInterface->execSqlQuery(query)) { return Response::RespContextError; } @@ -939,6 +953,22 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D return Response::RespOk; } +namespace +{ +/** @brief Keeps only the WUBRG colors from a color identity string, deduplicated. */ +QString sanitizeColorIdentity(const QString &colorIdentity) +{ + QString sanitized; + for (const QChar &color : colorIdentity) { + const QChar upper = color.toUpper(); + if (QStringLiteral("WUBRG").contains(upper) && !sanitized.contains(upper)) { + sanitized.append(upper); + } + } + return sanitized; +} +} // namespace + Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc) { @@ -963,6 +993,14 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman deckName = "Unnamed deck"; } + // The server derives the banner card and tags from the deck itself. Only the + // color identity must come from the client, since the server has no card + // database to compute it. All values are bounded to the column sizes. + const QString bannerCardName = deck.getBannerCard().name.left(255); + const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); + const QString tagsJson = serializeDeckTags(deck.getTags()); + const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); + if (cmd.has_path()) { int folderId = getDeckPathId(nameFromStdString(cmd.path())); if (folderId == -1) { @@ -979,11 +1017,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman 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); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); @@ -1002,11 +1042,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman 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); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } if (query->numRowsAffected() == 0) { return Response::RespNameNotFound; @@ -1017,7 +1059,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman "id_user = :id_user"); visibilityQuery->bindValue(":id", cmd.deck_id()); visibilityQuery->bindValue(":id_user", userInfo->id()); - sqlInterface->execSqlQuery(visibilityQuery); + if (!sqlInterface->execSqlQuery(visibilityQuery)) { + return Response::RespContextError; + } const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool(); Response_DeckUpload *re = new Response_DeckUpload; @@ -1077,14 +1121,7 @@ DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &c 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.colorIdentity = sanitizeColorIdentity(colorIdentity); item.content = deck.writeToString_Native(); return item; } @@ -1099,6 +1136,25 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C sqlInterface->checkSql(); + const int maxItems = servatrice->getDeckShareMaxDecksPerShare(); + if (maxItems > 0 && cmd.items_size() > maxItems) { + return Response::RespInvalidData; + } + + // Per-user rate limit so share links cannot be used to build an unbounded + // word-of-mouth leak of public decks. + const int maxSharesPerDay = servatrice->getDeckShareMaxSharesPerDay(); + if (maxSharesPerDay > 0) { + QSqlQuery *countQuery = sqlInterface->prepareQuery("select count(*) from {prefix}_deck_share where " + "created_by = :created_by and created_at >= " + "DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":created_by", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxSharesPerDay) { + return Response::RespTooManyRequests; + } + } + QList items; if (cmd.items_size() > 0) { for (const DeckShareItem &shareItem : cmd.items()) { @@ -1126,15 +1182,24 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C if (folderId == -1) { return Response::RespNameNotFound; } + + // Drain the deck list before resolving each deck: getDeckFromDatabase + // issues its own query on the same cached statement set. 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); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + QList deckIds; while (query->next()) { + deckIds.append(query->value(0).toInt()); + } + for (const int deckId : deckIds) { DeckList *deck; try { - deck = sqlInterface->getDeckFromDatabase(query->value(0).toInt(), userInfo->id()); + deck = sqlInterface->getDeckFromDatabase(deckId, userInfo->id()); } catch (Response::ResponseCode &r) { return r; } @@ -1145,13 +1210,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C return Response::RespInvalidData; } - if (items.isEmpty()) { + if (items.isEmpty() || (maxItems > 0 && items.size() > maxItems)) { return Response::RespInvalidData; } - const int maxItems = servatrice->getDeckShareMaxDecksPerShare(); - if (items.size() > maxItems) { - return Response::RespTooManyRequests; - } QString shareName = nameFromStdString(cmd.name()); if (shareName.isEmpty()) { @@ -1159,20 +1220,69 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C } const QString token = generateShareToken(); - if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays())) { + qint64 expiresAt = 0; + if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays(), + expiresAt)) { 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_expires_at(expiresAt); re->set_item_count(items.size()); rc.setResponseExtension(re); return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareListMine(const Command_DeckShareListMine & /*cmd*/, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QList shares; + if (!sqlInterface->getDeckSharesForUser(userInfo->id(), shares)) { + return Response::RespContextError; + } + + Response_DeckShareListMine *re = new Response_DeckShareListMine; + for (const DeckShareSummaryRecord &share : shares) { + ServerInfo_DeckShareSummary *summary = re->add_shares(); + summary->set_id(share.id); + summary->set_name(share.name.toStdString()); + summary->set_creation_time(share.creationTime); + summary->set_expires_at(share.expiresAt); + summary->set_item_count(share.itemCount); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareRemove(const Command_DeckShareRemove &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!cmd.has_share_id()) { + return Response::RespInvalidData; + } + + sqlInterface->checkSql(); + + if (!sqlInterface->deleteDeckShare(cmd.share_id(), userInfo->id())) { + return Response::RespNameNotFound; + } + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 0fa565e26..36b900d28 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -55,6 +55,8 @@ class Command_DeckUpload; class Command_DeckSetVisibility; class Command_DeckShareCreate; class Command_DeckShareList; +class Command_DeckShareListMine; +class Command_DeckShareRemove; class Command_DeckShareDownload; class Command_ReplayList; class Command_ReplayDownload; @@ -124,6 +126,8 @@ private: 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 cmdDeckShareListMine(const Command_DeckShareListMine &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareRemove(const Command_DeckShareRemove &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);