Address server review comments for deck share links

This commit is contained in:
Lukas Brübach 2026-09-18 22:56:51 +02:00
parent 8f674aab78
commit 30ed2a69a0
19 changed files with 348 additions and 66 deletions

View file

@ -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

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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;

View file

@ -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`;

View file

@ -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

View file

@ -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,

View file

@ -0,0 +1,39 @@
#ifndef DECK_TAG_SERIALIZATION_H
#define DECK_TAG_SERIALIZATION_H
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonValue>
#include <QString>
#include <QStringList>
/**
* @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

View file

@ -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();

View file

@ -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;

View file

@ -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<DeckShareItemRecord> &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<DeckShareSummaryRecord> &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,

View file

@ -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<GameReplay *> &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<DeckShareItemRecord> &items,
int expiryDays);
int expiryDays,
qint64 &expiresAt);
/** @brief Lists the share bundles created by a user, newest first. */
bool getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &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.

View file

@ -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 <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_share_list_mine.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_remove.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>
@ -92,6 +95,7 @@
#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_share_list_mine.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>
@ -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<DeckShareItemRecord> 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<int> 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<DeckShareSummaryRecord> 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)
{

View file

@ -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);