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
This commit is contained in:
RickyRister 2025-08-24 19:40:44 -07:00 committed by GitHub
parent 5e88a0f0cc
commit ab4373d025
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 342 additions and 7 deletions

View file

@ -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 <game-id>-<hash>
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 <game-id>-<hash>
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,