mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 09:05:10 -07:00
Address server review comments for deck share links
This commit is contained in:
parent
5d0a23dffb
commit
b7e4afa399
19 changed files with 348 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
39
servatrice/src/deck_tag_serialization.h
Normal file
39
servatrice/src/deck_tag_serialization.h
Normal 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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue