mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 00:55:09 -07:00
[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:
parent
a5e94d8a4f
commit
073ec29c4d
29 changed files with 1202 additions and 23 deletions
71
servatrice/migrations/servatrice_0036_to_0037.sql
Normal file
71
servatrice/migrations/servatrice_0036_to_0037.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue