From ab4373d025a892b7cdd1385f3a4c98c5c60ff232 Mon Sep 17 00:00:00 2001 From: RickyRister <42636155+RickyRister@users.noreply.github.com> Date: Sun, 24 Aug 2025 19:40:44 -0700 Subject: [PATCH] Implement replay sharing (#6066) * new protos * implement commands on server * add buttons * icons * run formatter * Message on get replay code failure * Add new commands to switch statement * Better failure messages * Fix permission check query * Change hash method * Prevent adding duplicate replays * Clean up TabReplay ui * Copy over replay name * base64 encode the hash * Shorten hash * Better failure messages * change icon back to search icon * check hash before checking if user already has access * update share icon * Update label text --- cockatrice/cockatrice.qrc | 1 + cockatrice/resources/icons/share.svg | 25 ++++ cockatrice/src/client/tabs/tab_replays.cpp | 139 ++++++++++++++++++++- cockatrice/src/client/tabs/tab_replays.h | 9 +- common/pb/CMakeLists.txt | 3 + common/pb/command_replay_get_code.proto | 9 ++ common/pb/command_replay_submit_code.proto | 9 ++ common/pb/response.proto | 1 + common/pb/response_replay_get_code.proto | 9 ++ common/pb/session_commands.proto | 2 + servatrice/src/serversocketinterface.cpp | 137 ++++++++++++++++++++ servatrice/src/serversocketinterface.h | 5 + 12 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 cockatrice/resources/icons/share.svg create mode 100644 common/pb/command_replay_get_code.proto create mode 100644 common/pb/command_replay_submit_code.proto create mode 100644 common/pb/response_replay_get_code.proto diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 72ca1d83f..67cde480e 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -33,6 +33,7 @@ resources/icons/scales.svg resources/icons/search.svg resources/icons/settings.svg + resources/icons/share.svg resources/icons/spectator.svg resources/icons/swap.svg resources/icons/sync.svg diff --git a/cockatrice/resources/icons/share.svg b/cockatrice/resources/icons/share.svg new file mode 100644 index 000000000..a84f55847 --- /dev/null +++ b/cockatrice/resources/icons/share.svg @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/cockatrice/src/client/tabs/tab_replays.cpp b/cockatrice/src/client/tabs/tab_replays.cpp index 59f19a9c4..e08cd35fb 100644 --- a/cockatrice/src/client/tabs/tab_replays.cpp +++ b/cockatrice/src/client/tabs/tab_replays.cpp @@ -6,15 +6,19 @@ #include "../../settings/cache_settings.h" #include "pb/command_replay_delete_match.pb.h" #include "pb/command_replay_download.pb.h" +#include "pb/command_replay_get_code.pb.h" #include "pb/command_replay_modify_match.pb.h" +#include "pb/command_replay_submit_code.pb.h" #include "pb/event_replay_added.pb.h" #include "pb/game_replay.pb.h" #include "pb/response.pb.h" #include "pb/response_replay_download.pb.h" +#include "pb/response_replay_get_code.pb.h" #include "tab_game.h" #include #include +#include #include #include #include @@ -129,13 +133,26 @@ QGroupBox *TabReplays::createRightLayout() serverDirView = new RemoteReplayList_TreeWidget(client); // Right side layout - QToolBar *toolBar = new QToolBar; + /* put an invisible dummy QToolBar in the leftmost column so that the main toolbar is centered. + * Really ugly workaround, but I couldn't figure out the proper way to make it centered */ + QToolBar *dummyToolBar = new QToolBar(this); + QSizePolicy sizePolicy = dummyToolBar->sizePolicy(); + sizePolicy.setRetainSizeWhenHidden(true); + dummyToolBar->setSizePolicy(sizePolicy); + dummyToolBar->setVisible(false); + + QToolBar *toolBar = new QToolBar(this); toolBar->setOrientation(Qt::Horizontal); toolBar->setIconSize(QSize(32, 32)); - QHBoxLayout *toolBarLayout = new QHBoxLayout; - toolBarLayout->addStretch(); - toolBarLayout->addWidget(toolBar); - toolBarLayout->addStretch(); + + QToolBar *rightmostToolBar = new QToolBar(this); + rightmostToolBar->setOrientation(Qt::Horizontal); + rightmostToolBar->setIconSize(QSize(32, 32)); + + QGridLayout *toolBarLayout = new QGridLayout; + toolBarLayout->addWidget(dummyToolBar, 0, 0, Qt::AlignLeft); + toolBarLayout->addWidget(toolBar, 0, 1, Qt::AlignHCenter); + toolBarLayout->addWidget(rightmostToolBar, 0, 2, Qt::AlignRight); QVBoxLayout *vbox = new QVBoxLayout; vbox->addWidget(serverDirView); @@ -157,12 +174,22 @@ QGroupBox *TabReplays::createRightLayout() aDeleteRemoteReplay = new QAction(this); aDeleteRemoteReplay->setIcon(QPixmap("theme:icons/remove_row")); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); + aGetReplayCode = new QAction(this); + aGetReplayCode->setIcon(QPixmap("theme:icons/share")); + connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); + + aSubmitReplayCode = new QAction(this); + aSubmitReplayCode->setIcon(QPixmap("theme:icons/search")); + connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars toolBar->addAction(aOpenRemoteReplay); toolBar->addAction(aDownload); toolBar->addAction(aKeep); toolBar->addAction(aDeleteRemoteReplay); + toolBar->addAction(aGetReplayCode); + + rightmostToolBar->addAction(aSubmitReplayCode); return groupBox; } @@ -181,6 +208,9 @@ void TabReplays::retranslateUi() aDownload->setText(tr("Download replay")); aKeep->setText(tr("Toggle expiration lock")); aDeleteRemoteReplay->setText(tr("Delete")); + aGetReplayCode->setText(tr("Get replay share code")); + + aSubmitReplayCode->setText(tr("Look up replay by share code")); } void TabReplays::handleConnected(const ServerInfo_User &userInfo) @@ -204,6 +234,8 @@ void TabReplays::setRemoteEnabled(bool enabled) aDownload->setEnabled(enabled); aKeep->setEnabled(enabled); aDeleteRemoteReplay->setEnabled(enabled); + aGetReplayCode->setEnabled(enabled); + aSubmitReplayCode->setEnabled(enabled); if (enabled) { serverDirView->refreshTree(); @@ -480,13 +512,108 @@ void TabReplays::deleteRemoteReplayFinished(const Response &r, const CommandCont serverDirView->removeMatchInfo(cmd.game_id()); } +void TabReplays::actGetReplayCode() +{ + const auto curRights = serverDirView->getSelectedReplayMatches(); + if (curRights.isEmpty()) { + return; + } + + for (const auto curRight : curRights) { + Command_ReplayGetCode cmd; + cmd.set_game_id(curRight->game_id()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReplays::getReplayCodeFinished); + client->sendCommand(pend); + } +} + +void TabReplays::getReplayCodeFinished(const Response &r, const CommandContainer & /*commandContainer*/) +{ + if (r.response_code() == Response::RespFunctionNotAllowed) { + QMessageBox msgBox; + msgBox.setIcon(QMessageBox::Warning); + msgBox.setText(tr("Failed to get code")); + msgBox.setInformativeText( + tr("Either this server does not support replay sharing, or does not permit replay sharing for you.")); + msgBox.exec(); + return; + } + + if (r.response_code() != Response::RespOk) { + QMessageBox::warning(this, tr("Failed"), tr("Could not get replay code")); + return; + } + + const Response_ReplayGetCode &resp = r.GetExtension(Response_ReplayGetCode::ext); + QString code = QString::fromStdString(resp.replay_code()); + + QMessageBox msgBox; + msgBox.setText(tr("Replay Share Code")); + msgBox.setInformativeText( + tr("Others can use this code to add the replay to their list of remote replays:\n%1").arg(code)); + msgBox.setStandardButtons(QMessageBox::Ok); + QPushButton *copyToClipboardButton = msgBox.addButton(tr("Copy to clipboard"), QMessageBox::ActionRole); + connect(copyToClipboardButton, &QPushButton::clicked, this, [code] { QApplication::clipboard()->setText(code); }); + msgBox.setDefaultButton(copyToClipboardButton); + msgBox.exec(); +} + +void TabReplays::actSubmitReplayCode() +{ + bool ok; + QString code = QInputDialog::getText(this, tr("Look up replay by share code"), tr("Replay share code"), + QLineEdit::Normal, "", &ok); + + if (!ok) { + return; + } + + Command_ReplaySubmitCode cmd; + cmd.set_replay_code(code.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReplays::submitReplayCodeFinished); + client->sendCommand(pend); +} + +void TabReplays::submitReplayCodeFinished(const Response &r, const CommandContainer & /*commandContainer*/) +{ + switch (r.response_code()) { + case Response::RespOk: { + QMessageBox msgBox; + msgBox.setIcon(QMessageBox::Information); + msgBox.setText(tr("Replay code found")); + msgBox.setInformativeText(tr("Replay was added, or you already had access to it.")); + msgBox.exec(); + break; + } + case Response::RespNameNotFound: + QMessageBox::warning(this, tr("Failed"), tr("Replay code not found")); + break; + case Response::RespFunctionNotAllowed: { + QMessageBox msgBox; + msgBox.setIcon(QMessageBox::Warning); + msgBox.setText(tr("Failed to submit code")); + msgBox.setInformativeText( + tr("Either this server does not support replay sharing, or does not permit replay sharing for you.")); + msgBox.exec(); + break; + } + default: + QMessageBox::warning(this, tr("Failed"), tr("Unexpected error")); + break; + } +} + void TabReplays::replayAddedEventReceived(const Event_ReplayAdded &event) { if (event.has_match_info()) { // 99.9% of events will have match info (Normal Workflow) serverDirView->addMatchInfo(event.match_info()); } else { - // When a Moderator force adds a replay, we need to refresh their view + // When a Moderator force adds a replay or a user submits a replay code, we need to refresh their view serverDirView->refreshTree(); } } diff --git a/cockatrice/src/client/tabs/tab_replays.h b/cockatrice/src/client/tabs/tab_replays.h index dafdcb5ae..af794535a 100644 --- a/cockatrice/src/client/tabs/tab_replays.h +++ b/cockatrice/src/client/tabs/tab_replays.h @@ -28,7 +28,8 @@ private: QAction *aOpenLocalReplay, *aRenameLocal, *aNewLocalFolder, *aDeleteLocalReplay; QAction *aOpenReplaysFolder; - QAction *aOpenRemoteReplay, *aDownload, *aKeep, *aDeleteRemoteReplay; + QAction *aOpenRemoteReplay, *aDownload, *aKeep, *aDeleteRemoteReplay, *aGetReplayCode; + QAction *aSubmitReplayCode; QGroupBox *createLeftLayout(); QGroupBox *createRightLayout(); @@ -62,6 +63,12 @@ private slots: void actDeleteRemoteReplay(); void deleteRemoteReplayFinished(const Response &r, const CommandContainer &commandContainer); + void actGetReplayCode(); + void getReplayCodeFinished(const Response &r, const CommandContainer &commandContainer); + + void actSubmitReplayCode(); + void submitReplayCodeFinished(const Response &r, const CommandContainer &commandContainer); + void replayAddedEventReceived(const Event_ReplayAdded &event); signals: void openReplay(GameReplay *replay); diff --git a/common/pb/CMakeLists.txt b/common/pb/CMakeLists.txt index e8af9ef46..43775a810 100644 --- a/common/pb/CMakeLists.txt +++ b/common/pb/CMakeLists.txt @@ -37,6 +37,8 @@ set(PROTO_FILES command_replay_list.proto command_replay_download.proto command_replay_modify_match.proto + command_replay_get_code.proto + command_replay_submit_code.proto command_reveal_cards.proto command_reverse_turn.proto command_roll_die.proto @@ -130,6 +132,7 @@ set(PROTO_FILES response_password_salt.proto response_register.proto response_replay_download.proto + response_replay_get_code.proto response_replay_list.proto response_viewlog_history.proto response_warn_history.proto diff --git a/common/pb/command_replay_get_code.proto b/common/pb/command_replay_get_code.proto new file mode 100644 index 000000000..e4573153c --- /dev/null +++ b/common/pb/command_replay_get_code.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReplayGetCode { + extend SessionCommand { + optional Command_ReplayGetCode ext = 1104; + } + optional sint32 game_id = 1 [default = -1]; +} diff --git a/common/pb/command_replay_submit_code.proto b/common/pb/command_replay_submit_code.proto new file mode 100644 index 000000000..73c5d0ba0 --- /dev/null +++ b/common/pb/command_replay_submit_code.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReplaySubmitCode { + extend SessionCommand { + optional Command_ReplaySubmitCode ext = 1105; + } + optional string replay_code = 1; +} diff --git a/common/pb/response.proto b/common/pb/response.proto index 18f4249c4..a63819a8e 100644 --- a/common/pb/response.proto +++ b/common/pb/response.proto @@ -65,6 +65,7 @@ message Response { GET_ADMIN_NOTES = 1018; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; + REPLAY_GET_CODE = 1102; } required uint64 cmd_id = 1; optional ResponseCode response_code = 2; diff --git a/common/pb/response_replay_get_code.proto b/common/pb/response_replay_get_code.proto new file mode 100644 index 000000000..5bb1b511a --- /dev/null +++ b/common/pb/response_replay_get_code.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ReplayGetCode { + extend Response { + optional Response_ReplayGetCode ext = 1102; + } + optional string replay_code = 1; +} diff --git a/common/pb/session_commands.proto b/common/pb/session_commands.proto index 64b3cd872..cecf87370 100644 --- a/common/pb/session_commands.proto +++ b/common/pb/session_commands.proto @@ -31,6 +31,8 @@ message SessionCommand { REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; REPLAY_DELETE_MATCH = 1103; + REPLAY_GET_CODE = 1104; + REPLAY_SUBMIT_CODE = 1105; } extensions 100 to max; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index bccefc438..f0d4771a7 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -31,13 +31,16 @@ #include "pb/command_deck_upload.pb.h" #include "pb/command_replay_delete_match.pb.h" #include "pb/command_replay_download.pb.h" +#include "pb/command_replay_get_code.pb.h" #include "pb/command_replay_list.pb.h" #include "pb/command_replay_modify_match.pb.h" +#include "pb/command_replay_submit_code.pb.h" #include "pb/commands.pb.h" #include "pb/event_add_to_list.pb.h" #include "pb/event_connection_closed.pb.h" #include "pb/event_notify_user.pb.h" #include "pb/event_remove_from_list.pb.h" +#include "pb/event_replay_added.pb.h" #include "pb/event_server_identification.pb.h" #include "pb/event_server_message.pb.h" #include "pb/event_user_message.pb.h" @@ -50,6 +53,7 @@ #include "pb/response_password_salt.pb.h" #include "pb/response_register.pb.h" #include "pb/response_replay_download.pb.h" +#include "pb/response_replay_get_code.pb.h" #include "pb/response_replay_list.pb.h" #include "pb/response_viewlog_history.pb.h" #include "pb/response_warn_history.pb.h" @@ -179,6 +183,10 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdReplayModifyMatch(cmd.GetExtension(Command_ReplayModifyMatch::ext), rc); case SessionCommand::REPLAY_DELETE_MATCH: return cmdReplayDeleteMatch(cmd.GetExtension(Command_ReplayDeleteMatch::ext), rc); + case SessionCommand::REPLAY_GET_CODE: + return cmdReplayGetCode(cmd.GetExtension(Command_ReplayGetCode::ext), rc); + case SessionCommand::REPLAY_SUBMIT_CODE: + return cmdReplaySubmitCode(cmd.GetExtension(Command_ReplaySubmitCode::ext), rc); case SessionCommand::REGISTER: return cmdRegisterAccount(cmd.GetExtension(Command_Register::ext), rc); break; @@ -735,6 +743,135 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplayDeleteMatch(const return query->numRowsAffected() > 0 ? Response::RespOk : Response::RespNameNotFound; } +/** + * Generates a hash for the given replay folder, used for auth when replay sharing. + * This is a separate function in case we change the hash implementation in the future. + * + * Currently, we append together the first 128 bytes of the first 3 replays in the game. + * Then we md5 hash it, base64 encode it, and truncate the result to 10 characters. + * + * @param gameId The replay match to hash + * @return The hash as a QString. Returns an empty string if failed + */ +QString AbstractServerSocketInterface::createHashForReplay(int gameId) +{ + QSqlQuery *query = + sqlInterface->prepareQuery("select replay from {prefix}_replays where id_game = :id_game limit 3"); + query->bindValue(":id_game", gameId); + + if (!sqlInterface->execSqlQuery(query)) + return ""; + + QByteArray replaysBytes; + while (query->next()) { + QByteArray replay = query->value(0).toByteArray(); + replay.truncate(128); + replaysBytes.append(replay); + } + + auto hash = + QCryptographicHash::hash(replaysBytes, QCryptographicHash::Md5).toBase64(QByteArray::OmitTrailingEquals); + hash.truncate(10); + return hash; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReplayGetCode(const Command_ReplayGetCode &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) + return Response::RespFunctionNotAllowed; + + // Check that user has access to replay match + { + QSqlQuery *query = sqlInterface->prepareQuery( + "select 1 from {prefix}_replays_access where id_game = :id_game and id_player = :id_player"); + query->bindValue(":id_game", cmd.game_id()); + query->bindValue(":id_player", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) + return Response::RespInternalError; + if (!query->next()) + return Response::RespAccessDenied; + } + + QString hash = createHashForReplay(cmd.game_id()); + if (hash.isEmpty()) { + return Response::RespInternalError; + } + + // code is of the form - + QString code = QString(QString::number(cmd.game_id()) + "-" + hash); + + Response_ReplayGetCode *re = new Response_ReplayGetCode; + re->set_replay_code(code.toStdString()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, + ResponseContainer & /*rc*/) +{ + // code is of the form - + QString code = QString::fromStdString(cmd.replay_code()); + QStringList split = code.split("-"); + if (split.size() != 2) { + // always return the same error response if code is incorrect, to not leak info to user + return Response::RespNameNotFound; + } + QString gameId = split[0]; + QString hash = split[1]; + + // Determine if the replay actually exists (and grab the replay name while at it) + auto *replayExistsQuery = + sqlInterface->prepareQuery("select replay_name from {prefix}_replays_access where id_game = :id_game limit 1"); + replayExistsQuery->bindValue(":id_game", gameId); + if (!sqlInterface->execSqlQuery(replayExistsQuery)) { + return Response::RespInternalError; + } + if (!replayExistsQuery->next()) { + return Response::RespNameNotFound; + } + + const auto &replayName = replayExistsQuery->value(0).toString(); + + // Check if hash is correct + if (hash != createHashForReplay(gameId.toInt())) { + return Response::RespNameNotFound; + } + + // Determine if user already has access to replay + auto *alreadyAccessQuery = sqlInterface->prepareQuery( + "select 1 from {prefix}_replays_access where id_game = :id_game and id_player = :id_player"); + alreadyAccessQuery->bindValue(":id_game", gameId); + alreadyAccessQuery->bindValue(":id_player", userInfo->id()); + if (!sqlInterface->execSqlQuery(alreadyAccessQuery)) { + return Response::RespInternalError; + } + if (alreadyAccessQuery->next()) { + return Response::RespOk; + } + + // Grant the User access to the replay + auto *grantReplayAccessQuery = + sqlInterface->prepareQuery("insert into {prefix}_replays_access (id_game, id_player, replay_name, do_not_hide) " + "values(:idgame, :idplayer, :replayname, 0)"); + grantReplayAccessQuery->bindValue(":idgame", gameId); + grantReplayAccessQuery->bindValue(":idplayer", userInfo->id()); + grantReplayAccessQuery->bindValue(":replayname", replayName); + + if (!sqlInterface->execSqlQuery(grantReplayAccessQuery)) { + return Response::RespInternalError; + } + + // update user's view + Event_ReplayAdded event; + SessionEvent *se = prepareSessionEvent(event); + sendProtocolItem(*se); + delete se; + + return Response::RespOk; +} + // MODERATOR FUNCTIONS. // May be called by admins and moderators. Permission is checked by the calling function. Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 88d0fc549..54a852fd7 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -44,6 +44,8 @@ class Command_ReplayList; class Command_ReplayDownload; class Command_ReplayModifyMatch; class Command_ReplayDeleteMatch; +class Command_ReplayGetCode; +class Command_ReplaySubmitCode; class Command_BanFromServer; class Command_UpdateServerMessage; @@ -97,6 +99,9 @@ private: Response::ResponseCode cmdReplayDownload(const Command_ReplayDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayDeleteMatch(const Command_ReplayDeleteMatch &cmd, ResponseContainer &rc); + QString createHashForReplay(int gameId); + Response::ResponseCode cmdReplayGetCode(const Command_ReplayGetCode &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, ResponseContainer &rc); Response::ResponseCode cmdBanFromServer(const Command_BanFromServer &cmd, ResponseContainer &rc); Response::ResponseCode cmdWarnUser(const Command_WarnUser &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc);