[Server] Add deck share links and public deck visibility (#7241)

* [Server] Add deck share links and public deck visibility

* Address server review comments for deck share links

* Document transaction teardown in deck share rollback paths

* Address second round of deck share review comments

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-20 20:22:16 +02:00 committed by GitHub
parent a5e94d8a4f
commit 073ec29c4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1202 additions and 23 deletions

View file

@ -15,9 +15,17 @@ 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_share_list_mine.proto
command_deck_share_remove.proto
command_deck_upload.proto
command_del_counter.proto
command_delete_arrow.proto
@ -137,6 +145,10 @@ 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_share_list_mine.proto
response_deck_upload.proto
response_dump_zone.proto
response_forgotpasswordrequest.proto
@ -175,6 +187,8 @@ set(PROTO_FILES
serverinfo_cardcounter.proto
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,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckDownloadPublic {
extend SessionCommand {
optional Command_DeckDownloadPublic ext = 1031;
}
optional uint32 deck_id = 1;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -8,4 +8,10 @@ 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)
// 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;
}

View file

@ -81,6 +81,10 @@ 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
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,11 @@
syntax = "proto2";
import "response.proto";
message Response_DeckShareCreate {
extend Response {
optional Response_DeckShareCreate ext = 1103;
}
optional string token = 1;
optional uint64 expires_at = 2;
optional uint32 item_count = 3;
}

View file

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

View file

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

View file

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

View file

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

@ -1,10 +1,21 @@
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;
// Tag names associated with the deck. Empty for decks uploaded before the
// tags column existed.
repeated string tags = 6;
}
message ServerInfo_DeckStorage_Folder {
repeated ServerInfo_DeckStorage_TreeItem items = 1;
optional bool is_public = 2;
}
message ServerInfo_DeckStorage_TreeItem {

View file

@ -28,6 +28,14 @@ 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;
DECK_SHARE_LIST_MINE = 1032;
DECK_SHARE_REMOVE = 1033;
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102;

View file

@ -0,0 +1,71 @@
-- Servatrice db migration from version 36 to version 37
-- Deck sharing (temporary share links + permanent public decks).
--
-- This feature was developed behind several intermediate migrations that have
-- never shipped, so they are folded into this single 36 -> 37 migration:
-- temporary share links, permanent public-deck visibility, preview metadata,
-- and per-deck tags.
-- 1. Temporary deck shares: a named bundle of decks that can be fetched by
-- anyone who knows the (unguessable) token, until the share expires.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`token` varchar(64) COLLATE utf8mb4_bin 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 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 `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 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`;
UPDATE cockatrice_schema_version SET version=37 WHERE version=36;

View file

@ -452,3 +452,24 @@ 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
; Maximum number of share links a single user may create per day.
; A value of 0 disables the limit.
; Default: 50
max_shares_per_day=50

View file

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

View file

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

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

View file

@ -146,6 +146,7 @@ public:
private slots:
void statusUpdate();
void shutdownTimeout();
void cleanupExpiredDeckShares();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -159,6 +160,7 @@ private:
AuthenticationMethod authenticationMethod;
DatabaseType databaseType;
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
@ -276,6 +278,10 @@ public:
int getMaxGameInactivityTime() const override;
int getMaxPlayerInactivityTime() const override;
int getClientKeepAlive() const override;
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"
@ -7,6 +8,7 @@
#include <QChar>
#include <QDateTime>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
@ -1079,6 +1081,183 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
return deck;
}
bool Servatrice_DatabaseInterface::createDeckShare(const QString &token,
const QString &name,
int userId,
const QList<DeckShareItemRecord> &items,
int expiryDays,
qint64 &expiresAt)
{
checkSql();
if (items.isEmpty()) {
return false;
}
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))");
query->bindValue(":token", token);
query->bindValue(":name", name);
query->bindValue(":created_by", userId < 1 ? QVariant() : userId);
query->bindValue(":days", expiryDays);
if (!execSqlQuery(query)) {
// A failed execSqlQuery has already closed and reopened the connection,
// which implicitly discards the transaction; rollback below is a no-op.
sqlDatabase.rollback();
return false;
}
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()) {
// See the note above: after a failed execSqlQuery the transaction is
// already gone because the connection was torn down.
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, "
"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);
itemQuery->bindValue(":tags", serializeDeckTags(item.tags));
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)) {
// See the note above: the transaction is already gone after the
// reconnect performed by a failed execSqlQuery.
sqlDatabase.rollback();
return false;
}
}
if (!sqlDatabase.commit()) {
sqlDatabase.rollback();
return false;
}
return true;
}
bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token,
QString &name,
qint64 &expiresAt,
QList<DeckShareItemRecord> &items)
{
checkSql();
QSqlQuery *query =
prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = "
":token and expires_at > now()");
query->bindValue(":token", token);
execSqlQuery(query);
if (!query->next()) {
return false;
}
const int shareId = query->value(0).toInt();
name = query->value(1).toString();
expiresAt = query->value(2).toLongLong();
items.clear();
QSqlQuery *itemQuery =
prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item "
"where share_id = :share_id order by position");
itemQuery->bindValue(":share_id", shareId);
execSqlQuery(itemQuery);
while (itemQuery->next()) {
DeckShareItemRecord item;
item.id = itemQuery->value(0).toInt();
item.name = itemQuery->value(1).toString();
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();
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);
}
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 and s.expires_at > now() "
"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

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

View file

@ -20,6 +20,7 @@
#include "serversocketinterface.h"
#include "deck_tag_serialization.h"
#include "email_parser.h"
#include "main.h"
#include "servatrice.h"
@ -35,10 +36,12 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <QSqlError>
#include <QSqlQuery>
#include <QString>
#include <algorithm>
#include <game/server_player.h>
#include <google/protobuf/descriptor.h>
#include <iostream>
@ -47,8 +50,16 @@
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download_public.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list_other_user.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_set_visibility.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_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>
@ -81,6 +92,10 @@
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_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>
@ -277,6 +292,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdRemoveFromList(cmd.GetExtension(Command_RemoveFromList::ext), rc);
case SessionCommand::DECK_LIST:
return cmdDeckList(cmd.GetExtension(Command_DeckList::ext), rc);
case SessionCommand::DECK_LIST_OTHER_USER:
return cmdDeckListOtherUser(cmd.GetExtension(Command_DeckListOtherUser::ext), rc);
case SessionCommand::DECK_SET_VISIBILITY:
return cmdDeckSetVisibility(cmd.GetExtension(Command_DeckSetVisibility::ext), rc);
case SessionCommand::DECK_DOWNLOAD_PUBLIC:
return cmdDeckDownloadPublic(cmd.GetExtension(Command_DeckDownloadPublic::ext), rc);
case SessionCommand::DECK_NEW_DIR:
return cmdDeckNewDir(cmd.GetExtension(Command_DeckNewDir::ext), rc);
case SessionCommand::DECK_DEL_DIR:
@ -320,6 +341,16 @@ 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_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:
return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc);
case SessionCommand::REQUEST_PASSWORD_SALT:
@ -566,46 +597,73 @@ int AbstractServerSocketInterface::getDeckPathId(const QString &path)
return getDeckPathId(0, path.split("/"));
}
bool AbstractServerSocketInterface::deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder)
bool AbstractServerSocketInterface::deckListHelper(int folderId,
ServerInfo_DeckStorage_Folder *folder,
int userId,
bool inheritedPublic,
bool publicOnly)
{
QSqlQuery *query = sqlInterface->prepareQuery(
"select id, name from {prefix}_decklist_folders where id_parent = :id_parent and id_user = :id_user");
QSqlQuery *query = sqlInterface->prepareQuery("select id, name, is_public from {prefix}_decklist_folders where "
"id_parent = :id_parent and id_user = :id_user");
query->bindValue(":id_parent", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
QMap<int, QString> results;
QList<std::pair<int, std::pair<QString, bool>>> folderRows;
while (query->next()) {
results[query->value(0).toInt()] = query->value(1).toString();
folderRows.append({query->value(0).toInt(), {query->value(1).toString(), query->value(2).toBool()}});
}
std::sort(folderRows.begin(), folderRows.end(), [](const auto &a, const auto &b) { return a.first < b.first; });
for (const auto &[folderIdValue, folderInfo] : folderRows) {
const QString name = folderInfo.first;
const bool ownPublic = folderInfo.second;
const bool effectivePublic = inheritedPublic || ownPublic;
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;
}
if (publicOnly && !effectivePublic && newItem->mutable_folder()->items_size() == 0) {
folder->mutable_items()->RemoveLast();
}
}
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());
for (const QString &tag : deserializeDeckTags(query->value(7).toString())) {
newFile->add_tags(tag.toStdString());
}
}
return true;
@ -626,7 +684,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_
Response_DeckList *re = new Response_DeckList;
ServerInfo_DeckStorage_Folder *root = re->mutable_root();
if (!deckListHelper(0, root)) {
if (!deckListHelper(0, root, userInfo->id(), false, false)) {
return Response::RespContextError;
}
@ -634,6 +692,160 @@ 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 "
"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;
}
} 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 "
"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;
}
} 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*/)
{
@ -742,6 +954,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)
{
@ -766,6 +994,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) {
@ -774,38 +1010,74 @@ 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);
sqlInterface->execSqlQuery(query);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
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();
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");
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags";
if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public";
}
updateQuery += " where id = :id_deck and id_user = :id_user";
QSqlQuery *query = sqlInterface->prepareQuery(updateQuery);
query->bindValue(":id_deck", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
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 (cmd.has_is_public()) {
query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
}
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
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());
if (!sqlInterface->execSqlQuery(visibilityQuery)) {
return Response::RespContextError;
}
const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool();
Response_DeckUpload *re = new Response_DeckUpload;
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
fileInfo->set_id(cmd.deck_id());
fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
fileInfo->mutable_file()->set_is_public(isPublic);
rc.setResponseExtension(re);
} else {
return Response::RespInvalidData;
@ -836,6 +1108,248 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm
return Response::RespOk;
}
namespace
{
/** @brief Builds a cryptographically random, URL-safe share token. */
QString generateShareToken()
{
QByteArray bytes(32, Qt::Uninitialized);
QRandomGenerator::system()->fillRange(reinterpret_cast<quint32 *>(bytes.data()), bytes.size() / sizeof(quint32));
return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
/** @brief Extracts the share metadata for a deck, materializing its content. */
DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &colorIdentity)
{
DeckShareItemRecord item;
item.name = deck.getName();
if (item.name.isEmpty()) {
item.name = "Unnamed deck";
}
item.tags = deck.getTags();
item.bannerCard = deck.getBannerCard().name;
item.gameFormat = deck.getGameFormat();
item.colorIdentity = sanitizeColorIdentity(colorIdentity);
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();
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()) {
return Response::RespContextError;
}
if (countQuery->value(0).toInt() >= maxSharesPerDay) {
return Response::RespTooManyRequests;
}
}
QList<DeckShareItemRecord> items;
if (cmd.items_size() > 0) {
for (const DeckShareItem &shareItem : cmd.items()) {
if (shareItem.has_deck_list()) {
DeckList deck;
if (!deck.loadFromString_Native(fileFromStdString(shareItem.deck_list()))) {
return Response::RespContextError;
}
items.append(makeShareItemFromDeck(deck, nameFromStdString(shareItem.color_identity())));
} else if (shareItem.has_deck_id()) {
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(shareItem.deck_id(), userInfo->id());
} catch (Response::ResponseCode &r) {
return r;
}
items.append(makeShareItemFromDeck(*deck, nameFromStdString(shareItem.color_identity())));
delete deck;
} else {
return Response::RespInvalidData;
}
}
} else if (cmd.has_folder_path()) {
const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path()));
if (folderId == -1) {
return Response::RespNameNotFound;
}
// 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, color_identity 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());
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
QList<std::pair<int, QString>> deckRows;
while (query->next()) {
deckRows.append({query->value(0).toInt(), query->value(1).toString()});
}
for (const auto &[deckId, colorIdentity] : deckRows) {
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(deckId, userInfo->id());
} catch (Response::ResponseCode &r) {
return r;
}
items.append(makeShareItemFromDeck(*deck, colorIdentity));
delete deck;
}
} else {
return Response::RespInvalidData;
}
if (items.isEmpty() || (maxItems > 0 && items.size() > maxItems)) {
return Response::RespInvalidData;
}
QString shareName = nameFromStdString(cmd.name());
if (shareName.isEmpty()) {
shareName = "Shared decks";
}
const QString token = generateShareToken();
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(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)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QString name;
qint64 expiresAt = 0;
QList<DeckShareItemRecord> items;
if (!sqlInterface->getDeckShareList(nameFromStdString(cmd.token()), name, expiresAt, items)) {
return Response::RespNameNotFound;
}
Response_DeckShareList *re = new Response_DeckShareList;
re->set_name(name.toStdString());
re->set_expires_at(expiresAt);
for (const DeckShareItemRecord &item : items) {
ServerInfo_DeckShareItem *itemInfo = re->add_items();
itemInfo->set_id(item.id);
itemInfo->set_name(item.name.toStdString());
for (const QString &tag : item.tags) {
itemInfo->add_tags(tag.toStdString());
}
itemInfo->set_banner_card(item.bannerCard.toStdString());
itemInfo->set_game_format(item.gameFormat.toStdString());
itemInfo->set_color_identity(item.colorIdentity.toStdString());
}
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareDownload(const Command_DeckShareDownload &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QString content;
if (!sqlInterface->getDeckShareItem(nameFromStdString(cmd.token()), cmd.item_id(), content)) {
return Response::RespNameNotFound;
}
Response_DeckShareDownload *re = new Response_DeckShareDownload;
re->set_deck(content.toStdString());
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdReplayList(const Command_ReplayList & /*cmd*/,
ResponseContainer &rc)
{

View file

@ -45,11 +45,19 @@ 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_DeckShareListMine;
class Command_DeckShareRemove;
class Command_DeckShareDownload;
class Command_ReplayList;
class Command_ReplayDownload;
class Command_ReplayModifyMatch;
@ -97,8 +105,16 @@ private:
Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc);
int getDeckPathId(int basePathId, QStringList path);
int getDeckPathId(const QString &path);
bool deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder);
bool deckListHelper(int folderId,
ServerInfo_DeckStorage_Folder *folder,
int userId,
bool inheritedPublic,
bool publicOnly);
int getDeckOwnerId(int deckId);
bool isDeckEffectivelyPublic(int deckId);
Response::ResponseCode cmdDeckList(const Command_DeckList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer &rc);
void deckDelDirHelper(int basePathId);
void sendServerMessage(const QString userName, const QString message);
@ -107,6 +123,12 @@ 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 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);
Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc);