Cockatrice/cockatrice/src/interface/widgets/server/user/user_info_box.cpp
BruebachL d5d99e4dfb
[Server/Client/Protocol] Add developer staff role (#7211)
* [Server/Client/Protocol] Add developer staff role

Introduce a Developer staff level (proto flag 32, DB admin bit 8) that
sits between admin and moderator: no kick/ban/warn/report/admin powers,
but gets server log access via a new developer command container family
(GET_SERVER_STATS, VIEWLOG_HISTORY) and an idle-timeout exemption.

- Protocol: IsDeveloper flag, developer_commands.proto envelope,
  Command_GetServerStats/Command_GetLogHistory, Response_GetServerStats,
  Command_AdjustMod.should_be_developer
- Servatrice: fail-closed developer dispatcher, uptime snapshot handler,
  shared log history handler reuse, bit-8 DB mapping
- Client: burgundy pawn/badge/labels/sort order, prepareDeveloperCommand,
  minimal Developer stats tab, log tab access, promote/demote actions

Took 24 minutes

Took 18 seconds

* [Server/Client/Protocol] Address developer role review feedback

Address ZeizaZach's review of the developer staff role:

- Nudge the developer log query to exclude private chat and sender IPs
  (the ModeratorCommand path still sees everything).
- Deduplicate Command_GetLogHistory into Command_ViewLogHistory, which now
  extends both ModeratorCommand (ext) and DeveloperCommand (dev_ext); the
  client picks the DeveloperCommand-scoped extension by extendee, and the
  server reads it via the extension number.
- Pull the uptime snapshot SQL into Servatrice_DatabaseInterface as
  getLatestUptimeSnapshot() and widen the reported counters to 64-bit.
- Document the admin bitfield (1 admin, 2 moderator, 4 judge, 8 developer)
  and add a server-side test for the developer command path.

* Add missing trailing newline to user_context_menu.cpp

* Remove stale includes of deleted command_get_log_history proto

The Command_GetLogHistory message was folded into Command_ViewLogHistory,
which deleted command_get_log_history.proto, but serversocketinterface
still #included its generated header. Fresh CI builds fail on the missing
file; local builds masked it by reusing a previously generated header.

* [Server] Exclude chat rows when private-chat filter is bypassable

A developer who omits log_location entirely — or sends only "chat" —
leaves chatType, gameType, roomType all false, so getMessageLogHistory
skips the target_type clause and returns every row, private messages
included. When !allowPrivateChat the server now forces game+room when
no surviving location was requested, guaranteeing the query always
carries a target_type restriction.

[Client] Demote mod+dev to moderator path in log-tab dispatch

The developer command family is strictly weaker than the moderator one
(no private chat, no sender_ip, ip filter ignored), so granting the
developer bit to an existing moderator must not silently strip their
capabilities. useDeveloperCommands is now true only when the user holds
the developer bit and not the moderator bit.

[Client] Hide the IP-address filter for developer log tab users

The developer path ignores the ip_address query field server-side.
Showing the field lets a developer type an IP and get results that are
silently unfiltered by it rather than an empty result set — reads as a
broken filter. Hide labelFindIPAddress/findIPAddress alongside the
privateChat checkbox.

* Developer pawn is silver.

* [Client] Fix indentation of merged Card Art Rules / Developer tabs

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:18:56 +02:00

436 lines
16 KiB
C++

#include "user_info_box.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/widgets/dialogs/dlg_edit_avatar.h"
#include "../../interface/widgets/dialogs/dlg_edit_password.h"
#include "../../interface/widgets/dialogs/dlg_edit_user.h"
#include "../../interface/widgets/utility/get_text_with_max.h"
#include "user_card_settings_dialog.h"
#include <QDateTime>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel>
#include <QMessageBox>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/passwordhasher.h>
UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *parent, Qt::WindowFlags flags)
: QWidget(parent, flags), client(_client), editable(_editable)
{
QFont nameFont = nameLabel.font();
nameFont.setBold(true);
nameFont.setPointSizeF(nameFont.pointSizeF() * 1.5);
nameLabel.setFont(nameFont);
auto *userIconAndNameLayout = new QHBoxLayout;
userIconAndNameLayout->addWidget(&userLevelIcon, 0, Qt::AlignCenter);
userIconAndNameLayout->addWidget(&nameLabel, 1);
userIconAndNameLayout->addStretch();
avatarPic.setMinimumSize(200, 200);
avatarPic.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
avatarPic.setAlignment(Qt::AlignCenter);
auto *avatarLayout = new QHBoxLayout;
avatarLayout->setContentsMargins(0, 0, 0, 0);
avatarLayout->addStretch(1);
avatarLayout->addWidget(&avatarPic, 3);
avatarLayout->addStretch(1);
auto *mainLayout = new QGridLayout;
mainLayout->addLayout(avatarLayout, 0, 0, 1, 3);
mainLayout->addLayout(userIconAndNameLayout, 1, 0, 1, 3);
mainLayout->addWidget(&realNameLabel1, 2, 0, 1, 1);
mainLayout->addWidget(&realNameLabel2, 2, 1, 1, 2);
mainLayout->addWidget(&countryLabel1, 3, 0, 1, 1);
mainLayout->addWidget(&countryLabel2, 3, 1, 1, 1, Qt::AlignCenter);
mainLayout->addWidget(&countryLabel3, 3, 2, 1, 1);
mainLayout->addWidget(&userLevelLabel1, 4, 0, 1, 1);
mainLayout->addWidget(&userLevelLabel2, 4, 1, 1, 2);
mainLayout->addWidget(&accountAgeLabel1, 5, 0, 1, 1);
mainLayout->addWidget(&accountAgeLabel2, 5, 1, 1, 2);
mainLayout->setColumnStretch(2, 10);
if (editable) {
auto *buttonsLayout = new QHBoxLayout;
buttonsLayout->addWidget(&editButton);
buttonsLayout->addWidget(&passwordButton);
buttonsLayout->addWidget(&avatarButton);
buttonsLayout->addWidget(&bannerCardButton);
mainLayout->addLayout(buttonsLayout, 7, 0, 1, 3);
connect(&editButton, &QPushButton::clicked, this, &UserInfoBox::actEdit);
connect(&passwordButton, &QPushButton::clicked, this, &UserInfoBox::actPassword);
connect(&avatarButton, &QPushButton::clicked, this, &UserInfoBox::actAvatar);
connect(&bannerCardButton, &QPushButton::clicked, this, &UserInfoBox::actBannerCard);
}
setWindowTitle(tr("User Information"));
setLayout(mainLayout);
retranslateUi();
}
void UserInfoBox::retranslateUi()
{
realNameLabel1.setText(tr("Real Name:"));
countryLabel1.setText(tr("Location:"));
userLevelLabel1.setText(tr("User Level:"));
accountAgeLabel1.setText(tr("Account Age:"));
editButton.setText(tr("Edit"));
passwordButton.setText(tr("Change password"));
avatarButton.setText(tr("Change avatar"));
bannerCardButton.setText(tr("Edit Banner Card"));
}
void UserInfoBox::updateInfo(const ServerInfo_User &user)
{
currentUserInfo = user;
hasUserInfo = true;
const UserLevelFlags userLevel(user.user_level());
pawnColors = user.pawn_colors();
privLevel = QString::fromStdString(user.privlevel());
const std::string &bmp = user.avatar_bmp();
if (!avatarPixmap.loadFromData((const uchar *)bmp.data(), static_cast<uint>(bmp.size()))) {
avatarPixmap = UserLevelPixmapGenerator::generatePixmap(64, userLevel, pawnColors, false, privLevel);
hasAvatar = false;
} else {
hasAvatar = true;
}
nameLabel.setText(QString::fromStdString(user.name()));
realNameLabel2.setText(QString::fromStdString(user.real_name()));
QString country = QString::fromStdString(user.country());
if (country.length() != 0) {
countryLabel2.setPixmap(CountryPixmapGenerator::generatePixmap(13, country));
countryLabel3.setText(QString("%1").arg(country.toUpper()));
} else {
countryLabel2.setText("");
countryLabel3.setText("");
}
userLevelIcon.setPixmap(UserLevelPixmapGenerator::generatePixmap(15, userLevel, pawnColors, false, privLevel));
QString userLevelText;
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
userLevelText = tr("Administrator");
} else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
userLevelText = tr("Developer");
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
userLevelText = tr("Moderator");
} else if (userLevel.testFlag(ServerInfo_User::IsRegistered)) {
userLevelText = tr("Registered user");
} else {
userLevelText = tr("Unregistered user");
}
if (userLevel.testFlag(ServerInfo_User::IsJudge)) {
userLevelText += " | " + tr("Judge");
}
if (user.has_privlevel() && user.privlevel() != "NONE") {
userLevelText += " | " + QString("%1").arg(user.privlevel().c_str());
}
userLevelLabel2.setText(userLevelText);
QString accountAgeString = tr("Unregistered user");
if (userLevel.testFlag(ServerInfo_User::IsAdmin) || userLevel.testFlag(ServerInfo_User::IsModerator) ||
userLevel.testFlag(ServerInfo_User::IsRegistered)) {
accountAgeString = getAgeString(user.accountage_secs());
}
accountAgeLabel2.setText(accountAgeString);
}
QString UserInfoBox::getAgeString(int ageSeconds)
{
QString accountAgeString = tr("Unknown");
if (ageSeconds <= 0) {
return accountAgeString;
}
// secsSinceEpoch is in utc
auto secsSinceEpoch = QDateTime::currentSecsSinceEpoch() - ageSeconds;
// the date is in local time, fromSecsSinceEpoch expects a timestamp from utc and converts it to local time
auto date = QDateTime::fromSecsSinceEpoch(secsSinceEpoch).date();
if (!date.isValid()) {
return accountAgeString;
}
// now can be local time as the date is also local time
auto now = QDate::currentDate();
auto daysAndYears = getDaysAndYearsBetween(date, now);
QString dateString = QLocale().toString(date, QLocale::ShortFormat);
QString yearString;
if (daysAndYears.second > 0) {
yearString = tr("%n Year(s), ", "amount of years (only shown if more than 0)", daysAndYears.second);
}
accountAgeString =
tr("%10%n Day(s) %20", "amount of years (if more than 0), amount of days, date in local short format",
daysAndYears.first)
.arg(yearString)
.arg(dateString);
return accountAgeString;
}
void UserInfoBox::updateInfo(const QString &userName)
{
Command_GetUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserInfoBox::processResponse);
client->sendCommand(pend);
}
void UserInfoBox::processResponse(const Response &r)
{
const Response_GetUserInfo &response = r.GetExtension(Response_GetUserInfo::ext);
updateInfo(response.user_info());
resize(sizeHint());
show();
}
void UserInfoBox::actEdit()
{
Command_GetUserInfo cmd;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserInfoBox::actEditInternal);
client->sendCommand(pend);
}
void UserInfoBox::actEditInternal(const Response &r)
{
const Response_GetUserInfo &response = r.GetExtension(Response_GetUserInfo::ext);
const ServerInfo_User &user = response.user_info();
QString email = QString::fromStdString(user.email());
QString country = QString::fromStdString(user.country());
QString realName = QString::fromStdString(user.real_name());
DlgEditUser dlg(this, email, country, realName);
if (!dlg.exec()) {
return;
}
Command_AccountEdit cmd;
cmd.set_real_name(dlg.getRealName().toStdString());
if (client->getServerSupportsPasswordHash()) {
if (email != dlg.getEmail()) {
// real password is required to change email
bool ok = false;
QString password =
getTextWithMax(this, tr("Enter Password"),
tr("Password verification is required in order to change your email address"),
QLineEdit::Password, "", &ok);
if (!ok) {
return;
}
cmd.set_password_check(password.toStdString());
cmd.set_email(dlg.getEmail().toStdString());
} // servers that support password hash do not require all fields to be filled anymore
} else {
cmd.set_email(dlg.getEmail().toStdString());
}
cmd.set_country(dlg.getCountry().toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserInfoBox::processEditResponse);
client->sendCommand(pend);
}
void UserInfoBox::actPassword()
{
DlgEditPassword dlg(this);
if (!dlg.exec()) {
return;
}
auto oldPassword = dlg.getOldPassword();
auto newPassword = dlg.getNewPassword();
if (client->getServerSupportsPasswordHash()) {
Command_RequestPasswordSalt cmd;
cmd.set_user_name(client->getUserName().toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, [=, this](const Response &response) {
if (response.response_code() == Response::RespOk) {
changePassword(oldPassword, newPassword);
} else {
QMessageBox::critical(this, tr("Error"),
tr("An error occurred while trying to update your user information."));
}
});
client->sendCommand(pend);
} else {
changePassword(oldPassword, newPassword);
}
}
void UserInfoBox::changePassword(const QString &oldPassword, const QString &newPassword)
{
Command_AccountPassword cmd;
cmd.set_old_password(oldPassword.toStdString());
if (client->getServerSupportsPasswordHash()) {
auto passwordSalt = PasswordHasher::generateRandomSalt();
QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt);
cmd.set_hashed_new_password(hashedPassword.toStdString());
} else {
cmd.set_new_password(newPassword.toStdString());
}
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserInfoBox::processPasswordResponse);
client->sendCommand(pend);
}
void UserInfoBox::actAvatar()
{
DlgEditAvatar dlg(this);
if (!dlg.exec()) {
return;
}
Command_AccountImage cmd;
cmd.set_image(dlg.getImage().data(), dlg.getImage().size());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserInfoBox::processAvatarResponse);
client->sendCommand(pend);
}
void UserInfoBox::actBannerCard()
{
CardArtParams initial;
if (hasUserInfo && currentUserInfo.has_card_art_params()) {
const auto &cap = currentUserInfo.card_art_params();
initial.cardName = QString::fromStdString(cap.card_name());
initial.cardProviderId = QString::fromStdString(cap.card_provider_id());
initial.marginPctL = cap.margin_pct_l();
initial.marginPctR = cap.margin_pct_r();
initial.verticalOffset = cap.vertical_offset();
initial.zoom = cap.zoom();
}
UserCardArtSettingsDialog dlg(initial, this);
if (dlg.exec() != QDialog::Accepted) {
return;
}
const CardArtParams p = dlg.params();
Command_SetCardArtParams cmd;
cmd.set_card_name(p.cardName.toStdString());
if (!p.cardName.isEmpty()) {
cmd.set_card_provider_id(p.cardProviderId.toStdString());
cmd.set_margin_pct_l(p.marginPctL);
cmd.set_margin_pct_r(p.marginPctR);
cmd.set_vertical_offset(p.verticalOffset);
cmd.set_zoom(p.zoom);
}
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, [p, this](const Response &r) {
if (r.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"),
tr("The selected card is blacklisted on this server or another error occurred."));
} else {
updateInfo(nameLabel.text()); // re-fetch so currentUserInfo reflects the change
QMessageBox::information(this, tr("Information"),
p.cardName.isEmpty() ? tr("Banner card removed.") : tr("Banner card updated."));
}
});
client->sendCommand(pend);
}
void UserInfoBox::processEditResponse(const Response &r)
{
switch (r.response_code()) {
case Response::RespOk:
updateInfo(nameLabel.text());
QMessageBox::information(this, tr("Information"), tr("User information updated."));
break;
case Response::RespFunctionNotAllowed:
QMessageBox::critical(this, tr("Error"),
tr("This server does not permit you to update your user informations."));
break;
case Response::RespWrongPassword:
QMessageBox::critical(this, tr("Error"), tr("The entered password does not match your account."));
break;
case Response::RespInternalError:
default:
QMessageBox::critical(this, tr("Error"),
tr("An error occurred while trying to update your user information."));
break;
}
}
void UserInfoBox::processPasswordResponse(const Response &r)
{
switch (r.response_code()) {
case Response::RespOk:
QMessageBox::information(this, tr("Information"), tr("Password changed."));
break;
case Response::RespFunctionNotAllowed:
QMessageBox::critical(this, tr("Error"), tr("This server does not permit you to change your password."));
break;
case Response::RespPasswordTooShort:
QMessageBox::critical(this, tr("Error"), tr("The new password is too short."));
break;
case Response::RespWrongPassword:
QMessageBox::critical(this, tr("Error"), tr("The old password is incorrect."));
break;
case Response::RespInternalError:
default:
QMessageBox::critical(this, tr("Error"),
tr("An error occurred while trying to update your user information."));
break;
}
}
void UserInfoBox::processAvatarResponse(const Response &r)
{
switch (r.response_code()) {
case Response::RespOk:
updateInfo(nameLabel.text());
QMessageBox::information(this, tr("Information"), tr("Avatar updated."));
break;
case Response::RespFunctionNotAllowed:
QMessageBox::critical(this, tr("Error"), tr("This server does not permit you to update your avatar."));
break;
case Response::RespInternalError:
default:
QMessageBox::critical(this, tr("Error"), tr("An error occured while trying to update your avatar."));
break;
}
}
void UserInfoBox::resizeEvent(QResizeEvent *event)
{
QPixmap resizedPixmap;
if (hasAvatar) {
resizedPixmap = avatarPixmap.scaled(avatarPic.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
} else {
int height = qMin(avatarPic.size().width(), avatarPic.size().height());
resizedPixmap = UserLevelPixmapGenerator::generatePixmap(height, userLevel, pawnColors, false, privLevel);
}
avatarPic.setPixmap(resizedPixmap);
QWidget::resizeEvent(event);
}