diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto index 1a5d44e9e..89ad94899 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -14,4 +14,7 @@ message Command_DeckUpload { reserved 5, 6, 8; reserved "banner_card_name", "banner_card_provider", "tags"; optional string color_identity = 7; + // Stable per-deck identity for two-way collection sync. Sent on first + // synced upload, echoed back in ServerInfo_DeckStorage_File afterwards. + optional string guid = 9; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_download.proto index f1839a9e2..11a72763a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_download.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_download.proto @@ -6,4 +6,9 @@ message Response_DeckDownload { optional Response_DeckDownload ext = 1007; } optional string deck = 1; + // Sync identity echoed so the client can update its sidecar without an + // extra list fetch. + optional string guid = 2; + optional string hash = 3; + optional uint64 last_modified = 4; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index b04d676d8..04dd37cf5 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -11,6 +11,15 @@ message ServerInfo_DeckStorage_File { // Tag names associated with the deck. Empty for decks uploaded before the // tags column existed. repeated string tags = 6; + // Stable per-deck identity for two-way collection sync. Empty for decks + // uploaded before the sync migration until they are re-uploaded. + optional string guid = 7; + // Content hash (DeckList::getDeckHash) for change detection without + // downloading the full list. + optional string hash = 8; + // Last modification time, seconds since Unix epoch. Drives newest-wins + // default and conflict-dialog recency highlight. + optional uint64 last_modified = 9; } message ServerInfo_DeckStorage_Folder { diff --git a/servatrice/migrations/servatrice_0037_to_0038.sql b/servatrice/migrations/servatrice_0037_to_0038.sql new file mode 100644 index 000000000..c3c823f9c --- /dev/null +++ b/servatrice/migrations/servatrice_0037_to_0038.sql @@ -0,0 +1,32 @@ +-- Servatrice db migration from version 37 to version 38 + +-- Deck collection sync + versioning. +-- +-- 1. Stable per-deck identity and change detection for two-way sync: +-- guid links a local file to its server row across renames, hash detects +-- content changes without downloading, last_modified drives newest-wins +-- default and conflict highlighting, deleted implements tombstones so a +-- delete on one device does not resurrect from the other. +-- 2. Limited per-deck version history: every hash-changing upload inserts a +-- row; the server prunes beyond the configured retention (default 20). + +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `guid` char(36) NULL AFTER `tags`, + ADD COLUMN `hash` char(40) NULL AFTER `guid`, + ADD COLUMN `last_modified` datetime NULL AFTER `hash`, + ADD COLUMN `deleted` tinyint(1) NOT NULL DEFAULT 0 AFTER `last_modified`; + +CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `deck_id` int(7) unsigned zerofill NOT NULL, + `version_no` int(7) unsigned NOT NULL, + `content` text NOT NULL, + `hash` char(40) NULL, + `created_at` datetime NOT NULL, + `created_by_clientid` varchar(15) NULL, + PRIMARY KEY (`id`), + KEY `deck_id` (`deck_id`), + FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +UPDATE cockatrice_schema_version SET version=38 WHERE version=37; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index c625705da..209f616f7 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -479,3 +479,12 @@ max_decks_per_share=50 ; A value of 0 disables the limit. ; Default: 50 max_shares_per_day=50 + + +[deck_sync] + +; Maximum number of stored versions kept per deck. Each upload with changed +; content adds a version; older versions beyond this limit are pruned. +; Minimum enforced: 1. +; Default: 20 +max_versions=20 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 4fbc1d8bd..0143ee6bd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -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(37); +INSERT INTO cockatrice_schema_version VALUES(38); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -71,11 +71,30 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` ( `banner_card_provider` varchar(32) NULL, `color_identity` varchar(5) NULL, `tags` text NULL, + `guid` char(36) NULL, + `hash` char(40) NULL, + `last_modified` datetime NULL, + `deleted` tinyint(1) NOT NULL DEFAULT 0, 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; +-- Per-deck version history for collection sync. +-- One row per hash-changing upload; pruned beyond retention (default 20). +CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `deck_id` int(7) unsigned zerofill NOT NULL, + `version_no` int(7) unsigned NOT NULL, + `content` text NOT NULL, + `hash` char(40) NULL, + `created_at` datetime NOT NULL, + `created_by_clientid` varchar(15) NULL, + PRIMARY KEY (`id`), + KEY `deck_id` (`deck_id`), + FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`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` ( diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 0329a31fd..a979a74b2 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -1122,6 +1122,11 @@ int Servatrice::getDeckShareMaxSharesPerDay() const return settingsCache->value("deck_share/max_shares_per_day", 50).toInt(); } +int Servatrice::getDeckSyncMaxVersions() const +{ + return qMax(1, settingsCache->value("deck_sync/max_versions", 20).toInt()); +} + int Servatrice::getNumberOfTCPPools() const { return settingsCache->value("server/number_pools", 1).toInt(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 242d445ad..a6bbacfc6 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -292,6 +292,7 @@ public: int getDeckShareCleanupInterval() const; int getDeckShareMaxDecksPerShare() const; int getDeckShareMaxSharesPerDay() const; + int getDeckSyncMaxVersions() const; int getMaxUsersPerAddress() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0509d1a03..ee8074171 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -21,7 +21,7 @@ class QSqlQuery; -#define DATABASE_SCHEMA_VERSION 37 +#define DATABASE_SCHEMA_VERSION 38 class Servatrice; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index d46a9b0ee..4c58748a6 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -70,6 +70,7 @@ #include #include #include +#include #include #include #include @@ -681,8 +682,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId, } 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"); + "banner_card_provider, color_identity, tags, guid, hash, last_modified from " + "{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { @@ -708,6 +709,12 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId, for (const QString &tag : deserializeDeckTags(query->value(7).toString())) { newFile->add_tags(tag.toStdString()); } + newFile->set_guid(query->value(8).toString().toStdString()); + newFile->set_hash(query->value(9).toString().toStdString()); + const QDateTime lastModified = query->value(10).toDateTime(); + if (lastModified.isValid()) { + newFile->set_last_modified(lastModified.toSecsSinceEpoch()); + } } return true; @@ -1014,6 +1021,38 @@ QString sanitizeColorIdentity(const QString &colorIdentity) } } // namespace +bool AbstractServerSocketInterface::storeDeckVersion(int deckId, const QString &content, const QString &hash) +{ + QSqlQuery *maxQuery = + sqlInterface->prepareQuery("select coalesce(max(version_no), 0) from {prefix}_deck_versions where deck_id = " + ":deck_id"); + maxQuery->bindValue(":deck_id", deckId); + if (!sqlInterface->execSqlQuery(maxQuery) || !maxQuery->next()) { + return false; + } + const int nextVersion = maxQuery->value(0).toInt() + 1; + + QSqlQuery *insertQuery = + sqlInterface->prepareQuery("insert into {prefix}_deck_versions (deck_id, version_no, content, hash, " + "created_at, created_by_clientid) values(:deck_id, :version_no, :content, :hash, " + "NOW(), :created_by_clientid)"); + insertQuery->bindValue(":deck_id", deckId); + insertQuery->bindValue(":version_no", nextVersion); + insertQuery->bindValue(":content", content); + insertQuery->bindValue(":hash", hash); + insertQuery->bindValue(":created_by_clientid", QString::fromStdString(userInfo->clientid())); + if (!sqlInterface->execSqlQuery(insertQuery)) { + return false; + } + + QSqlQuery *pruneQuery = sqlInterface->prepareQuery("delete from {prefix}_deck_versions where deck_id = :deck_id " + "and version_no <= :min_kept_version"); + pruneQuery->bindValue(":deck_id", deckId); + pruneQuery->bindValue(":min_kept_version", nextVersion - servatrice->getDeckSyncMaxVersions()); + sqlInterface->execSqlQuery(pruneQuery); + return true; +} + Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc) { @@ -1049,6 +1088,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); const QString tagsJson = serializeDeckTags(deck.getTags()); const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); + const QString deckHash = deck.getDeckHash(); + QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString(); + if (QUuid::fromString(guid).isNull()) { + guid = QUuid::createUuid().toString(QUuid::WithoutBraces); + } if (cmd.has_path()) { int folderId = getDeckPathId(nameFromStdString(cmd.path())); @@ -1059,8 +1103,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman QSqlQuery *query = sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, " "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)"); + "tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), " + ":content, :is_public, :banner_card_name, :banner_card_provider, " + ":color_identity, :tags, :guid, :hash, NOW())"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); @@ -1070,21 +1115,63 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman query->bindValue(":banner_card_provider", bannerCardProvider); query->bindValue(":color_identity", colorIdentity); query->bindValue(":tags", tagsJson); + query->bindValue(":guid", guid); + query->bindValue(":hash", deckHash); if (!sqlInterface->execSqlQuery(query)) { return Response::RespContextError; } + const int deckId = query->lastInsertId().toInt(); + if (!storeDeckVersion(deckId, deckStr, deckHash)) { + QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id"); + cleanupQuery->bindValue(":id", deckId); + sqlInterface->execSqlQuery(cleanupQuery); + return Response::RespContextError; + } + + QSqlQuery *stampQuery = + sqlInterface->prepareQuery("select unix_timestamp(upload_time), unix_timestamp(last_modified) from " + "{prefix}_decklist_files where id = :id and id_user = :id_user"); + stampQuery->bindValue(":id", deckId); + stampQuery->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) { + QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id"); + cleanupQuery->bindValue(":id", deckId); + sqlInterface->execSqlQuery(cleanupQuery); + 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_id(deckId); fileInfo->set_name(deckName.toStdString()); - fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_creation_time(stampQuery->value(0).toUInt()); fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public()); + fileInfo->mutable_file()->set_guid(guid.toStdString()); + fileInfo->mutable_file()->set_hash(deckHash.toStdString()); + fileInfo->mutable_file()->set_last_modified(stampQuery->value(1).toULongLong()); rc.setResponseExtension(re); } else if (cmd.has_deck_id()) { + QSqlQuery *currentQuery = + sqlInterface->prepareQuery("select guid, hash from {prefix}_decklist_files where id = :id and id_user = " + ":id_user"); + currentQuery->bindValue(":id", cmd.deck_id()); + currentQuery->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(currentQuery)) { + return Response::RespContextError; + } + if (!currentQuery->next()) { + return Response::RespNameNotFound; + } + if (!QUuid::fromString(currentQuery->value(0).toString()).isNull()) { + guid = currentQuery->value(0).toString(); + } else if (cmd.has_guid() && !QUuid::fromString(nameFromStdString(cmd.guid()).left(36)).isNull()) { + guid = nameFromStdString(cmd.guid()).left(36); + } + const QString oldHash = currentQuery->value(1).toString(); + 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"; + "color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()"; if (cmd.has_is_public()) { updateQuery += ", is_public=:is_public"; } @@ -1099,33 +1186,56 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman query->bindValue(":banner_card_provider", bannerCardProvider); query->bindValue(":color_identity", colorIdentity); query->bindValue(":tags", tagsJson); + query->bindValue(":guid", guid); + query->bindValue(":hash", deckHash); 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; } + if (oldHash != deckHash) { + if (!storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) { + return Response::RespContextError; + } + } else { + // Self-heal a version gap left by an earlier failed version write: + // a same-content re-upload must still leave a version behind. + QSqlQuery *versionQuery = + sqlInterface->prepareQuery("select count(*) from {prefix}_deck_versions where deck_id = :deck_id and " + "hash = :hash"); + versionQuery->bindValue(":deck_id", cmd.deck_id()); + versionQuery->bindValue(":hash", deckHash); + if (!sqlInterface->execSqlQuery(versionQuery) || !versionQuery->next()) { + return Response::RespContextError; + } + if (versionQuery->value(0).toInt() == 0 && !storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) { + return Response::RespContextError; + } + } + QSqlQuery *visibilityQuery = - sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and " - "id_user = :id_user"); + sqlInterface->prepareQuery("select is_public, unix_timestamp(upload_time), unix_timestamp(last_modified) " + "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)) { + if (!sqlInterface->execSqlQuery(visibilityQuery) || !visibilityQuery->next()) { 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); + fileInfo->mutable_file()->set_creation_time(visibilityQuery->value(1).toUInt()); + fileInfo->mutable_file()->set_is_public(visibilityQuery->value(0).toBool()); + fileInfo->mutable_file()->set_guid(guid.toStdString()); + fileInfo->mutable_file()->set_hash(deckHash.toStdString()); + fileInfo->mutable_file()->set_last_modified(visibilityQuery->value(2).toULongLong()); rc.setResponseExtension(re); } else { return Response::RespInvalidData; @@ -1147,11 +1257,29 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm } catch (Response::ResponseCode &r) { return r; } + const QString deckNative = deck->writeToString_Native(); + delete deck; + + QSqlQuery *syncQuery = + sqlInterface->prepareQuery("select guid, hash, last_modified from {prefix}_decklist_files where id = :id and " + "id_user = :id_user"); + syncQuery->bindValue(":id", cmd.deck_id()); + syncQuery->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(syncQuery)) { + return Response::RespContextError; + } Response_DeckDownload *re = new Response_DeckDownload; - re->set_deck(deck->writeToString_Native().toStdString()); + re->set_deck(deckNative.toStdString()); + if (syncQuery->next()) { + re->set_guid(syncQuery->value(0).toString().toStdString()); + re->set_hash(syncQuery->value(1).toString().toStdString()); + const QDateTime lastModified = syncQuery->value(2).toDateTime(); + if (lastModified.isValid()) { + re->set_last_modified(lastModified.toSecsSinceEpoch()); + } + } rc.setResponseExtension(re); - delete deck; return Response::RespOk; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 27708ed27..6f000d478 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -154,6 +154,7 @@ private: Response::ResponseCode cmdDeckDelDir(const Command_DeckDelDir &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckDel(const Command_DeckDel &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc); + bool storeDeckVersion(int deckId, const QString &content, const QString &hash); DeckList *getDeckFromDatabase(int deckId); Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);