From bbe438b09a59172589c9a747cf8ead4913471c59 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:01:38 +0200 Subject: [PATCH 01/13] [Completer] Highlight popup rows without rewriting the line edit (#7502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Completer] Highlight popup rows without rewriting the line edit Selecting a popup row with ClearAndSelect emits selectionChanged, which QCompleter turns into highlighted() and QLineEdit turns into setText(). Since be2c16eca5 (#7466) that rewrites the typed text on every completion reset / arrow-key press (e.g. typing "A" becomes "Axe" and cannot be edited), regressing the 514014dc00 (#7447) fix that used NoUpdate. Add selectWithoutRewrite(), which blocks the selection-model signals while applying ClearAndSelect so the highlight stays visible without touching the line edit, then syncs the preview manually. Route both the above-popup auto-select and Up/Down navigation through it, and handle Up/Down for below-popups too instead of falling through to Qt's rewriting default. Enter/click acceptance via activated() is unchanged. * [Completer] Repaint popup highlight after silent selection change Blocking the selection model's signals also suppresses the view's own selectionChanged/currentChanged repaints, so force a viewport update after restoring signals. Use EnsureVisible to match Qt's normal scroll behaviour. --------- Co-authored-by: Lukas Brübach --- .../widgets/utility/card_completer_styler.cpp | 61 ++++++++++++++----- .../widgets/utility/card_completer_styler.h | 1 + 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 8aa32556f..5b33303a0 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -133,29 +133,33 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event) return false; } - if (!above) { - return false; - } - auto *popup = completer->popup(); const int rowCount = completer->completionModel()->rowCount(); if (rowCount == 0) { return true; } + // Handle navigation ourselves so arrow keys move the highlight without + // rewriting the line edit. QCompleter's default handling selects with a + // selection change that QLineEdit turns into setText(), which fights the + // user while typing (e.g. typing "A" becomes "Axe" and cannot be edited). // With the popup above the text edit the rows are shown in reverse order and // the closest match is in the bottom-most row. The up arrow then advances // through the list (towards its end at the top of the popup). - const int currentRow = popup->currentIndex().row(); - const int step = (key == Qt::Key_Up) ? -1 : 1; - const int newRow = qBound(0, currentRow + step, rowCount - 1); - - if (newRow != currentRow) { - const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn()); - popup->selectionModel()->setCurrentIndex(newIndex, - QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + const QModelIndex current = popup->currentIndex(); + int newRow; + if (!current.isValid()) { + newRow = (key == Qt::Key_Down) ? 0 : rowCount - 1; + } else { + const int step = (key == Qt::Key_Up) ? -1 : 1; + newRow = qBound(0, current.row() + step, rowCount - 1); + if (newRow == current.row()) { + return true; + } } + selectWithoutRewrite(completer->completionModel()->index(newRow, completer->completionColumn())); + return true; } @@ -218,8 +222,37 @@ void CardCompleterStyler::ensureClosestSelected() const int closestRow = rowCount - 1; if (currentRow != closestRow) { - popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()), - QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + selectWithoutRewrite(completionModel->index(closestRow, completer->completionColumn())); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::selectWithoutRewrite(const QModelIndex &index) +{ + auto *popup = completer->popup(); + auto *selectionModel = popup->selectionModel(); + + // Changing the popup selection emits selectionChanged, which QCompleter + // turns into highlighted() and QLineEdit turns into setText(). That + // rewrites what the user typed (e.g. "A" becomes "Axe") and fights further + // typing. Block the signals so the highlight and preview move without + // touching the line edit; Enter/click still activates via activated(). + const bool wasBlocked = selectionModel->blockSignals(true); + if (!index.isValid()) { + selectionModel->clear(); + } else { + selectionModel->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } + selectionModel->blockSignals(wasBlocked); + popup->viewport()->update(); + + // currentRowChanged was suppressed above, so sync the preview manually. + updatePreview(index); + if (index.isValid()) { + popup->scrollTo(index, QAbstractItemView::EnsureVisible); + } else { + popup->scrollToTop(); } } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.h b/cockatrice/src/interface/widgets/utility/card_completer_styler.h index 2912f7312..04310e676 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.h @@ -55,6 +55,7 @@ private: void applyPopupWidth(); void updateOrientation(); void ensureClosestSelected(); + void selectWithoutRewrite(const QModelIndex &index); bool handlePopupKeyPress(QKeyEvent *event); bool isPopupAboveWidget() const; From b11b096828627c58fcb2ffcbcda33d0a48e383e4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:50:43 +0200 Subject: [PATCH 02/13] [Server] Apply card art rules to playmats (#7532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Apply card art rules to playmats Card art DENY rules were only enforced for user card art (SetCardArtParams) while playmats using the same art bypassed them. Move the rule lookup to Servatrice_DatabaseInterface::isCardArtAllowed with a default-allow base on Server_DatabaseInterface, delegate the session check to it, reject DENY-listed Command_SetPlaymat with RespFunctionNotAllowed, and strip DENY-listed deck playmats on DeckSelect so banned art is never broadcast. * [Server] Address review nits on playmat card art rules --------- Co-authored-by: Lukas Brübach --- .../server/remote/game/server_player.cpp | 21 ++++++++++++++++++ .../server/remote/server_database_interface.h | 10 +++++++++ .../src/servatrice_database_interface.cpp | 22 +++++++++++++++++++ .../src/servatrice_database_interface.h | 1 + servatrice/src/serversocketinterface.cpp | 22 +------------------ servatrice/src/serversocketinterface.h | 1 - 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index a4c904c01..949d04b4b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -257,6 +257,19 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r return Response::RespInternalError; } + // Card art rules apply to playmats as well: never broadcast a DENY-listed + // printing as a playmat. Stripping keeps the deck selectable while hiding + // the banned art from all clients. + const auto deckPlaymat = newDeck->getPlaymat(); + if (!deckPlaymat.card.name.isEmpty()) { + auto *db = game->getRoom()->getServer()->getDatabaseInterface(); + // Check the truncated values actually broadcast below; the rule columns are varchar(255). + if (db && !db->isCardArtAllowed(deckPlaymat.card.name.left(MAX_NAME_LENGTH), + deckPlaymat.card.providerId.left(MAX_NAME_LENGTH))) { + newDeck->setPlaymat({}); + } + } + delete deck; deck = newDeck; sideboardLocked = true; @@ -631,6 +644,14 @@ Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &r if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) { return Response::RespInvalidData; } + // Card art rules apply to playmats as well: reject DENY-listed art, like + // cmdSetCardArtParams does. Empty clears the playmat and is always allowed. + if (!rawName.isEmpty()) { + auto *db = game->getRoom()->getServer()->getDatabaseInterface(); + if (db && !db->isCardArtAllowed(rawName, rawProviderId)) { + return Response::RespFunctionNotAllowed; + } + } PlaymatInfo playmat; playmat.card.name = rawName; playmat.card.providerId = rawProviderId; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index e0be7990b..167adfeaf 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -188,6 +188,16 @@ public: virtual void setForcePasswordChange(const QString & /* user */, bool /* force */) { } + /** + * @brief Whether the given card art may be shown (card art rules). + * + * Default allows everything; the real server overrides this with a lookup + * in {prefix}_card_art_name_rules. Empty names are always allowed (clear path). + */ + virtual bool isCardArtAllowed(const QString & /* cardName */, const QString & /* providerId */) + { + return true; + } }; #endif diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index ac3a19c8e..e7d46c050 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -2068,3 +2068,25 @@ void Servatrice_DatabaseInterface::addAuditRecord(const QString &user, query->bindValue(":details", details); execSqlQuery(query); } + +bool Servatrice_DatabaseInterface::isCardArtAllowed(const QString &cardName, const QString &cardProviderId) +{ + if (cardName.isEmpty() || !checkSql()) { + return true; + } + QSqlQuery *q = prepareQuery( + "SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider"); + q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); + + if (!execSqlQuery(q)) { + qWarning() << "Card art rule lookup failed; failing open for" << cardName; + return true; + } + + if (!q->next()) { + return true; // default allow + } + + return q->value(0).toString() != "DENY"; +} diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0509d1a03..411360fa7 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -218,6 +218,7 @@ public: }; UptimeSnapshot getLatestUptimeSnapshot(int serverId); bool removeUserAvatar(const QString &userName); + bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override; bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; bool doesForgotPasswordExist(const QString &user); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index bf8d02fc7..b4d12599d 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -3481,26 +3481,6 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm return Response::RespOk; } -bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName, const QString &cardProviderId) -{ - QSqlQuery *q = sqlInterface->prepareQuery( - "SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider"); - - q->bindValue(":name", cardName); - q->bindValue(":provider", cardProviderId); - - if (!sqlInterface->execSqlQuery(q)) { - qWarning() << "Card art rule lookup failed; failing open for" << cardName; - return true; - } - - if (!q->next()) { - return true; // default allow - } - - return q->value(0).toString() != "DENY"; -} - Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer & /* rc */) { @@ -3527,7 +3507,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const return Response::RespOk; } - if (!isCardNameAllowed(cardName, cardProviderId)) { + if (!sqlInterface->isCardArtAllowed(cardName, cardProviderId)) { return Response::RespFunctionNotAllowed; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 7de742c66..586bff831 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -216,7 +216,6 @@ private: Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); - bool isCardNameAllowed(const QString &cardName, const QString &cardProviderId); Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &); Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &); Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &); From 711386574d80bfe0bcdd502bed259732f6c0d7a2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:50:57 +0200 Subject: [PATCH 03/13] [Client] Tell the user when their playmat art is blocked (#7533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Tell the user when their playmat art is blocked A server DENY rule silently drops banned playmat art: SetPlaymat fails with no handler and DeckSelect strips the mat while answering RespOk. Surface both cases with the same critical message box the banner card art flow uses. DeckSelect strips are detected by comparing the requested mat, passed via PendingCommand extraData, against the server-echoed deck. Notices dedupe per card and reset on success. * [Client] Gate playmat blocked notice on RespFunctionNotAllowed per review Only SetPlaymat with RespFunctionNotAllowed signals a card art DENY rule. Older servers answer RespInvalidCommand and strip the mat from DeckSelect, so drop the DeckSelect comparison to avoid false blacklisted popups on pre-playmat servers. --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../deckview/deck_view_container.cpp | 22 +++++++++++++++++++ .../deckview/deck_view_container.h | 8 ++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index c97767ec7..a6e541fca 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -354,6 +354,19 @@ void DeckViewContainer::resolveAndSendPlaymat() pp->set_vertical_offset(resolved.params.verticalOffset); pp->set_zoom(resolved.params.zoom); PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd); + const QString sentName = resolved.card.name; + connect(playmatPend, &PendingCommand::finished, this, [this, sentName](const Response &r) { + // Only RespFunctionNotAllowed signals a card art DENY rule. Older servers that predate + // playmats answer RespInvalidCommand and strip the mat from DeckSelect, which must stay silent. + if (r.response_code() == Response::RespFunctionNotAllowed) { + if (!sentName.isEmpty()) { + notifyPlaymatBlacklisted( + sentName, tr("The selected playmat card is blacklisted on this server, so it was not applied.")); + } + } else if (r.response_code() == Response::RespOk && !sentName.isEmpty()) { + lastBlacklistedPlaymatNotice.clear(); + } + }); parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId); } @@ -412,6 +425,15 @@ void DeckViewContainer::deckSelectFinished(const Response &r) switchToDeckLoadedView(); } +void DeckViewContainer::notifyPlaymatBlacklisted(const QString &cardName, const QString &message) +{ + if (cardName.isEmpty() || cardName == lastBlacklistedPlaymatNotice) { + return; + } + lastBlacklistedPlaymatNotice = cardName; + QMessageBox::critical(this, tr("Error"), message); +} + void DeckViewContainer::readyStart() { sendReadyStartCommand(!readyStartButton->getState()); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.h b/cockatrice/src/game_graphics/deckview/deck_view_container.h index 80b82b6e5..c064a5b5a 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -60,12 +60,14 @@ private: VisualDeckStorageWidget *visualDeckStorageWidget; TabGame *parentGame; int playerId; - int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode. - DeckList currentDeck; ///< Cached deck for live settings re-resolution. - PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode. + int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode. + DeckList currentDeck; ///< Cached deck for live settings re-resolution. + PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode. + QString lastBlacklistedPlaymatNotice; ///< Card name already reported as blacklisted, to avoid repeat popups. void tryCreateVisualDeckStorageWidget(); void sendReadyStartCommand(bool ready); + void notifyPlaymatBlacklisted(const QString &cardName, const QString &message); private slots: void switchToDeckSelectView(); void switchToDeckLoadedView(); From 6c660d6b21f2dec69ea34cddd84e08634d024d59 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:05:42 +0200 Subject: [PATCH 04/13] [Installer][Client] Back up user data before old uninstallers can delete it (#7503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer][Client] Back up user data before old uninstallers can delete it * [Installer][Client] Fix stale backup resurrection, batch session backups, extend coverage NSIS: clear stale %TEMP% backup and only arm restore when something was copied, directory-existence checks, back up loose .dec decks, drop downloaded pics but keep pics\CUSTOM. Helper: single session dir per run (ms timestamps), same-basename disambiguation, partial copies still report their location. * [Installer][Client] Fix review findings: throttle backups, NSIS old dir, portable messaging * [Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap * [Installer][Client] Fix review findings: sounds, session coverage, NSIS wording Client: back up data/sounds in portable mode and mention it in the warning; reuse a recent session only when complete and covering the current sources (source manifest), estimate size only for fresh copies, normalize dedup via isInsideDir. Helper: source manifest per session with sessionCoversSources(), clear out-params on all failure paths. NSIS: document defensive portable branch, merge backup over surviving data/ to repair partial deletes, copy (not move) wording in relocate, note .txt exclusion in deck notice. * [Installer][Client] Fix review findings: stale backup, empty reuse, relocate, portable warning NSIS: wipe TEMP backup once per run to avoid resurrecting deleted files, check data dir existence in relocate so dir-only trees are moved. Client: never reuse a recent session for an empty source set, only fall back to a covering session on copy failure, warn fresh portable installs instead of staying silent. * [Installer][Client] Fix review findings: usable session reuse, loose decks, NSIS pics, symlinks * [Installer][Client] Fix portable backup exclusions in offender merge * [Client] Split at-risk data warning into focused helpers Ricky's nit on the 200+ line warnAboutDataPathsInProgramDir: extract collection (portable/installed), backup, message building and dialog into named helpers. No behavior change, all tr() strings untouched. * [Client] Read path settings inside isPortableBackupExcluded * [Client] Make stateless at-risk helpers static * [Client] Extract at-risk data warning out of MainWindow * [Installer][Client] Fix review findings: subdir backup, portable warning, backup API NSIS: - Back up unknown top-level install subdirs (e.g. \decks), not just loose deck files, so the 3.1.1 -> new upgrade no longer loses data folders the old RMDir /r deletes before the client ever runs. Payload dirs (plugins/translations/sounds/themes) stay in place to avoid restoring stale DLLs; restore + relocate handle the new backup set and the one-time notice covers both decks and folders. - Restart the %TEMP% backup when the 32/64-bit passes point at different dirs instead of merging (which restored one dir's files into the other); document that only the last pass relocates and the %TEMP% copy is not a durable second copy. Client: - Drop the portable at-risk warning: portable installs never shipped an uninstaller and portable updates preserve data/, so every portable user got a spurious dialog plus daily host-machine copies. - AtRiskDataWarning is now a plain class with Q_DECLARE_TR_FUNCTIONS instead of QObject+Q_OBJECT; logging category renamed to at_risk_data_warning; MainWindow wrapper inlined into startupConfigCheck. - Lower the synchronous startup backup cap 500 -> 200 MB. Utility/tests: - Remove test-only backupAtRiskPath()/findRecentBackupSession() and the no-marker/no-manifest legacy paths; isBackupSessionComplete() now requires the complete marker; tests updated to the plural API. * [Client] Restore collectInstalledAtRiskPaths per review a23e02b5 removed the definition along with the portable helpers, breaking the build (undefined reference at link time, and isInsideAnyDir unused under -Werror on Arch/Debian 13). Restores the 202c0d65 version (pathsInsideDir plus loose top-level deck files) as suggested. --------- Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 351 ++++++++++- cockatrice/CMakeLists.txt | 2 + .../src/interface/at_risk_data_warning.cpp | 215 +++++++ .../src/interface/at_risk_data_warning.h | 50 ++ cockatrice/src/interface/window_main.cpp | 35 +- cockatrice/src/interface/window_main.h | 3 - libcockatrice_utility/CMakeLists.txt | 2 + .../utility/user_data_backup.cpp | 405 ++++++++++++ .../libcockatrice/utility/user_data_backup.h | 132 ++++ tests/CMakeLists.txt | 1 + tests/user_data_backup_test.cpp | 581 ++++++++++++++++++ 11 files changed, 1734 insertions(+), 43 deletions(-) create mode 100644 cockatrice/src/interface/at_risk_data_warning.cpp create mode 100644 cockatrice/src/interface/at_risk_data_warning.h create mode 100644 libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/user_data_backup.h create mode 100644 tests/user_data_backup_test.cpp diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index b6125238f..811a3cb7f 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,8 +15,17 @@ Var ReinstallMode Var AllowForceClose Var GuardDir Var OldUninstallString +Var OldInstallDir +Var DataBackupDir +Var DataBackupDone +Var DataBackupForDir +Var LooseDecksBackupDone +Var InstallSubdirsBackupDone +Var LooseDeckNoticeShown Var StaleFindHandle Var StaleFindName +Var DataBackupFindHandle +Var DataBackupFindName Var IsUpgrade Var AbortReason Var WaitPid @@ -125,6 +134,17 @@ FunctionEnd Function .onInit StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log" +; No backup has been taken yet; RestoreUserDataAfterUninstall must be a no-op +; until BackupUserDataBeforeUninstall sets this after actually copying something. +StrCpy $DataBackupDir "" +StrCpy $DataBackupDone "" +StrCpy $DataBackupForDir "" +StrCpy $LooseDecksBackupDone "" +StrCpy $InstallSubdirsBackupDone "" +StrCpy $LooseDeckNoticeShown "" +; No old install location resolved yet; Backup/Restore fall back to $INSTDIR +; when this is empty (the /R path where $INSTDIR already is the old dir). +StrCpy $OldInstallDir "" ; Start each run from a clean file so the log that gets sent back is the last ; run, not every past run glued together. FileOpen $LogHandle "$InstallLogPath" w @@ -314,10 +334,15 @@ SetShellVarContext all SetRegView 32 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" -!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'" +; On the /R path the updater passes /D=, so $INSTDIR already is the old +; install dir. Publish it for the backup/restore pair below. +StrCpy $OldInstallDir $INSTDIR +!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." -ExecWait '$R0 _?=$INSTDIR' +Call BackupUserDataBeforeUninstall +ExecWait '$R0 _?=$OldInstallDir' +Call RestoreUserDataAfterUninstall done32: @@ -326,16 +351,312 @@ ${If} ${RunningX64} SetRegView 64 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" - !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'" + StrCpy $OldInstallDir $INSTDIR + !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." - ExecWait '$R0 _?=$INSTDIR' + Call BackupUserDataBeforeUninstall + ExecWait '$R0 _?=$OldInstallDir' + Call RestoreUserDataAfterUninstall done64: ${EndIf} FunctionEnd +; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall +; +; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR". +; For a portable install that deletes $INSTDIR\data (decks, settings, pictures) +; along with the payload, and for a normal install it deletes anything the user +; pointed into $INSTDIR. The fixed uninstaller preserves foreign files, but the +; destructive one is already on disk on affected machines, so stash a copy +; outside $INSTDIR before invoking it and put it back afterwards. Best effort +; only: a failed backup must never abort the install. +; +; The backup is left in %TEMP% afterwards on a best-effort basis only: the next +; installer run that takes a new backup wipes it first (see below), so it is not +; a durable second copy. Anything this backup does not cover - e.g. loose files +; with other extensions - relies on the client's startup safety backup +; (warnAboutDataPathsInProgramDir), which only exists after the first launch of +; the new client. +; +; The 32-bit and 64-bit uninstall blocks below share $TEMP\CockatriceDataBackup. +; When both are registered with different InstallLocations, backing up the second +; directory into the first one's backup would restore the first dir's files into +; the second one. BackupUserDataBeforeUninstall therefore starts over when +; $OldInstallDir changes: the previous pass already restored its own backup, so +; only its leftover %TEMP% copy is lost. RelocateRestoredDataToInstDir only sees +; the last pass, so with two different old dirs plus a new target dir the first +; dir's restored data stays in place instead of moving along - no loss, but the +; user has to copy it manually. +; +; The portable-data branch below is defensive: both callers (AutoUninstallIfNeeded +; on /R, componentsPagePre interactively) only run for non-portable installs, and +; portable installs never write the registry uninstall keys that trigger them. +; It fires only when $OldInstallDir nevertheless contains portable.dat - e.g. a +; stale registry InstallLocation pointing at a portable dir, or portable.dat added +; to a normal install dir. The loose-decks and install-subdirs branches are the +; live ones for normal installs. +Function BackupUserDataBeforeUninstall +; $OldInstallDir is the directory being uninstalled: on the /R path it equals +; $INSTDIR (updater passes /D=), on the interactive path componentsPagePre +; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is +; still the default there (directory page comes later). +${If} $OldInstallDir == "" + StrCpy $OldInstallDir $INSTDIR +${EndIf} +; %TEMP% survives across installer runs, so a previous run's safety copy would +; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files +; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B +; from run 2) or restoring stale content after a failed copy. Wipe once per +; installer run - $DataBackupDir is empty until the first backup of this run, +; so the second call in sequence (32-bit then 64-bit uninstall) still merges. +${If} $DataBackupDir == "" + RMDir /r "$TEMP\CockatriceDataBackup" +${EndIf} +; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second +; block points at a different directory, merging would restore the first dir's +; files into the second one, so start over instead. The previous pass already +; restored its own backup; only its leftover %TEMP% copy is discarded. +; StrCmp is case-sensitive while Windows paths are not, so a casing-only +; difference triggers a harmless redundant re-backup of the same content. +${If} $DataBackupDir != "" +${AndIf} $DataBackupForDir != "" +${AndIf} $OldInstallDir != $DataBackupForDir + !insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup" + RMDir /r "$TEMP\CockatriceDataBackup" + StrCpy $DataBackupDir "" + StrCpy $DataBackupDone "" + StrCpy $LooseDecksBackupDone "" + StrCpy $InstallSubdirsBackupDone "" +${EndIf} +${If} ${FileExists} "$OldInstallDir\portable.dat" +${AndIf} ${FileExists} "$OldInstallDir\data" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + StrCpy $DataBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)" + DetailPrint "Backing up portable user data before removing previous version..." + CreateDirectory "$DataBackupDir\data" + ; Copy every top-level entry except pics (downloaded pictures are large and + ; re-downloadable): copying everything first and deleting pics afterwards + ; would duplicate gigabytes into %TEMP% just to throw them away. + FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*" + ${If} $DataBackupFindHandle != "" + backup_data_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} $DataBackupFindName != "pics" + ${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*" + CreateDirectory "$DataBackupDir\data\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\" + ${Else} + CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\" + ${EndIf} + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 backup_data_loop + FindClose $DataBackupFindHandle + ${EndIf} + ; Keep user-made custom pics (data\pics\CUSTOM). + ${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM" + CreateDirectory "$DataBackupDir\data\pics\CUSTOM" + CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\" + ${EndIf} + DetailPrint "Portable user data backup complete." +${EndIf} +; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a +; blanket *.txt copy would also grab payload-adjacent text files, while the +; deck-specific extensions are unambiguous user content. +${If} ${FileExists} "$OldInstallDir\*.cod" +${OrIf} ${FileExists} "$OldInstallDir\*.dec" +${OrIf} ${FileExists} "$OldInstallDir\*.dek" +${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" + ${If} $DataBackupDir == "" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + ${EndIf} + StrCpy $LooseDecksBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying loose deck files" + DetailPrint "Backing up deck files stored in the install directory..." + CreateDirectory "$DataBackupDir\loose_decks" + CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\" +${EndIf} +; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the +; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the +; client's startup backup has never run yet, so stash every top-level directory +; that is not part of the payload. Payload dirs stay in place: they are rewritten +; by the section below, and restoring them could put stale DLLs next to the new +; executables. "data" is skipped once the portable branch above already took it; +; without portable.dat it is user content like any other unknown dir. +FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*" +${If} $DataBackupFindHandle != "" +backup_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} $DataBackupFindName != "plugins" + ${AndIf} $DataBackupFindName != "Plugins" + ${AndIf} $DataBackupFindName != "translations" + ${AndIf} $DataBackupFindName != "sounds" + ${AndIf} $DataBackupFindName != "themes" + ${If} $DataBackupFindName == "data" + ${AndIf} $DataBackupDone == "1" + Goto backup_subdirs_next + ${EndIf} + ${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" + ${If} $DataBackupDir == "" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + ${EndIf} + StrCpy $InstallSubdirsBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName" + DetailPrint "Backing up $DataBackupFindName stored in the install directory..." + CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\" + ${EndIf} + ${EndIf} +backup_subdirs_next: + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 backup_subdirs_loop + FindClose $DataBackupFindHandle +${EndIf} +; Remember which directory the current backup belongs to, so a later pass for a +; different directory starts over instead of merging (see above). +${If} $DataBackupDir != "" + StrCpy $DataBackupForDir $OldInstallDir +${EndIf} +FunctionEnd + +Function RestoreUserDataAfterUninstall +StrCmp $DataBackupDir "" done +${If} $OldInstallDir == "" + StrCpy $OldInstallDir $INSTDIR +${EndIf} +${If} $DataBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\data" + ${If} ${FileExists} "$OldInstallDir\data" + ; Existence-only: a partially-deleted data dir (locked files during the old + ; uninstall) looks the same as a survived one, so the backup is always kept. + ; Merge the backup back over it to repair partial deletes; CopyFiles only + ; adds/overwrites, so a fully-survived dir is unaffected. + !insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it" + CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" + DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data." + ${Else} + !insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data" + CreateDirectory "$OldInstallDir\data" + CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" + DetailPrint "Restored portable user data from pre-uninstall backup." + ${EndIf} +${EndIf} +${If} $LooseDecksBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\loose_decks" + !insertmacro CockLog "post-uninstall restore: restoring loose deck files" + CreateDirectory "$OldInstallDir" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\" + DetailPrint "Restored deck files stored in the install directory." +${EndIf} +${If} $InstallSubdirsBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" + !insertmacro CockLog "post-uninstall restore: restoring install subdirectories" + CreateDirectory "$OldInstallDir" + FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" + ${If} $DataBackupFindHandle != "" + restore_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" + !insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName" + CreateDirectory "$OldInstallDir\$DataBackupFindName" + CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\" + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 restore_subdirs_loop + FindClose $DataBackupFindHandle + ${EndIf} + DetailPrint "Restored folders stored in the install directory." +${EndIf} +; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the +; notice a single time per installer run, covering both loose decks and +; restored subdirectories. +${IfNot} ${Silent} +${AndIf} $LooseDeckNoticeShown != "1" + ${If} $LooseDecksBackupDone == "1" + ${OrIf} $InstallSubdirsBackupDone == "1" + StrCpy $LooseDeckNoticeShown "1" + MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again." + ${EndIf} +${EndIf} +done: +FunctionEnd + +; RelocateRestoredDataToInstDir +; +; componentsPagePre backs up and restores around the old uninstaller before the +; directory page, so $OldInstallDir (registry InstallLocation) is authoritative +; there while $INSTDIR is still the default. When the user then picks a different +; target directory, the restored user data would otherwise stay orphaned in the +; old directory while the new install starts empty. Copy it across when they differ +; (a copy, not a move: the source is user data and is deliberately left in place). +; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup +; comment above); with two different old dirs the first one's data stays restored +; in place instead of moving along. +; StrCmp is case-sensitive while Windows paths are not; a casing-only difference +; triggers a harmless redundant copy onto itself. +Function RelocateRestoredDataToInstDir +StrCmp $DataBackupDir "" done +StrCmp $OldInstallDir "" done +StrCmp $OldInstallDir $INSTDIR done +${If} $DataBackupDone == "1" +${AndIf} ${FileExists} "$OldInstallDir\data" + !insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data" + CreateDirectory "$INSTDIR\data" + CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\" + DetailPrint "Copied restored user data to the new install folder." +${EndIf} +${If} $LooseDecksBackupDone == "1" + ${If} ${FileExists} "$OldInstallDir\*.cod" + ${OrIf} ${FileExists} "$OldInstallDir\*.dec" + ${OrIf} ${FileExists} "$OldInstallDir\*.dek" + ${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" + !insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR" + CreateDirectory "$INSTDIR" + CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\" + DetailPrint "Copied restored deck files to the new install folder." + ${EndIf} +${EndIf} +${If} $InstallSubdirsBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" + FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" + ${If} $DataBackupFindHandle != "" + relocate_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" + !insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName" + CreateDirectory "$INSTDIR\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\" + DetailPrint "Copied restored folder $DataBackupFindName to the new install folder." + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 relocate_subdirs_loop + FindClose $DataBackupFindHandle + ${EndIf} +${EndIf} +done: +FunctionEnd + ; --- Running instance handling --- ; Cockatrice, Oracle and Servatrice must not be running while files are ; replaced or deleted. A still-running process holds locks on its .exe and @@ -1037,11 +1358,17 @@ ${If} $PortableMode = 0 ; instance of the application is still running out of the *old* install ; directory before letting it delete anything. The uninstall string is ; stashed away first, the check works on $R0. + ; $INSTDIR is still the default here (directory page comes later), so the + ; backup/restore dir comes from the registry InstallLocation via $GuardDir, + ; not from $INSTDIR - otherwise a custom-location install is unprotected. StrCpy $OldUninstallString $R0 - !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning + StrCpy $OldInstallDir $GuardDir + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors - ExecWait "$OldUninstallString _?=$INSTDIR" + Call BackupUserDataBeforeUninstall + ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RestoreUserDataAfterUninstall done32: @@ -1061,10 +1388,13 @@ ${If} $PortableMode = 0 uninst64: StrCpy $OldUninstallString $R0 - !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning + StrCpy $OldInstallDir $GuardDir + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors - ExecWait "$OldUninstallString _?=$INSTDIR" + Call BackupUserDataBeforeUninstall + ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RestoreUserDataAfterUninstall done64: ${EndIf} @@ -1188,6 +1518,11 @@ ${EndIf} !insertmacro CockLog "section: writing payload to $INSTDIR" @CPACK_NSIS_FULL_INSTALL@ !insertmacro CockLog "section: payload written" +; The pre-uninstall backup/restore above runs before the directory page, so a +; user picking a different target folder would otherwise leave the restored data +; orphaned in the old directory. Move it across when they differ (no-op on the +; /R path where they are equal). +Call RelocateRestoredDataToInstDir !insertmacro CockLogListing "final" "Qt6*.dll" ${If} $PortableMode = 0 diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 63ce7d1af..e59326214 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -141,6 +141,8 @@ set(cockatrice_SOURCES src/game_graphics/zones/view_zone.cpp src/game_graphics/zones/view_zone_widget.cpp src/game_graphics/board/abstract_graphics_item.cpp + src/interface/at_risk_data_warning.cpp + src/interface/at_risk_data_warning.h src/interface/card_database_update/card_database_update_status_bar.cpp src/interface/card_database_update/card_database_update_status_bar.h src/interface/card_database_update/card_update_progress.cpp diff --git a/cockatrice/src/interface/at_risk_data_warning.cpp b/cockatrice/src/interface/at_risk_data_warning.cpp new file mode 100644 index 000000000..5145d2ebb --- /dev/null +++ b/cockatrice/src/interface/at_risk_data_warning.cpp @@ -0,0 +1,215 @@ +#include "at_risk_data_warning.h" + +#include "../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(AtRiskDataWarningLog, "at_risk_data_warning"); + +static bool isDeckFileSuffix(const QString &suffix) +{ + return suffix == QStringLiteral("cod") || suffix == QStringLiteral("dec") || suffix == QStringLiteral("dek") || + suffix == QStringLiteral("mwdeck"); +} + +static bool isInsideAnyDir(const QString &path, const QStringList &bases) +{ + for (const QString &base : bases) { + if (PathsSettings::isInsideDir(path, base)) { + return true; + } + } + return false; +} + +static bool hasLooseDeckFile(const QStringList &offenders) +{ + for (const QString &offender : offenders) { + if (isDeckFileSuffix(QFileInfo(offender).suffix().toLower())) { + return true; + } + } + return false; +} + +static QString resolveBackupRoot(const QString &appDir) +{ + QString backupRoot = UserDataBackup::defaultBackupRoot(); + if (PathsSettings::isInsideDir(backupRoot, appDir)) { + backupRoot = QDir::temp().absoluteFilePath(QStringLiteral("CockatriceBackups")); + } + return backupRoot; +} + +QStringList AtRiskDataWarning::collectInstalledAtRiskPaths(const QString &appDir) +{ + QStringList offenders = SettingsCache::instance().paths().pathsInsideDir(appDir); + + // Loose decks sitting directly in the program folder are wiped by the old uninstaller + // just like configured paths. Mirror the installer backup set (DeckLoader extensions + // minus *.txt) so a manual run of the old uninstall.exe is covered too. + const QDir appDirHandle(appDir); + const QStringList topLevelFiles = appDirHandle.entryList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QString &fileName : topLevelFiles) { + if (!isDeckFileSuffix(QFileInfo(fileName).suffix().toLower())) { + continue; + } + const QString looseDeck = QDir::cleanPath(appDirHandle.absoluteFilePath(fileName)); + if (!isInsideAnyDir(looseDeck, offenders)) { + offenders.append(looseDeck); + } + } + return offenders; +} + +AtRiskDataWarning::AtRiskBackupOutcome AtRiskDataWarning::backUpAtRiskPaths(const QStringList &sources, + const QString &appDir) +{ + AtRiskBackupOutcome outcome; + const QString backupRoot = resolveBackupRoot(appDir); + + // Throttle: dismissing the dialog without "Don't show again" must not create + // a full duplicate on every launch. A recent session is reused only when it is + // complete and its source manifest covers the current source set; otherwise a + // fresh copy is taken so the dialog never claims an uncovered path is backed up. + // The size estimate runs only when a fresh copy is actually going to happen, + // keeping a synchronous tree walk off the reuse fast path at startup. + // Search all recent sessions for a complete, covering one: a partial failure must not + // pin the newest slot and force a full re-copy on every later launch. + const QString reusableSession = UserDataBackup::findRecentUsableBackupSession(backupRoot, sources, 24); + if (!reusableSession.isEmpty()) { + outcome.sessionDir = reusableSession; + outcome.locations = UserDataBackup::listSessionBackupLocations(reusableSession); + outcome.reused = true; + qCInfo(AtRiskDataWarningLog) << "Reusing recent at-risk user data backup at" << outcome.sessionDir; + } else if (!sources.isEmpty()) { + // The copy runs synchronously on the UI thread at startup, so cap it: a + // pictures folder pointed into the program dir can be gigabytes, and freezing + // the client to duplicate it is worse than asking for a manual copy. + outcome.bytes = UserDataBackup::estimateBackupSize(sources); + if (outcome.bytes >= UserDataBackup::MAX_AUTO_BACKUP_BYTES) { + outcome.skippedDueToSize = true; + qCWarning(AtRiskDataWarningLog) + << "At-risk user data backup skipped: estimated" << outcome.bytes << "bytes exceeds automatic cap"; + } else { + // One timestamped session directory for all sources, so a second boundary + // never splits a single startup check across two backup folders and + // same-named sources cannot overwrite each other. + outcome.complete = + UserDataBackup::backupAtRiskPaths(sources, backupRoot, &outcome.locations, &outcome.sessionDir); + UserDataBackup::pruneOldBackupSessions(backupRoot, 5); + if (outcome.locations.isEmpty()) { + // Fresh copy failed entirely: fall back to the newest covering session so the + // dialog still points at something, flagged incomplete below. + const QString fallbackSession = + UserDataBackup::findRecentCoveringBackupSession(backupRoot, sources, 24); + if (!fallbackSession.isEmpty()) { + outcome.sessionDir = fallbackSession; + outcome.locations = UserDataBackup::listSessionBackupLocations(fallbackSession); + outcome.complete = false; + } + } + } + } + for (const QString &backupLocation : outcome.locations) { + qCInfo(AtRiskDataWarningLog) << "Backed up at-risk user data to" << backupLocation; + } + if (!outcome.complete) { + qCWarning(AtRiskDataWarningLog) << "At-risk user data backup incomplete, session dir:" << outcome.sessionDir; + } + return outcome; +} + +QString AtRiskDataWarning::buildInstalledAtRiskMessage(const QStringList &offenders, + const QStringList &sources, + const AtRiskBackupOutcome &outcome) +{ + const QString backupVerb = outcome.reused ? tr("is already available") : tr("has been created"); + const QString sizeInfo = tr("%1 MB (automatic backups are capped at %2 MB)") + .arg(QString::number(outcome.bytes / 1048576.0, 'f', 1), + QString::number(UserDataBackup::MAX_AUTO_BACKUP_BYTES / 1048576)); + QString message = tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n" + "Cockatrice keeps its own data outside the program directory by default. Files " + "stored inside it can be deleted by an update or by uninstalling Cockatrice. " + "Change them in the Settings dialog - most are on the General page - to keep " + "them safe.") + .arg(offenders.join(QLatin1Char('\n'))); + if (!outcome.locations.isEmpty()) { + message += tr("\n\nA safety backup %1 at:\n%2").arg(backupVerb, outcome.locations.join(QLatin1Char('\n'))); + if (!outcome.complete) { + message += tr("\n\nThe backup may be incomplete; please back up these folders manually as well."); + } + if (hasLooseDeckFile(offenders)) { + message += tr("\n\nDeck files ending in .txt are not backed up - move those manually."); + } + } else if (outcome.skippedDueToSize) { + message += tr("\n\nThe automatic safety backup was skipped because of its size: %1. " + "Please back up these folders manually.") + .arg(sizeInfo); + } else if (sources.isEmpty()) { + message += tr("\n\nNone of these paths exist on disk yet, so there was nothing to back up. " + "Files created there later are still at risk until the paths are moved."); + } else { + message += tr("\n\nThe automatic safety backup failed; please back up these folders manually."); + } + return message; +} + +void AtRiskDataWarning::showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message) +{ + QMessageBox box(QMessageBox::Warning, title, message, QMessageBox::Ok, parent); + box.setTextFormat(Qt::PlainText); + auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); + box.setCheckBox(dontShowAgain); + box.exec(); + if (dontShowAgain->isChecked()) { + SettingsCache::instance().paths().setAppDirWarningAcknowledged(true); + } +} + +void AtRiskDataWarning::warnAboutDataPathsInProgramDir(QWidget *parent) +{ + auto &paths = SettingsCache::instance().paths(); + if (paths.getAppDirWarningAcknowledged()) { + return; + } + + // Portable installs keep their data inside the program directory by design, but they + // never shipped an uninstaller, and portable updates use the installer's payload + // purge, which preserves data/ - so there is nothing destructive to warn about. + if (SettingsCache::instance().getIsPortableBuild()) { + return; + } + + const QString appDir = QCoreApplication::applicationDirPath(); + + // Sources to back up outside the install directory before warning. The uninstallers + // shipped in 3.1.0/3.1.1 delete the whole install tree, so a safety copy is kept + // even though the fixed uninstaller preserves foreign files - the destructive one + // is already on disk on affected machines. + const QStringList offenders = collectInstalledAtRiskPaths(appDir); + if (offenders.isEmpty()) { + return; + } + // Only existing paths can be copied; a configured-but-never-created path has + // nothing to lose yet and must not turn the warning into a bogus "failed". + QStringList sourcesToBackUp; + for (const QString &offender : offenders) { + if (QFileInfo::exists(offender)) { + sourcesToBackUp.append(offender); + } + } + + const AtRiskBackupOutcome outcome = backUpAtRiskPaths(sourcesToBackUp, appDir); + + const QString message = buildInstalledAtRiskMessage(offenders, sourcesToBackUp, outcome); + showAtRiskDataWarning(parent, tr("Data paths inside the program directory"), message); +} diff --git a/cockatrice/src/interface/at_risk_data_warning.h b/cockatrice/src/interface/at_risk_data_warning.h new file mode 100644 index 000000000..22cad86fa --- /dev/null +++ b/cockatrice/src/interface/at_risk_data_warning.h @@ -0,0 +1,50 @@ +#ifndef AT_RISK_DATA_WARNING_H +#define AT_RISK_DATA_WARNING_H + +#include +#include +#include +#include + +class QWidget; + +/** + * @brief Warns when user data lives inside the program directory and backs it up first. + * + * The uninstallers shipped in Cockatrice 3.1.0/3.1.1 delete the whole install + * tree, so data paths configured inside the program directory get a safety + * backup outside the install directory before the warning is shown. + * Portable installs keep their data inside the program directory by design but + * never shipped an uninstaller, so they are skipped. + */ +class AtRiskDataWarning +{ + Q_DECLARE_TR_FUNCTIONS(AtRiskDataWarning) + +public: + struct AtRiskBackupOutcome + { + QStringList locations; ///< backup targets created for this check + QString sessionDir; ///< session directory holding the backup + bool complete = true; ///< false when only a partial/fallback backup exists + bool reused = false; ///< true when a recent session was reused instead of copying + bool skippedDueToSize = false; ///< true when the automatic copy was skipped for size + qint64 bytes = 0; ///< estimated bytes, only set when a fresh copy was attempted + }; + + /** + * @brief Backs up at-risk data paths and warns about them. + * @param parent Parent widget for the warning dialog + */ + static void warnAboutDataPathsInProgramDir(QWidget *parent); + +private: + static QStringList collectInstalledAtRiskPaths(const QString &appDir); + static AtRiskBackupOutcome backUpAtRiskPaths(const QStringList &sources, const QString &appDir); + static QString buildInstalledAtRiskMessage(const QStringList &offenders, + const QStringList &sources, + const AtRiskBackupOutcome &outcome); + static void showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message); +}; + +#endif // AT_RISK_DATA_WARNING_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 57d6e8b15..76092e765 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -37,6 +37,7 @@ #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" #include "abstract_client.h" +#include "at_risk_data_warning.h" #include "card_database_update/card_database_update_status_bar.h" #include "card_database_update/card_update_progress.h" #include "intents/contexts/context_connect_to_server.h" @@ -58,13 +59,12 @@ #include #include #include -#include #include -#include #include #include #include #include +#include #include #include #include @@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent) QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } -void MainWindow::warnAboutDataPathsInProgramDir() -{ - auto &paths = SettingsCache::instance().paths(); - if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) { - return; - } - - const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath()); - if (offenders.isEmpty()) { - return; - } - - QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"), - tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n" - "Cockatrice keeps its own data outside the program directory by default. Files " - "stored inside it can be deleted by an update or by uninstalling Cockatrice. " - "Change them in the Settings dialog - most are on the General page - to keep " - "them safe.") - .arg(offenders.join(QLatin1Char('\n'))), - QMessageBox::Ok, this); - box.setTextFormat(Qt::PlainText); - auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); - box.setCheckBox(dontShowAgain); - box.exec(); - if (dontShowAgain->isChecked()) { - paths.setAppDirWarningAcknowledged(true); - } -} - void MainWindow::startupConfigCheck() { - warnAboutDataPathsInProgramDir(); + AtRiskDataWarning::warnAboutDataPathsInProgramDir(this); const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 2b6ed5633..4b3da716d 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -149,9 +149,6 @@ private: void runFirstRunWizard(bool firstRun = false); - /** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */ - void warnAboutDataPathsInProgramDir(); - inline QString getCardUpdaterBinaryName() { return "oracle"; diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 5e6fa986d..9b21a071f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -13,6 +13,7 @@ set(UTILITY_SOURCES libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/translation_loader.cpp + libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp ) @@ -35,6 +36,7 @@ set(UTILITY_HEADERS libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h libcockatrice/utility/translation_loader.h + libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.h ) diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp new file mode 100644 index 000000000..aa7095545 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp @@ -0,0 +1,405 @@ +#include "user_data_backup.h" + +#include +#include +#include +#include +#include +#include + +namespace UserDataBackup +{ + +namespace +{ + +const QString COMPLETE_MARKER = QStringLiteral("backup.complete"); +const QString INCOMPLETE_MARKER = QStringLiteral("backup.incomplete"); +const QString SOURCES_MANIFEST = QStringLiteral("backup.sources"); + +bool isMarkerFile(const QString &fileName) +{ + return fileName == COMPLETE_MARKER || fileName == INCOMPLETE_MARKER || fileName == SOURCES_MANIFEST; +} + +bool pathsEqual(const QString &a, const QString &b) +{ +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + return QDir::cleanPath(a).compare(QDir::cleanPath(b), Qt::CaseInsensitive) == 0; +#else + return QDir::cleanPath(a) == QDir::cleanPath(b); +#endif +} + +void writeCompletionMarker(const QString &sessionDir, bool complete) +{ + // Best effort: a missing marker only means isBackupSessionComplete() + // reports the session as incomplete. + QFile marker(QDir(sessionDir).absoluteFilePath(complete ? COMPLETE_MARKER : INCOMPLETE_MARKER)); + if (marker.open(QIODevice::WriteOnly | QIODevice::Text)) { + marker.write(complete ? "complete\n" : "incomplete\n"); + } +} + +QString uniqueTargetPath(const QString &sessionDir, const QString &fileName) +{ + QDir dir(sessionDir); + QString candidate = dir.absoluteFilePath(fileName); + if (!QFileInfo::exists(candidate)) { + return candidate; + } + const QFileInfo info(fileName); + const QString base = info.completeBaseName(); + const QString suffix = info.suffix(); + for (int i = 2;; ++i) { + QString numbered = suffix.isEmpty() ? QStringLiteral("%1 (%2)").arg(base).arg(i) + : QStringLiteral("%1 (%2).%3").arg(base).arg(i).arg(suffix); + candidate = dir.absoluteFilePath(numbered); + if (!QFileInfo::exists(candidate)) { + return candidate; + } + } +} + +} // namespace + +bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir) +{ + QDir source(sourceDir); + if (!source.exists()) { + return false; + } + if (!QDir().mkpath(destDir)) { + return false; + } + + bool ok = true; + QDirIterator it(sourceDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + QString entryRel = source.relativeFilePath(it.filePath()); + QString target = QDir(destDir).absoluteFilePath(entryRel); + if (it.fileInfo().isSymLink()) { + // Copy the link itself best-effort without dereferencing it. QDirIterator does not + // follow symlinks here, so a symlink-to-directory must not take the isDir() branch + // below (which would create an empty dir and silently drop the contents). + QDir().mkpath(QFileInfo(target).absolutePath()); + if (QFile::exists(target) && !QFile::remove(target)) { + ok = false; + continue; + } + if (!QFile::copy(it.filePath(), target)) { + ok = false; + } + } else if (it.fileInfo().isDir()) { + if (!QDir().mkpath(target)) { + ok = false; + } + } else { + // Ensure the parent exists even if the iterator yields a file + // before its directory entry. + QDir().mkpath(QFileInfo(target).absolutePath()); + if (QFile::exists(target) && !QFile::remove(target)) { + ok = false; + continue; + } + if (!QFile::copy(it.filePath(), target)) { + ok = false; + } + } + } + return ok; +} + +bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut) +{ + if (!QDir().mkpath(backupRoot)) { + return false; + } + const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-hhmmss-zzz")); + const QDir root(backupRoot); + QString sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-") + timestamp); + for (int i = 1; QFileInfo::exists(sessionDir); ++i) { + sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-%1-%2").arg(timestamp).arg(i)); + } + if (!QDir().mkpath(sessionDir)) { + return false; + } + if (sessionDirOut) { + *sessionDirOut = sessionDir; + } + return true; +} + +bool backupAtRiskPaths(const QStringList &sourcePaths, + const QString &backupRoot, + QStringList *backupLocationsOut, + QString *sessionDirOut) +{ + QStringList existing; + for (const QString &sourcePath : sourcePaths) { + if (QFileInfo::exists(sourcePath)) { + existing.append(sourcePath); + } + } + if (existing.isEmpty()) { + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + + QString sessionDir; + if (!createBackupSessionDir(backupRoot, &sessionDir)) { + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + if (sessionDirOut) { + *sessionDirOut = sessionDir; + } + + bool allOk = true; + QStringList locations; + for (const QString &sourcePath : existing) { + const QFileInfo source(sourcePath); + const QString backupLocation = uniqueTargetPath(sessionDir, source.fileName()); + bool ok; + if (source.isDir()) { + ok = copyDirectoryRecursively(sourcePath, backupLocation); + } else { + ok = QFile::copy(sourcePath, backupLocation); + } + // Report the location whenever a target was created, even for partial + // copies, so callers can point the user at what was salvaged. + if (QFileInfo::exists(backupLocation)) { + locations.append(backupLocation); + } else { + // Total failure with no target created (e.g. QFile::copy failed). + ok = false; + } + allOk = allOk && ok; + } + if (locations.isEmpty()) { + // Nothing salvaged: remove the session again so the empty directory + // neither blocks a retry via the recent-session reuse nor evicts a + // good session in pruneOldBackupSessions(). + QDir(sessionDir).removeRecursively(); + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + writeCompletionMarker(sessionDir, allOk); + // Record which sources this session covers so callers can reuse it only + // when it actually covers the current source set (see sessionCoversSources). + QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST)); + if (manifest.open(QIODevice::WriteOnly | QIODevice::Text)) { + for (const QString &sourcePath : existing) { + manifest.write(QDir::cleanPath(sourcePath).toUtf8() + "\n"); + } + } + if (backupLocationsOut) { + *backupLocationsOut = locations; + } + return allOk; +} + +qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes) +{ + qint64 total = 0; + for (const QString &sourcePath : sourcePaths) { + const QFileInfo source(sourcePath); + if (!source.exists()) { + continue; + } + if (source.isDir()) { + QDirIterator it(sourcePath, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + total += it.fileInfo().size(); + if (total >= capBytes) { + return total; + } + } + } else { + total += source.size(); + if (total >= capBytes) { + return total; + } + } + } + return total; +} + +QString defaultBackupRoot() +{ + QString documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (!documents.isEmpty()) { + return QDir(documents).absoluteFilePath("CockatriceBackups"); + } + QString appData = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + if (!appData.isEmpty()) { + return QDir::cleanPath(QDir(appData).absoluteFilePath("../CockatriceBackups")); + } + return QDir::temp().absoluteFilePath("CockatriceBackups"); +} + +QStringList listBackupSessions(const QString &backupRoot) +{ + QDir root(backupRoot); + if (!root.exists()) { + return {}; + } + const QStringList names = root.entryList(QStringList() << QStringLiteral("CockatriceDataBackup-*"), + QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name | QDir::Reversed); + QStringList sessions; + sessions.reserve(names.size()); + for (const QString &name : names) { + sessions.append(root.absoluteFilePath(name)); + } + return sessions; +} + +QString findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours) +{ + const QStringList sessions = listBackupSessions(backupRoot); + const QDateTime now = QDateTime::currentDateTime(); + for (const QString &session : sessions) { + const QFileInfo info(session); + if (!info.exists()) { + continue; + } + if (info.lastModified().secsTo(now) >= static_cast(maxAgeHours) * 3600) { + continue; + } + if (listSessionBackupLocations(session).isEmpty()) { + continue; + } + if (!sessionCoversSources(session, sourcePaths)) { + continue; + } + return session; + } + return {}; +} + +QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours) +{ + if (sourcePaths.isEmpty()) { + return {}; + } + const QStringList sessions = listBackupSessions(backupRoot); + const QDateTime now = QDateTime::currentDateTime(); + for (const QString &session : sessions) { + const QFileInfo info(session); + if (!info.exists()) { + continue; + } + if (info.lastModified().secsTo(now) >= static_cast(maxAgeHours) * 3600) { + continue; + } + if (listSessionBackupLocations(session).isEmpty()) { + continue; + } + if (!isBackupSessionComplete(session)) { + continue; + } + if (!sessionCoversSources(session, sourcePaths)) { + continue; + } + return session; + } + return {}; +} + +bool isBackupSessionComplete(const QString &sessionDir) +{ + const QDir session(sessionDir); + if (!session.exists()) { + return false; + } + return QFileInfo::exists(session.absoluteFilePath(COMPLETE_MARKER)); +} + +void pruneOldBackupSessions(const QString &backupRoot, int keepCount) +{ + const QStringList sessions = listBackupSessions(backupRoot); + QStringList kept; + kept.reserve(sessions.size()); + for (const QString &session : sessions) { + // Empty sessions are junk from failed attempts: drop them outright so + // they can never evict a good session from the kept set. + if (listSessionBackupLocations(session).isEmpty()) { + QDir(session).removeRecursively(); + continue; + } + if (kept.size() < keepCount) { + kept.append(session); + continue; + } + QDir(session).removeRecursively(); + } +} + +QStringList listSessionBackupLocations(const QString &sessionDir) +{ + const QDir session(sessionDir); + if (!session.exists()) { + return {}; + } + const QStringList entries = session.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden); + QStringList locations; + locations.reserve(entries.size()); + for (const QString &entry : entries) { + if (isMarkerFile(entry)) { + continue; + } + locations.append(session.absoluteFilePath(entry)); + } + return locations; +} + +bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths) +{ + QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST)); + if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { + // No readable manifest (e.g. the write failed): coverage cannot be + // verified, so report not-covered and let the caller take a fresh copy. + return false; + } + QStringList recorded; + while (!manifest.atEnd()) { + const QString line = QString::fromUtf8(manifest.readLine()).trimmed(); + if (!line.isEmpty()) { + recorded.append(line); + } + } + for (const QString &source : sourcePaths) { + bool found = false; + for (const QString &entry : recorded) { + if (pathsEqual(source, entry)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; +} + +} // namespace UserDataBackup diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.h b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h new file mode 100644 index 000000000..4a4dc1d7f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h @@ -0,0 +1,132 @@ +/** + * @file user_data_backup.h + * @ingroup Utility + * @brief Best-effort safety copies of user data that lives inside the install directory. + * + * Background: Windows uninstallers shipped in 3.1.0/3.1.1 ran a blanket + * "RMDir /r $INSTDIR". For a portable install that deletes $INSTDIR\data + * (decks, settings, pictures) along with the payload, and for a normal install + * it deletes anything the user pointed into $INSTDIR. The fixed uninstaller + * preserves foreign files, but the destructive one is already on disk on + * affected machines, so the client keeps a safety copy outside the install + * directory before warning the user. Detection of at-risk paths lives in + * PathsSettings::pathsInsideDir; this helper only copies. + */ + +#ifndef USER_DATA_BACKUP_H +#define USER_DATA_BACKUP_H + +#include +#include + +namespace UserDataBackup +{ + +/** @brief Above this total, the client skips the automatic startup backup and asks for a manual copy. + * Kept small on purpose: the copy runs synchronously on the UI thread at startup. */ +constexpr qint64 MAX_AUTO_BACKUP_BYTES = 200LL * 1024 * 1024; + +/** + * @brief Recursively copies @p sourceDir into @p destDir, creating it as needed. + * @returns true when every file was copied (or there was nothing to copy). + * Symlinks (file or directory) are copied best-effort via QFile::copy without + * dereferencing them; a dangling link fails that entry only. + */ +bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir); + +/** + * @brief Creates a fresh timestamped session directory under @p backupRoot. + * @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups. + * @param sessionDirOut receives the created directory on success. + * @returns true on success. Millisecond timestamps plus a numeric suffix keep rapid + * successive calls (e.g. one per installer phase) from sharing a directory. + */ +bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut); + +/** + * @brief Copies several at-risk paths into a single timestamped session directory. + * @param sourcePaths files or directories holding user data; missing entries are skipped. + * @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups. + * @param backupLocationsOut receives one entry per source that produced a backup target + * (including partial copies). + * @param sessionDirOut optionally receives the shared session directory; set to empty + * when nothing was backed up (all sources missing, or every copy failed outright). + * @returns true when every existing source was copied completely. + * A false return with a non-empty @p backupLocationsOut means a partial backup exists. + * When nothing at all could be salvaged the session directory is removed again, so a + * failed attempt never leaves an empty session behind that would block retries. + * Completeness is recorded with a marker file; see isBackupSessionComplete(). + */ +bool backupAtRiskPaths(const QStringList &sourcePaths, + const QString &backupRoot, + QStringList *backupLocationsOut, + QString *sessionDirOut = nullptr); + +/** + * @brief Estimates the total bytes that backing up @p sourcePaths would copy. + * @param capBytes stop counting once the total exceeds this; the returned value is then + * at least @p capBytes (used to skip the scan early on huge trees). + * Missing entries contribute 0. Best effort only. + */ +qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes = MAX_AUTO_BACKUP_BYTES); + +/** + * @brief Default backup root guaranteed to live outside the install directory. + */ +QString defaultBackupRoot(); + +/** + * @brief Lists existing backup session directories under @p backupRoot, newest first. + */ +QStringList listBackupSessions(const QString &backupRoot); + +/** + * @brief Returns the newest session that is non-empty, complete and covering @p sourcePaths, or empty. + * + * An incomplete newest session (partial failure) or one covering a different + * source set does not block reuse of an older good session. + */ +QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24); + +/** + * @brief Returns the newest non-empty session covering @p sourcePaths, regardless of completeness. + * + * Used as a fallback pointer when a fresh copy fails entirely, so the dialog can still + * point at salvaged content. Completeness is left to the caller to report. + */ +QString +findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24); + +/** + * @brief Reports whether @p sessionDir holds a complete backup. + * @returns true when the session carries the complete marker, false otherwise + * (partial backups and empty/missing sessions). + */ +bool isBackupSessionComplete(const QString &sessionDir); + +/** + * @brief Deletes oldest sessions keeping only the newest @p keepCount ones. + * Empty sessions are junk from failed attempts and are always removed, so they + * can never evict a good session from the kept set. + */ +void pruneOldBackupSessions(const QString &backupRoot, int keepCount = 5); + +/** + * @brief Lists the backup targets inside an existing session directory. + * @returns absolute paths of the entries, for reuse messages without a new copy. + * Completeness marker and source manifest files are internal bookkeeping and are never listed. + */ +QStringList listSessionBackupLocations(const QString &sessionDir); + +/** + * @brief Reports whether @p sessionDir was recorded as covering all of @p sourcePaths. + * @returns true when the session's source manifest lists every entry of @p sourcePaths + * (normalized via QDir::cleanPath, case-insensitive on Windows/macOS). Sessions + * without a readable manifest return false so a changed source set always triggers + * a fresh copy instead of reusing a backup that may not cover it. + */ +bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths); + +} // namespace UserDataBackup + +#endif // USER_DATA_BACKUP_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 784aac3be..ee0fd0698 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,6 +174,7 @@ endfunction() cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) diff --git a/tests/user_data_backup_test.cpp b/tests/user_data_backup_test.cpp new file mode 100644 index 000000000..b74b26fa3 --- /dev/null +++ b/tests/user_data_backup_test.cpp @@ -0,0 +1,581 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include + +namespace +{ + +TEST(UserDataBackup, CopyDirectoryKeepsNestedStructure) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("decks/sub")); + QFile deck(source.absoluteFilePath("decks/sub/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QFile::exists(dest + "/decks/sub/mydeck.cod")); +} + +TEST(UserDataBackup, CopyDirectoryKeepsHiddenFiles) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QFile hidden(QDir(sourceDir.path()).absoluteFilePath(".hidden.ini")); + ASSERT_TRUE(hidden.open(QIODevice::WriteOnly | QIODevice::Text)); + hidden.write("[general]\n"); + hidden.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QFile::exists(dest + "/.hidden.ini")); +} + +TEST(UserDataBackup, CopyDirectoryOverwritesExistingTarget) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + const QString sourceFile = QDir(sourceDir.path()).absoluteFilePath("settings.ini"); + QFile first(sourceFile); + ASSERT_TRUE(first.open(QIODevice::WriteOnly | QIODevice::Text)); + first.write("version=1\n"); + first.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + ASSERT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + + QFile second(sourceFile); + ASSERT_TRUE(second.open(QIODevice::WriteOnly | QIODevice::Text)); + second.write("version=2\n"); + second.close(); + + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + QFile result(dest + "/settings.ini"); + ASSERT_TRUE(result.open(QIODevice::ReadOnly | QIODevice::Text)); + EXPECT_EQ(result.readAll(), QByteArray("version=2\n")); +} + +TEST(UserDataBackup, CopyEmptyDirectorySucceeds) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + ASSERT_TRUE(QDir(sourceDir.path()).mkpath("empty")); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QDir(dest + "/empty").exists()); +} +TEST(UserDataBackup, CopyMissingDirectoryFails) +{ + QTemporaryDir destParent; + ASSERT_TRUE(destParent.isValid()); + EXPECT_FALSE( + UserDataBackup::copyDirectoryRecursively("/nonexistent-cockatrice-path-xyz", destParent.path() + "/copy")); +} + +TEST(UserDataBackup, BackupDirectoryCreatesTimestampedCopy) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + ASSERT_EQ(locations.size(), 1); + EXPECT_TRUE(QFile::exists(locations.first() + "/mydeck.cod")); + EXPECT_TRUE(locations.first().startsWith(QDir(backupRoot.path()).absolutePath())); +} + +TEST(UserDataBackup, BackupSingleFile) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QString cardDb = QDir(sourceDir.path()).absoluteFilePath("cards.xml"); + QFile db(cardDb); + ASSERT_TRUE(db.open(QIODevice::WriteOnly | QIODevice::Text)); + db.write("\n"); + db.close(); + + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({cardDb}, backupRoot.path(), &locations, nullptr)); + ASSERT_EQ(locations.size(), 1); + EXPECT_TRUE(QFile::exists(locations.first())); + EXPECT_TRUE(locations.first().endsWith("cards.xml")); +} + +TEST(UserDataBackup, BackupMissingSourceFails) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations; + QString sessionDir; + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod")}, + backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); +} + +TEST(UserDataBackup, BackupPathsShareSingleSessionDir) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("decks")); + ASSERT_TRUE(source.mkpath("settings")); + QFile deck(source.absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + QFile settings(source.absoluteFilePath("settings/settings.ini")); + ASSERT_TRUE(settings.open(QIODevice::WriteOnly | QIODevice::Text)); + settings.write("[general]\n"); + settings.close(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE( + UserDataBackup::backupAtRiskPaths({source.absoluteFilePath("decks"), source.absoluteFilePath("settings")}, + backupRoot.path(), &locations, &sessionDir)); + ASSERT_EQ(locations.size(), 2); + // Both backups live in the same timestamped session, even across a second boundary. + for (const QString &location : locations) { + EXPECT_TRUE(location.startsWith(QDir(sessionDir).absolutePath())); + } + EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"))); + EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("settings/settings.ini"))); +} + +TEST(UserDataBackup, BackupPathsWithSameNameDoNotCollide) +{ + QTemporaryDir firstDir; + QTemporaryDir secondDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(firstDir.isValid()); + ASSERT_TRUE(secondDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + for (const QString &dir : {firstDir.path(), secondDir.path()}) { + QFile deck(QDir(dir).absoluteFilePath("cards.xml")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + } + + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths( + {QDir(firstDir.path()).absoluteFilePath("cards.xml"), QDir(secondDir.path()).absoluteFilePath("cards.xml")}, + backupRoot.path(), &locations, nullptr)); + ASSERT_EQ(locations.size(), 2); + EXPECT_NE(locations.at(0), locations.at(1)); + EXPECT_TRUE(QFile::exists(locations.at(0))); + EXPECT_TRUE(QFile::exists(locations.at(1))); +} + +TEST(UserDataBackup, RapidBackupsGetUniqueSessionDirs) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QString firstSession; + QString secondSession; + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession)); + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession)); + EXPECT_NE(firstSession, secondSession); +} + +TEST(UserDataBackup, DefaultBackupRootIsUsable) +{ + QString root = UserDataBackup::defaultBackupRoot(); + EXPECT_FALSE(root.isEmpty()); + EXPECT_TRUE(QDir(root).isAbsolute()); +} + +TEST(UserDataBackup, DefaultBackupRootHasNoDotDotSegments) +{ + QString root = UserDataBackup::defaultBackupRoot(); + EXPECT_FALSE(root.contains(QStringLiteral(".."))); + EXPECT_EQ(root, QDir::cleanPath(root)); +} + +TEST(UserDataBackup, ListSessionsFindsRecentAndPrunesOld) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString firstSession; + QString secondSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession)); + + const QStringList sessions = UserDataBackup::listBackupSessions(backupRoot.path()); + ASSERT_EQ(sessions.size(), 2); + // Newest first + EXPECT_EQ(sessions.first(), secondSession); + + const QString recent = UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24); + EXPECT_EQ(recent, secondSession); + + const QStringList reusedLocations = UserDataBackup::listSessionBackupLocations(recent); + EXPECT_FALSE(reusedLocations.isEmpty()); + for (const QString &location : reusedLocations) { + EXPECT_TRUE(location.startsWith(QDir(recent).absolutePath())); + EXPECT_TRUE(QFile::exists(location)); + } + + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 1); + const QStringList pruned = UserDataBackup::listBackupSessions(backupRoot.path()); + ASSERT_EQ(pruned.size(), 1); + EXPECT_EQ(pruned.first(), secondSession); +} + +TEST(UserDataBackup, FindRecentReturnsEmptyWhenNone) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(backupRoot.path() + "/missing").isEmpty()); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(backupRoot.path() + "/missing")); + // Pruning an empty root must not fail + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5); +} + +TEST(UserDataBackup, AllMissingSourcesCreateNoSession) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations; + QString sessionDir; + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({backupRoot.path() + "/nope1", backupRoot.path() + "/nope2"}, + backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); + // No empty session left behind to block a retry or evict good sessions + EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty()); +} + +TEST(UserDataBackup, EmptySessionsAreSkippedAndPruned) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString emptySession; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &emptySession)); + // An empty session from a failed attempt must never block a retry + EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(emptySession).isEmpty()); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(emptySession)); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5); + EXPECT_FALSE(QDir(emptySession).exists()); +} + +TEST(UserDataBackup, SuccessfulBackupIsMarkedComplete) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // Marker files are bookkeeping and must not leak into the user-facing locations + const QStringList listed = UserDataBackup::listSessionBackupLocations(sessionDir); + EXPECT_FALSE(listed.isEmpty()); + for (const QString &location : listed) { + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.complete"))); + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.incomplete"))); + } +} + +TEST(UserDataBackup, IncompleteMarkerIsNotUsableButRemainsCovering) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + ASSERT_TRUE(QFile::remove(QDir(sessionDir).absoluteFilePath("backup.complete"))); + QFile marker(QDir(sessionDir).absoluteFilePath("backup.incomplete")); + ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text)); + marker.write("incomplete\n"); + marker.close(); + + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // Incomplete sessions are skipped by the usable search but still found by the + // covering search, so a failed copy falls back to the salvaged content. + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24).isEmpty()); + EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 24), sessionDir); +} + +TEST(UserDataBackup, SessionWithoutMarkerCountsAsIncomplete) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString sessionDir; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir)); + ASSERT_TRUE(QDir(sessionDir).mkpath("decks")); + QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // No source manifest either, so neither search reuses it. + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); +} + +TEST(UserDataBackup, FindRecentIgnoresExpiredSessions) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty()); +} + +TEST(UserDataBackup, EstimateBackupSize) +{ + QTemporaryDir sourceDir; + ASSERT_TRUE(sourceDir.isValid()); + const QString missing = QDir(sourceDir.path()).absoluteFilePath("missing"); + EXPECT_EQ(UserDataBackup::estimateBackupSize({missing}), 0); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("123456"); + deck.close(); + EXPECT_EQ(UserDataBackup::estimateBackupSize({sourceDir.path(), missing}), 6); + // Early-out: once over the cap the exact total no longer matters + EXPECT_GE(UserDataBackup::estimateBackupSize({sourceDir.path()}, 1), 1); +} + +TEST(UserDataBackup, SessionCoversSourcesRequiresManifestMatch) +{ + QTemporaryDir sourceA; + QTemporaryDir sourceB; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceA.isValid()); + ASSERT_TRUE(sourceB.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod")); + ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text)); + fa.write("a"); + fa.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path()})); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path(), sourceB.path()})); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceB.path()})); +} + +TEST(UserDataBackup, SessionWithoutManifestIsNotCovering) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString sessionDir; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir)); + ASSERT_TRUE(QDir(sessionDir).mkpath("decks")); + QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {QDir(sessionDir).absoluteFilePath("decks")})); +} + +TEST(UserDataBackup, ManifestIsHiddenFromSessionLocations) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("x"); + deck.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + for (const QString &location : UserDataBackup::listSessionBackupLocations(sessionDir)) { + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.sources"))); + } +} + +TEST(UserDataBackup, FindRecentUsableSkipsIncompleteNewest) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QStringList locations; + QString oldSession; + QString newSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &oldSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &newSession)); + ASSERT_NE(oldSession, newSession); + + // Newest session becomes incomplete: usable search must fall back to the older complete one. + ASSERT_TRUE(QFile::remove(QDir(newSession).absoluteFilePath("backup.complete"))); + QFile marker(QDir(newSession).absoluteFilePath("backup.incomplete")); + ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text)); + marker.write("incomplete\n"); + marker.close(); + + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}), oldSession); + // Covering search still finds the newest session even though it is incomplete. + EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}), newSession); +} + +TEST(UserDataBackup, FindRecentUsableRequiresCoveringSet) +{ + QTemporaryDir sourceA; + QTemporaryDir sourceB; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceA.isValid()); + ASSERT_TRUE(sourceB.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod")); + ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text)); + fa.write("a"); + fa.close(); + QFile fb(QDir(sourceB.path()).absoluteFilePath("b.cod")); + ASSERT_TRUE(fb.open(QIODevice::WriteOnly | QIODevice::Text)); + fb.write("b"); + fb.close(); + + QStringList locations; + QString oldSession; + QString newSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &oldSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceB.path()}, backupRoot.path(), &locations, &newSession)); + + // Newest covers B only, so a query for A must reuse the older session. + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path()}), oldSession); + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceB.path()}), newSession); + EXPECT_TRUE( + UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path(), sourceB.path()}).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {}).isEmpty()); +} + +TEST(UserDataBackup, CopyDirectoryDoesNotSilentlyEmptyDirSymlink) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("real")); + QFile inner(source.absoluteFilePath("real/card.cod")); + ASSERT_TRUE(inner.open(QIODevice::WriteOnly | QIODevice::Text)); + inner.write("x"); + inner.close(); + if (!QFile::link(source.absoluteFilePath("real"), source.absoluteFilePath("link"))) { + GTEST_SKIP() << "Symlinks not permitted in this environment"; + } + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + const bool ok = UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest); + const QFileInfo linkCopy(QDir(dest).absoluteFilePath("link")); + // Old behavior created an empty plain dir and reported success, silently dropping contents. + // New behavior copies the link best-effort; if that fails it must report failure. + if (ok) { + EXPECT_TRUE(linkCopy.exists() || linkCopy.isSymLink()); + if (linkCopy.isDir() && !linkCopy.isSymLink()) { + EXPECT_FALSE(QDir(linkCopy.absoluteFilePath()).isEmpty()); + } + } else { + EXPECT_TRUE(linkCopy.isSymLink() || !linkCopy.exists() || !QDir(linkCopy.absoluteFilePath()).isEmpty()); + } +} + +TEST(UserDataBackup, PluralBackupClearsOutParamsOnTotalFailure) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations = {QStringLiteral("stale")}; + QString sessionDir = QStringLiteral("stale"); + const QString missing = QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod"); + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({missing}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From be38e8d429d6792dbf22165329c27ede0ec46533 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:37 +0200 Subject: [PATCH 05/13] [Protocol] Add room chat channel extensions and catalog payload (#7475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Protocol] Add room chat channel extensions and catalog payload * [Protocol] Add per-channel write level and banner to room channel catalog Adds two optional fields to ServerInfo_RoomChannel: - write_access_level: minimum privilege level required to send to the channel. When unset, writing requires the same level as reading (access_level), preserving the previous single-level behavior. This allows read-mostly channels such as a developer news feed: readable by everyone, writable only by developers. - banner_message: static notice shown at the top of the channel, analogous to the room join message on Main. Empty means none. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 ++ .../protocol/pb/event_room_channel_say.proto | 12 ++++++++++ .../protocol/pb/room_event.proto | 1 + .../protocol/pb/serverinfo_chat_message.proto | 1 + .../protocol/pb/serverinfo_room.proto | 2 ++ .../protocol/pb/serverinfo_room_channel.proto | 23 +++++++++++++++++++ 6 files changed, 41 insertions(+) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 4e4f0e190..bd0b5bda2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -117,6 +117,7 @@ set(PROTO_FILES event_reveal_cards.proto event_reverse_turn.proto event_roll_die.proto + event_room_channel_say.proto event_room_say.proto event_server_complete_list.proto event_server_identification.proto @@ -201,6 +202,7 @@ set(PROTO_FILES serverinfo_replay_match.proto serverinfo_report.proto serverinfo_room.proto + serverinfo_room_channel.proto serverinfo_user.proto serverinfo_user_alt.proto serverinfo_user_session.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto new file mode 100644 index 000000000..c8de1a799 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "room_event.proto"; + +message Event_RoomChannelSay { + extend RoomEvent { + optional Event_RoomChannelSay ext = 1005; // next free RoomEvent extension + } + optional string channel_id = 1; + optional string name = 2; + optional string message = 3; + optional uint64 time_of = 4; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto index 9fa70e7e1..ec6c4f636 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto @@ -6,6 +6,7 @@ message RoomEvent { ROOM_SAY = 1002; LIST_GAMES = 1003; REMOVE_MESSAGES = 1004; + ROOM_CHANNEL_SAY = 1005; } optional sint32 room_id = 1; extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto index d47ec4fc7..b6d35a32d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto @@ -13,4 +13,5 @@ message ServerInfo_ChatMessage { optional string target_type = 6; // target type (room,game,chat) optional string target_id = 7; // id of target optional string target_name = 8; // name of target + optional string channel = 9; // channel id, for room_channel targets } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto index c1c2e363b..8e99de48f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto @@ -2,6 +2,7 @@ syntax = "proto2"; import "serverinfo_game.proto"; import "serverinfo_user.proto"; import "serverinfo_gametype.proto"; +import "serverinfo_room_channel.proto"; message ServerInfo_Room { optional sint32 room_id = 1; @@ -15,4 +16,5 @@ message ServerInfo_Room { repeated ServerInfo_GameType gametype_list = 9; optional string permissionlevel = 10; optional string privilegelevel = 11; + repeated ServerInfo_RoomChannel channel_list = 12; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto new file mode 100644 index 000000000..d38b1f651 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto @@ -0,0 +1,23 @@ +syntax = "proto2"; + +message ServerInfo_RoomChannel { + enum AccessLevel { + // Minimum privilege level, evaluated against the privilege ladder + // Public < Registered < Judge < Moderator < Admin < Developer. + Public = 0; + Registered = 1; + Judge = 2; + Moderator = 3; + Admin = 4; + Developer = 5; + } + optional string id = 1; // stable ASCII id, e.g. "deck-discussion" + optional string display_name = 2; // server-provided, localizable only server-side + optional AccessLevel access_level = 3 [default = Public]; // minimum privilege level required to read the channel + // Minimum privilege level required to write to the channel. When unset, writing requires the same level as + // reading. This allows read-mostly channels such as a developer news feed: readable by everyone, writable + // only by developers. + optional AccessLevel write_access_level = 4; + // Static banner shown at the top of the channel, analogous to the room join message on Main. Empty = none. + optional string banner_message = 5; +} From 17d2e8d86ec9e05afcd9d5567fcbea8e5cf26cc9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:38 +0200 Subject: [PATCH 06/13] [Server] Add room chat channels with per-channel fan-out and history (#7476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Add room chat channels with per-channel fan-out and history * Fixup: redact all channel histories on message removal removeSaidMessages only redacted the main history, so banned/warned content stayed visible to late joiners on every other channel. Drop the unused channelId parameter and redact up to amount messages per channel across the whole channelChatHistory map. * [Server] Validate room channel ids and fix catalog schema per review - servatrice.sql: expand rooms_channels access_level enum to full set to match 0037->0038 migration - servatrice.cpp: skip empty, reserved (__main__) and duplicate channel ids when loading from ini and DB with qWarning - server_protocolhandler.cpp: clarify catalog replay comment (filtering happens in moderator stack) - server_room: drop duplicate channelIndex, linear search preserves catalog order * [Server] Fix server_room.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../network/server/remote/server.cpp | 2 +- .../server/remote/server_protocolhandler.cpp | 48 +++++++++- .../server/remote/server_protocolhandler.h | 3 + .../network/server/remote/server_room.cpp | 84 +++++++++++++---- .../network/server/remote/server_room.h | 27 ++++-- .../protocol/pb/room_commands.proto | 9 ++ .../migrations/servatrice_0037_to_0038.sql | 21 +++++ servatrice/servatrice.ini.example | 16 ++++ servatrice/servatrice.sql | 12 ++- servatrice/src/servatrice.cpp | 91 ++++++++++++++++++- .../src/servatrice_database_interface.h | 2 +- 11 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 servatrice/migrations/servatrice_0037_to_0038.sql diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 73b13e23e..d0fb67446 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -451,7 +451,7 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString qDebug() << "externalRoomSay: room id=" << roomId << "not found"; return; } - room->say(userName, message, false); + room->say(userName, message, QString(), false); getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom, room->getId(), room->getName()); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index fbca9589a..0a91bbe77 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -13,6 +13,7 @@ #include "libcockatrice/protocol/pb/server_message.pb.h" #include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h" #include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_room_channel.pb.h" #include "libcockatrice/protocol/pb/session_commands.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" #include "server_database_interface.h" @@ -40,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -252,6 +254,9 @@ Response::ResponseCode Server_ProtocolHandler::processRoomCommandContainer(const case RoomCommand::ROOM_SAY: resp = cmdRoomSay(sc.GetExtension(Command_RoomSay::ext), room, rc); break; + case RoomCommand::ROOM_CHANNEL_SAY: + resp = cmdRoomChannelSay(sc.GetExtension(Command_RoomChannelSay::ext), room, rc); + break; case RoomCommand::CREATE_GAME: resp = cmdCreateGame(sc.GetExtension(Command_CreateGame::ext), room, rc); break; @@ -856,10 +861,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rooms.insert(room->getId(), room); QReadLocker chatHistoryLocker(&room->historyLock); - QList chatHistory = room->getChatHistory(); - ServerInfo_ChatMessage chatMessage; - for (int i = 0; i < chatHistory.size(); ++i) { - chatMessage = chatHistory.at(i); + const QList mainChatHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY); + for (const ServerInfo_ChatMessage &chatMessage : mainChatHistory) { Event_RoomSay roomChatHistory; roomChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message()); roomChatHistory.set_message_type(Event_RoomSay::ChatHistory); @@ -868,6 +871,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory)); } + // Replay the history of every catalog channel. Privilege filtering for + // restricted channels is applied on top of this in the moderator stack. + for (const ServerInfo_RoomChannel &channel : room->getChannels()) { + const QList channelHistory = room->getChatHistory(QString::fromStdString(channel.id())); + for (const ServerInfo_ChatMessage &chatMessage : channelHistory) { + Event_RoomChannelSay channelChatHistory; + channelChatHistory.set_channel_id(channel.id()); + channelChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message()); + channelChatHistory.set_time_of( + QDateTime::fromString(QString::fromStdString(chatMessage.time())).toMSecsSinceEpoch()); + rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(channelChatHistory)); + } + } + Event_RoomSay joinMessageEvent; joinMessageEvent.set_message(room->getJoinMessage().toStdString()); joinMessageEvent.set_message_type(Event_RoomSay::Welcome); @@ -959,6 +976,29 @@ Server_ProtocolHandler::cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room return Response::RespOk; } +Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_RoomChannelSay &cmd, + Server_Room *room, + ResponseContainer & /*rc*/) +{ + const QString channelId = QString::fromStdString(cmd.channel_id()); + const ServerInfo_RoomChannel *channel = room->findChannel(channelId); + if (channel == nullptr) { + // The client only ever offers catalog ids; a crafted or stale id is rejected. + return Response::RespInvalidCommand; + } + + if (!addSaidMessageSize(static_cast(cmd.message().size()))) { + return Response::RespChatFlood; + } + QString msg = QString::fromStdString(cmd.message()); + + msg.replace(QChar('\n'), QChar(' ')); + + room->say(QString::fromStdString(userInfo->name()), msg, channelId); + + return Response::RespOk; +} + Response::ResponseCode Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc) { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index a12706757..65dab99ed 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -30,6 +30,7 @@ class Command_ListRooms; class Command_JoinRoom; class Command_LeaveRoom; class Command_RoomSay; +class Command_RoomChannelSay; class Command_CreateGame; class Command_JoinGame; @@ -66,6 +67,8 @@ private: Response::ResponseCode cmdListUsers(const Command_ListUsers &cmd, ResponseContainer &rc); Response::ResponseCode cmdLeaveRoom(const Command_LeaveRoom &cmd, Server_Room *room, ResponseContainer &rc); Response::ResponseCode cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room, ResponseContainer &rc); + Response::ResponseCode + cmdRoomChannelSay(const Command_RoomChannelSay &cmd, Server_Room *room, ResponseContainer &rc); Response::ResponseCode cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc); Response::ResponseCode cmdJoinGame(const Command_JoinGame &cmd, Server_Room *room, ResponseContainer &rc); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 614de8d63..7a8c59536 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -26,16 +26,20 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include #include #include +const QString Server_Room::MAIN_CHANNEL_KEY = QStringLiteral("__main__"); + Server_Room::Server_Room(int _id, int _chatHistorySize, const QString &_name, @@ -45,10 +49,11 @@ Server_Room::Server_Room(int _id, bool _autoJoin, const QString &_joinMessage, const QStringList &_gameTypes, - Server *parent) + Server *parent, + const QList &_channels) : QObject(parent), id(_id), chatHistorySize(_chatHistorySize), name(_name), description(_description), permissionLevel(_permissionLevel), privilegeLevel(_privilegeLevel), autoJoin(_autoJoin), - joinMessage(_joinMessage), gameTypes(_gameTypes), gamesLock(QReadWriteLock::Recursive) + joinMessage(_joinMessage), gameTypes(_gameTypes), channels(_channels), gamesLock(QReadWriteLock::Recursive) { connect( this, &Server_Room::gameListChanged, this, [this](auto gameInfo) { broadcastGameListUpdate(gameInfo); }, @@ -152,9 +157,33 @@ Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, } } + for (const ServerInfo_RoomChannel &channel : channels) { + result.add_channel_list()->CopyFrom(channel); + } + return result; } +bool Server_Room::isMainChannel(const QString &channelId) +{ + return channelId.isEmpty() || channelId == MAIN_CHANNEL_KEY; +} + +const ServerInfo_RoomChannel *Server_Room::findChannel(const QString &channelId) const +{ + for (const ServerInfo_RoomChannel &channel : channels) { + if (QString::fromStdString(channel.id()) == channelId) { + return &channel; + } + } + return nullptr; +} + +QList Server_Room::getChatHistory(const QString &channelId) const +{ + return channelChatHistory.value(isMainChannel(channelId) ? MAIN_CHANNEL_KEY : channelId); +} + RoomEvent *Server_Room::prepareRoomEvent(const ::google::protobuf::Message &roomEvent) { auto *event = new RoomEvent; @@ -310,12 +339,22 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam return result; } -void Server_Room::say(const QString &userName, const QString &userMessage, bool sendToIsl) +void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl) { - Event_RoomSay event; - event.set_name(userName.toStdString()); - event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + const bool isMain = isMainChannel(channelId); + + if (isMain) { + Event_RoomSay event; + event.set_name(userName.toStdString()); + event.set_message(userMessage.toStdString()); + sendRoomEvent(prepareRoomEvent(event), sendToIsl); + } else { + Event_RoomChannelSay event; + event.set_channel_id(channelId.toStdString()); + event.set_name(userName.toStdString()); + event.set_message(userMessage.toStdString()); + sendRoomEvent(prepareRoomEvent(event), sendToIsl); + } if (chatHistorySize != 0) { ServerInfo_ChatMessage chatMessage; @@ -324,13 +363,18 @@ void Server_Room::say(const QString &userName, const QString &userMessage, bool chatMessage.set_time(dateTimeString.toStdString()); chatMessage.set_sender_name(userName.toStdString()); chatMessage.set_message(userMessage.simplified().toStdString()); - - historyLock.lockForWrite(); - if (chatHistory.size() >= chatHistorySize) { - chatHistory.removeAt(0); + if (!isMain) { + chatMessage.set_channel(channelId.toStdString()); } - chatHistory.push_back(std::move(chatMessage)); + const QString storageKey = isMain ? MAIN_CHANNEL_KEY : channelId; + historyLock.lockForWrite(); + QList &history = channelChatHistory[storageKey]; + if (history.size() >= chatHistorySize) { + history.removeAt(0); + } + + history.push_back(std::move(chatMessage)); historyLock.unlock(); } } @@ -344,13 +388,17 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s sendRoomEvent(prepareRoomEvent(event), sendToIsl); if (chatHistorySize != 0) { - int removed = 0; historyLock.lockForWrite(); - // redact [amount] of the most recent messages from this user from history - for (auto message = chatHistory.rbegin(); message != chatHistory.rend() && removed != amount; ++message) { - if (message->sender_name() == stdStringUserName) { - message->clear_message(); - ++removed; + // redact [amount] of the most recent messages from this user from every channel history, + // so removed content does not resurface for late joiners on any stream + for (auto historyIt = channelChatHistory.begin(); historyIt != channelChatHistory.end(); ++historyIt) { + QList &history = historyIt.value(); + int removed = 0; + for (auto message = history.rbegin(); message != history.rend() && removed != amount; ++message) { + if (message->sender_name() == stdStringUserName) { + message->clear_message(); + ++removed; + } } } historyLock.unlock(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h index 5933894d5..e9227862f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -9,6 +9,7 @@ #include #include #include +#include #include class Server_ProtocolHandler; @@ -47,15 +48,18 @@ private: bool autoJoin; QString joinMessage; QStringList gameTypes; + QList channels; QMap games; QMap externalGames; QMap users; QMap externalUsers; - QList chatHistory; + QMap> channelChatHistory; private slots: void broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool sendToIsl = true); public: + static const QString MAIN_CHANNEL_KEY; + mutable QReadWriteLock usersLock; mutable QReadWriteLock gamesLock; mutable QReadWriteLock historyLock; @@ -68,7 +72,8 @@ public: bool _autoJoin, const QString &_joinMessage, const QStringList &_gameTypes, - Server *parent); + Server *parent, + const QList &_channels = {}); ~Server_Room() override; int getId() const { @@ -103,6 +108,17 @@ public: { return gameTypes; } + const QList &getChannels() const + { + return channels; + } + /** + * @brief Looks up a catalog channel by its stable id. + * @return The matching channel, or nullptr when the id is not in the catalog. + */ + const ServerInfo_RoomChannel *findChannel(const QString &channelId) const; + /** @brief True when @p channelId refers to the implicit Main channel. */ + static bool isMainChannel(const QString &channelId); const QMap &getGames() const { return games; @@ -116,10 +132,7 @@ public: getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const; int getGamesCreatedByUser(const QString &name) const; QList getGamesOfUser(const QString &name) const; - QList &getChatHistory() - { - return chatHistory; - } + QList getChatHistory(const QString &channelId) const; void addClient(Server_ProtocolHandler *client); void removeClient(Server_ProtocolHandler *client); @@ -136,7 +149,7 @@ public: ResponseContainer &rc, Server_AbstractUserInterface *userInterface); - void say(const QString &userName, const QString &s, bool sendToIsl = true); + void say(const QString &userName, const QString &s, const QString &channelId = QString(), bool sendToIsl = true); void removeSaidMessages(const QString &userName, int amount, bool sendToIsl = true); void addGame(Server_Game *game); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index a8c90ec6c..9d9c6d277 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -5,6 +5,7 @@ message RoomCommand { ROOM_SAY = 1001; CREATE_GAME = 1002; JOIN_GAME = 1003; + ROOM_CHANNEL_SAY = 1004; } extensions 100 to max; } @@ -22,6 +23,14 @@ message Command_RoomSay { optional string message = 1; } +message Command_RoomChannelSay { + extend RoomCommand { + optional Command_RoomChannelSay ext = 1004; + } + optional string channel_id = 1; + optional string message = 2; +} + // Create a new game in the room message Command_CreateGame { extend RoomCommand { diff --git a/servatrice/migrations/servatrice_0037_to_0038.sql b/servatrice/migrations/servatrice_0037_to_0038.sql new file mode 100644 index 000000000..6b8f155ab --- /dev/null +++ b/servatrice/migrations/servatrice_0037_to_0038.sql @@ -0,0 +1,21 @@ +-- Servatrice db migration from version 37 to version 38 + +-- Room chat channels. +-- +-- A room now offers a fixed catalog of named message streams in addition to the +-- implicit Main channel. Public channels are broadcast to every room member; +-- restricted channels are only delivered to (and sendable by) members whose +-- privilege level meets the channel's access level. + +-- 1. Catalog table, mirroring the config-driven channels of no-DB servers. +CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( + `id_room` int(7) unsigned NOT NULL, + `id_server` tinyint(3) NOT NULL DEFAULT 1, + `channel_id` varchar(50) NOT NULL, + `display_name` varchar(50) NOT NULL, + `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + PRIMARY KEY (`id_room`, `id_server`, `channel_id`), + FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`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; \ No newline at end of file diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index f802f0244..e45b38804 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -332,6 +332,22 @@ roomlist\1\game_types\1\name="GameType1" roomlist\1\game_types\2\name="GameType2" roomlist\1\game_types\3\name="GameType3" +; Optional chat channels for room number 1, in addition to the implicit Main +; channel every room has. Each channel needs a stable ASCII id, a display name +; (server data, like the room name) and an access level. Supported levels, from +; least to most restrictive, are: public, registered, judge, moderator, admin +; and developer. A channel is readable and writable by every member whose own +; privilege level is at least the channel's level -- so a moderator channel is +; also reachable by admins and developers, and an admin channel by developers. +; Public channels are visible to every room member. +roomlist\1\channels\size=2 +roomlist\1\channels\1\id=looking-for-players +roomlist\1\channels\1\displayname="Looking for players" +roomlist\1\channels\1\access=public +roomlist\1\channels\2\id=moderators +roomlist\1\channels\2\displayname="Moderators" +roomlist\1\channels\2\access=moderator + [game] diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 4fbc1d8bd..c83f5bb06 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` ( @@ -158,6 +158,16 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_gametypes` ( FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( + `id_room` int(7) unsigned NOT NULL, + `id_server` tinyint(3) NOT NULL DEFAULT 1, + `channel_id` varchar(50) NOT NULL, + `display_name` varchar(50) NOT NULL, + `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + PRIMARY KEY (`id_room`, `id_server`, `channel_id`), + FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + -- games CREATE TABLE IF NOT EXISTS `cockatrice_games` ( `room_name` varchar(255) NOT NULL, diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index a832c1652..9dd5a987c 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -249,6 +251,34 @@ Servatrice::~Servatrice() prepareDestroy(); } +/** + * @brief Maps a configured channel access level (database column or ini value) + * onto the ServerInfo_RoomChannel privilege ladder. + * + * Unknown keywords fall back to Public so a typo never hides a channel that was + * meant to be broadly readable. + */ +static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QString &key) +{ + const QString level = key.trimmed().toLower(); + if (level == "developer") { + return ServerInfo_RoomChannel::Developer; + } + if (level == "admin") { + return ServerInfo_RoomChannel::Admin; + } + if (level == "moderator") { + return ServerInfo_RoomChannel::Moderator; + } + if (level == "judge") { + return ServerInfo_RoomChannel::Judge; + } + if (level == "registered") { + return ServerInfo_RoomChannel::Registered; + } + return ServerInfo_RoomChannel::Public; +} + bool Servatrice::initServer() { @@ -360,10 +390,41 @@ bool Servatrice::initServer() while (query2->next()) { gameTypes.append(query2->value(0).toString()); } + + QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery( + "select channel_id, display_name, access_level from {prefix}_rooms_channels where id_room = :id_room " + "AND id_server = :id_server order by channel_id asc"); + query3->bindValue(":id_server", serverId); + query3->bindValue(":id_room", query->value(0).toInt()); + servatriceDatabaseInterface->execSqlQuery(query3); + QList channels; + QSet seenChannelIds; + while (query3->next()) { + const QString channelId = query3->value(0).toString(); + if (channelId.isEmpty()) { + qWarning() << "Skipping room channel with empty id"; + continue; + } + if (Server_Room::isMainChannel(channelId)) { + qWarning() << "Skipping room channel with reserved id" << channelId; + continue; + } + if (seenChannelIds.contains(channelId)) { + qWarning() << "Skipping duplicate room channel id" << channelId; + continue; + } + seenChannelIds.insert(channelId); + ServerInfo_RoomChannel channel; + channel.set_id(channelId.toStdString()); + channel.set_display_name(query3->value(1).toString().toStdString()); + channel.set_access_level(roomChannelAccessFromKey(query3->value(2).toString())); + channels.append(channel); + } + addRoom(new Server_Room(query->value(0).toInt(), query->value(7).toInt(), query->value(1).toString(), query->value(2).toString(), query->value(3).toString().toLower(), query->value(4).toString().toLower(), static_cast(query->value(5).toInt()), - query->value(6).toString(), gameTypes, this)); + query->value(6).toString(), gameTypes, this, channels)); } } else { int size = settingsCache->beginReadArray("rooms/roomlist"); @@ -376,12 +437,38 @@ bool Servatrice::initServer() gameTypes.append(settingsCache->value("name").toString()); } settingsCache->endArray(); + QList channels; + QSet seenChannelIds; + int size3 = settingsCache->beginReadArray("channels"); + for (int k = 0; k < size3; ++k) { + settingsCache->setArrayIndex(k); + const QString channelId = settingsCache->value("id").toString(); + if (channelId.isEmpty()) { + qWarning() << "Skipping room channel with empty id"; + continue; + } + if (Server_Room::isMainChannel(channelId)) { + qWarning() << "Skipping room channel with reserved id" << channelId; + continue; + } + if (seenChannelIds.contains(channelId)) { + qWarning() << "Skipping duplicate room channel id" << channelId; + continue; + } + seenChannelIds.insert(channelId); + ServerInfo_RoomChannel channel; + channel.set_id(channelId.toStdString()); + channel.set_display_name(settingsCache->value("displayname").toString().toStdString()); + channel.set_access_level(roomChannelAccessFromKey(settingsCache->value("access").toString())); + channels.append(channel); + } + settingsCache->endArray(); Server_Room *newRoom = new Server_Room( i, settingsCache->value("chathistorysize").toInt(), settingsCache->value("name").toString(), settingsCache->value("description").toString(), settingsCache->value("permissionlevel").toString().toLower(), settingsCache->value("privilegelevel").toString().toLower(), settingsCache->value("autojoin").toBool(), - settingsCache->value("joinmessage").toString(), gameTypes, this); + settingsCache->value("joinmessage").toString(), gameTypes, this, channels); addRoom(newRoom); } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 411360fa7..0bae67e6c 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; From 05dcde69dbd9d868abaa9196512da1c1528c43b5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:38 +0200 Subject: [PATCH 07/13] [Server] Gate room channels on privilege levels (#7477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Gate room channels on privilege levels * [Server] Filter channel catalog per user, split read/write gating, add banners - Server_Room::getInfo() now takes the requesting user and only lists channels that user may read. Previously every room member received the full catalog (e.g. the moderators channel) while message delivery was already gated, so restricted channels were visible but silent. - Split channel access into mayReadChannel/mayWriteChannel: history replay, catalog listing, and event fan-out use the read level (access_level); Command_RoomChannelSay uses the write level (write_access_level, falling back to the read level when unset). - Plumb write_access_level and banner_message through both room sources (SQL rooms_channels table and config-file channels via writeaccess and banner keys). - Schema 38 -> 39: add write_access_level (NULL means inherit the read level) and banner_message columns; also widen access_level to the full six-value privilege enum to match the 37 -> 38 migration. * Fixup: evaluate channel access per user flag instead of ladder User flags are independent bits: IsDeveloper does not imply IsModerator/IsAdmin, so developer-only users must not reach moderator/admin channels. Replace the userPrivilegeLevel ladder with hasChannelPrivilege: moderator channels need IsModerator (admins hold it alongside IsAdmin), admin channels need IsAdmin, developer channels need IsDeveloper or IsAdmin, judge channels admit IsJudge/IsModerator. Covers catalog filtering, history replay, write gating and fan-out, and corrects the ini example and proto docs. * [Server] Make channel privilege helpers static per review * [Server] Fail closed on unknown channel access levels per review - roomChannelAccessFromKey returns nullopt for unknown keywords instead of silently falling back to Public - skip channels with unknown read/write levels with qWarning, empty read defaults to Public, empty write means no override - mayWriteChannel now also requires read access so write-below-read configs cannot post into invisible channels * [Server] Remove stray conflict marker in channel replay comment * [Server] Fix servatrice.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../server/remote/server_protocolhandler.cpp | 17 +++-- .../network/server/remote/server_room.cpp | 72 +++++++++++++++++-- .../network/server/remote/server_room.h | 29 +++++++- .../protocol/pb/serverinfo_room_channel.proto | 6 +- .../migrations/servatrice_0038_to_0039.sql | 19 +++++ servatrice/servatrice.ini.example | 26 +++++-- servatrice/servatrice.sql | 4 +- servatrice/src/servatrice.cpp | 65 +++++++++++++++-- .../src/servatrice_database_interface.h | 2 +- 9 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 servatrice/migrations/servatrice_0038_to_0039.sql diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 0a91bbe77..8b4ade900 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -769,7 +769,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetGamesOfUser(const Command_G while (roomIterator.hasNext()) { Server_Room *room = roomIterator.next().value(); room->gamesLock.lockForRead(); - room->getInfo(*re->add_room_list(), false, true); + room->getInfo(*re->add_room_list(), false, true, true, userInfo); QListIterator gameIterator(room->getGamesOfUser(nameFromStdString(cmd.user_name()))); while (gameIterator.hasNext()) { re->add_game_list()->CopyFrom(gameIterator.next()); @@ -827,7 +827,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdListRooms(const Command_ListRo Event_ListRooms event; QMapIterator roomIterator(server->getRooms()); while (roomIterator.hasNext()) { - roomIterator.next().value()->getInfo(*event.add_room_list(), false); + roomIterator.next().value()->getInfo(*event.add_room_list(), false, false, true, userInfo); } rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); @@ -871,9 +871,13 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory)); } - // Replay the history of every catalog channel. Privilege filtering for - // restricted channels is applied on top of this in the moderator stack. + // Replay the history of every catalog channel the joining user may see. + // Public channels are replayed to everyone; restricted channels only to + // members whose privilege level meets the channel's access level. for (const ServerInfo_RoomChannel &channel : room->getChannels()) { + if (!room->mayReadChannel(*userInfo, channel)) { + continue; + } const QList channelHistory = room->getChatHistory(QString::fromStdString(channel.id())); for (const ServerInfo_ChatMessage &chatMessage : channelHistory) { Event_RoomChannelSay channelChatHistory; @@ -891,7 +895,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(joinMessageEvent)); auto *re = new Response_JoinRoom; - room->getInfo(*re->mutable_room_info(), true); + room->getInfo(*re->mutable_room_info(), true, false, true, userInfo); rc.setResponseExtension(re); return Response::RespOk; @@ -986,6 +990,9 @@ Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_R // The client only ever offers catalog ids; a crafted or stale id is rejected. return Response::RespInvalidCommand; } + if (!room->mayWriteChannel(*userInfo, *channel)) { + return Response::RespFunctionNotAllowed; + } if (!addSaidMessageSize(static_cast(cmd.message().size()))) { return Response::RespChatFlood; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 7a8c59536..68b137cd4 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -107,8 +107,11 @@ Server *Server_Room::getServer() const return static_cast(parent()); } -const ServerInfo_Room & -Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, bool includeExternalData) const +const ServerInfo_Room &Server_Room::getInfo(ServerInfo_Room &result, + bool complete, + bool showGameTypes, + bool includeExternalData, + const ServerInfo_User *forUser) const { result.set_room_id(id); result.set_name(name.toStdString()); @@ -158,6 +161,9 @@ Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, } for (const ServerInfo_RoomChannel &channel : channels) { + if (forUser != nullptr && !mayReadChannel(*forUser, channel)) { + continue; + } result.add_channel_list()->CopyFrom(channel); } @@ -179,6 +185,43 @@ const ServerInfo_RoomChannel *Server_Room::findChannel(const QString &channelId) return nullptr; } +bool Server_Room::hasChannelPrivilege(const ServerInfo_User &userInfo, + ServerInfo_RoomChannel::AccessLevel requiredLevel) +{ + // User flags are independent bits, not a ladder: holding IsDeveloper does + // not imply IsModerator or IsAdmin. The database grants admins the admin + // and the moderator bit together, so admins still reach moderator (and + // judge) channels; moderators likewise reach judge channels. + const uint32_t userLevel = userInfo.user_level(); + switch (requiredLevel) { + case ServerInfo_RoomChannel::Public: + return true; + case ServerInfo_RoomChannel::Registered: + return userLevel & ServerInfo_User::IsRegistered; + case ServerInfo_RoomChannel::Judge: + return userLevel & (ServerInfo_User::IsJudge | ServerInfo_User::IsModerator); + case ServerInfo_RoomChannel::Moderator: + return userLevel & ServerInfo_User::IsModerator; + case ServerInfo_RoomChannel::Admin: + return userLevel & ServerInfo_User::IsAdmin; + case ServerInfo_RoomChannel::Developer: + return userLevel & (ServerInfo_User::IsDeveloper | ServerInfo_User::IsAdmin); + default: + return false; + } +} + +bool Server_Room::mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel) +{ + return hasChannelPrivilege(userInfo, channel.access_level()); +} + +bool Server_Room::mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel) +{ + const auto writeLevel = channel.has_write_access_level() ? channel.write_access_level() : channel.access_level(); + return mayReadChannel(userInfo, channel) && hasChannelPrivilege(userInfo, writeLevel); +} + QList Server_Room::getChatHistory(const QString &channelId) const { return channelChatHistory.value(isMainChannel(channelId) ? MAIN_CHANNEL_KEY : channelId); @@ -342,18 +385,31 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl) { const bool isMain = isMainChannel(channelId); + ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public; + if (!isMain) { + const ServerInfo_RoomChannel *channel = findChannel(channelId); + if (channel == nullptr) { + // The channel was not whitelisted against the catalog; drop the message. + return; + } + accessLevel = channel->access_level(); + // V1 does not relay restricted channels across ISL (plan N5). + if (accessLevel != ServerInfo_RoomChannel::Public) { + sendToIsl = false; + } + } if (isMain) { Event_RoomSay event; event.set_name(userName.toStdString()); event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel); } else { Event_RoomChannelSay event; event.set_channel_id(channelId.toStdString()); event.set_name(userName.toStdString()); event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl); + sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel); } if (chatHistorySize != 0) { @@ -405,13 +461,17 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s } } -void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl) +void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl, ServerInfo_RoomChannel::AccessLevel accessLevel) { usersLock.lockForRead(); { QMapIterator userIterator(users); while (userIterator.hasNext()) { - userIterator.next().value()->sendProtocolItem(*event); + Server_ProtocolHandler *user = userIterator.next().value(); + if (!hasChannelPrivilege(*user->getUserInfo(), accessLevel)) { + continue; + } + user->sendProtocolItem(*event); } } usersLock.unlock(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h index e9227862f..b6e6f269a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -119,6 +119,24 @@ public: const ServerInfo_RoomChannel *findChannel(const QString &channelId) const; /** @brief True when @p channelId refers to the implicit Main channel. */ static bool isMainChannel(const QString &channelId); + /** + * @brief True when the flags in @p userInfo satisfy @p requiredLevel. + * + * User flags are independent bits, not a ladder: IsDeveloper does not + * imply IsModerator or IsAdmin, so developer-only users cannot read or + * post in moderator/admin channels. Admins hold the moderator bit + * alongside the admin bit and moderators are admitted to judge channels. + */ + static bool hasChannelPrivilege(const ServerInfo_User &userInfo, ServerInfo_RoomChannel::AccessLevel requiredLevel); + /** @brief True when @p userInfo may read @p channel. */ + static bool mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel); + /** + * @brief True when @p userInfo may write to @p channel. + * + * Writing requires the channel's write level, or — when no explicit write + * level is configured — the same level as reading. + */ + static bool mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel); const QMap &getGames() const { return games; @@ -128,8 +146,11 @@ public: return externalGames; } Server *getServer() const; - const ServerInfo_Room & - getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const; + const ServerInfo_Room &getInfo(ServerInfo_Room &result, + bool complete, + bool showGameTypes = false, + bool includeExternalData = true, + const ServerInfo_User *forUser = nullptr) const; int getGamesCreatedByUser(const QString &name) const; QList getGamesOfUser(const QString &name) const; QList getChatHistory(const QString &channelId) const; @@ -155,7 +176,9 @@ public: void addGame(Server_Game *game); void removeGame(Server_Game *game); - void sendRoomEvent(RoomEvent *event, bool sendToIsl = true); + void sendRoomEvent(RoomEvent *event, + bool sendToIsl = true, + ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public); RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent); }; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto index d38b1f651..0f3a9ccad 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto @@ -2,8 +2,10 @@ syntax = "proto2"; message ServerInfo_RoomChannel { enum AccessLevel { - // Minimum privilege level, evaluated against the privilege ladder - // Public < Registered < Judge < Moderator < Admin < Developer. + // Required user flag set, evaluated per level (not as a ladder): + // Registered needs IsRegistered; Judge needs IsJudge or IsModerator; + // Moderator needs IsModerator (admins hold it alongside IsAdmin); + // Admin needs IsAdmin; Developer needs IsDeveloper or IsAdmin. Public = 0; Registered = 1; Judge = 2; diff --git a/servatrice/migrations/servatrice_0038_to_0039.sql b/servatrice/migrations/servatrice_0038_to_0039.sql new file mode 100644 index 000000000..0cad5f494 --- /dev/null +++ b/servatrice/migrations/servatrice_0038_to_0039.sql @@ -0,0 +1,19 @@ +-- Servatrice db migration from version 38 to version 39 + +-- Per-channel read/write split and banner messages. +-- +-- write_access_level gates sending: NULL (the default) means writing requires +-- the same level as reading (access_level), preserving the previous +-- single-level behavior. Set it higher than access_level for read-mostly +-- channels (e.g. a developer news feed readable by everyone but writable only +-- by developers). +-- +-- banner_message is a static notice shown at the top of the channel, +-- analogous to the room join message on Main. Empty = none. + +ALTER TABLE `cockatrice_rooms_channels` + MODIFY `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + ADD COLUMN `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL, + ADD COLUMN `banner_message` varchar(255) NOT NULL DEFAULT ''; + +UPDATE cockatrice_schema_version SET version=39 WHERE version=38; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index e45b38804..90207aa70 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -336,17 +336,35 @@ roomlist\1\game_types\3\name="GameType3" ; channel every room has. Each channel needs a stable ASCII id, a display name ; (server data, like the room name) and an access level. Supported levels, from ; least to most restrictive, are: public, registered, judge, moderator, admin -; and developer. A channel is readable and writable by every member whose own -; privilege level is at least the channel's level -- so a moderator channel is -; also reachable by admins and developers, and an admin channel by developers. +; and developer. Access is evaluated against the member's user flags, not as a +; ladder: a moderator channel admits moderators (admins hold the moderator +; flag alongside the admin flag, so they are admitted too), an admin channel +; admits only admins, and a developer channel admits only developers and +; admins. In particular, developers cannot read moderator or admin channels. +; A judge channel additionally admits moderators. ; Public channels are visible to every room member. -roomlist\1\channels\size=2 +; +; Write access defaults to the read level, so by default a channel is readable +; and writable by the same members. Set writeaccess higher for read-mostly +; channels (e.g. a developer news feed readable by everyone but writable only +; by developers). Members who can read but not write see the channel with a +; disabled message input. +; +; banner is an optional static notice shown at the top of the channel, similar +; to the room joinmessage on Main. Empty or unset = none. +roomlist\1\channels\size=3 roomlist\1\channels\1\id=looking-for-players roomlist\1\channels\1\displayname="Looking for players" roomlist\1\channels\1\access=public +roomlist\1\channels\1\banner="Find opponents here. Be friendly!" roomlist\1\channels\2\id=moderators roomlist\1\channels\2\displayname="Moderators" roomlist\1\channels\2\access=moderator +roomlist\1\channels\3\id=developer-news +roomlist\1\channels\3\displayname="Developer news" +roomlist\1\channels\3\access=public +roomlist\1\channels\3\writeaccess=developer +roomlist\1\channels\3\banner="Official announcements from the developers." [game] diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index c83f5bb06..839dbf572 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(38); +INSERT INTO cockatrice_schema_version VALUES(39); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -164,6 +164,8 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( `channel_id` varchar(50) NOT NULL, `display_name` varchar(50) NOT NULL, `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public', + `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL, + `banner_message` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id_room`, `id_server`, `channel_id`), FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 9dd5a987c..040030320 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -68,6 +68,7 @@ #include #include #include +#include #include #include #include @@ -255,10 +256,10 @@ Servatrice::~Servatrice() * @brief Maps a configured channel access level (database column or ini value) * onto the ServerInfo_RoomChannel privilege ladder. * - * Unknown keywords fall back to Public so a typo never hides a channel that was - * meant to be broadly readable. + * Returns nullopt for unknown keywords so callers can fail closed instead of + * silently downgrading a restricted channel to Public. */ -static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QString &key) +static std::optional roomChannelAccessFromKey(const QString &key) { const QString level = key.trimmed().toLower(); if (level == "developer") { @@ -276,7 +277,10 @@ static ServerInfo_RoomChannel::AccessLevel roomChannelAccessFromKey(const QStrin if (level == "registered") { return ServerInfo_RoomChannel::Registered; } - return ServerInfo_RoomChannel::Public; + if (level == "public") { + return ServerInfo_RoomChannel::Public; + } + return std::nullopt; } bool Servatrice::initServer() @@ -392,7 +396,8 @@ bool Servatrice::initServer() } QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery( - "select channel_id, display_name, access_level from {prefix}_rooms_channels where id_room = :id_room " + "select channel_id, display_name, access_level, write_access_level, banner_message from " + "{prefix}_rooms_channels where id_room = :id_room " "AND id_server = :id_server order by channel_id asc"); query3->bindValue(":id_server", serverId); query3->bindValue(":id_room", query->value(0).toInt()); @@ -414,10 +419,33 @@ bool Servatrice::initServer() continue; } seenChannelIds.insert(channelId); + const QString accessKey = query3->value(2).toString(); + auto accessLevel = + accessKey.trimmed().isEmpty() + ? std::optional(ServerInfo_RoomChannel::Public) + : roomChannelAccessFromKey(accessKey); + if (!accessLevel.has_value()) { + qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id" + << channelId; + continue; + } ServerInfo_RoomChannel channel; channel.set_id(channelId.toStdString()); channel.set_display_name(query3->value(1).toString().toStdString()); - channel.set_access_level(roomChannelAccessFromKey(query3->value(2).toString())); + channel.set_access_level(accessLevel.value()); + if (!query3->isNull(3)) { + const QString writeKey = query3->value(3).toString(); + if (!writeKey.trimmed().isEmpty()) { + auto writeLevel = roomChannelAccessFromKey(writeKey); + if (!writeLevel.has_value()) { + qWarning() << "Skipping room channel with unknown write access level" << writeKey + << "for id" << channelId; + continue; + } + channel.set_write_access_level(writeLevel.value()); + } + } + channel.set_banner_message(query3->value(4).toString().toStdString()); channels.append(channel); } @@ -456,10 +484,33 @@ bool Servatrice::initServer() continue; } seenChannelIds.insert(channelId); + const QString accessKey = settingsCache->value("access").toString(); + auto accessLevel = + accessKey.trimmed().isEmpty() + ? std::optional(ServerInfo_RoomChannel::Public) + : roomChannelAccessFromKey(accessKey); + if (!accessLevel.has_value()) { + qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id" + << channelId; + continue; + } ServerInfo_RoomChannel channel; channel.set_id(channelId.toStdString()); channel.set_display_name(settingsCache->value("displayname").toString().toStdString()); - channel.set_access_level(roomChannelAccessFromKey(settingsCache->value("access").toString())); + channel.set_access_level(accessLevel.value()); + if (settingsCache->contains("writeaccess")) { + const QString writeKey = settingsCache->value("writeaccess").toString(); + if (!writeKey.trimmed().isEmpty()) { + auto writeLevel = roomChannelAccessFromKey(writeKey); + if (!writeLevel.has_value()) { + qWarning() << "Skipping room channel with unknown write access level" << writeKey + << "for id" << channelId; + continue; + } + channel.set_write_access_level(writeLevel.value()); + } + } + channel.set_banner_message(settingsCache->value("banner").toString().toStdString()); channels.append(channel); } settingsCache->endArray(); diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0bae67e6c..2e0d69113 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 38 +#define DATABASE_SCHEMA_VERSION 39 class Servatrice; From c99113ab0efa94761ba752e61afddac1a2467f33 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:39 +0200 Subject: [PATCH 08/13] [Client] Tabbed room channels with unread states and collapsible chat (#7478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Add room channel selector with per-channel chat views * [Client] Tabbed room channels with unread states, collapse, read-only, banners Reworks the room chat channel display: channels move out of the combobox into a tab bar below the message input showing every channel available to the user. - Each tab shows its unread message count and is tinted when it holds unreads or a mention; both colors are user-configurable chat settings (channelUnreadColor, channelMentionColor) with a settings-page section. - Clicking the active tab collapses the chat: message view and input hide and the splitter section minimizes to the tab strip; expanding restores the previous splitter sizes. Viewing a channel clears its unread state. - Channels the user may read but not write (per write_access_level) show with the message input disabled and a read-only placeholder; sends are guarded locally, the server still enforces. - Channel banner_message strings render at the top of their channel view on join, like the room join message on Main. - Replayed channel history no longer inflates unread counts; moderation redaction now applies across all channel views. * Fixup: keep chat tab bar visible when chat is collapsed QSplitter children are collapsible by default, so setSizes({1, 0}) hid the whole chat group box including the channel tab bar. Mark the chat widget non-collapsible so collapsing clamps to its minimum size (title + tab bar) instead. * [Room] Explicitly initialize MAIN_CHANNEL per review * [Room] Address channel tab review follow-ups - gate @all mentions on moderator/admin sender to match ChatView, whole-word matching for own name and highlight words - hide single-channel tab bar so old servers do not show a lone Main tab that collapses chat on misclick - capture target ChatView in sendMessage so errors land in the channel the message was sent from - share ignore/unregistered filtering between RoomSay and RoomChannelSay via isRoomMessageIgnored * [Client] Fix tab_room.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../settings_page/messages_settings_page.cpp | 66 +++ .../settings_page/messages_settings_page.h | 11 + .../src/interface/widgets/tabs/tab_room.cpp | 420 ++++++++++++++++-- .../src/interface/widgets/tabs/tab_room.h | 32 +- .../interface_chat_settings_provider.h | 2 + .../libcockatrice/settings/chat_settings.cpp | 20 + .../libcockatrice/settings/chat_settings.h | 4 + tests/settings/settings_defaults_test.cpp | 26 ++ 8 files changed, 543 insertions(+), 38 deletions(-) diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 9494155af..6128a7830 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -118,6 +118,26 @@ MessagesSettingsPage::MessagesSettingsPage() highlightGroupBox = new QGroupBox; highlightGroupBox->setLayout(highlightNotice); + channelUnreadColor = new QLineEdit(); + channelUnreadColor->setText(SettingsCache::instance().chat().getChannelUnreadColor()); + updateChannelUnreadPreview(); + connect(channelUnreadColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelUnreadColor); + + channelMentionColor = new QLineEdit(); + channelMentionColor->setText(SettingsCache::instance().chat().getChannelMentionColor()); + updateChannelMentionPreview(); + connect(channelMentionColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelMentionColor); + + auto *channelGrid = new QGridLayout; + channelGrid->addWidget(&channelUnreadColorLabel, 0, 0); + channelGrid->addWidget(channelUnreadColor, 0, 1); + channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2); + channelGrid->addWidget(&channelMentionColorLabel, 1, 0); + channelGrid->addWidget(channelMentionColor, 1, 1); + channelGrid->addWidget(&hexChannelMentionLabel, 1, 2); + channelGroupBox = new QGroupBox; + channelGroupBox->setLayout(channelGrid); + messageList = new QListWidget; int count = SettingsCache::instance().messages().getCount(); @@ -159,6 +179,7 @@ MessagesSettingsPage::MessagesSettingsPage() mainLayout->addWidget(messageGroupBox); mainLayout->addWidget(chatGroupBox); mainLayout->addWidget(highlightGroupBox); + mainLayout->addWidget(channelGroupBox); setLayout(mainLayout); @@ -195,6 +216,34 @@ void MessagesSettingsPage::updateHighlightColor(const QString &value) } } +void MessagesSettingsPage::updateChannelUnreadColor(const QString &value) +{ +#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) + QColor colorToSet = QColor::fromString("#" + value); +#else + QColor colorToSet; + colorToSet.setNamedColor("#" + value); +#endif + if (colorToSet.isValid()) { + SettingsCache::instance().chat().setChannelUnreadColor(value); + updateChannelUnreadPreview(); + } +} + +void MessagesSettingsPage::updateChannelMentionColor(const QString &value) +{ +#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) + QColor colorToSet = QColor::fromString("#" + value); +#else + QColor colorToSet; + colorToSet.setNamedColor("#" + value); +#endif + if (colorToSet.isValid()) { + SettingsCache::instance().chat().setChannelMentionColor(value); + updateChannelMentionPreview(); + } +} + void MessagesSettingsPage::updateTextColor(QT_STATE_CHANGED_T value) { SettingsCache::instance().chat().setChatMentionForeground(value); @@ -221,6 +270,18 @@ void MessagesSettingsPage::updateHighlightPreview() ";color: " + (SettingsCache::instance().chat().getChatHighlightForeground() ? "white" : "black") + ";}"); } +void MessagesSettingsPage::updateChannelUnreadPreview() +{ + channelUnreadColor->setStyleSheet("QLineEdit{background:#" + + SettingsCache::instance().chat().getChannelUnreadColor() + ";}"); +} + +void MessagesSettingsPage::updateChannelMentionPreview() +{ + channelMentionColor->setStyleSheet("QLineEdit{background:#" + + SettingsCache::instance().chat().getChannelMentionColor() + ";}"); +} + void MessagesSettingsPage::storeSettings() { SettingsCache::instance().messages().setCount(messageList->count()); @@ -267,6 +328,9 @@ void MessagesSettingsPage::retranslateUi() { chatGroupBox->setTitle(tr("Chat settings")); highlightGroupBox->setTitle(tr("Custom alert words")); + channelGroupBox->setTitle(tr("Room channel tabs")); + channelUnreadColorLabel.setText(tr("Unread messages color")); + channelMentionColorLabel.setText(tr("Mentioned messages color")); chatMentionCheckBox.setText(tr("Enable chat mentions")); chatMentionCompleterCheckbox.setText(tr("Enable mention completer")); messageGroupBox->setTitle(tr("In-game message macros")); @@ -284,6 +348,8 @@ void MessagesSettingsPage::retranslateUi() useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs")); hexLabel.setText(tr("(Color is hexadecimal)")); hexHighlightLabel.setText(tr("(Color is hexadecimal)")); + hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)")); + hexChannelMentionLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); customAlertString->setPlaceholderText(tr("Word1 Word2 Word3")); aAdd->setText(tr("Add New Message")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h index e85e6cca7..112ff4e29 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -27,6 +27,8 @@ private slots: void actRemove(); void updateColor(const QString &value); void updateHighlightColor(const QString &value); + void updateChannelUnreadColor(const QString &value); + void updateChannelMentionColor(const QString &value); void updateTextColor(QT_STATE_CHANGED_T value); void updateTextHighlightColor(QT_STATE_CHANGED_T value); @@ -49,18 +51,27 @@ private: QCheckBox useGameTimeCheckBox; QGroupBox *chatGroupBox; QGroupBox *highlightGroupBox; + QGroupBox *channelGroupBox; QGroupBox *messageGroupBox; QLineEdit *mentionColor; QLineEdit *highlightColor; + QLineEdit *channelUnreadColor; + QLineEdit *channelMentionColor; QLineEdit *customAlertString; QLabel hexLabel; QLabel hexHighlightLabel; + QLabel channelUnreadColorLabel; + QLabel channelMentionColorLabel; + QLabel hexChannelUnreadLabel; + QLabel hexChannelMentionLabel; QLabel customAlertStringLabel; QLabel explainMessagesLabel; void storeSettings(); void updateMentionPreview(); void updateHighlightPreview(); + void updateChannelUnreadPreview(); + void updateChannelMentionPreview(); }; #endif // COCKATRICE_MESSAGES_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 67106ba34..e5f2020ca 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -38,9 +39,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -52,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -61,12 +65,45 @@ #include #include +namespace +{ +// Empty id refers to the implicit Main channel. +const QString MAIN_CHANNEL = QString(); + +/** + * @brief Privilege rank mirroring Server_Room::userPrivilegeLevel. + * + * Higher = more privileged. Used to evaluate channel write access locally so + * the input can reflect read-only channels; the server re-checks on send. + */ +int userPrivilegeRank(uint32_t userLevel) +{ + if (userLevel & ServerInfo_User::IsDeveloper) { + return static_cast(ServerInfo_RoomChannel::Developer); + } + if (userLevel & ServerInfo_User::IsAdmin) { + return static_cast(ServerInfo_RoomChannel::Admin); + } + if (userLevel & ServerInfo_User::IsModerator) { + return static_cast(ServerInfo_RoomChannel::Moderator); + } + if (userLevel & ServerInfo_User::IsJudge) { + return static_cast(ServerInfo_RoomChannel::Judge); + } + if (userLevel & ServerInfo_User::IsRegistered) { + return static_cast(ServerInfo_RoomChannel::Registered); + } + return static_cast(ServerInfo_RoomChannel::Public); +} +} // namespace + TabRoom::TabRoom(TabSupervisor *_tabSupervisor, AbstractClient *_client, ServerInfo_User *_ownUser, const ServerInfo_Room &info) : Tab(_tabSupervisor), client(_client), roomId(info.room_id()), roomName(QString::fromStdString(info.name())), - ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager()) + ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager()), activeChannelId(MAIN_CHANNEL), + chatCollapsed(false) { const int gameTypeListSize = info.gametype_list_size(); for (int i = 0; i < gameTypeListSize; ++i) { @@ -86,14 +123,61 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; userList->setGameInviteLinkProvider(gameInviteLinkProvider); - chatView = new ChatView(tabSupervisor, nullptr, true, this); - connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); - connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); - connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); - connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); - connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); - connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); - connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); + chatViewStack = new QStackedWidget; + channelTabBar = new QTabBar; + channelTabBar->setShape(QTabBar::RoundedSouth); + channelTabBar->setExpanding(true); + channelTabBar->setUsesScrollButtons(true); + + channelViews.insert(MAIN_CHANNEL, makeChatView()); + channelIds << MAIN_CHANNEL; + channelDisplayNames.insert(MAIN_CHANNEL, tr("Main")); + channelUnreadCounts.insert(MAIN_CHANNEL, 0); + channelMentioned.insert(MAIN_CHANNEL, false); + channelWritable.insert(MAIN_CHANNEL, true); + channelTabBar->addTab(channelDisplayNames.value(MAIN_CHANNEL)); + chatViewStack->addWidget(channelViews.value(MAIN_CHANNEL)); + + const uint32_t ownLevel = ownUser != nullptr ? ownUser->user_level() : 0; + const int ownRank = userPrivilegeRank(ownLevel); + + const int channelCount = info.channel_list_size(); + for (int i = 0; i < channelCount; ++i) { + const ServerInfo_RoomChannel &channel = info.channel_list(i); + const QString channelId = QString::fromStdString(channel.id()); + if (channelId.isEmpty()) { + continue; + } + QString displayName = QString::fromStdString(channel.display_name()); + if (displayName.isEmpty()) { + displayName = channelId; + } + const int writeLevel = channel.has_write_access_level() ? static_cast(channel.write_access_level()) + : static_cast(channel.access_level()); + ChatView *channelView = makeChatView(); + const QString banner = QString::fromStdString(channel.banner_message()); + if (!banner.isEmpty()) { + channelView->appendMessage(banner); + } + channelViews.insert(channelId, channelView); + channelIds << channelId; + channelDisplayNames.insert(channelId, displayName); + channelUnreadCounts.insert(channelId, 0); + channelMentioned.insert(channelId, false); + channelWritable.insert(channelId, ownRank >= writeLevel); + channelTabBar->addTab(displayName); + chatViewStack->addWidget(channelViews.value(channelId)); + } + + chatView = channelViews.value(MAIN_CHANNEL); + chatViewStack->setCurrentWidget(chatView); + channelTabBar->setCurrentIndex(0); + // Old servers never send a channel catalog, so the tab bar would only show + // a single "Main" tab where a misclick hides the chat. Only show it when + // there is more than one channel to switch between. + channelTabBar->setVisible(channelIds.size() > 1); + connect(channelTabBar, &QTabBar::currentChanged, this, &TabRoom::onChannelChanged); + connect(channelTabBar, &QTabBar::tabBarClicked, this, &TabRoom::onChannelTabClicked); connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this, &TabRoom::actCompleterChanged); sayLabel = new QLabel; @@ -101,6 +185,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, sayEdit->setMaxLength(MAX_TEXT_LENGTH); sayLabel->setBuddy(sayEdit); connect(sayEdit, &LineEditCompleter::returnPressed, this, &TabRoom::sendMessage); + updateSayEnabled(); auto *chatSettingsMenu = new QMenu(this); @@ -122,19 +207,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, sayHbox->addWidget(sayEdit); sayHbox->addWidget(chatSettingsButton); + sayWidget = new QWidget; + sayWidget->setLayout(sayHbox); + auto *chatVbox = new QVBoxLayout; - chatVbox->addWidget(chatView); - chatVbox->addLayout(sayHbox); + chatVbox->addWidget(chatViewStack); + chatVbox->addWidget(sayWidget); + chatVbox->addWidget(channelTabBar); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatVbox); - auto *splitter = new QSplitter(Qt::Vertical); - splitter->addWidget(gameSelector); - splitter->addWidget(chatGroupBox); + chatSplitter = new QSplitter(Qt::Vertical); + chatSplitter->addWidget(gameSelector); + chatSplitter->addWidget(chatGroupBox); + // Splitter children are collapsible by default, so a zero size would hide + // the whole chat group box including the tab bar. Keep it clamped to its + // minimum size (title + tab bar) when collapsed. + chatSplitter->setCollapsible(1, false); auto *hbox = new QHBoxLayout; - hbox->addWidget(splitter, 3); + hbox->addWidget(chatSplitter, 3); hbox->addWidget(userListPanel, 1); aLeaveRoom = new QAction(this); @@ -189,7 +282,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, void TabRoom::retranslateUi() { gameSelector->retranslateUi(); - chatView->retranslateUi(); + for (auto it = channelViews.constBegin(); it != channelViews.constEnd(); ++it) { + it.value()->retranslateUi(); + } userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); @@ -197,6 +292,11 @@ void TabRoom::retranslateUi() aLeaveRoom->setText(tr("&Leave room")); aClearChat->setText(tr("&Clear chat")); aOpenChatSettings->setText(tr("Chat Settings...")); + channelDisplayNames.insert(MAIN_CHANNEL, tr("Main")); + for (const QString &channelId : channelIds) { + updateChannelTab(channelId); + } + updateSayEnabled(); } void TabRoom::focusTab() @@ -233,6 +333,11 @@ void TabRoom::tabActivated() if (!sayEdit->hasFocus()) { sayEdit->setFocus(); } + if (!chatCollapsed) { + channelUnreadCounts[activeChannelId] = 0; + channelMentioned[activeChannelId] = false; + updateChannelTab(activeChannelId); + } } QString TabRoom::sanitizeHtml(QString dirty) const @@ -240,29 +345,232 @@ QString TabRoom::sanitizeHtml(QString dirty) const return dirty.replace("&", "&").replace("<", "<").replace(">", ">"); } +void TabRoom::updateSayEnabled() +{ + const bool writable = channelWritable.value(activeChannelId, true); + sayLabel->setEnabled(writable); + sayEdit->setEnabled(writable); + sayEdit->setPlaceholderText(writable ? QString() : tr("This channel is read-only.")); +} + void TabRoom::sendMessage() { + if (!channelWritable.value(activeChannelId, true)) { + return; + } if (sayEdit->text().isEmpty()) { return; } else if (sayEdit->hasVisibleCompleterPopup()) { sayEdit->hideCompleterPopups(); return; } else { - Command_RoomSay cmd; - cmd.set_message(sayEdit->text().toStdString()); + ChatView *targetView = channelViews.value(activeChannelId, chatView); + auto onFinished = [this, targetView](const Response &response) { + if (response.response_code() == Response::RespChatFlood) { + targetView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds.")); + } else if (response.response_code() == Response::RespFunctionNotAllowed || + response.response_code() == Response::RespInvalidCommand) { + targetView->appendMessage(tr("Your message could not be sent: the channel is not available or you " + "are not allowed to talk there.")); + } + }; - PendingCommand *pend = prepareRoomCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabRoom::sayFinished); - sendRoomCommand(pend); + if (activeChannelId.isEmpty()) { + Command_RoomSay cmd; + cmd.set_message(sayEdit->text().toStdString()); + + PendingCommand *pend = prepareRoomCommand(cmd); + connect(pend, &PendingCommand::finished, this, onFinished); + sendRoomCommand(pend); + } else { + Command_RoomChannelSay cmd; + cmd.set_channel_id(activeChannelId.toStdString()); + cmd.set_message(sayEdit->text().toStdString()); + + PendingCommand *pend = prepareRoomCommand(cmd); + connect(pend, &PendingCommand::finished, this, onFinished); + sendRoomCommand(pend); + } sayEdit->clear(); } } -void TabRoom::sayFinished(const Response &response) +void TabRoom::onChannelChanged(int index) { - if (response.response_code() == Response::RespChatFlood) { - chatView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds.")); + if (index < 0 || index >= channelIds.size()) { + return; } + activeChannelId = channelIds.at(index); + ChatView *view = channelViews.value(activeChannelId); + if (view) { + chatView = view; + chatViewStack->setCurrentWidget(view); + } + // Viewing a channel clears its unread state + channelUnreadCounts[activeChannelId] = 0; + channelMentioned[activeChannelId] = false; + updateChannelTab(activeChannelId); + updateSayEnabled(); + if (chatCollapsed) { + setChatCollapsed(false); + } +} + +void TabRoom::onChannelTabClicked(int index) +{ + if (index < 0 || index >= channelIds.size()) { + return; + } + // Clicking the active tab toggles collapse; clicking another tab switches to it + if (channelTabBar->currentIndex() == index && channelIds.at(index) == activeChannelId) { + setChatCollapsed(!chatCollapsed); + } +} + +void TabRoom::setChatCollapsed(bool collapsed) +{ + chatCollapsed = collapsed; + chatViewStack->setVisible(!collapsed); + sayWidget->setVisible(!collapsed); + if (collapsed) { + // Minimize the chat section of the splitter down to just the tab bar + savedSplitterSizes = chatSplitter->sizes(); + chatSplitter->setSizes({1, 0}); + } else { + // Expanding onto the active channel marks it read + channelUnreadCounts[activeChannelId] = 0; + channelMentioned[activeChannelId] = false; + updateChannelTab(activeChannelId); + chatViewStack->setCurrentWidget(chatView); + if (!savedSplitterSizes.isEmpty()) { + chatSplitter->setSizes(savedSplitterSizes); + } + } +} + +QColor TabRoom::parseColorHex(const QString &hex, const QColor &fallback) +{ +#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) + QColor color = QColor::fromString("#" + hex); +#else + QColor color; + color.setNamedColor("#" + hex); +#endif + return color.isValid() ? color : fallback; +} + +void TabRoom::updateChannelTab(const QString &channelId) +{ + int index = channelIds.indexOf(channelId); + if (index < 0) { + return; + } + QString baseName = channelDisplayNames.value(channelId, channelId.isEmpty() ? tr("Main") : channelId); + int unread = channelUnreadCounts.value(channelId, 0); + bool mentioned = channelMentioned.value(channelId, false); + + QString label = baseName; + if (unread > 0) { + label += tr(" (%1)").arg(unread); + } + channelTabBar->setTabText(index, label); + + if (mentioned) { + channelTabBar->setTabTextColor( + index, parseColorHex(SettingsCache::instance().chat().getChannelMentionColor(), QColor(194, 31, 47))); + } else if (unread > 0) { + channelTabBar->setTabTextColor( + index, parseColorHex(SettingsCache::instance().chat().getChannelUnreadColor(), QColor(26, 123, 0))); + } else { + channelTabBar->setTabTextColor(index, QColor()); + } +} + +bool TabRoom::messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const +{ + if (ownUser == nullptr) { + return false; + } + QString ownName = QString::fromStdString(ownUser->name()); + if (ownName.isEmpty()) { + return false; + } + // Match ChatView::checkMention / checkWord: whole-word, case-insensitive. + // "@zach" must not match "@zachary", and highlight words match full words. + auto containsWholeWord = [](const QString &haystack, const QString &needle) { + if (needle.isEmpty()) { + return false; + } + QRegularExpression re(QStringLiteral("\\b%1\\b").arg(QRegularExpression::escape(needle)), + QRegularExpression::CaseInsensitiveOption); + return re.match(haystack).hasMatch(); + }; + + if (containsWholeWord(message, "@" + ownName)) { + return true; + } + // ChatView only highlights @all for moderator/admin senders + // (ChatView::isModeratorSendingGlobal). Gating here keeps the tab + // mention color from drifting ahead of the message view. + const uint32_t senderLevel = senderInfo.user_level(); + const bool senderIsPrivileged = + (senderLevel & ServerInfo_User::IsModerator) || (senderLevel & ServerInfo_User::IsAdmin); + if (senderIsPrivileged && containsWholeWord(message, "@all")) { + return true; + } + const QStringList words = SettingsCache::instance().chat().getHighlightWords().split(' ', Qt::SkipEmptyParts); + for (const QString &word : words) { + if (containsWholeWord(message, word)) { + return true; + } + } + return false; +} + +void TabRoom::markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo) +{ + bool roomVisible = (tabSupervisor->currentWidget() == this); + bool viewing = (channelId == activeChannelId) && !chatCollapsed && roomVisible; + if (viewing) { + return; + } + channelUnreadCounts[channelId] = channelUnreadCounts.value(channelId, 0) + 1; + if (messageContainsMention(message, senderInfo)) { + channelMentioned[channelId] = true; + } + updateChannelTab(channelId); +} + +bool TabRoom::isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const +{ + if (userListProxy->isUserIgnored(senderName)) { + return true; + } + + UserListTWI *twi = userList->getUsers().value(senderName); + if (twi) { + userInfoOut = twi->getUserInfo(); + if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() && + !UserLevelFlags(userInfoOut.user_level()).testFlag(ServerInfo_User::IsRegistered)) { + return true; + } + } else { + userInfoOut = ServerInfo_User{}; + } + return false; +} + +ChatView *TabRoom::makeChatView() +{ + ChatView *view = new ChatView(tabSupervisor, nullptr, true, this); + connect(view, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); + connect(view, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); + connect(view, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); + connect(view, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); + connect(view, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); + connect(view, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); + connect(view, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); + return view; } void TabRoom::actClearChat() @@ -298,6 +606,9 @@ void TabRoom::processRoomEvent(const RoomEvent &event) case RoomEvent::ROOM_SAY: processRoomSayEvent(event.GetExtension(Event_RoomSay::ext)); break; + case RoomEvent::ROOM_CHANNEL_SAY: + processRoomChannelSayEvent(event.GetExtension(Event_RoomChannelSay::ext)); + break; case RoomEvent::REMOVE_MESSAGES: processRemoveMessagesEvent(event.GetExtension(Event_RemoveMessages::ext)); break; @@ -335,18 +646,9 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) QString senderName = QString::fromStdString(event.name()); QString message = QString::fromStdString(event.message()); - if (userListProxy->isUserIgnored(senderName)) { - return; - } - - UserListTWI *twi = userList->getUsers().value(senderName); ServerInfo_User userInfo = {}; - if (twi) { - userInfo = twi->getUserInfo(); - if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() && - !UserLevelFlags(userInfo.user_level()).testFlag(ServerInfo_User::IsRegistered)) { - return; - } + if (isRoomMessageIgnored(senderName, userInfo)) { + return; } if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) { @@ -360,7 +662,49 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) "] " + message; } - chatView->appendMessage(message, event.message_type(), userInfo, true); + ChatView *mainView = channelViews.value(MAIN_CHANNEL); + if (mainView == nullptr) { + return; + } + + mainView->appendMessage(message, event.message_type(), userInfo, true); + if (event.message_type() != Event_RoomSay::ChatHistory) { + markChannelMessage(MAIN_CHANNEL, message, userInfo); + } + emit userEvent(false); +} + +void TabRoom::processRoomChannelSayEvent(const Event_RoomChannelSay &event) +{ + const QString channelId = QString::fromStdString(event.channel_id()); + ChatView *view = channelViews.value(channelId); + if (view == nullptr) { + return; + } + + QString senderName = QString::fromStdString(event.name()); + QString message = QString::fromStdString(event.message()); + + ServerInfo_User userInfo = {}; + if (isRoomMessageIgnored(senderName, userInfo)) { + return; + } + + if (senderName.isEmpty()) { + if (!SettingsCache::instance().chat().getRoomHistory()) { + return; + } + message = + "[" + + QString(QDateTime::fromMSecsSinceEpoch(event.time_of()).toLocalTime().toString("d MMM yyyy HH:mm:ss")) + + "] " + message; + } + + view->appendMessage(message, {}, userInfo, true); + if (!senderName.isEmpty()) { + // Empty senders mark replayed history, which must not count as unread + markChannelMessage(channelId, message, userInfo); + } emit userEvent(false); } @@ -368,7 +712,9 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event) { QString userName = QString::fromStdString(event.name()); int amount = event.amount(); - chatView->redactMessages(userName, amount); + for (ChatView *view : channelViews) { + view->redactMessages(userName, amount); + } } void TabRoom::refreshShortcuts() diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index 031056145..8f7c8f918 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -10,6 +10,7 @@ #include "tab.h" +#include #include #include #include @@ -18,8 +19,12 @@ class UserListProxy; class QAction; +class QTabBar; +class QWidget; class QGroupBox; +class QSplitter; class QMenu; +class QStackedWidget; class QStringListModel; class TabSupervisor; @@ -42,6 +47,7 @@ class Event_ListGames; class Event_JoinRoom; class Event_LeaveRoom; class Event_RoomSay; +class Event_RoomChannelSay; class Event_RemoveMessages; class GameSelector; class Response; @@ -64,10 +70,23 @@ private: UserListWidget *userList; const UserListProxy *userListProxy; ChatView *chatView; + QStackedWidget *chatViewStack; + QTabBar *channelTabBar; + QWidget *sayWidget; + QMap channelViews; + QMap channelDisplayNames; + QMap channelUnreadCounts; + QMap channelMentioned; + QMap channelWritable; + QStringList channelIds; + QString activeChannelId; + bool chatCollapsed; QLabel *sayLabel; LineEditCompleter *sayEdit; QStringListModel *mentionModel; QGroupBox *chatGroupBox; + QSplitter *chatSplitter; + QList savedSplitterSizes; QMenu *roomMenu; QAction *aLeaveRoom; @@ -85,7 +104,9 @@ signals: void gameListUpdated(); private slots: void sendMessage(); - void sayFinished(const Response &response); + void onChannelChanged(int index); + void onChannelTabClicked(int index); + void setChatCollapsed(bool collapsed); void actClearChat(); void actOpenChatSettings(); void addMentionTag(QString mentionTag); @@ -98,9 +119,18 @@ private slots: void processJoinRoomEvent(const Event_JoinRoom &event); void processLeaveRoomEvent(const Event_LeaveRoom &event); void processRoomSayEvent(const Event_RoomSay &event); + void processRoomChannelSayEvent(const Event_RoomChannelSay &event); void processRemoveMessagesEvent(const Event_RemoveMessages &event); void refreshShortcuts(); + ChatView *makeChatView(); + void updateChannelTab(const QString &channelId); + void updateSayEnabled(); + void markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo); + [[nodiscard]] bool messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const; + [[nodiscard]] bool isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const; + static QColor parseColorHex(const QString &hex, const QColor &fallback); + protected slots: void closeEvent(QCloseEvent *event) override; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h index 04dad9243..03f98f7bd 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h @@ -12,6 +12,8 @@ public: [[nodiscard]] virtual bool getChatMentionCompleter() const = 0; [[nodiscard]] virtual QString getChatMentionColor() const = 0; [[nodiscard]] virtual QString getChatHighlightColor() const = 0; + [[nodiscard]] virtual QString getChannelUnreadColor() const = 0; + [[nodiscard]] virtual QString getChannelMentionColor() const = 0; [[nodiscard]] virtual bool getChatMentionForeground() const = 0; [[nodiscard]] virtual bool getChatHighlightForeground() const = 0; [[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0; diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp index d9ae0ddac..10567f2ff 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp @@ -31,6 +31,16 @@ QString ChatSettings::getChatHighlightColor() const return getValue("highlightColor", QString(), QString(), "A6120D").toString(); } +QString ChatSettings::getChannelUnreadColor() const +{ + return getValue("channelUnreadColor", QString(), QString(), "1A7B00").toString(); +} + +QString ChatSettings::getChannelMentionColor() const +{ + return getValue("channelMentionColor", QString(), QString(), "A6120D").toString(); +} + bool ChatSettings::getChatMentionForeground() const { return getValue("mentionForeground", QString(), QString(), true).toBool(); @@ -107,6 +117,16 @@ void ChatSettings::setChatHighlightColor(const QString &_chatHighlightColor) setValue(_chatHighlightColor, "highlightColor"); } +void ChatSettings::setChannelUnreadColor(const QString &_channelUnreadColor) +{ + setValue(_channelUnreadColor, "channelUnreadColor"); +} + +void ChatSettings::setChannelMentionColor(const QString &_channelMentionColor) +{ + setValue(_channelMentionColor, "channelMentionColor"); +} + void ChatSettings::setChatMentionForeground(bool _chatMentionForeground) { setValue(_chatMentionForeground, "mentionForeground"); diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.h b/libcockatrice_settings/libcockatrice/settings/chat_settings.h index e00da4527..d107dfb67 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h @@ -19,6 +19,8 @@ public: [[nodiscard]] bool getChatMentionCompleter() const override; [[nodiscard]] QString getChatMentionColor() const override; [[nodiscard]] QString getChatHighlightColor() const override; + [[nodiscard]] QString getChannelUnreadColor() const override; + [[nodiscard]] QString getChannelMentionColor() const override; [[nodiscard]] bool getChatMentionForeground() const override; [[nodiscard]] bool getChatHighlightForeground() const override; [[nodiscard]] bool getIgnoreUnregisteredUsers() const override; @@ -35,6 +37,8 @@ public: void setChatMentionCompleter(bool _chatMentionCompleter); void setChatMentionColor(const QString &_chatMentionColor); void setChatHighlightColor(const QString &_chatHighlightColor); + void setChannelUnreadColor(const QString &_channelUnreadColor); + void setChannelMentionColor(const QString &_channelMentionColor); void setChatMentionForeground(bool _chatMentionForeground); void setChatHighlightForeground(bool _chatHighlightForeground); void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 2ba9f3fbb..0d4cdbafe 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -285,6 +285,32 @@ TEST_F(SettingsDefaultsTest, Chat_HighlightColor_Default) ASSERT_EQ(s.getChatHighlightColor(), QString("A6120D")); } +TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChannelUnreadColor(), QString("1A7B00")); +} + +TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_SetAndGet) +{ + ChatSettings s(settingsPath, nullptr); + s.setChannelUnreadColor(QString("0000FF")); + ASSERT_EQ(s.getChannelUnreadColor(), QString("0000FF")); +} + +TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChannelMentionColor(), QString("A6120D")); +} + +TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_SetAndGet) +{ + ChatSettings s(settingsPath, nullptr); + s.setChannelMentionColor(QString("FF0000")); + ASSERT_EQ(s.getChannelMentionColor(), QString("FF0000")); +} + TEST_F(SettingsDefaultsTest, Chat_MentionForeground_Default) { ChatSettings s(settingsPath, nullptr); From ce51e7147dc029327733ca0decb3b6a86c8ac259 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:39 +0200 Subject: [PATCH 09/13] [Servatrice] Log room channel messages in audit log (#7479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Log room channel messages and bump schema to 38 * Fixup: show room_channel audit rows and keep migration nullable The log history query returns room_channel rows, but the log tab dropped them. Render them in the room table with an extra Channel column. Also drop the NOT NULL on target_type from the 37->38 migration so migrated schemas match the nullable servatrice.sql definition (and strict-mode ALTERs cannot fail on legacy NULL rows). Update the channel tests to the flag-based privilege model: the admin fixture now carries IsModerator alongside IsAdmin, as the database layer assigns. --------- Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_logs.cpp | 12 +- .../server/remote/server_database_interface.h | 6 +- .../server/remote/server_protocolhandler.cpp | 4 + .../migrations/servatrice_0037_to_0038.sql | 8 + servatrice/servatrice.sql | 3 +- .../src/servatrice_database_interface.cpp | 20 +- .../src/servatrice_database_interface.h | 3 +- tests/CMakeLists.txt | 11 + tests/server_room_channels_test.cpp | 550 ++++++++++++++++++ 9 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 tests/server_room_channels_test.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index 9f17afea2..67e076983 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -35,10 +35,10 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); - roomTable->setColumnCount(6); + roomTable->setColumnCount(7); roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); roomTable->setHorizontalHeaderLabels( - QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";")); + QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName;Channel")).split(";")); gameTable = new QTableWidget(); gameTable->setColumnCount(6); @@ -317,7 +317,8 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) for (int i = 0; i < response.log_message_size(); ++i) { ServerInfo_ChatMessage message = response.log_message(i); - if (QString::fromStdString(message.target_type()) == "room") { + const QString targetType = QString::fromStdString(message.target_type()); + if (targetType == "room" || targetType == "room_channel") { roomTable->insertRow(roomCounter); roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); @@ -325,10 +326,11 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); + roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel()))); ++roomCounter; } - if (QString::fromStdString(message.target_type()) == "game") { + if (targetType == "game") { gameTable->insertRow(gameCounter); gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); @@ -339,7 +341,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) ++gameCounter; } - if (QString::fromStdString(message.target_type()) == "chat") { + if (targetType == "chat") { chatTable->insertRow(chatCounter); chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index 167adfeaf..3fef26904 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -151,7 +151,8 @@ public: MessageTargetRoom, MessageTargetGame, MessageTargetChat, - MessageTargetIslRoom + MessageTargetIslRoom, + MessageTargetRoomChannel }; virtual void logMessage(const int /* senderId */, const QString & /* senderName */, @@ -159,7 +160,8 @@ public: const QString & /* logMessage */, LogMessage_TargetType /* targetType */, const int /* targetId */, - const QString & /* targetName */) + const QString & /* targetName */, + const QString & /* channel */ = QString()) { } virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 8b4ade900..f75e2d3e3 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1003,6 +1003,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_R room->say(QString::fromStdString(userInfo->name()), msg, channelId); + databaseInterface->logMessage( + userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()), msg, + Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(), room->getName(), channelId); + return Response::RespOk; } diff --git a/servatrice/migrations/servatrice_0037_to_0038.sql b/servatrice/migrations/servatrice_0037_to_0038.sql index 6b8f155ab..ad6477c46 100644 --- a/servatrice/migrations/servatrice_0037_to_0038.sql +++ b/servatrice/migrations/servatrice_0037_to_0038.sql @@ -18,4 +18,12 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +-- 2. Audit log: room_channel target type plus a channel column so moderators +-- can tell which stream a logged message belongs to. +ALTER TABLE `cockatrice_log` + MODIFY `target_type` ENUM('room', 'game', 'chat', 'room_channel'); + +ALTER TABLE `cockatrice_log` + ADD COLUMN `channel` varchar(50) NULL AFTER `target_name`; + UPDATE cockatrice_schema_version SET version=38 WHERE version=37; \ No newline at end of file diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 839dbf572..438b32ccd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -341,9 +341,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_log` ( `sender_name` varchar(35) NOT NULL, `sender_ip` varchar(45) NOT NULL, `log_message` text NOT NULL, - `target_type` ENUM('room', 'game', 'chat'), + `target_type` ENUM('room', 'game', 'chat', 'room_channel'), `target_id` int(7) NULL, `target_name` varchar(50) NOT NULL, + `channel` varchar(50) NULL, KEY `sender_name` (`sender_name`), KEY `sender_ip` (`sender_ip`), KEY `target_id` (`target_id`), diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index e7d46c050..d7912cfe6 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1286,7 +1286,8 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName) + const QString &targetName, + const QString &channel) { QString targetTypeString; switch (targetType) { @@ -1296,6 +1297,12 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, } targetTypeString = "room"; break; + case MessageTargetRoomChannel: + if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) { + return; + } + targetTypeString = "room_channel"; + break; case MessageTargetGame: if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) { return; @@ -1319,8 +1326,9 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, } QSqlQuery *query = prepareQuery("insert into {prefix}_log (log_time, sender_id, sender_name, sender_ip, " - "log_message, target_type, target_id, target_name) values (now(), :sender_id, " - ":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)"); + "log_message, target_type, target_id, target_name, channel) values (now(), " + ":sender_id, :sender_name, :sender_ip, :log_message, :target_type, :target_id, " + ":target_name, :channel)"); query->bindValue(":sender_id", senderId < 1 ? QVariant() : senderId); query->bindValue(":sender_name", senderName); query->bindValue(":sender_ip", senderIp); @@ -1328,6 +1336,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, query->bindValue(":target_type", targetTypeString); query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId); query->bindValue(":target_name", targetName); + query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel); execSqlQuery(query); } @@ -1846,9 +1855,9 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory if (room) { if (game || chat) { - queryString.append(" OR `target_type` = 'room'"); + queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'"); } else { - queryString.append("`target_type` = 'room'"); + queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'"); } } queryString.append(")"); @@ -1899,6 +1908,7 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory chatMessage.set_target_type(QString(query->value(5).toString()).toStdString()); chatMessage.set_target_id(QString(query->value(6).toString()).toStdString()); chatMessage.set_target_name(QString(query->value(7).toString()).toStdString()); + chatMessage.set_channel(QString(query->value(8).toString()).toStdString()); results << chatMessage; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 2e0d69113..eccd0d943 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -177,7 +177,8 @@ public: const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName) override; + const QString &targetName, + const QString &channel = QString()) override; bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override; bool changeUserPassword(const QString &user, const QString &oldPassword, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ee0fd0698..b58942f3f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -274,6 +274,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_room_channels_test + SOURCES + server_room_channels_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME latency_tracker_test diff --git a/tests/server_room_channels_test.cpp b/tests/server_room_channels_test.cpp new file mode 100644 index 000000000..006c9fa39 --- /dev/null +++ b/tests/server_room_channels_test.cpp @@ -0,0 +1,550 @@ +/** @file server_room_channels_test.cpp + * @brief Tests for room chat channels: fan-out, privilege gating, whitelist, history replay and audit logging. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_protocolhandler.h" +#include "server_room.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + struct LogEntry + { + QString senderName; + QString message; + LogMessage_TargetType targetType; + int targetId; + QString targetName; + QString channel; + }; + QList logEntries; + + void logMessage(const int /* senderId */, + const QString &senderName, + const QString & /* senderIp */, + const QString &message, + LogMessage_TargetType targetType, + const int targetId, + const QString &targetName, + const QString &channel = QString()) override + { + logEntries.append({senderName, message, targetType, targetId, targetName, channel}); + } + + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return NotLoggedIn; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } + ServerInfo_User getUserData(const QString &, bool) override + { + return ServerInfo_User(); + } +}; + +class FakeServer : public Server +{ +public: + explicit FakeServer(Server_DatabaseInterface *db) : Server() + { + setDatabaseInterface(db); + } + void insertRoom(Server_Room *newRoom) + { + addRoom(newRoom); + } +}; + +class TestRoomHandler : public Server_ProtocolHandler +{ +public: + TestRoomHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + void authenticate(const ServerInfo_User &userInfo_) + { + setUserInfo(userInfo_); + authState = PasswordRight; + } + void attachToRoom(Server_Room *targetRoom) + { + rooms.insert(targetRoom->getId(), targetRoom); + } + + QList receivedRoomEvents; + Response::ResponseCode lastResponseCode = Response::RespNothing; + + void clearReceived() + { + receivedRoomEvents.clear(); + lastResponseCode = Response::RespNothing; + } + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::ROOM_EVENT) { + receivedRoomEvents.append(item.room_event()); + } else if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } +}; + +class RoomChannelTest : public ::testing::Test +{ +protected: + MockDatabaseInterface db; + FakeServer server{&db}; + QList catalog; + Server_Room *room = nullptr; + TestRoomHandler mod{&server, &db}; + TestRoomHandler adminUser{&server, &db}; + TestRoomHandler user{&server, &db}; + ServerInfo_User moderator; + ServerInfo_User admin; + ServerInfo_User plainUser; + + void SetUp() override + { + ServerInfo_RoomChannel lobby; + lobby.set_id("lobby"); + lobby.set_display_name("Lobby"); + lobby.set_access_level(ServerInfo_RoomChannel::Public); + catalog.append(lobby); + + ServerInfo_RoomChannel mods; + mods.set_id("mods"); + mods.set_display_name("Moderators"); + mods.set_access_level(ServerInfo_RoomChannel::Moderator); + catalog.append(mods); + + ServerInfo_RoomChannel ops; + ops.set_id("ops"); + ops.set_display_name("Admins"); + ops.set_access_level(ServerInfo_RoomChannel::Admin); + catalog.append(ops); + + room = new Server_Room(1, 1000, "test room", "", "", "", false, "Welcome", QStringList(), &server, catalog); + + moderator.set_name("mod"); + moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | + ServerInfo_User::IsModerator); + // Mirrors the database layer: the admin bit grants IsAdmin alongside + // IsModerator, so admins reach moderator channels via the moderator flag. + admin.set_name("admin"); + admin.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsAdmin | + ServerInfo_User::IsModerator); + plainUser.set_name("user"); + plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + } + + void TearDown() override + { + delete room; + } + + // Make @p handler a member of the room directly, without history replay or JOIN_ROOM bookkeeping. + void joinMember(TestRoomHandler &handler, const ServerInfo_User &userInfo_) + { + handler.authenticate(userInfo_); + handler.attachToRoom(room); + room->addClient(&handler); + } + + void sendMainSay(TestRoomHandler &handler, const QString &message) + { + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(room->getId()); + Command_RoomSay cmd; + cmd.set_message(message.toStdString()); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + void sendChannelSay(TestRoomHandler &handler, const QString &channelId, const QString &message) + { + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(room->getId()); + Command_RoomChannelSay cmd; + cmd.set_channel_id(channelId.toStdString()); + cmd.set_message(message.toStdString()); + cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + void joinThroughCommand(TestRoomHandler &handler) + { + CommandContainer cont; + cont.set_cmd_id(1); + Command_JoinRoom cmd; + cmd.set_room_id(room->getId()); + cont.add_session_command()->MutableExtension(Command_JoinRoom::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + static QList channelSayEvents(const TestRoomHandler &handler) + { + QList result; + for (const RoomEvent &event : handler.receivedRoomEvents) { + if (static_cast(getPbExtension(event)) == RoomEvent::ROOM_CHANNEL_SAY) { + result.append(event); + } + } + return result; + } + + static QList roomSayEvents(const TestRoomHandler &handler) + { + QList result; + for (const RoomEvent &event : handler.receivedRoomEvents) { + if (static_cast(getPbExtension(event)) == RoomEvent::ROOM_SAY) { + result.append(event); + } + } + return result; + } + + static QString channelIdOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).channel_id()); + } + + static QString messageOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).message()); + } + + static QString mainMessageOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomSay::ext).message()); + } +}; + +TEST_F(RoomChannelTest, MainSayStillBroadcastsToAllRoomMembers) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendMainSay(user, "hello everyone"); + + EXPECT_EQ(user.lastResponseCode, Response::RespOk); + EXPECT_EQ(roomSayEvents(mod).size(), 1); + EXPECT_EQ(mainMessageOf(roomSayEvents(mod).first()), "hello everyone"); + EXPECT_EQ(roomSayEvents(user).size(), 1); + EXPECT_EQ(mainMessageOf(roomSayEvents(user).first()), "hello everyone"); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, PublicChannelSaysBroadcastToEveryone) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendChannelSay(user, "lobby", "is there a game tonight?"); + + EXPECT_EQ(user.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "lobby"); + EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "is there a game tonight?"); + EXPECT_EQ(channelSayEvents(user).size(), 1); +} + +TEST_F(RoomChannelTest, ModeratorChannelReachesModeratorAndAbove) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + joinMember(user, plainUser); + mod.clearReceived(); + adminUser.clearReceived(); + user.clearReceived(); + + sendChannelSay(mod, "mods", "quiet channel"); + + EXPECT_EQ(mod.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "mods"); + // An admin holds the moderator flag alongside the admin flag and so also receives the channel message. + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + EXPECT_TRUE(roomSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, AdminReachesModeratorChannel) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + mod.clearReceived(); + adminUser.clearReceived(); + + sendChannelSay(adminUser, "mods", "admin checking in"); + + EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "admin checking in"); +} + +TEST_F(RoomChannelTest, UserBelowRequiredLevelCannotSendToChannel) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendChannelSay(user, "mods", "let me in"); + + EXPECT_EQ(user.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + bool loggedAsChannel = false; + for (const MockDatabaseInterface::LogEntry &entry : db.logEntries) { + loggedAsChannel |= entry.targetType == Server_DatabaseInterface::MessageTargetRoomChannel; + } + EXPECT_FALSE(loggedAsChannel); +} + +TEST_F(RoomChannelTest, AdminChannelRejectsModerator) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + mod.clearReceived(); + adminUser.clearReceived(); + + sendChannelSay(mod, "ops", "not allowed"); + + EXPECT_EQ(mod.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(adminUser).isEmpty()); +} + +TEST_F(RoomChannelTest, AdminChannelReachesAdminsOnly) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + joinMember(user, plainUser); + mod.clearReceived(); + adminUser.clearReceived(); + user.clearReceived(); + + sendChannelSay(adminUser, "ops", "staff only"); + + EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, UnknownChannelIdIsRejected) +{ + joinMember(user, plainUser); + user.clearReceived(); + + sendChannelSay(user, "not-a-channel", "spam"); + + EXPECT_EQ(user.lastResponseCode, Response::RespInvalidCommand); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + EXPECT_TRUE(db.logEntries.isEmpty()); +} + +TEST_F(RoomChannelTest, MissingAccessLevelDefaultsToPublic) +{ + ServerInfo_RoomChannel defaultAccess; + defaultAccess.set_id("public-by-default"); + defaultAccess.set_display_name("Default Access"); + catalog.append(defaultAccess); + auto *secondRoom = new Server_Room(2, 1000, "second", "", "", "", false, "", QStringList(), &server, catalog); + + TestRoomHandler joiner{&server, &db}; + joiner.authenticate(plainUser); + joiner.attachToRoom(secondRoom); + secondRoom->addClient(&joiner); + + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(2); + Command_RoomChannelSay cmd; + cmd.set_channel_id("public-by-default"); + cmd.set_message("hi"); + cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd); + joiner.processCommandContainer(cont); + + EXPECT_EQ(joiner.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(joiner).size(), 1); + + delete secondRoom; +} + +TEST_F(RoomChannelTest, HistoryStoredPerChannel) +{ + room->say("user", "main message", QString(), false); + room->say("user", "lobby message", "lobby", false); + room->say("user", "mods message", "mods", false); + + const QList mainHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY); + EXPECT_EQ(mainHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(mainHistory.first().message()), "main message"); + EXPECT_TRUE(QString::fromStdString(mainHistory.first().channel()).isEmpty()); + + const QList lobbyHistory = room->getChatHistory("lobby"); + EXPECT_EQ(lobbyHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(lobbyHistory.first().message()), "lobby message"); + EXPECT_EQ(QString::fromStdString(lobbyHistory.first().channel()), "lobby"); + + const QList modsHistory = room->getChatHistory("mods"); + EXPECT_EQ(modsHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(modsHistory.first().message()), "mods message"); + EXPECT_EQ(QString::fromStdString(modsHistory.first().channel()), "mods"); + + // A channel id maps to its own history bucket, never to the main one. + EXPECT_EQ(room->getChatHistory("").size(), mainHistory.size()); +} + +TEST_F(RoomChannelTest, JoinReplaysOnlyVisibleChannelHistory) +{ + server.insertRoom(room); + joinMember(mod, moderator); + joinMember(user, plainUser); + + room->say("user", "main message", QString(), false); + room->say("user", "lobby message", "lobby", false); + room->say("mod", "mods message", "mods", false); + room->say("admin", "ops message", "ops", false); + + TestRoomHandler lateUser{&server, &db}; + lateUser.authenticate(plainUser); + joinThroughCommand(lateUser); + + EXPECT_EQ(lateUser.lastResponseCode, Response::RespOk); + const QList lateChannelEvents = channelSayEvents(lateUser); + // Replay: one main history event, one public channel history event, the welcome message -- but no restricted + // history. + EXPECT_EQ(roomSayEvents(lateUser).size(), 2); + EXPECT_EQ(lateChannelEvents.size(), 1); + EXPECT_EQ(channelIdOf(lateChannelEvents.first()), "lobby"); + + TestRoomHandler lateMod{&server, &db}; + lateMod.authenticate(moderator); + joinThroughCommand(lateMod); + + EXPECT_EQ(lateMod.lastResponseCode, Response::RespOk); + const QList lateModChannelEvents = channelSayEvents(lateMod); + EXPECT_EQ(lateModChannelEvents.size(), 2); + EXPECT_EQ(channelIdOf(lateModChannelEvents.at(0)), "lobby"); + EXPECT_EQ(channelIdOf(lateModChannelEvents.at(1)), "mods"); + + TestRoomHandler lateAdmin{&server, &db}; + lateAdmin.authenticate(admin); + joinThroughCommand(lateAdmin); + + EXPECT_EQ(lateAdmin.lastResponseCode, Response::RespOk); + const QList lateAdminChannelEvents = channelSayEvents(lateAdmin); + // An admin sees the moderator channel and the admin channel on top of the public one. + EXPECT_EQ(lateAdminChannelEvents.size(), 3); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(0)), "lobby"); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(1)), "mods"); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(2)), "ops"); +} + +TEST_F(RoomChannelTest, AuditLoggingTagsChannelMessages) +{ + joinMember(user, plainUser); + + sendMainSay(user, "main log line"); + sendChannelSay(user, "lobby", "channel log line"); + + ASSERT_EQ(db.logEntries.size(), 2); + EXPECT_EQ(db.logEntries.at(0).targetType, Server_DatabaseInterface::MessageTargetRoom); + EXPECT_TRUE(db.logEntries.at(0).channel.isEmpty()); + EXPECT_EQ(db.logEntries.at(1).targetType, Server_DatabaseInterface::MessageTargetRoomChannel); + EXPECT_EQ(db.logEntries.at(1).channel, "lobby"); + EXPECT_EQ(db.logEntries.at(1).senderName, "user"); + EXPECT_EQ(db.logEntries.at(1).message, "channel log line"); +} + +TEST_F(RoomChannelTest, PublicChannelRelaysOverIslButRestrictedChannelsDoNot) +{ + QList relMessages; + QObject::connect(&server, &Server::sigSendIslMessage, + [&relMessages](const IslMessage &message, int) { relMessages.append(message); }); + + room->say("mod", "public msg", "lobby"); + room->say("mod", "mods msg", "mods"); + room->say("admin", "ops msg", "ops"); + + ASSERT_EQ(relMessages.size(), 1); + const RoomEvent &relayed = relMessages.first().room_event(); + EXPECT_EQ(static_cast(getPbExtension(relayed)), RoomEvent::ROOM_CHANNEL_SAY); + EXPECT_EQ(channelIdOf(relayed), "lobby"); + EXPECT_EQ(messageOf(relayed), "public msg"); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file From 155f7b01433c3466bb95883afe5f3667c34fe6db Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:40 +0200 Subject: [PATCH 10/13] [Servatrice] Relay room channel messages over ISL (#7480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Relay room channel messages over ISL * [Servatrice] Skip audit log for unknown ISL channel per review - externalRoomSay drops messages for channels not in the local catalog, matching Server_Room::say, instead of logging what was never delivered --------- Co-authored-by: Lukas Brübach --- .../network/server/remote/server.cpp | 20 +++++++++++++++---- .../network/server/remote/server.h | 2 +- servatrice/src/isl_interface.cpp | 13 +++++++++++- servatrice/src/isl_interface.h | 4 +++- servatrice/src/servatrice.cpp | 4 ++-- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index d0fb67446..63989bbfa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -441,7 +441,7 @@ void Server::externalRoomUserLeft(int roomId, const QString &userName) room->removeExternalUser(userName); } -void Server::externalRoomSay(int roomId, const QString &userName, const QString &message) +void Server::externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId) { // This function is always called from the main thread via signal/slot. QReadLocker locker(&roomsLock); @@ -451,10 +451,22 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString qDebug() << "externalRoomSay: room id=" << roomId << "not found"; return; } - room->say(userName, message, QString(), false); + if (!channelId.isEmpty() && !Server_Room::isMainChannel(channelId) && room->findChannel(channelId) == nullptr) { + // The receiver does not know this channel; say() would drop it, so do + // not log what was never delivered. + qDebug() << "externalRoomSay: unknown channel" << channelId << "for room id=" << roomId << "dropping"; + return; + } + room->say(userName, message, channelId, false); - getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom, - room->getId(), room->getName()); + if (channelId.isEmpty()) { + getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom, + room->getId(), room->getName()); + } else { + getDatabaseInterface()->logMessage(0, userName, "ISL", message, + Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(), + room->getName(), channelId); + } } void Server::externalRoomRemoveMessages(int roomId, const QString &userName, int amount) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 932a136ff..8e4e1a8f1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -265,7 +265,7 @@ protected slots: void externalUserLeft(const QString &userName); void externalRoomUserJoined(int roomId, const ServerInfo_User &userInfo); void externalRoomUserLeft(int roomId, const QString &userName); - void externalRoomSay(int roomId, const QString &userName, const QString &message); + void externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId); void externalRoomRemoveMessages(int roomId, const QString &userName, int amount); void externalRoomGameListChanged(int roomId, const ServerInfo_Game &gameInfo); void diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index a78ee9e72..8733c9a40 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -383,7 +384,14 @@ void IslInterface::roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event) void IslInterface::roomEvent_Say(int roomId, const Event_RoomSay &event) { - emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message())); + emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()), + QString()); +} + +void IslInterface::roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event) +{ + emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()), + QString::fromStdString(event.channel_id())); } void IslInterface::roomEvent_ListGames(int roomId, const Event_ListGames &event) @@ -459,6 +467,9 @@ void IslInterface::processRoomEvent(const RoomEvent &event) case RoomEvent::ROOM_SAY: roomEvent_Say(event.room_id(), event.GetExtension(Event_RoomSay::ext)); break; + case RoomEvent::ROOM_CHANNEL_SAY: + roomEvent_ChannelSay(event.room_id(), event.GetExtension(Event_RoomChannelSay::ext)); + break; case RoomEvent::LIST_GAMES: roomEvent_ListGames(event.room_id(), event.GetExtension(Event_ListGames::ext)); break; diff --git a/servatrice/src/isl_interface.h b/servatrice/src/isl_interface.h index f7680c0a2..c1d9334a5 100644 --- a/servatrice/src/isl_interface.h +++ b/servatrice/src/isl_interface.h @@ -26,6 +26,7 @@ class Event_UserLeft; class Event_JoinRoom; class Event_LeaveRoom; class Event_RoomSay; +class Event_RoomChannelSay; class Event_ListGames; class Event_RemoveMessages; class Command_JoinGame; @@ -49,7 +50,7 @@ signals: void externalUserLeft(QString userName); void externalRoomUserJoined(int roomId, ServerInfo_User userInfo); void externalRoomUserLeft(int roomId, QString userName); - void externalRoomSay(int roomId, QString userName, QString message); + void externalRoomSay(int roomId, QString userName, QString message, QString channelId); void externalRoomGameListChanged(int roomId, ServerInfo_Game gameInfo); void externalRoomRemoveMessages(int roomId, QString userName, int amount); void joinGameCommandReceived(const Command_JoinGame &cmd, int cmdId, int roomId, int serverId, qint64 sessionId); @@ -79,6 +80,7 @@ private: void roomEvent_UserJoined(int roomId, const Event_JoinRoom &event); void roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event); void roomEvent_Say(int roomId, const Event_RoomSay &event); + void roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event); void roomEvent_ListGames(int roomId, const Event_ListGames &event); void roomEvent_RemoveMessages(int roomId, const Event_RemoveMessages &event); diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 040030320..a03444bf5 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -1078,8 +1078,8 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface) connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this, SLOT(externalRoomUserJoined(int, ServerInfo_User))); connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString))); - connect(interface, SIGNAL(externalRoomSay(int, QString, QString)), this, - SLOT(externalRoomSay(int, QString, QString))); + connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this, + SLOT(externalRoomSay(int, QString, QString, QString))); connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this, SLOT(externalRoomRemoveMessages(int, QString, int))); connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this, From a139084ad628f4273f9fd4b00dfe5bdd6f9d5951 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:52:26 +0200 Subject: [PATCH 11/13] [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords (#7193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords Challenge-response authentication: the client derives a scrypt verifier (RFC 7914, EVP_PBE_scrypt, N=32768, r=8, p=1) and authenticates with HMAC-SHA256(key, nonce), so neither the password nor its hash is transmitted. The stored format becomes "$scrypt$$$

$$" and Response_PasswordSalt now carries the cost parameters. Strict servers only accept scrypt verifiers; legacy accounts are migrated after a successful login. Fix #344 for challenge-response servers: a saved profile stores the derived verifier under the password key instead of the plaintext password. The connect dialog loads it without revealing it, autoconnect passes it through, the change-password dialog no longer prefills the old password field with it, and the client only persists the verifier when "Save password" is checked. Took 3 minutes Took 1 minute Took 10 seconds Took 7 minutes * [Security] Harden credential format acceptance and challenge nonce validation * [Security] Refuse KDF downgrade; trim profile name and warn on silent credential drop * [Security] Add SupportsAll ServerOptions enumerator to fix enum validation assert The server advertised both SupportsPasswordHash (1) and SupportsChallengeResponseAuth (2) via a bitwise-OR that produced the value 3, which no enumerator declared. Protobuf's proto2 enum validation rejects the value in _internal_set_server_options, crashing servatrice on the debug assertion. Add a SupportsAll = 3 enumerator with an identical wire value so the bitmask reads unchanged while the value is now a documented, valid member of the enum. * [Security] Normalize username when issuing challenge nonce cmdRequestPasswordSalt bound the challenge nonce to the raw, un-simplified username while cmdLogin verifies it against the simplified name. A user who typed leading or trailing whitespace in the username box would be silently rejected during challenge-response login even with the correct password. Simplify at the nonce-issuing site so both commands agree on the normalized name. Also drive-by formatting: collapse the now single-expression serverOptions initializer onto one line. * [Security] Keep an encrypted legacy password backup for old-client logins Migrating an account to scrypt stranded pre-challenge clients, which can only present the legacy salt+hash: after migration the server served them a scrypt salt they cannot use, so the only recovery was a password reset. When security/legacy_backup_key is configured (64 hex chars, openssl rand -hex 32), submitPasswordVerifier now seals the legacy credential with AES-256-GCM under that key before overwriting the row, storing it in password_legacy_backup with the time it was last used. Old clients keep working: cmdRequestPasswordSalt serves the backup's 16-char salt in password_salt so they can derive the legacy hash they were always able to compute, while new clients derive the verifier from the new scrypt_salt field, which always carries the verifier salt. The non-challenge login path accepts the plaintext or legacy hash against the decrypted backup and refreshes its window; a migrated row with no usable backup fails closed. Backups age out so a database dump taken after the window closes never carries a crackable legacy hash: getUsableLegacyBackup purges on first use past security/legacy_backup_ttl_days (default 90, clamped 1..3650), and a six-hourly sweep drops backups idle accounts never touch. changeUserPassword clears the backup since a new credential invalidates it. Without a configured key nothing is sealed or purged, so a temporarily missing key cannot destroy existing backups. The AAD is the simplified user name on both sides, sealing the blob to its row so it cannot be moved between accounts. Sealing failures degrade to the previous lockout behaviour rather than blocking migration. The columns extend the unmerged 0036->0037 migration, so the schema version stays 37. * [Security] Address review: proto compat, plaintext verify, AAD case, strict scope Fix the two blockers and follow-ups from the 2026-10-16 review: - Advertise challenge-response in a new supports_challenge_response field instead of the ServerOptions enum and drop SupportsAll: proto2 enums are closed, so already-released clients parsed server_options = 3 as NoOptions and fell back to plaintext passwords. server_options stays exactly SupportsPasswordHash on the wire; old clients ignore the new field. - Verify a plaintext password directly against a migrated scrypt row with verifyPassword() so email changes (cmdAccountEdit) and old clients sending password keep working without a legacy backup key. Pre-hashed values are still checked against the backup, and its TTL window is refreshed only when the backup is what actually matched. - Use the stored row name as the AES-GCM AAD on both seal and open so a login whose case differs from the stored name (utf8mb4_unicode_ci) still opens its backup. - Require challenge-response in strict mode only for registered SQL accounts (userExists gate), leaving guest/unregistered logins and none/password auth methods unaffected. - Document in servatrice.ini.example that a captured legacy hash can take over a not-yet-migrated account and that strict only constrains registered SQL accounts. Schema version is 38. * [Security] Raise legacy backup TTL default to 180 days Gives operators and slow-to-upgrade users a longer downgrade window for pre-challenge clients before unused encrypted legacy password backups age out. * [Security] Fix servatrice.cpp formatting per clang-format-18 * [Security] Add missing forward declaration for Command_SubmitPasswordVerifier The cmdSubmitPasswordVerifier declaration references the proto-generated type, which otherwise does not name a type in this header and breaks every C++ build. --------- Co-authored-by: Lukas Brübach Co-authored-by: BruebachL --- .../remote_connection_controller.cpp | 21 +- .../remote_connection_controller.h | 16 +- .../interface/widgets/dialogs/dlg_connect.cpp | 18 +- .../interface/widgets/dialogs/dlg_connect.h | 18 ++ .../widgets/dialogs/dlg_edit_password.cpp | 5 +- .../widgets/server/user/user_info_box.cpp | 4 +- cockatrice/src/interface/window_main.cpp | 3 +- .../client/abstract/abstract_client.cpp | 3 +- .../network/client/abstract/abstract_client.h | 5 + .../network/client/remote/remote_client.cpp | 128 +++++++- .../network/client/remote/remote_client.h | 21 ++ .../network/server/remote/server.h | 5 + .../server/remote/server_database_interface.h | 8 + .../server/remote/server_protocolhandler.cpp | 39 +++ .../server/remote/server_protocolhandler.h | 12 + .../libcockatrice/protocol/featureset.cpp | 3 +- .../pb/event_server_identification.proto | 6 + .../protocol/pb/response_password_salt.proto | 18 ++ .../protocol/pb/session_commands.proto | 12 + .../settings/servers_settings.cpp | 13 + .../libcockatrice/settings/servers_settings.h | 2 + .../libcockatrice/utility/cryptoutil.cpp | 103 ++++++ .../libcockatrice/utility/cryptoutil.h | 9 + .../libcockatrice/utility/passwordhasher.cpp | 114 +++++++ .../libcockatrice/utility/passwordhasher.h | 44 +++ .../migrations/servatrice_0039_to_0040.sql | 17 + servatrice/servatrice.ini.example | 42 +++ servatrice/servatrice.sql | 6 +- servatrice/src/servatrice.cpp | 53 +++ servatrice/src/servatrice.h | 17 + .../src/servatrice_database_interface.cpp | 303 +++++++++++++++++- .../src/servatrice_database_interface.h | 14 +- servatrice/src/serversocketinterface.cpp | 131 +++++++- servatrice/src/serversocketinterface.h | 3 + tests/password_hash_test.cpp | 200 ++++++++++++ 35 files changed, 1385 insertions(+), 31 deletions(-) create mode 100644 servatrice/migrations/servatrice_0039_to_0040.sql diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 9b985073d..037565c6a 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -95,6 +95,9 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this, &ConnectionController::onPromptForgotPasswordChallenge); + + connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this, + &ConnectionController::onPasswordVerifierReady); } void ConnectionController::connectToServer() @@ -103,16 +106,32 @@ void ConnectionController::connectToServer() connect(dlgConnect, &DlgConnect::sigStartForgotPasswordRequest, this, &ConnectionController::forgotPasswordRequest); if (dlgConnect->exec()) { + pendingSaveName = dlgConnect->getSaveName(); + pendingSavePassword = dlgConnect->getSavePassword(); + remoteClient->setStoredVerifier(dlgConnect->getStoredVerifier()); remoteClient->connectToServer(dlgConnect->getHost(), static_cast(dlgConnect->getPort()), dlgConnect->getPlayerName(), dlgConnect->getPassword()); } } +void ConnectionController::onPasswordVerifierReady(const QString &verifier) +{ + if (pendingSavePassword) { + SettingsCache::instance().servers().setServerPassword(pendingSaveName, verifier); + } +} + void ConnectionController::connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, - const QString &password) + const QString &password, + const QString &storedVerifier, + const QString &saveName, + bool savePassword) { + pendingSaveName = saveName; + pendingSavePassword = savePassword; + remoteClient->setStoredVerifier(storedVerifier); remoteClient->connectToServer(host, port, playerName, password); } diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h index d4a832c75..150ec9e5b 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -40,8 +40,13 @@ public: void registerToServer(); void forgotPasswordRequest(); void connectToServer(); - void - connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, const QString &password); + void connectToServerDirect(const QString &host, + unsigned int port, + const QString &playerName, + const QString &password, + const QString &storedVerifier = QString(), + const QString &saveName = QString(), + bool savePassword = false); void disconnectFromServer(); void refreshWindowTitle() @@ -86,6 +91,9 @@ private slots: void onPromptForgotPasswordReset(); void onPromptForgotPasswordChallenge(); + // Persists the derived scrypt verifier after a successful challenge-response login + void onPasswordVerifierReady(const QString &verifier); + private: void wireClientSignals(); void updateWindowTitle(); @@ -102,6 +110,10 @@ private: // Kept as a member so the forgot-password signal can be wired to it DlgConnect *dlgConnect{nullptr}; + + // Captured from the connect dialog when a connection is initiated + QString pendingSaveName; + bool pendingSavePassword{false}; }; #endif // COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 7a7e31a58..43b40bf30 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -289,9 +289,15 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) playernameEdit->setText(_data.at(3)); playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); + storedVerifier.clear(); if (savePasswordStatus) { - passwordEdit->setText(_data.at(4)); + const QString stored = _data.at(4); + if (stored.startsWith("$")) { + storedVerifier = stored; + } else { + passwordEdit->setText(stored); + } } if (!_data.at(6).isEmpty()) { @@ -317,6 +323,7 @@ void DlgConnect::newHostSelected(bool state) portEdit->setDisabled(false); playernameEdit->clear(); passwordEdit->clear(); + storedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); saveEdit->setDisabled(false); @@ -342,6 +349,11 @@ void DlgConnect::actOk() { ServersSettings &servers = SettingsCache::instance().servers(); + // Never write a newly typed plaintext password to disk when a verifier is already stored: + // the typed value is used for this connection and a fresh verifier is persisted after a + // successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior. + const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier; + if (newHostButton->isChecked()) { if (saveEdit->text().isEmpty()) { QMessageBox::critical(this, tr("Connection Warning"), tr("You need to name your new connection profile.")); @@ -349,10 +361,10 @@ void DlgConnect::actOk() } servers.addNewServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(), - playernameEdit->text().trimmed(), passwordEdit->text(), savePasswordCheckBox->isChecked()); + playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked()); } else { servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(), - playernameEdit->text().trimmed(), passwordEdit->text(), + playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked()); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index 10cb5366a..bef5f136e 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -7,6 +7,7 @@ #ifndef DLG_CONNECT_H #define DLG_CONNECT_H +#include #include #include #include @@ -51,6 +52,22 @@ public: { return passwordEdit->text(); } + //! \brief Stored "$scrypt$..." verifier for challenge-response servers (never the plaintext password). + [[nodiscard]] QString getStoredVerifier() const + { + return storedVerifier; + } + [[nodiscard]] QString getSaveName() const + { + // Trim so the profile lookup key always matches the name DlgConnect::actOk + // stored (it saves under saveEdit->text().trimmed()); otherwise a trailing + // space here misses the index and silently drops the saved verifier. + return saveEdit->text().trimmed(); + } + [[nodiscard]] bool getSavePassword() const + { + return savePasswordCheckBox->isChecked(); + } public slots: void downloadThePublicServers(); @@ -81,6 +98,7 @@ private: QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer; QMap> savedHostList; HandlePublicServers *hps; + QString storedVerifier; const QString placeHolderText = tr("Downloading..."); }; #endif diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp index 59f80c79c..979a4519c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp @@ -23,7 +23,10 @@ DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent) auto &servers = SettingsCache::instance().servers(); if (servers.getSavePassword()) { - oldPasswordEdit->setText(servers.getPassword()); + const QString stored = servers.getPassword(); + if (!stored.startsWith("$")) { + oldPasswordEdit->setText(stored); + } } oldPasswordLabel->setBuddy(oldPasswordEdit); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index ff3d9a224..60bc97edc 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -301,7 +301,9 @@ void UserInfoBox::changePassword(const QString &oldPassword, const QString &newP { Command_AccountPassword cmd; cmd.set_old_password(oldPassword.toStdString()); - if (client->getServerSupportsPasswordHash()) { + if (client->getServerSupportsChallengeResponse()) { + cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); + } else if (client->getServerSupportsPasswordHash()) { auto passwordSalt = PasswordHasher::generateRandomSalt(); QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt); cmd.set_hashed_new_password(hashedPassword.toStdString()); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 76092e765..d23ce254c 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -1000,7 +1000,8 @@ void MainWindow::attemptStartupAutoConnect() qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; DlgConnect dlg(this); connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword()); + dlg.getPlayerName(), dlg.getPassword(), dlg.getStoredVerifier(), + dlg.getSaveName(), dlg.getSavePassword()); } } diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index d66653fb1..0c523d776 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -41,7 +41,8 @@ #include AbstractClient::AbstractClient(QObject *parent) - : QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false) + : QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false), + serverSupportsChallengeResponse(false) { qRegisterMetaType("QVariant"); qRegisterMetaType("CommandContainer"); diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 442e96eb2..7efd67972 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -133,6 +133,7 @@ protected: QMap pendingCommands; QString userName, password, email, country, realName, token; bool serverSupportsPasswordHash; + bool serverSupportsChallengeResponse; void setStatus(ClientStatus _status); int getNewCmdId() { @@ -166,6 +167,10 @@ public: { return serverSupportsPasswordHash; } + bool getServerSupportsChallengeResponse() const + { + return serverSupportsChallengeResponse; + } const QString &getUserName() const { return userName; diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index feda9e3f4..cbf7dbda2 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -42,7 +42,8 @@ static const unsigned int protocolVersion = 14; RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider) : AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0), - messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword() + messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword(), + passwordNeedsMigration(false) { clearNewClientFeatures(); @@ -129,6 +130,11 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica return; } serverSupportsPasswordHash = event.server_options() & Event_ServerIdentification::SupportsPasswordHash; + // Challenge-response is advertised in its own field so server_options stays + // exactly SupportsPasswordHash on the wire for already-released clients + // (proto2 enums are closed: an unknown value parses as NoOptions and those + // clients would fall back to plaintext passwords). + serverSupportsChallengeResponse = event.has_supports_challenge_response() && event.supports_challenge_response(); if (getStatus() == StatusRequestingForgotPassword) { Command_ForgotPasswordRequest cmdForgotPasswordRequest; @@ -145,7 +151,13 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica cmdForgotPasswordReset.set_user_name(userName.toStdString()); cmdForgotPasswordReset.set_clientid(getSrvClientID(lastHostname).toStdString()); cmdForgotPasswordReset.set_token(token.toStdString()); - if (!password.isEmpty() && serverSupportsPasswordHash) { + if (!password.isEmpty() && serverSupportsChallengeResponse) { + hashedPassword = PasswordHasher::generatePasswordVerifier(password); + // Only this branch yields a challenge-response verifier worth persisting; the + // legacy-hash and plaintext branches below must not be saved under the password key. + derivedVerifier = hashedPassword; + cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString()); + } else if (!password.isEmpty() && serverSupportsPasswordHash) { auto passwordSalt = PasswordHasher::generateRandomSalt(); hashedPassword = PasswordHasher::computeHash(password, passwordSalt); cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString()); @@ -173,7 +185,10 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica if (getStatus() == StatusRegistering) { Command_Register cmdRegister; cmdRegister.set_user_name(userName.toStdString()); - if (!password.isEmpty() && serverSupportsPasswordHash) { + if (!password.isEmpty() && serverSupportsChallengeResponse) { + hashedPassword = PasswordHasher::generatePasswordVerifier(password); + cmdRegister.set_hashed_password(hashedPassword.toStdString()); + } else if (!password.isEmpty() && serverSupportsPasswordHash) { auto passwordSalt = PasswordHasher::generateRandomSalt(); hashedPassword = PasswordHasher::computeHash(password, passwordSalt); cmdRegister.set_hashed_password(hashedPassword.toStdString()); @@ -238,7 +253,9 @@ Command_Login RemoteClient::generateCommandLogin() void RemoteClient::doLogin() { - if (!password.isEmpty() && serverSupportsPasswordHash) { + if ((!password.isEmpty() || !storedVerifier.isEmpty()) && serverSupportsChallengeResponse) { + doRequestPasswordSalt(); // ask salt + nonce to build the challenge response + } else if (!password.isEmpty() && serverSupportsPasswordHash) { //! \todo Store and log in using stored hashed password. if (hashedPassword.isEmpty()) { doRequestPasswordSalt(); // ask salt to create hashedPassword, then log in @@ -272,6 +289,30 @@ void RemoteClient::doHashedLogin() sendCommand(pend); } +void RemoteClient::doSubmitPasswordVerifier() +{ + pendingVerifier = PasswordHasher::generatePasswordVerifier(password); + Command_SubmitPasswordVerifier cmdSubmitVerifier; + cmdSubmitVerifier.set_password_verifier(pendingVerifier.toStdString()); + + PendingCommand *pend = prepareSessionCommand(cmdSubmitVerifier); + connect(pend, &PendingCommand::finished, this, &RemoteClient::submitPasswordVerifierResponse); + sendCommand(pend); +} + +void RemoteClient::submitPasswordVerifierResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + qCDebug(RemoteClientLog) << "Password verifier migrated successfully"; + if (!pendingVerifier.isEmpty()) { + emit sigPasswordVerifierReady(pendingVerifier); + pendingVerifier.clear(); + } + } else { + qCWarning(RemoteClientLog) << "Failed to migrate password verifier:" << response.response_code(); + } +} + void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/) { doDisconnectFromServer(); @@ -284,7 +325,70 @@ void RemoteClient::passwordSaltResponse(const Response &response) auto passwordSalt = QString::fromStdString(resp.password_salt()); if (passwordSalt.isEmpty()) { // the server does not recognize the user but allows them to enter unregistered password.clear(); // the password will not be used + storedVerifier.clear(); doLogin(); + } else if (serverSupportsChallengeResponse && resp.has_nonce()) { + const QByteArray nonce = QByteArray::fromStdString(resp.nonce()); + // needs_migration is a server-controlled flag over an unauthenticated + // transport (plain TCP by default). If we already hold a scrypt + // verifier for this account, a server telling us to fall back to the + // 1000-round legacy hash is a downgrade of the KDF — refuse it rather + // than hand an attacker a weakly-keyed HMAC they can attack offline. + if (resp.needs_migration() && PasswordHasher::parsePasswordVerifier(storedVerifier).isValid) { + emit loginError(Response::RespClientUpdateRequired, + QStringLiteral("Server asked to downgrade authentication for a migrated account."), 0, + {}); + return; + } + QByteArray key; + if (resp.needs_migration()) { + // The account still uses the legacy format; the legacy full hash + // is only derivable from the plaintext password. + if (password.isEmpty()) { + emit loginError(Response::RespClientUpdateRequired, + QStringLiteral("This account must be logged in with its password once."), 0, {}); + return; + } + key = PasswordHasher::computeHash(password, passwordSalt).toUtf8(); + } else if (!password.isEmpty()) { + // A hostile server must not be able to make us run or allocate for + // unreasonable scrypt parameters. + const int n = resp.has_n() ? resp.n() : SCRYPT_N; + const int r = resp.has_r() ? resp.r() : SCRYPT_R; + const int p = resp.has_p() ? resp.p() : SCRYPT_P; + if (!PasswordHasher::costParamsAreSane(n, r, p)) { + emit loginError(Response::RespClientUpdateRequired, + QStringLiteral("The server requested unreasonable scrypt cost parameters."), 0, {}); + return; + } + // password_salt may carry a legacy backup salt for pre-challenge clients; + // the verifier itself is always derived from scrypt_salt when present. + const QString scryptSalt = + resp.has_scrypt_salt() ? QString::fromStdString(resp.scrypt_salt()) : passwordSalt; + key = PasswordHasher::deriveKey(password, QByteArray::fromBase64(scryptSalt.toUtf8()), n, r, p); + if (key.isEmpty()) { + emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Unable to derive verifier."), 0, + {}); + return; + } + derivedVerifier = + QString("$scrypt$%1$%2$%3$%4$%5").arg(n).arg(r).arg(p).arg(scryptSalt).arg(QString(key.toBase64())); + } else if (!storedVerifier.isEmpty()) { + const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedVerifier); + if (!verifier.isValid) { + emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Stored verifier is invalid."), + 0, {}); + return; + } + key = verifier.verifier; + } else { + emit loginError(Response::RespLoginNeeded, {}, 0, {}); + return; + } + passwordNeedsMigration = resp.needs_migration(); + const QByteArray responseBytes = PasswordHasher::computeResponse(key, nonce); + hashedPassword = "$challenge$" + QString(nonce.toBase64()) + "$" + QString(responseBytes.toBase64()); + doHashedLogin(); } else { hashedPassword = PasswordHasher::computeHash(password, passwordSalt); doHashedLogin(); @@ -309,6 +413,14 @@ void RemoteClient::loginResponse(const Response &response) setStatus(StatusLoggedIn); emit userInfoChanged(resp.user_info()); + if (passwordNeedsMigration) { + // The account still used the legacy password format; upgrade it to scrypt. + doSubmitPasswordVerifier(); + } else if (!derivedVerifier.isEmpty()) { + emit sigPasswordVerifierReady(derivedVerifier); + derivedVerifier.clear(); + } + QList buddyList; for (int i = resp.buddy_list_size() - 1; i >= 0; --i) { buddyList.append(resp.buddy_list(i)); @@ -565,6 +677,8 @@ void RemoteClient::doDisconnectFromServer() websocket->close(); } socket->close(); + derivedVerifier.clear(); + pendingVerifier.clear(); } void RemoteClient::ping() @@ -736,6 +850,12 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response) { if (response.response_code() == Response::RespOk) { emit sigForgotPasswordSuccess(); + // Persist only a real scrypt verifier; a legacy hash must not be stored under + // the password key, where it would break future challenge-response logins. + if (!derivedVerifier.isEmpty()) { + emit sigPasswordVerifierReady(derivedVerifier); + derivedVerifier.clear(); + } } else { emit sigForgotPasswordError(); } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index 4d8c24fd2..dd91c4c6d 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -66,6 +66,11 @@ signals: unsigned int port, const QString &_userName, const QString &_email); + //! \brief Emitted once a scrypt verifier for the given account is known and + //! can be persisted instead of the plaintext password. The receiving side + //! should only store it for the connection it is currently negotiating, so + //! the hostname and user name are intentionally not part of the signal. + void sigPasswordVerifierReady(const QString &verifier); private slots: void slotConnected(); void readData(); @@ -92,6 +97,8 @@ private slots: void doLogin(); void doHashedLogin(); Command_Login generateCommandLogin(); + void doSubmitPasswordVerifier(); + void submitPasswordVerifierResponse(const Response &response); void doDisconnectFromServer(); void doActivateToServer(const QString &_token); void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); @@ -123,6 +130,14 @@ private: QString lastHostname; unsigned int lastPort; QString hashedPassword; + bool passwordNeedsMigration; + //! \brief A previously stored "$scrypt$..." verifier used to authenticate + //! without the plaintext password. + QString storedVerifier; + //! \brief Verifier derived during the current login, persisted after success. + QString derivedVerifier; + //! \brief Verifier sent for migration, persisted once the server accepts it. + QString pendingVerifier; QString getSrvClientID(const QString &_hostname); bool newMissingFeatureFound(const QString &_serversMissingFeatures); @@ -161,6 +176,12 @@ public: } void connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password); + //! \brief Provide a stored "$scrypt$..." verifier so the client can + //! authenticate without the plaintext password. + void setStoredVerifier(const QString &verifier) + { + storedVerifier = verifier; + } void registerToServer(const QString &hostname, unsigned int port, const QString &_userName, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 8e4e1a8f1..9e05750ac 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -87,6 +87,11 @@ public: { return QMap(); } + /** @brief True when only challenge-response logins are accepted (strict mode). */ + virtual bool requiresChallengeResponseAuth() const + { + return false; + } void addClient(Server_ProtocolHandler *player); void removeClient(Server_ProtocolHandler *player); QList getOnlineModeratorList() const; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index 3fef26904..936c48880 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -53,6 +53,14 @@ public: { return {}; } + virtual QString getUserPasswordData(const QString & /* user */) + { + return {}; + } + virtual bool submitPasswordVerifier(const QString & /* user */, const QString & /* passwordVerifier */) + { + return false; + } virtual QMap getBuddyList(const QString & /* name */) { return QMap(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index f75e2d3e3..eb2b2fc1c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1,5 +1,8 @@ #include "server_protocolhandler.h" +// Challenge-response nonces are valid for at most one minute from issuance. +static constexpr qint64 kAuthNonceLifetimeSeconds = 60; + #include "game/game_config.h" #include "game/server_abstract_participant.h" #include "game/server_game.h" @@ -72,6 +75,28 @@ Server_ProtocolHandler::~Server_ProtocolHandler() { } +void Server_ProtocolHandler::setAuthNonce(const QByteArray &nonce, const QString &userName) +{ + authNonce = nonce; + authNonceUser = userName; + authNonceCreated = QDateTime::currentDateTimeUtc(); +} + +bool Server_ProtocolHandler::isAuthNonceValid(const QByteArray &nonce, const QString &userName) const +{ + // secsTo is signed, so a wall-clock step backwards (NTP correction, VM resume) + // must not make the elapsed time negative and re-validate an old nonce. + const qint64 elapsed = authNonceCreated.secsTo(QDateTime::currentDateTimeUtc()); + return !authNonce.isEmpty() && authNonce == nonce && authNonceUser == userName && authNonceCreated.isValid() && + elapsed >= 0 && elapsed < kAuthNonceLifetimeSeconds; +} + +void Server_ProtocolHandler::clearAuthNonce() +{ + authNonce.clear(); + authNonceUser.clear(); +} + // This function must only be called from the thread this object lives in. // Except when the server is shutting down. // The thread must not hold any server locks when calling this (e.g. clientsLock, roomsLock). @@ -568,6 +593,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd return Response::RespContextError; } + // In strict mode only challenge-response logins are accepted, but only for + // registered SQL accounts: guests (no DB row) and non-SQL authentication + // methods have no stored verifier to challenge against. userExists is false + // for both (it requires AuthenticationSql), so a single check covers them. + const bool isRegisteredAccount = databaseInterface != nullptr && databaseInterface->userExists(userName); + if (server->requiresChallengeResponseAuth() && isRegisteredAccount && + (!cmd.has_hashed_password() || cmd.hashed_password().rfind("$challenge$", 0) != 0)) { + auto *re = new Response_Login; + re->set_denied_reason_str("Client upgrade required"); + re->add_missing_features("challenge_response_auth"); + rc.setResponseExtension(re); + return Response::RespClientUpdateRequired; + } + // check client feature set against server feature set FeatureSet features; QMap receivedClientFeatures; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 65dab99ed..c0a1ffe9a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -4,6 +4,8 @@ #include "server.h" #include "server_abstractuserinterface.h" +#include +#include #include #include #include @@ -47,6 +49,9 @@ protected: bool acceptsUserListChanges; bool acceptsRoomListChanges; bool idleClientWarningSent; + QByteArray authNonce; + QString authNonceUser; + QDateTime authNonceCreated; virtual void logDebugMessage(const QString & /* message */) { } @@ -124,6 +129,13 @@ public: return databaseInterface; } + /** @brief Store a fresh challenge nonce bound to @p userName for the next challenge-response login attempt. */ + void setAuthNonce(const QByteArray &nonce, const QString &userName); + /** @brief True if nonce matches the pending one, was issued for @p userName, and is less than 60 seconds old. */ + bool isAuthNonceValid(const QByteArray &nonce, const QString &userName) const; + /** @brief Invalidate the pending nonce (single-use). */ + void clearAuthNonce(); + int getLastCommandTime() const { return timeRunning - lastDataReceived; diff --git a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp index 0f8fd4210..b0d67c00d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp @@ -26,7 +26,8 @@ void FeatureSet::initalizeFeatureList(QMap &_featureList) _featureList.insert("idle_client", false); _featureList.insert("forgot_password", false); _featureList.insert("websocket", false); - // featureList.insert("hashed_password_login", false); + _featureList.insert("hashed_password_login", false); + _featureList.insert("challenge_response_auth", false); // These are temp to force users onto a newer client _featureList.insert("2.7.0_min_version", false); _featureList.insert("2.8.0_min_version", false); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto index 987ab20d1..1f1775063 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto @@ -13,4 +13,10 @@ message Event_ServerIdentification { optional string server_version = 2; optional uint32 protocol_version = 3; optional ServerOptions server_options = 4 [default = NoOptions]; + // Advertises challenge-response authentication separately from server_options. + // server_options must stay exactly SupportsPasswordHash on the wire: it is a + // proto2 enum (closed), so already-released clients compiled without any new + // enumerator parse an unknown value as NoOptions and fall back to sending + // the plaintext password. New clients check this field instead. + optional bool supports_challenge_response = 5; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto index 3fc228530..aeb3eade2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto @@ -5,5 +5,23 @@ message Response_PasswordSalt { extend Response { optional Response_PasswordSalt ext = 1017; } + // Salt for the legacy password hash. Pre-challenge clients derive and send + // that hash from it. For migrated accounts holding a fresh legacy backup + // this carries the backup's salt so those clients can still log in; clients + // that speak challenge-response must use scrypt_salt instead when present. optional string password_salt = 1; + // scrypt cost parameters for the verifier. Absent/zero for legacy accounts. + optional int32 n = 2; + optional int32 r = 3; + optional int32 p = 4; + // Server-generated challenge. When present the client must authenticate + // with a challenge-response instead of transmitting the password hash. + optional bytes nonce = 5; + // True when the account still uses the legacy password format and should + // be migrated to the scrypt format after a successful login. + optional bool needs_migration = 6; + // Salt of the stored scrypt verifier. Present for migrated accounts; + // challenge-response clients derive the verifier from this rather than + // from password_salt, which may carry a legacy backup salt. + optional string scrypt_salt = 7; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 4b7fe9c85..10aefe7b5 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -36,6 +36,7 @@ message SessionCommand { DECK_DOWNLOAD_PUBLIC = 1031; DECK_SHARE_LIST_MINE = 1032; DECK_SHARE_REMOVE = 1033; + SUBMIT_PASSWORD_VERIFIER = 1034; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; @@ -231,3 +232,14 @@ message Command_SetCardArtParams { optional double vertical_offset = 5; optional double zoom = 6; } + +// Client uploads the new password verifier to migrate a legacy account +// after a successful challenge-response login. Idempotent; only applies +// to accounts still using the legacy password format. +message Command_SubmitPasswordVerifier { + extend SessionCommand { + optional Command_SubmitPasswordVerifier ext = 1034; + } + // Full verifier string to store, e.g. "$scrypt$32768$8$1$$" + required string password_verifier = 1; +} diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index c5c4b2592..4595da2a2 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -155,6 +155,19 @@ void ServersSettings::setFPPlayerName(QString playerName) setValue(playerName, "fpPlayerName"); } +void ServersSettings::setServerPassword(const QString &saveName, const QString &password) +{ + const int index = getPrevioushostindex(saveName); + if (index >= 0) { + setValue(password, QString("password%1").arg(index), "server", "server_details"); + } else { + // A credential write that silently no-ops is hard to diagnose from a + // bug report; surface a mismatched profile name instead. + qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName + << "- password not saved"; + } +} + QString ServersSettings::getFPPlayerName(QString defaultName) const { QVariant name = getValue("fpPlayerName"); diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 40648baa9..38bb02876 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -51,6 +51,8 @@ public: void setFPHostName(QString hostname); void setFPPort(QString port); void setFPPlayerName(QString playerName); + //! \brief Store a password (or a "$scrypt$..." verifier) for the given saved server. + void setServerPassword(const QString &saveName, const QString &password); void addNewServer(const QString &saveName, const QString &serv, const QString &port, diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp index fb59022de..871ee1357 100644 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -1,8 +1,16 @@ #include "cryptoutil.h" +#include +#include #include #include +// Sealed secret layout: "enc1::". +static constexpr int SECRET_KEY_LENGTH = 32; +static constexpr int SECRET_NONCE_LENGTH = 12; +static constexpr int SECRET_TAG_LENGTH = 16; +static const char *SECRET_FORMAT_PREFIX = "enc1"; + namespace CryptoUtil { QByteArray randomBytes(int count) @@ -23,4 +31,99 @@ quint64 randomUInt64() } return value; } + +QString encryptSecret(const QByteArray &plaintext, const QByteArray &key, const QByteArray &aad) +{ + if (key.size() != SECRET_KEY_LENGTH) { + return {}; + } + const QByteArray nonce = randomBytes(SECRET_NONCE_LENGTH); + QByteArray ciphertext(plaintext.size() + SECRET_TAG_LENGTH, Qt::Uninitialized); + QByteArray tag(SECRET_TAG_LENGTH, Qt::Uninitialized); + + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + if (ctx == nullptr) { + return {}; + } + int written = 0; + int finalWritten = 0; + bool ok = EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, nonce.size(), nullptr) == 1 && + EVP_EncryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast(key.constData()), + reinterpret_cast(nonce.constData())) == 1; + if (ok && !aad.isEmpty()) { + ok = EVP_EncryptUpdate(ctx, nullptr, &written, reinterpret_cast(aad.constData()), + aad.size()) == 1; + } + if (ok) { + ok = EVP_EncryptUpdate(ctx, reinterpret_cast(ciphertext.data()), &written, + reinterpret_cast(plaintext.constData()), plaintext.size()) == 1; + } + if (ok) { + ok = EVP_EncryptFinal_ex(ctx, reinterpret_cast(ciphertext.data() + written), &finalWritten) == + 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, SECRET_TAG_LENGTH, tag.data()) == 1; + } + EVP_CIPHER_CTX_free(ctx); + if (!ok) { + return {}; + } + ciphertext.truncate(written + finalWritten); + ciphertext.append(tag); + + return QString::fromLatin1(SECRET_FORMAT_PREFIX) + QLatin1Char(':') + nonce.toBase64() + QLatin1Char(':') + + ciphertext.toBase64(); +} + +QByteArray decryptSecret(const QString &sealed, const QByteArray &key, const QByteArray &aad) +{ + if (key.size() != SECRET_KEY_LENGTH) { + return {}; + } + const QStringList parts = sealed.split(QLatin1Char(':')); + if (parts.size() != 3 || parts.at(0) != QLatin1String(SECRET_FORMAT_PREFIX)) { + return {}; + } + const QByteArray nonce = QByteArray::fromBase64(parts.at(1).toUtf8()); + const QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8()); + if (nonce.size() != SECRET_NONCE_LENGTH || ciphertext.size() < SECRET_TAG_LENGTH) { + return {}; + } + const int plaintextLength = ciphertext.size() - SECRET_TAG_LENGTH; + QByteArray plaintext(qMax(plaintextLength, 1), Qt::Uninitialized); + QByteArray tag = ciphertext.right(SECRET_TAG_LENGTH); + const QByteArray body = ciphertext.left(plaintextLength); + + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + if (ctx == nullptr) { + return {}; + } + int written = 0; + int finalWritten = 0; + bool ok = EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, nonce.size(), nullptr) == 1 && + EVP_DecryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast(key.constData()), + reinterpret_cast(nonce.constData())) == 1; + if (ok && !aad.isEmpty()) { + ok = EVP_DecryptUpdate(ctx, nullptr, &written, reinterpret_cast(aad.constData()), + aad.size()) == 1; + } + if (ok) { + // The tag must be set before the final call, which authenticates it; a + // mismatch makes EVP_DecryptFinal_ex fail and the secret stays opaque. + ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, tag.size(), tag.data()) == 1 && + EVP_DecryptUpdate(ctx, reinterpret_cast(plaintext.data()), &written, + reinterpret_cast(body.constData()), body.size()) == 1; + } + if (ok) { + ok = + EVP_DecryptFinal_ex(ctx, reinterpret_cast(plaintext.data() + written), &finalWritten) == 1; + } + EVP_CIPHER_CTX_free(ctx); + if (!ok) { + return {}; + } + plaintext.truncate(written + finalWritten); + return plaintext; +} } // namespace CryptoUtil diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h index dba9dc37d..d12200bc7 100644 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h @@ -2,12 +2,21 @@ #define CRYPTOUTIL_H #include +#include #include namespace CryptoUtil { QByteArray randomBytes(int count); quint64 randomUInt64(); +/** @brief Seal a secret with AES-256-GCM as "enc1::". + * Returns an empty string unless @p key is a valid 32-byte key and the cipher + * completes. @p aad is authenticated but not stored (bind the ciphertext to its + * context, e.g. the user name it belongs to). */ +QString encryptSecret(const QByteArray &plaintext, const QByteArray &key, const QByteArray &aad = {}); +/** @brief Open a string produced by encryptSecret(). Returns an empty array on any + * failure: unknown format, malformed base64, wrong key, wrong aad, or tampering. */ +QByteArray decryptSecret(const QString &sealed, const QByteArray &key, const QByteArray &aad = {}); } // namespace CryptoUtil #endif diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index b4a412fa6..a7106aadc 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -4,6 +4,9 @@ #include #include #include +#include +#include +#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -54,3 +57,114 @@ QString PasswordHasher::generateActivationToken() { return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); } + +QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p) +{ + QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0'); + const QByteArray passwordUtf8 = password.toUtf8(); + // EVP_PBE_scrypt aborts unless maxmem covers the required working memory, + // which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array). + const auto maxmem = static_cast(128) * n * r + static_cast(128) * r * p + 4096; + if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(), + reinterpret_cast(salt.constData()), salt.size(), n, r, p, maxmem, + reinterpret_cast(key.data()), key.size()) != 1) { + return QByteArray(); + } + return key; +} + +bool PasswordHasher::costParamsAreSane(int n, int r, int p) +{ + // Bounds adopted during review: n in [1024, 2**20] and a power of two, r in [1, 32], + // p in [1, 16]. Anything else is rejected before we allocate or derive for it. + return n >= 1024 && n <= (1 << 20) && (n & (n - 1)) == 0 && r >= 1 && r <= 32 && p >= 1 && p <= 16; +} + +QString PasswordHasher::generatePasswordVerifier(const QString &password) +{ + const QByteArray salt = CryptoUtil::randomBytes(SCRYPT_SALT_LENGTH); + const QByteArray verifier = deriveKey(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P); + return QString("$scrypt$%1$%2$%3$%4$%5") + .arg(SCRYPT_N) + .arg(SCRYPT_R) + .arg(SCRYPT_P) + .arg(QString(salt.toBase64())) + .arg(QString(verifier.toBase64())); +} + +PasswordVerifier PasswordHasher::parsePasswordVerifier(const QString &stored) +{ + PasswordVerifier result; + const QStringList parts = stored.split("$"); + if (parts.size() != 7 || parts.at(1) != "scrypt") { + return result; + } + + bool ok = false; + const int n = parts.at(2).toInt(&ok); + if (!ok) { + return result; + } + const int r = parts.at(3).toInt(&ok); + if (!ok) { + return result; + } + const int p = parts.at(4).toInt(&ok); + if (!ok || !costParamsAreSane(n, r, p)) { + return result; + } + + const QByteArray salt = QByteArray::fromBase64(parts.at(5).toUtf8()); + const QByteArray verifier = QByteArray::fromBase64(parts.at(6).toUtf8()); + if (salt.isEmpty() || verifier.size() != SCRYPT_VERIFIER_LENGTH) { + return result; + } + + result.format = PasswordFormat::Scrypt; + result.n = n; + result.r = r; + result.p = p; + result.salt = salt; + result.verifier = verifier; + result.isValid = true; + return result; +} + +bool PasswordHasher::isLegacyFormat(const QString &stored) +{ + return !stored.startsWith("$"); +} + +bool PasswordHasher::verifyPassword(const QString &password, const QString &storedPasswordData) +{ + if (isLegacyFormat(storedPasswordData)) { + return storedPasswordData == computeHash(password, storedPasswordData.left(16)); + } + + const PasswordVerifier verifier = parsePasswordVerifier(storedPasswordData); + if (!verifier.isValid) { + return false; + } + const QByteArray derived = deriveKey(password, verifier.salt, verifier.n, verifier.r, verifier.p); + return !derived.isEmpty() && constantTimeEquals(derived, verifier.verifier); +} + +QByteArray PasswordHasher::computeResponse(const QByteArray &key, const QByteArray &nonce) +{ + QByteArray response(EVP_MAX_MD_SIZE, '\0'); + unsigned int responseLength = 0; + if (HMAC(EVP_sha256(), key.constData(), key.size(), reinterpret_cast(nonce.constData()), + nonce.size(), reinterpret_cast(response.data()), &responseLength) == nullptr) { + qFatal("PasswordHasher::computeResponse: HMAC failed"); + } + response.resize(responseLength); + return response; +} + +bool PasswordHasher::constantTimeEquals(const QByteArray &a, const QByteArray &b) +{ + if (a.size() != b.size()) { + return false; + } + return CRYPTO_memcmp(a.constData(), b.constData(), a.size()) == 0; +} diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.h b/libcockatrice_utility/libcockatrice/utility/passwordhasher.h index 8ad83f089..f46d2f21a 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.h +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.h @@ -1,14 +1,58 @@ #ifndef PASSWORDHASHER_H #define PASSWORDHASHER_H +#include +#include #include +// scrypt cost parameters used for newly created password verifiers. These match +// the RFC 7914 recommended parameters for interactive use. +constexpr int SCRYPT_N = 32768; +constexpr int SCRYPT_R = 8; +constexpr int SCRYPT_P = 1; +constexpr int SCRYPT_SALT_LENGTH = 16; +constexpr int SCRYPT_VERIFIER_LENGTH = 64; + +enum class PasswordFormat +{ + None = 0, + Scrypt +}; + +struct PasswordVerifier +{ + PasswordFormat format = PasswordFormat::None; + int n = 0; + int r = 0; + int p = 0; + QByteArray salt; + QByteArray verifier; + bool isValid = false; +}; + class PasswordHasher { public: static QString computeHash(const QString &password, const QString &salt); static QString generateRandomSalt(const int len = 16); static QString generateActivationToken(); + + /** @brief Derive the scrypt verifier for the given password, salt and cost parameters. Empty on failure. */ + static QByteArray deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p); + /** @brief True if the scrypt cost parameters are acceptable for server and client use. */ + static bool costParamsAreSane(int n, int r, int p); + /** @brief Build a "$scrypt$$$

$$" string with a fresh random salt. */ + static QString generatePasswordVerifier(const QString &password); + /** @brief Parse a stored "$scrypt$..." string into its components. */ + static PasswordVerifier parsePasswordVerifier(const QString &stored); + /** @brief True if the stored value is not in the scrypt format (legacy salt+hash). */ + static bool isLegacyFormat(const QString &stored); + /** @brief True if the password matches the stored credential, whether legacy salt+hash or scrypt. */ + static bool verifyPassword(const QString &password, const QString &storedPasswordData); + /** @brief HMAC-SHA256 of nonce keyed with the password verifier, used for challenge-response logins. */ + static QByteArray computeResponse(const QByteArray &key, const QByteArray &nonce); + /** @brief Constant-time byte comparison. */ + static bool constantTimeEquals(const QByteArray &a, const QByteArray &b); }; #endif diff --git a/servatrice/migrations/servatrice_0039_to_0040.sql b/servatrice/migrations/servatrice_0039_to_0040.sql new file mode 100644 index 000000000..8298d03f9 --- /dev/null +++ b/servatrice/migrations/servatrice_0039_to_0040.sql @@ -0,0 +1,17 @@ +-- Servatrice db migration from version 39 to version 40 + +-- The column must hold "$scrypt$$$

$$" (up to ~255 chars) and arbitrary +-- legacy base64 hashes, so it grows beyond the old 120-char size. varchar(255) is used because +-- widening a CHAR requires a table rebuild, which ALGORITHM=INSTANT cannot perform — dropping the +-- clause lets the server pick a suitable algorithm (and any row-format change is avoided anyway). +ALTER TABLE `cockatrice_users` MODIFY `password_sha512` varchar(255) NOT NULL; + +-- Encrypted downgrade backup: lets pre-challenge clients keep logging in after the +-- account migrates to scrypt, without storing the fast-crackable legacy hash in the +-- clear. Sealed with security/legacy_backup_key and purged after +-- security/legacy_backup_ttl_days without use (see password_legacy_backup_used_at). +ALTER TABLE `cockatrice_users` + ADD COLUMN `password_legacy_backup` varchar(255) NOT NULL DEFAULT '', + ADD COLUMN `password_legacy_backup_used_at` datetime DEFAULT NULL; + +UPDATE cockatrice_schema_version SET version=40 WHERE version=39; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 90207aa70..68478810b 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -420,6 +420,48 @@ max_forgot_password_requests_per_ip=3 ; Length in seconds of the forgot-password window. Default is 3600 (1 hour). forgot_password_window_seconds=3600 +; How strictly new authentication features are enforced. Possible values: +; * legacy: accounts that still use the legacy 1000-round SHA-512 hash keep logging in with it +; (they are served the legacy salt, never a challenge-response nonce) and are never +; auto-migrated to scrypt. Already-migrated scrypt rows keep logging in via +; challenge-response. New credentials may use either format; +; * mixed: accept both legacy hashes and challenge-response authentication (default); +; * strict: only accept challenge-response authentication from clients that support it, +; and reject plain password submissions. Legacy accounts are migrated to scrypt +; automatically on their next successful login. Strict only constrains +; registered SQL accounts: guest/unregistered logins and servers with +; authentication/method set to none or password are unaffected, since +; they have no stored verifier to challenge against. +; +; Note: anyone holding only the legacy salt+hash of a not-yet-migrated account +; (e.g. from a wire capture of an old client, or an old database dump) can log in +; with it and then set a brand new password of their choosing. Before +; challenge-response a captured hash yielded a session, but changing the password +; still required the plaintext old password. There is no way around this without +; a plaintext proof at migration time, so not-yet-migrated rows should be treated +; accordingly. +; +; Challenge-response verifiers are scrypt (RFC 7914, N=32768, r=8, p=1) stored as +; "$scrypt$$$

$$". The stored verifier is password-equivalent: +; plaintext passwords never reach the client configuration and never go over the wire, but +; a database dump yields credentials that can answer a login challenge directly. +authentication_strictness=mixed + +; Optional key that seals the legacy password backup (64 hex characters). +; Migrating an account to scrypt normally strands old clients, which can only present +; the legacy hash. With a key configured, the server keeps that hash encrypted next to +; the scrypt verifier, so old clients can still log in during the window below without +; the database holding a fast-crackable hash in the clear. +; Generate with: openssl rand -hex 32 +; If unset, migrated accounts have no downgrade window: old clients must use a password +; reset to get back in. Rotating the key invalidates existing backups (they become +; unusable immediately; expired ones are still purged). +legacy_backup_key= + +; Days a legacy password backup may go unused before it is purged from the database. +; Logging in with an old client refreshes the window; backups that are never touched +; age out. Default 180. +legacy_backup_ttl_days=180 ; You may want to allow an unlimited number of users from a trusted source. This setting can contain a ; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 438b32ccd..e942e4e91 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(39); +INSERT INTO cockatrice_schema_version VALUES(40); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `admin` tinyint(1) NOT NULL, `name` varchar(35) NOT NULL, `realname` varchar(255) NOT NULL, - `password_sha512` char(120) NOT NULL, + `password_sha512` varchar(255) NOT NULL, + `password_legacy_backup` varchar(255) NOT NULL DEFAULT '', + `password_legacy_backup_used_at` datetime DEFAULT NULL, `email` varchar(255) NOT NULL, `country` char(2) NOT NULL, `avatar_bmp` mediumblob NOT NULL, diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index a03444bf5..a306de523 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -76,6 +77,10 @@ inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice"); +// Expired legacy password backups are also purged on first use, but idle accounts +// need this sweep so credentials that are never touched still age out of the database. +static constexpr qint64 kLegacyBackupSweepIntervalMs = 6 * 60 * 60 * 1000; + Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, int _numberPools, const QSqlDatabase &_sqlDatabase, @@ -613,6 +618,10 @@ bool Servatrice::initServer() deckShareCleanupClock->start(deckShareCleanupInterval); } + legacyBackupSweepClock = new QTimer(this); + connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups); + legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs); + // SOCKET SERVER if (getNumberOfTCPPools() > 0) { gameServer = @@ -1002,6 +1011,14 @@ SessionEvent *Servatrice::getLoginSessionEvent() const return nullptr; } +void Servatrice::sweepLegacyBackups() +{ + if (!servatriceDatabaseInterface->checkSql()) { + return; + } + servatriceDatabaseInterface->purgeExpiredLegacyBackups(); +} + void Servatrice::scheduleShutdown(const QString &reason, int minutes) { shutdownStateMutex.lock(); @@ -1194,6 +1211,42 @@ QString Servatrice::getRequiredFeatures() const return settingsCache->value("server/requiredfeatures", "").toString(); } +Servatrice::AuthenticationStrictness Servatrice::getAuthenticationStrictness() const +{ + const QString strictness = settingsCache->value("security/authentication_strictness", "mixed").toString(); + if (strictness == "strict") { + return AuthenticationStrict; + } + if (strictness == "legacy") { + return AuthenticationLegacy; + } + return AuthenticationMixed; +} + +QByteArray Servatrice::getLegacyBackupKey() const +{ + const QString keyHex = settingsCache->value("security/legacy_backup_key").toString().trimmed(); + if (keyHex.isEmpty()) { + return {}; + } + static const QRegularExpression validHexKey(QStringLiteral("^[0-9a-fA-F]{64}$")); + if (!validHexKey.match(keyHex).hasMatch()) { + qDebug() << "security/legacy_backup_key is not 64 hex characters;" << "legacy password backups are disabled"; + return {}; + } + return QByteArray::fromHex(keyHex.toUtf8()); +} + +int Servatrice::getLegacyBackupTtlDays() const +{ + bool ok = false; + const int days = settingsCache->value("security/legacy_backup_ttl_days", 180).toInt(&ok); + if (!ok || days < 1 || days > 3650) { + return 180; + } + return days; +} + QString Servatrice::getDBTypeString() const { if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) { diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index d5414f810..119adc8b2 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -155,8 +155,15 @@ public: AuthenticationSql, AuthenticationPassword }; + enum AuthenticationStrictness + { + AuthenticationLegacy, + AuthenticationMixed, + AuthenticationStrict + }; private slots: void statusUpdate(); + void sweepLegacyBackups(); void shutdownTimeout(); void cleanupExpiredDeckShares(); @@ -173,6 +180,7 @@ private: DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; QTimer *deckShareCleanupClock; + QTimer *legacyBackupSweepClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -251,6 +259,14 @@ public: { return serverRequiredFeatureList; } + bool requiresChallengeResponseAuth() const override + { + return getAuthenticationStrictness() == AuthenticationStrict; + } + /** @brief Key sealing legacy password backups, or empty when they are disabled. */ + QByteArray getLegacyBackupKey() const; + /** @brief Days a legacy password backup may go unused before it is purged. */ + int getLegacyBackupTtlDays() const; QString getServerName() const; QString getLoginMessage() const override { @@ -272,6 +288,7 @@ public: { return authenticationMethod; } + AuthenticationStrictness getAuthenticationStrictness() const; bool permitUnregisteredUsers() const override { return authenticationMethod != AuthenticationNone; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index d7912cfe6..04f6047fa 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,17 @@ inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); +// Oldest password_legacy_backup_used_at that is still inside the TTL. The value is +// compared against (and stored as) a UTC "yyyy-MM-dd HH:mm:ss" string, which sorts +// lexicographically, so no timezone-aware parsing is needed on either side. +static QString legacyBackupExpiryCutoff(int ttlDays) +{ + return QDateTime::currentDateTimeUtc().addDays(-ttlDays).toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); +} + +// Width of the password_legacy_backup column; a sealed backup must fit inside it. +static constexpr int MAX_LEGACY_BACKUP_LENGTH = 255; + Servatrice_DatabaseInterface::Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server) : instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server) { @@ -429,13 +441,104 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot qCWarning(DatabaseInterfaceLog) << "Login denied: user not active"; return UserIsInactive; } + + // Fail closed on an absent stored credential: an empty key would + // otherwise authenticate anyone who can compute HMAC("", nonce). + if (correctPasswordSha512.isEmpty()) { + qCWarning(DatabaseInterfaceLog) << "Login denied: empty stored credential"; + return NotLoggedIn; + } + + if (password.startsWith("$challenge$")) { + // Challenge-response login: verify HMAC(stored_key, nonce) without + // ever transmitting the stored credential or password hash. + const QStringList parts = password.split("$"); + if (parts.size() != 4) { + return NotLoggedIn; + } + const QByteArray nonce = QByteArray::fromBase64(parts.at(2).toUtf8()); + const QByteArray response = QByteArray::fromBase64(parts.at(3).toUtf8()); + if (nonce.isEmpty() || response.isEmpty() || !handler->isAuthNonceValid(nonce, user)) { + return NotLoggedIn; + } + + QByteArray key; + if (PasswordHasher::isLegacyFormat(correctPasswordSha512)) { + key = correctPasswordSha512.toUtf8(); + } else { + // Design note: the stored scrypt verifier IS the challenge-response key, so a + // database dump yields credentials that can answer a login challenge directly. + // We deliberately accept this trade: it removes plaintext passwords from the + // client config and from the wire, but does not protect against DB compromise. + // A password-equivalent proof scheme (e.g. SRP-6a/OPAQUE) would be the proper + // escalation and is out of scope here. + const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(correctPasswordSha512); + if (!verifier.isValid) { + return NotLoggedIn; + } + key = verifier.verifier; + } + + const QByteArray expected = PasswordHasher::computeResponse(key, nonce); + handler->clearAuthNonce(); + if (PasswordHasher::constantTimeEquals(expected, response)) { + qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right"; + return PasswordRight; + } + qCDebug(DatabaseInterfaceLog) << "Login denied: challenge-response password wrong"; + return NotLoggedIn; + } + + // A migrated (scrypt) row distinguishes how the credential arrived: + // a plaintext password (passwordNeedsHash) is verified directly against + // the scrypt verifier, so email changes (cmdAccountEdit) and old clients + // that still send `password` keep working without a legacy backup key. + // A pre-hashed legacy value is accepted against the encrypted legacy + // backup while that backup is fresh. Without one there is no downgrade + // path. The backup window is refreshed only when the backup is what + // actually matched, so a plaintext proof never keeps the + // fast-crackable hash alive. + if (!PasswordHasher::isLegacyFormat(correctPasswordSha512)) { + if (passwordNeedsHash) { + // Cost is bounded by costParamsAreSane inside parsePasswordVerifier. + if (PasswordHasher::verifyPassword(password, correctPasswordSha512)) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } + qCDebug(DatabaseInterfaceLog) << "Login accepted: password right"; + return PasswordRight; + } + qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong"; + return NotLoggedIn; + } + const QString backupCredential = getUsableLegacyBackup(user); + if (backupCredential.isEmpty()) { + qCDebug(DatabaseInterfaceLog) + << "Login denied: migrated account without a usable legacy backup"; + return NotLoggedIn; + } + if (PasswordHasher::constantTimeEquals(backupCredential.toUtf8(), password.toUtf8())) { + touchLegacyBackup(user); + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } + qCDebug(DatabaseInterfaceLog) << "Login accepted: password right"; + return PasswordRight; + } + qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong"; + return NotLoggedIn; + } + + QString storedCredential = correctPasswordSha512; QString hashedPassword; if (passwordNeedsHash) { - hashedPassword = PasswordHasher::computeHash(password, correctPasswordSha512.left(16)); + hashedPassword = PasswordHasher::computeHash(password, storedCredential.left(16)); } else { hashedPassword = password; } - if (correctPasswordSha512 == hashedPassword) { + if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) { if (forceChange) { qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; return PasswordChangeRequired; @@ -631,6 +734,185 @@ QString Servatrice_DatabaseInterface::getUserSalt(const QString &user) return {}; } +QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return {}; + } + + checkSql(); + + QSqlQuery *query = prepareQuery("SELECT password_sha512 FROM {prefix}_users WHERE name = :name"); + query->bindValue(":name", user); + if (!execSqlQuery(query)) { + return {}; + } + + if (!query->next()) { + return {}; + } + + return query->value(0).toString(); +} + +bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return false; + } + + checkSql(); + + // Read the credential that is about to be replaced: while it is still in the + // legacy format it becomes the encrypted downgrade backup, sealed before the + // row is overwritten with the scrypt verifier. + QSqlQuery *currentQuery = prepareQuery("SELECT name, password_sha512 FROM {prefix}_users WHERE name = :name"); + currentQuery->bindValue(":name", user); + if (!execSqlQuery(currentQuery) || !currentQuery->next()) { + return false; + } + // Use the stored row name (not the caller-supplied one) as the AAD: name is + // utf8mb4_unicode_ci, so `alice` logs in as `Alice` and the SELECT still finds + // the row while a client-typed name would fail the GCM tag check on open. + const QString storedName = currentQuery->value(0).toString(); + const QString currentCredential = currentQuery->value(1).toString(); + + QString sealedBackup; + QDateTime backupUsedAt; + if (PasswordHasher::isLegacyFormat(currentCredential)) { + const QByteArray key = server->getLegacyBackupKey(); + if (!key.isEmpty()) { + const QByteArray aad = storedName.simplified().toUtf8(); + sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad); + if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) { + qCWarning(DatabaseInterfaceLog) + << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; + sealedBackup.clear(); + } else { + backupUsedAt = QDateTime::currentDateTimeUtc(); + } + } + } + + // Only migrate accounts that still use the credential this backup was taken from; + // the guard makes a re-migration (or a raced second submission) a no-op. + QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512 = :verifier, " + "password_legacy_backup = :backup, password_legacy_backup_used_at = :used_at " + "where name = :user and password_sha512 = :expected and password_sha512 not like " + "'$%'"); + query->bindValue(":verifier", passwordVerifier); + query->bindValue(":backup", sealedBackup); + query->bindValue(":used_at", backupUsedAt.isValid() + ? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))) + : QVariant()); + query->bindValue(":user", user); + query->bindValue(":expected", currentCredential); + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to submit password verifier for user" << user << query->lastError(); + return false; + } + // The guard makes a re-migration a no-op; only report success when a row was actually updated. + return query->numRowsAffected() > 0; +} + +QString Servatrice_DatabaseInterface::getUsableLegacyBackup(const QString &user) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return {}; + } + + // Without a configured key the backup cannot be sealed or opened; do not touch + // (and never purge on this path) so a temporarily missing key cannot destroy it. + const QByteArray key = server->getLegacyBackupKey(); + if (key.isEmpty()) { + return {}; + } + + checkSql(); + + QSqlQuery *query = prepareQuery( + "SELECT name, password_legacy_backup, password_legacy_backup_used_at FROM {prefix}_users WHERE name = :name"); + query->bindValue(":name", user); + if (!execSqlQuery(query) || !query->next()) { + return {}; + } + + // Open with the stored row name as the AAD: sealing used the canonical DB name + // while the caller passes the client-typed name, and the two differ whenever + // the login case does not match the stored case (utf8mb4_unicode_ci). + const QString storedName = query->value(0).toString(); + const QString sealed = query->value(1).toString(); + const QString usedAt = query->value(2).toString(); + if (sealed.isEmpty()) { + return {}; + } + + // Age out: a backup unused for the whole TTL is purged on sight, so a database + // dump taken after the window closes never carries a crackable legacy hash. + if (usedAt.isEmpty() || usedAt < legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())) { + qCDebug(DatabaseInterfaceLog) << "Purging expired legacy backup for user" << user; + clearLegacyBackup(user); + return {}; + } + + const QByteArray legacyCredential = CryptoUtil::decryptSecret(sealed, key, storedName.simplified().toUtf8()); + if (legacyCredential.isEmpty()) { + qCWarning(DatabaseInterfaceLog) << "Legacy backup for user" << user << "failed to decrypt" + << "- check security/legacy_backup_key"; + return {}; + } + return QString::fromUtf8(legacyCredential); +} + +void Servatrice_DatabaseInterface::touchLegacyBackup(const QString &user) +{ + if (!checkSql()) { + return; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup_used_at = UTC_TIMESTAMP() " + "WHERE name = :name AND password_legacy_backup <> ''"); + query->bindValue(":name", user); + execSqlQuery(query); +} + +void Servatrice_DatabaseInterface::clearLegacyBackup(const QString &user) +{ + if (!checkSql()) { + return; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', " + "password_legacy_backup_used_at = NULL WHERE name = :name"); + query->bindValue(":name", user); + execSqlQuery(query); +} + +void Servatrice_DatabaseInterface::purgeExpiredLegacyBackups() +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return; + } + + if (!checkSql()) { + return; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', " + "password_legacy_backup_used_at = NULL " + "WHERE password_legacy_backup <> '' AND " + "(password_legacy_backup_used_at IS NULL OR password_legacy_backup_used_at < " + ":cutoff)"); + query->bindValue(":cutoff", legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())); + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Legacy backup sweep failed:" << query->lastError(); + return; + } + if (query->numRowsAffected() > 0) { + qCDebug(DatabaseInterfaceLog) << "Purged" << query->numRowsAffected() << "expired legacy password backups"; + } +} + int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name) { if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) { @@ -1349,7 +1631,10 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt()); } + // The backup authenticates the credential it was taken from; a new credential + // invalidates it (the old password is dead, and the new one has no legacy form). QSqlQuery *passwordQuery = prepareQuery("update {prefix}_users set password_sha512=:password, " + "password_legacy_backup='', password_legacy_backup_used_at=NULL, " "passwordLastChangedDate = NOW() where name = :name"); passwordQuery->bindValue(":password", passwordSha512); passwordQuery->bindValue(":name", user); @@ -1402,13 +1687,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, return false; } - const QString correctPasswordSha512 = passwordQuery->value(0).toString(); - QString oldPasswordSha512 = oldPassword; - if (oldPasswordNeedsHash) { - QString salt = correctPasswordSha512.left(16); - oldPasswordSha512 = PasswordHasher::computeHash(oldPassword, salt); - } - if (correctPasswordSha512 != oldPasswordSha512) { + const QString storedPassword = passwordQuery->value(0).toString(); + // oldPasswordNeedsHash means the client sent the old password in plaintext. Verify it + // against whatever is stored: legacy salt+hash rows or already-migrated scrypt rows + // (which must NOT be re-hashed with a salt torn out of the "$scrypt$..." string). + const bool oldPasswordMatches = oldPasswordNeedsHash ? PasswordHasher::verifyPassword(oldPassword, storedPassword) + : (oldPassword == storedPassword); + if (!oldPasswordMatches) { return false; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index eccd0d943..822accada 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 39 +#define DATABASE_SCHEMA_VERSION 40 class Servatrice; @@ -96,6 +96,18 @@ public: bool activeUserExists(const QString &user) override; bool userExists(const QString &user) override; QString getUserSalt(const QString &user) override; + QString getUserPasswordData(const QString &user) override; + bool submitPasswordVerifier(const QString &user, const QString &passwordVerifier) override; + /** @brief Decrypt the account's legacy password backup when it exists, is decryptable + * with the configured key and has not aged out; purges it when it has. Returns the + * legacy credential in the stored "salt + hash" format, or an empty string. */ + QString getUsableLegacyBackup(const QString &user); + /** @brief Refresh the age window of the account's legacy password backup. */ + void touchLegacyBackup(const QString &user); + /** @brief Drop the account's legacy password backup. */ + void clearLegacyBackup(const QString &user); + /** @brief Drop every legacy password backup that has not been used within the TTL. */ + void purgeExpiredLegacyBackups(); int getUserIdInDB(const QString &name); QMap getBuddyList(const QString &name) override; QMap getIgnoreList(const QString &name) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index b4d12599d..b24126d93 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -161,6 +161,7 @@ #include #include #include +#include #include #include #include @@ -204,7 +205,15 @@ bool AbstractServerSocketInterface::initSession() identEvent.set_server_version(VERSION_STRING); identEvent.set_protocol_version(protocolVersion); if (servatrice->getAuthenticationMethod() == Servatrice::AuthenticationSql) { + // Challenge-response is advertised in every strictness mode: legacy accounts keep + // logging in with the legacy hash, but already-migrated scrypt rows are always + // served challenge-response (authentication_strictness only governs NEW credentials). + // server_options stays exactly SupportsPasswordHash on the wire: already-released + // clients parse an unknown proto2 enum value as NoOptions and would fall back to + // plaintext passwords. Challenge support is advertised in the separate + // supports_challenge_response field, which old clients simply ignore. identEvent.set_server_options(Event_ServerIdentification::SupportsPasswordHash); + identEvent.set_supports_challenge_response(true); } SessionEvent *identSe = prepareSessionEvent(identEvent); sendProtocolItem(*identSe); @@ -409,6 +418,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc); case SessionCommand::REPORT_DETAILS: return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc); + case SessionCommand::SUBMIT_PASSWORD_VERIFIER: + return cmdSubmitPasswordVerifier(cmd.GetExtension(Command_SubmitPasswordVerifier::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -3298,6 +3309,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C password = QString::fromStdString(cmd.hashed_password()); } + // Reject credential formats the configured authentication strictness does not accept. + if (!acceptsCredentialFormat(passwordNeedsHash, password)) { + return Response::RespClientUpdateRequired; + } + bool requireEmailActivation = settingsCache->value("registration/requireemailactivation", true).toBool(); bool regSucceeded = sqlInterface->registerUser(userName, realName, password, passwordNeedsHash, parsedEmailAddress, country, !requireEmailActivation); @@ -3342,6 +3358,30 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i return servatrice->recordRegistrationAttempt(ipAddress); } +bool AbstractServerSocketInterface::acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const +{ + // An empty credential must never reach the database: it would be accepted + // as a legacy format and stored as '' (fail-open on login, see the empty + // stored-credential guard in Servatrice_DatabaseInterface). + if (password.isEmpty()) { + return false; + } + // "scryptFormat" means the client sent a derived verifier rather than a + // password to hash ourselves. parsePasswordVerifier enforces the sane-cost + // clamp, so nothing starting with '$' reaches the database unparsed. + const bool scryptFormat = !passwordNeedsHash && PasswordHasher::parsePasswordVerifier(password).isValid; + + // The strictness mode governs how existing legacy accounts are served, not which new-credential + // formats are tolerated: a legacy-mode server must still accept scrypt verifiers, because clients + // derive them whenever challenge-response is advertised (and it must be, so already-migrated + // scrypt rows keep logging in). strict is the only mode that rejects legacy formats. + if (servatrice->getAuthenticationStrictness() == Servatrice::AuthenticationStrict) { + return scryptFormat; + } + // legacy and mixed accept a valid scrypt verifier or a genuine legacy salt+hash. + return scryptFormat || PasswordHasher::isLegacyFormat(password); +} + Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd, ResponseContainer & /*rc*/) { @@ -3656,6 +3696,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C newPassword = QString::fromStdString(cmd.hashed_new_password()); } + // Reject new credential formats the configured authentication strictness does not accept. + if (!acceptsCredentialFormat(newPasswordNeedsHash, newPassword)) { + return Response::RespClientUpdateRequired; + } + QString userName = QString::fromStdString(userInfo->name()); if (!databaseInterface->changeUserPassword(userName, oldPassword, true, newPassword, newPasswordNeedsHash)) { return Response::RespWrongPassword; @@ -3802,6 +3847,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con password = QString::fromStdString(cmd.hashed_new_password()); } + // Reject new credential formats the configured authentication strictness does not accept. + if (!acceptsCredentialFormat(passwordNeedsHash, password)) { + return Response::RespClientUpdateRequired; + } + if (sqlInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) { if (servatrice->getEnableForgotPasswordAudit()) { sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), @@ -3869,9 +3919,11 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd, ResponseContainer &rc) { - const QString userName = nameFromStdString(cmd.user_name()); - QString passwordSalt = sqlInterface->getUserSalt(userName); - if (passwordSalt.isEmpty()) { + // Simplified exactly like cmdLogin, so the nonce bound to userName here validates + // against the same normalized name during the login's challenge-response check. + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + const QString storedPasswordData = sqlInterface->getUserPasswordData(userName); + if (storedPasswordData.isEmpty()) { if (server->getRegOnlyServerEnabled()) { return Response::RespRegistrationRequired; } else { @@ -3879,8 +3931,46 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con return Response::RespOk; } } + auto *re = new Response_PasswordSalt; - re->set_password_salt(passwordSalt.toStdString()); + if (PasswordHasher::isLegacyFormat(storedPasswordData)) { + re->set_password_salt(storedPasswordData.left(16).toStdString()); + re->set_needs_migration(true); + // Legacy rows get a challenge-response nonce only outside legacy mode (there the client + // logs in with the legacy hash and the account is migrated). In legacy mode the row is + // served the legacy salt, since legacy mode only governs what NEW credentials are accepted. + if (servatrice->getAuthenticationStrictness() != Servatrice::AuthenticationLegacy) { + const QByteArray nonce = CryptoUtil::randomBytes(32); + setAuthNonce(nonce, userName); + re->set_nonce(nonce.constData(), nonce.size()); + } + } else { + const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedPasswordData); + if (!verifier.isValid) { + delete re; + return Response::RespContextError; + } + const QString scryptSalt = QString(verifier.salt.toBase64()); + re->set_password_salt(scryptSalt.toStdString()); + re->set_scrypt_salt(scryptSalt.toStdString()); + re->set_n(verifier.n); + re->set_r(verifier.r); + re->set_p(verifier.p); + re->set_needs_migration(false); + // Pre-challenge clients derive the legacy hash from password_salt. While a fresh + // encrypted legacy backup exists, serve its salt there so they can still log in; + // challenge-response clients must use scrypt_salt, which always carries the + // verifier salt. + const QString legacyBackup = sqlInterface->getUsableLegacyBackup(userName); + if (!legacyBackup.isEmpty()) { + re->set_password_salt(legacyBackup.left(16).toStdString()); + } + // scrypt rows are served challenge-response in every mode so migrated accounts never lock out. + const QByteArray nonce = CryptoUtil::randomBytes(32); + setAuthNonce(nonce, userName); + re->set_nonce(nonce.constData(), nonce.size()); + } + rc.setResponseExtension(re); return Response::RespOk; } @@ -3978,6 +4068,39 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Re return Response::RespOk; } +Response::ResponseCode +AbstractServerSocketInterface::cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespLoginNeeded; + } + + // Limit to the size of the database column (password_sha512 varchar(255)). + constexpr int MAX_PASSWORD_VERIFIER_LENGTH = 255; + const QString passwordVerifier = QString::fromStdString(cmd.password_verifier()); + if (passwordVerifier.isEmpty() || passwordVerifier.length() > MAX_PASSWORD_VERIFIER_LENGTH || + PasswordHasher::isLegacyFormat(passwordVerifier)) { + return Response::RespContextError; + } + + // Reject unparseable or hostile cost parameters before they reach the database. + const PasswordVerifier parsedVerifier = PasswordHasher::parsePasswordVerifier(passwordVerifier); + if (!parsedVerifier.isValid) { + qCWarning(AbstractServerSocketInterfaceLog) + << "Rejecting password verifier submission with invalid or insane cost parameters"; + return Response::RespContextError; + } + + if (!sqlInterface->submitPasswordVerifier(QString::fromStdString(userInfo->name()), passwordVerifier)) { + return Response::RespContextError; + } + + qCDebug(AbstractServerSocketInterfaceLog) + << "Password verifier migrated for user" << QString::fromStdString(userInfo->name()); + return Response::RespOk; +} + // ADMIN FUNCTIONS. // Permission is checked by the calling function. diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 586bff831..dd9d3f2bb 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -103,6 +103,7 @@ class Command_ReportUserInfo; class Command_RequestPasswordSalt; class Command_ResetUserPassword; class Command_SetCardArtParams; +class Command_SubmitPasswordVerifier; class Command_UpdateAdminNotes; class Command_ViewLogHistory; class Command_WarnUser; @@ -124,6 +125,7 @@ protected: void logDebugMessage(const QString &message) override; bool tooManyRegistrationAttempts(const QString &ipAddress); void processCommandContainer(const CommandContainer &cont) override; + bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const; virtual void writeToSocket(QByteArray &data) = 0; virtual void flushSocket() = 0; @@ -205,6 +207,7 @@ private: Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc); + Response::ResponseCode cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, ResponseContainer &rc); Response::ResponseCode processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 2a504b81c..99f2e1400 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,7 +1,9 @@ #include "gtest/gtest.h" #include #include +#include #include +#include #include #include @@ -39,6 +41,204 @@ TEST(PasswordHashTest, TokenHasExpectedLength) const QString token = PasswordHasher::generateActivationToken(); ASSERT_EQ(token.size(), 16); } + +TEST(PasswordHashTest, DeriveKeyMatchesKnownVector) +{ + // RFC 7914 scrypt test vector, P="password", S="NaCl", N=1024, r=8, p=16 + const QByteArray expected = QByteArray::fromHex("fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b" + "3731622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cb" + "dfa2cc0640"); + const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16); + ASSERT_EQ(derived.toHex(), expected.toHex()); +} + +TEST(PasswordHashTest, PasswordVerifierRoundTrip) +{ + const QString stored = PasswordHasher::generatePasswordVerifier("hunter2"); + ASSERT_FALSE(stored.isEmpty()); + ASSERT_TRUE(stored.startsWith("$scrypt$")); + + const PasswordVerifier parsed = PasswordHasher::parsePasswordVerifier(stored); + ASSERT_TRUE(parsed.isValid); + ASSERT_EQ(parsed.format, PasswordFormat::Scrypt); + ASSERT_EQ(parsed.n, SCRYPT_N); + ASSERT_EQ(parsed.r, SCRYPT_R); + ASSERT_EQ(parsed.p, SCRYPT_P); + ASSERT_EQ(parsed.salt.size(), SCRYPT_SALT_LENGTH); + ASSERT_EQ(parsed.verifier.size(), SCRYPT_VERIFIER_LENGTH); +} + +TEST(PasswordHashTest, PasswordVerifierInvalidInput) +{ + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("garbage").isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$not-an-int$8$1$AAAA$BBBB").isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$1024$8$1$AAAA$too-short").isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$pbkdf2-sha512$1000$AAAA$BBBB").isValid); +} + +TEST(PasswordHashTest, LegacyFormatDetection) +{ + ASSERT_TRUE(PasswordHasher::isLegacyFormat("salt+hash")); + ASSERT_FALSE(PasswordHasher::isLegacyFormat(PasswordHasher::generatePasswordVerifier("password"))); +} + +TEST(PasswordHashTest, DeriveKeyDependsOnCostParameters) +{ + const QByteArray keyA = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16); + const QByteArray keyB = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 2048, 8, 16); + const QByteArray keyC = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 1); + ASSERT_NE(keyA, keyB); + ASSERT_NE(keyA, keyC); +} + +TEST(PasswordHashTest, ComputeResponseIsDeterministic) +{ + const QByteArray nonce = QByteArray("a nonce value"); + const QByteArray key = QByteArray("the verifier bytes"); + const QByteArray r1 = PasswordHasher::computeResponse(key, nonce); + const QByteArray r2 = PasswordHasher::computeResponse(key, nonce); + const QByteArray r3 = PasswordHasher::computeResponse(QByteArray("a different key"), nonce); + ASSERT_EQ(r1, r2); + ASSERT_NE(r1, r3); +} + +TEST(PasswordHashTest, ConstantTimeEquals) +{ + ASSERT_TRUE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("same"))); + ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("diff"))); + ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("short"), QByteArray("longer"))); +} + +TEST(PasswordHashTest, CostParamsAreSane) +{ + // Accept the recommended interactive parameters and the RFC 7914 test vector's. + ASSERT_TRUE(PasswordHasher::costParamsAreSane(SCRYPT_N, SCRYPT_R, SCRYPT_P)); + ASSERT_TRUE(PasswordHasher::costParamsAreSane(1024, 8, 16)); + + // n must be in [1024, 2**20] and a power of two. + ASSERT_FALSE(PasswordHasher::costParamsAreSane(512, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1 << 21, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1025, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(0, 8, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(-1024, 8, 1)); + + // r in [1, 32], p in [1, 16]. + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 0, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 33, 1)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 0)); + ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 17)); +} + +TEST(PasswordHashTest, ParsePasswordVerifierRejectsHostileCostParams) +{ + // 16 bytes of salt and 64 bytes of verifier, base64 encoded. + const QString saltB64 = QLatin1String("c2FsdHNhbHRzYWx0c2FsdA=="); + const QString verifierB64 = QString(QByteArray(SCRYPT_VERIFIER_LENGTH, '\x42').toBase64()); + + ASSERT_TRUE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid); + + // n not a power of two, below 1024, or above 2**20. + ASSERT_FALSE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$1025$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid); + ASSERT_FALSE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$512$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid); + ASSERT_FALSE( + PasswordHasher::parsePasswordVerifier(QString("$scrypt$1073741824$8$1$%1$%2").arg(saltB64).arg(verifierB64)) + .isValid); + + // r and p out of range. + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$33$1$%1$%2").arg(saltB64).arg(verifierB64)) + .isValid); + ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$17$%1$%2").arg(saltB64).arg(verifierB64)) + .isValid); +} + +TEST(PasswordHashTest, VerifyPasswordLegacyRow) +{ + const QString salt = PasswordHasher::generateRandomSalt(); + const QString legacyStored = PasswordHasher::computeHash("correct horse", salt); + ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", legacyStored)); + ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", legacyStored)); +} + +TEST(PasswordHashTest, VerifyPasswordScryptRow) +{ + const QString scryptStored = PasswordHasher::generatePasswordVerifier("correct horse"); + // Regression for the changeUserPassword bug that re-hashed the old password with + // a 16-char salt torn out of the "$scrypt$..." string, which could never match. + ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", scryptStored)); + ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", scryptStored)); + ASSERT_FALSE(PasswordHasher::verifyPassword("correct horse", "garbage")); +} + +TEST(CryptoUtilTest, SecretRoundTrip) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QByteArray plaintext = PasswordHasher::computeHash("correct horse", "saltsaltsaltsalt").toUtf8(); + const QByteArray aad = "alice"; + + const QString sealed = CryptoUtil::encryptSecret(plaintext, key, aad); + ASSERT_FALSE(sealed.isEmpty()); + ASSERT_TRUE(sealed.startsWith("enc1:")); + ASSERT_EQ(CryptoUtil::decryptSecret(sealed, key, aad), plaintext); +} + +TEST(CryptoUtilTest, SealIsNonDeterministic) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QByteArray plaintext = "same secret"; + // Fresh random nonce per seal: identical inputs must not produce identical blobs. + ASSERT_NE(CryptoUtil::encryptSecret(plaintext, key), CryptoUtil::encryptSecret(plaintext, key)); +} + +TEST(CryptoUtilTest, RejectsWrongKey) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QByteArray otherKey = CryptoUtil::randomBytes(32); + const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice"); + ASSERT_FALSE(sealed.isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, otherKey, "alice").isEmpty()); +} + +TEST(CryptoUtilTest, RejectsWrongAad) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice"); + ASSERT_FALSE(sealed.isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key, "bob").isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key).isEmpty()); +} + +TEST(CryptoUtilTest, RejectsTampering) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + const QString sealed = CryptoUtil::encryptSecret("secret", key); + ASSERT_FALSE(sealed.isEmpty()); + + // Flip a character inside the ciphertext segment (keep it valid base64). + QStringList parts = sealed.split(':'); + ASSERT_EQ(parts.size(), 3); + QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8()); + ciphertext[0] = static_cast(ciphertext.at(0) ^ 0x01); + parts[2] = QString(ciphertext.toBase64()); + ASSERT_TRUE(CryptoUtil::decryptSecret(parts.join(':'), key).isEmpty()); +} + +TEST(CryptoUtilTest, RejectsMalformedInput) +{ + const QByteArray key = CryptoUtil::randomBytes(32); + ASSERT_TRUE(CryptoUtil::decryptSecret("garbage", key).isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:notbase64!!:also-not!", key).isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAA", key).isEmpty()); + ASSERT_TRUE(CryptoUtil::decryptSecret("", key).isEmpty()); + // A short or oversized key must never reach the cipher. + ASSERT_TRUE( + CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", CryptoUtil::randomBytes(16)) + .isEmpty()); + ASSERT_TRUE(CryptoUtil::encryptSecret("secret", CryptoUtil::randomBytes(16)).isEmpty()); +} + } // namespace int main(int argc, char **argv) From 232b302c73e940ac1d892ee8b96a86b9487f55a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 11:40:20 +0200 Subject: [PATCH 12/13] [Client] Settings: extract SettingsView from DlgSettings Move the search bar, tab bar, stacked pages and search results out of DlgSettings into a reusable SettingsView widget. The dialog becomes a thin frame (title, OK button, close validation) delegating to the view. No visual or behavior change; tr() contexts preserved as DlgSettings. --- cockatrice/CMakeLists.txt | 1 + .../widgets/dialogs/dlg_settings.cpp | 462 +--------------- .../interface/widgets/dialogs/dlg_settings.h | 86 +-- .../widgets/dialogs/settings_view.cpp | 498 ++++++++++++++++++ .../interface/widgets/dialogs/settings_view.h | 98 ++++ 5 files changed, 626 insertions(+), 519 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/settings_view.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/settings_view.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e59326214..5baf10b1e 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -60,6 +60,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/path_safety_warning.cpp + src/interface/widgets/dialogs/settings_view.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp src/interface/widgets/deck_share/deck_share_utils.cpp src/interface/widgets/deck_share/shared_deck_preview_widget.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index d41c948eb..9b20372db 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -1,205 +1,41 @@ /** * @file dlg_settings.cpp - * @brief Implementation of the main settings dialog + * @brief Implementation of the main settings dialog (thin frame around SettingsView) * @ingroup Dialogs */ #include "dlg_settings.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" #include "../main.h" #include "../settings_page/abstract_settings_page.h" -#include "../settings_page/appearance_settings_page.h" -#include "../settings_page/deck_editor_settings_page.h" -#include "../settings_page/general_settings_page.h" -#include "../settings_page/messages_settings_page.h" -#include "../settings_page/settings_search_delegate.h" -#include "../settings_page/settings_search_model.h" -#include "../settings_page/shortcut_settings_page.h" -#include "../settings_page/sound_settings_page.h" -#include "../settings_page/storage_settings_page.h" -#include "../settings_page/user_interface_settings_page.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_manager.h" -#include -#include #include -#include -#include #include -#include -#include #include -#include -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include #include -#include #include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include #include #include #include #include #include -#include -class QModelIndex; - -/** - * @brief Wraps a widget in a scroll area for long settings pages - * @param widget The widget to wrap - * @return The scroll area containing the widget - */ -static QScrollArea *makeScrollable(QWidget *widget) -{ - widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); - - auto *scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); - scrollArea->setContentsMargins(0, 0, 0, 0); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->horizontalScrollBar()->setEnabled(false); - scrollArea->setWidget(widget); - return scrollArea; -} - -/** - * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order - */ -static QStringList pageIconResources() -{ - return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), - QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), - QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), - QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; -} - -DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false) +DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) { auto rec = QGuiApplication::primaryScreen()->availableGeometry(); setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height())); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); - setupUi(); - - connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); - retranslateUi(); - - searchEdit->setFocus(); - - adjustSize(); -} - -void DlgSettings::setupUi() -{ - // Search bar - searchEdit = new QLineEdit; - searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - searchEdit->installEventFilter(this); - connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged); - - auto *searchLayout = new QHBoxLayout; - searchLayout->addWidget(searchEdit); - - // Tab bar (built in setupTabBar) - setupTabBar(); - - // Pages stacked widget - pagesWidget = new QStackedWidget; - - auto *generalPage = new GeneralSettingsPage; - auto *appearancePage = new AppearanceSettingsPage; - auto *userInterfacePage = new UserInterfaceSettingsPage; - auto *deckEditorPage = new DeckEditorSettingsPage; - auto *storagePage = new StorageSettingsPage; - auto *messagesPage = new MessagesSettingsPage; - auto *soundPage = new SoundSettingsPage; - auto *shortcutsPage = new ShortcutSettingsPage; - - pages.append(generalPage); - pages.append(appearancePage); - pages.append(userInterfacePage); - pages.append(deckEditorPage); - pages.append(storagePage); - pages.append(messagesPage); - pages.append(soundPage); - pages.append(shortcutsPage); - - pagesWidget->addWidget(makeScrollable(generalPage)); - pagesWidget->addWidget(makeScrollable(appearancePage)); - pagesWidget->addWidget(makeScrollable(userInterfacePage)); - pagesWidget->addWidget(makeScrollable(deckEditorPage)); - pagesWidget->addWidget(makeScrollable(storagePage)); - pagesWidget->addWidget(makeScrollable(messagesPage)); - pagesWidget->addWidget(soundPage); - pagesWidget->addWidget(shortcutsPage); - - Q_ASSERT(pages.size() == NumPages); - - // Search results view (hidden by default) - searchResultsView = new QListView; - searchResultsView->setUniformItemSizes(false); - searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); - searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - searchResultsView->setVisible(false); - searchResultsView->setStyleSheet( - "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); - - searchModel = new SettingsSearchModel(this); - searchDelegate = new SettingsSearchDelegate(this); - searchResultsView->setModel(searchModel); - searchResultsView->setItemDelegate(searchDelegate); - connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked); - - connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { - const QStringList icons = pageIconResources(); - for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { - tabButtons[i]->setIcon(QPixmap(icons[i])); - } - searchDelegate->setPageIcons(icons); - searchResultsView->viewport()->update(); - }); - - // Build search index after pages are created - buildSearchIndex(); - - // Pages container (stacked widget + search results overlay) - pagesContainer = new QWidget; - auto *containerLayout = new QStackedLayout; - containerLayout->setStackingMode(QStackedLayout::StackAll); - containerLayout->addWidget(pagesWidget); - containerLayout->addWidget(searchResultsView); - pagesContainer->setLayout(containerLayout); + settingsView = new SettingsView(this); // Bottom buttons auto *buttonBox = new QHBoxLayout; @@ -211,241 +47,25 @@ void DlgSettings::setupUi() // Main layout auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(searchLayout); - mainLayout->addWidget(tabBarWidget); - auto *separator = new QFrame; - separator->setFrameShape(QFrame::HLine); - separator->setFrameShadow(QFrame::Sunken); - mainLayout->addWidget(separator); - mainLayout->addWidget(pagesContainer); + mainLayout->addWidget(settingsView); mainLayout->addSpacing(4); mainLayout->addLayout(buttonBox); setLayout(mainLayout); - // Keyboard shortcuts - auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); - connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); + retranslateUi(); - auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); - connect(nextTabShortcut, &QShortcut::activated, this, [this] { - int next = (currentTabIndex + 1) % tabButtons.size(); - setActiveTab(next); - }); - - auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); - connect(prevTabShortcut, &QShortcut::activated, this, [this] { - int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); - setActiveTab(prev); - }); - - // Initialize to first tab - setActiveTab(0); -} - -void DlgSettings::setupTabBar() -{ - tabBarWidget = new QWidget; - auto *tabLayout = new QHBoxLayout; - tabLayout->setContentsMargins(0, 0, 0, 0); - tabLayout->setSpacing(2); - - const QStringList iconResources = pageIconResources(); - - for (int i = 0; i < iconResources.size(); ++i) { - auto *tabButton = new QToolButton; - tabButton->setCheckable(true); - tabButton->setIcon(QPixmap(iconResources[i])); - tabButton->setIconSize(QSize(48, 48)); - tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); - tabButton->setAutoExclusive(true); - tabButton->setMinimumHeight(85); - tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - - connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); - - tabButtons.append(tabButton); - tabLayout->addWidget(tabButton); - } - - tabBarWidget->setLayout(tabLayout); -} - -void DlgSettings::buildSearchIndex() -{ - QList allEntries; - - const QStringList pageNames = translatedPageNames(); - searchDelegate->setPageNames(pageNames); - searchDelegate->setPageIcons(pageIconResources()); - - for (int i = 0; i < pages.size(); ++i) { - QList pageEntries = pages[i]->getSearchEntries(); - for (auto &entry : pageEntries) { - if (entry.pageIndex == -1) { - entry.pageIndex = i; - } - } - allEntries.append(pageEntries); - } - - searchModel->setSourceEntries(allEntries); -} - -void DlgSettings::onTabClicked(int index) -{ - if (searchActive) { - switchToTabMode(); - } - setActiveTab(index); -} - -void DlgSettings::setActiveTab(int index) -{ - if (index < 0 || index >= tabButtons.size()) { - return; - } - - currentTabIndex = index; - pagesWidget->setCurrentIndex(index); - - for (int i = 0; i < tabButtons.size(); ++i) { - tabButtons[i]->setChecked(i == index); - } - - // Style active tab with a thick accent border + subtle background tint - for (int i = 0; i < tabButtons.size(); ++i) { - if (i == index) { - tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " - "border-top-left-radius: 4px; border-top-right-radius: 4px; " - "background: palette(window); padding-bottom: 1px; }"); - } else { - tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " - "border-top-left-radius: 4px; border-top-right-radius: 4px; " - "background: transparent; }"); - } - } -} - -void DlgSettings::flashWidget(QWidget *widget) -{ - auto *overlay = new QWidget(widget); - overlay->setGeometry(widget->rect()); - overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); - - QPalette pal = overlay->palette(); - QColor flashColor = pal.color(QPalette::Highlight); - flashColor.setAlpha(100); - pal.setBrush(QPalette::Window, flashColor); - overlay->setPalette(pal); - overlay->setAutoFillBackground(true); - - auto *effect = new QGraphicsOpacityEffect(overlay); - effect->setOpacity(0.0); - overlay->setGraphicsEffect(effect); - overlay->show(); - overlay->raise(); - - auto *flashIn = new QPropertyAnimation(effect, "opacity"); - flashIn->setDuration(120); - flashIn->setStartValue(0.0); - flashIn->setEndValue(0.6); - flashIn->setEasingCurve(QEasingCurve::OutCubic); - - auto *fadeOut = new QPropertyAnimation(effect, "opacity"); - fadeOut->setDuration(900); - fadeOut->setStartValue(0.6); - fadeOut->setEndValue(0.0); - fadeOut->setEasingCurve(QEasingCurve::InCubic); - - auto *group = new QSequentialAnimationGroup(overlay); - group->addAnimation(flashIn); - group->addAnimation(fadeOut); - - connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); - - group->start(QAbstractAnimation::DeleteWhenStopped); -} - -void DlgSettings::onSearchTextChanged(const QString &text) -{ - searchModel->setFilterString(text); - - if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { - if (!searchActive) { - switchToSearchMode(); - } - if (searchModel->rowCount(QModelIndex()) > 0) { - searchResultsView->setCurrentIndex(searchModel->index(0)); - } - } else if (searchActive) { - switchToTabMode(); - } -} - -void DlgSettings::switchToSearchMode() -{ - searchActive = true; - tabBarWidget->setVisible(false); - pagesWidget->setVisible(false); - searchResultsView->setVisible(true); - if (searchModel->rowCount(QModelIndex()) > 0) { - searchResultsView->setCurrentIndex(searchModel->index(0)); - } -} - -void DlgSettings::switchToTabMode() -{ - searchActive = false; - tabBarWidget->setVisible(true); - pagesWidget->setVisible(true); - searchResultsView->setVisible(false); - searchEdit->blockSignals(true); - searchEdit->clear(); - searchEdit->blockSignals(false); - setActiveTab(currentTabIndex); -} - -void DlgSettings::onSearchResultClicked(const QModelIndex &index) -{ - navigateToSearchResult(index); -} - -void DlgSettings::navigateToSearchResult(const QModelIndex &index) -{ - SettingsSearchEntry entry = searchModel->entryForIndex(index); - if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { - return; - } - - // Switch to the page - switchToTabMode(); - setActiveTab(entry.pageIndex); - - // Scroll to the widget, focus it, and flash to highlight it - if (entry.widget) { - QWidget *widget = entry.widget; - while (widget) { - if (auto *scrollArea = qobject_cast(widget)) { - scrollArea->ensureWidgetVisible(entry.widget); - break; - } - widget = widget->parentWidget(); - } - entry.widget->setFocus(); - flashWidget(entry.widget); - } + adjustSize(); } void DlgSettings::setTab(int index) { - if (index >= 0 && index < tabButtons.size()) { - setActiveTab(index); - } + settingsView->setTab(index); } AbstractSettingsPage *DlgSettings::page(SettingsPage which) const { - return pages.value(static_cast(which)); + return settingsView->page(which); } void DlgSettings::updateLanguage() @@ -454,49 +74,6 @@ void DlgSettings::updateLanguage() installNewTranslator(); } -bool DlgSettings::eventFilter(QObject *watched, QEvent *event) -{ - if (watched == searchEdit && event->type() == QEvent::KeyPress) { - auto *keyEvent = static_cast(event); - if (keyEvent->key() == Qt::Key_Escape) { - if (searchActive) { - switchToTabMode(); - return true; - } - } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { - if (searchActive) { - if (searchResultsView->currentIndex().isValid()) { - navigateToSearchResult(searchResultsView->currentIndex()); - } - return true; - } - } else if (keyEvent->key() == Qt::Key_Down) { - if (searchActive) { - int nextRow = searchResultsView->currentIndex().row() + 1; - if (nextRow >= searchModel->rowCount()) { - nextRow = 0; - } - searchResultsView->setCurrentIndex(searchModel->index(nextRow)); - searchResultsView->scrollTo(searchModel->index(nextRow)); - return true; - } - } else if (keyEvent->key() == Qt::Key_Up) { - if (searchActive) { - int prevRow = searchResultsView->currentIndex().row() - 1; - if (prevRow < 0) { - prevRow = searchModel->rowCount() - 1; - } - if (prevRow >= 0) { - searchResultsView->setCurrentIndex(searchModel->index(prevRow)); - searchResultsView->scrollTo(searchModel->index(prevRow)); - } - return true; - } - } - } - return QDialog::eventFilter(watched, event); -} - void DlgSettings::closeEvent(QCloseEvent *event) { bool showLoadError = true; @@ -576,26 +153,5 @@ void DlgSettings::closeEvent(QCloseEvent *event) void DlgSettings::retranslateUi() { setWindowTitle(tr("Settings")); - retranslateTabNames(); - - searchEdit->setPlaceholderText(tr("Search settings...")); okButton->setText(tr("OK")); - - // Rebuild search index for translated text - buildSearchIndex(); -} - -QStringList DlgSettings::translatedPageNames() -{ - return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"), - tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")}; -} - -void DlgSettings::retranslateTabNames() -{ - const QStringList tabLabels = translatedPageNames(); - - for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { - tabButtons[i]->setText(tabLabels[i]); - } } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index 1f15e76d5..9942b410c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -1,105 +1,59 @@ /** * @file dlg_settings.h - * @brief Main settings dialog for the Cockatrice client + * @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView) * @ingroup Dialogs */ #ifndef DLG_SETTINGS_H #define DLG_SETTINGS_H +#include "settings_view.h" + #include -#include #include -#include -#include #include class QPushButton; -class QModelIndex; -class QWidget; +class AbstractSettingsPage; inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); -class QStackedWidget; -class QToolButton; -class QListView; -class QLineEdit; -class AbstractSettingsPage; -class SettingsSearchModel; -class SettingsSearchDelegate; - /** - * @brief Main application settings dialog with tabbed navigation and search + * @brief Main application settings dialog hosting the shared SettingsView * - * Provides a modern settings interface organized into tabbed pages. Users can - * either navigate by clicking tabs or search for specific settings using the - * built-in search bar. Search results are filtered and ranked by relevance. + * Thin QDialog frame around SettingsView: only the window title, the OK button + * and the close validation live here. All pages, navigation and search are + * owned by SettingsView so other hosts (e.g. a settings tab) can reuse them. */ class DlgSettings : public QDialog { Q_OBJECT public: - /** - * @brief Page order in the tab bar, matching the order pages are added in setupUi() - * - * Use these values instead of raw indices so reordering pages never silently - * breaks external callers like tab_room.cpp. - */ - enum SettingsPage - { - GeneralPage = 0, - AppearancePage, - UserInterfacePage, - DeckEditorPage, - StoragePage, - MessagesPage, - SoundPage, - ShortcutsPage, - NumPages - }; + using SettingsPage = SettingsView::SettingsPage; + static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage; + static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage; + static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage; + static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage; + static constexpr SettingsPage StoragePage = SettingsView::StoragePage; + static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage; + static constexpr SettingsPage SoundPage = SettingsView::SoundPage; + static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage; + static constexpr SettingsPage NumPages = SettingsView::NumPages; explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); AbstractSettingsPage *page(SettingsPage which) const; private slots: - void onTabClicked(int index); - void onSearchTextChanged(const QString &text); - void onSearchResultClicked(const QModelIndex &index); void updateLanguage(); private: - // UI elements - QLineEdit *searchEdit; ///< Search bar for filtering settings - QWidget *tabBarWidget; ///< Container widget for the tab buttons - QList tabButtons; ///< Navigation tab buttons - QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages - QListView *searchResultsView; ///< Search results list view - QWidget *pagesContainer; ///< Container stacking pages and search results - QPushButton *okButton; ///< Button to close the dialog - - // Data - QList pages; ///< All settings page instances - SettingsSearchModel *searchModel; ///< Model for search results - SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering - int currentTabIndex; ///< Currently active tab index - bool searchActive; ///< Whether search mode is active - - void setupUi(); - void setupTabBar(); - void buildSearchIndex(); - void switchToTabMode(); - void switchToSearchMode(); - void navigateToSearchResult(const QModelIndex &index); - void setActiveTab(int index); - static void flashWidget(QWidget *widget); - static QStringList translatedPageNames(); + SettingsView *settingsView; ///< Shared settings content + QPushButton *okButton; ///< Button to close the dialog void retranslateUi(); - void retranslateTabNames(); protected: void closeEvent(QCloseEvent *event) override; - bool eventFilter(QObject *watched, QEvent *event) override; }; #endif // DLG_SETTINGS_H diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp new file mode 100644 index 000000000..46b964e7e --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -0,0 +1,498 @@ +/** + * @file settings_view.cpp + * @brief Implementation of the settings content widget + * @ingroup Dialogs + */ +#include "settings_view.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" +#include "../settings_page/abstract_settings_page.h" +#include "../settings_page/appearance_settings_page.h" +#include "../settings_page/deck_editor_settings_page.h" +#include "../settings_page/general_settings_page.h" +#include "../settings_page/messages_settings_page.h" +#include "../settings_page/settings_search_delegate.h" +#include "../settings_page/settings_search_model.h" +#include "../settings_page/shortcut_settings_page.h" +#include "../settings_page/sound_settings_page.h" +#include "../settings_page/storage_settings_page.h" +#include "../settings_page/user_interface_settings_page.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Wraps a widget in a scroll area for long settings pages + * @param widget The widget to wrap + * @return The scroll area containing the widget + */ +static QScrollArea *makeScrollable(QWidget *widget) +{ + widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); + + auto *scrollArea = new QScrollArea; + scrollArea->setWidgetResizable(true); + scrollArea->setContentsMargins(0, 0, 0, 0); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->horizontalScrollBar()->setEnabled(false); + scrollArea->setWidget(widget); + return scrollArea; +} + +/** + * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order + */ +static QStringList pageIconResources() +{ + return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), + QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), + QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), + QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; +} + +SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false) +{ + setupUi(); + + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &SettingsView::retranslateUi); + retranslateUi(); + + searchEdit->setFocus(); +} + +void SettingsView::setupUi() +{ + // Search bar + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->installEventFilter(this); + connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); + + auto *searchLayout = new QHBoxLayout; + searchLayout->addWidget(searchEdit); + + // Tab bar (built in setupTabBar) + setupTabBar(); + + // Pages stacked widget + pagesWidget = new QStackedWidget; + + auto *generalPage = new GeneralSettingsPage; + auto *appearancePage = new AppearanceSettingsPage; + auto *userInterfacePage = new UserInterfaceSettingsPage; + auto *deckEditorPage = new DeckEditorSettingsPage; + auto *storagePage = new StorageSettingsPage; + auto *messagesPage = new MessagesSettingsPage; + auto *soundPage = new SoundSettingsPage; + auto *shortcutsPage = new ShortcutSettingsPage; + + pages.append(generalPage); + pages.append(appearancePage); + pages.append(userInterfacePage); + pages.append(deckEditorPage); + pages.append(storagePage); + pages.append(messagesPage); + pages.append(soundPage); + pages.append(shortcutsPage); + + pagesWidget->addWidget(makeScrollable(generalPage)); + pagesWidget->addWidget(makeScrollable(appearancePage)); + pagesWidget->addWidget(makeScrollable(userInterfacePage)); + pagesWidget->addWidget(makeScrollable(deckEditorPage)); + pagesWidget->addWidget(makeScrollable(storagePage)); + pagesWidget->addWidget(makeScrollable(messagesPage)); + pagesWidget->addWidget(soundPage); + pagesWidget->addWidget(shortcutsPage); + + Q_ASSERT(pages.size() == NumPages); + + // Search results view (hidden by default) + searchResultsView = new QListView; + searchResultsView->setUniformItemSizes(false); + searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); + searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + searchResultsView->setVisible(false); + searchResultsView->setStyleSheet( + "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); + + searchModel = new SettingsSearchModel(this); + searchDelegate = new SettingsSearchDelegate(this); + searchResultsView->setModel(searchModel); + searchResultsView->setItemDelegate(searchDelegate); + connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked); + + connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { + const QStringList icons = pageIconResources(); + for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { + tabButtons[i]->setIcon(QPixmap(icons[i])); + } + searchDelegate->setPageIcons(icons); + searchResultsView->viewport()->update(); + }); + + // Build search index after pages are created + buildSearchIndex(); + + // Pages container (stacked widget + search results overlay) + pagesContainer = new QWidget; + auto *containerLayout = new QStackedLayout; + containerLayout->setStackingMode(QStackedLayout::StackAll); + containerLayout->addWidget(pagesWidget); + containerLayout->addWidget(searchResultsView); + pagesContainer->setLayout(containerLayout); + + // Main layout + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(searchLayout); + mainLayout->addWidget(tabBarWidget); + auto *separator = new QFrame; + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + mainLayout->addWidget(separator); + mainLayout->addWidget(pagesContainer); + mainLayout->addSpacing(4); + setLayout(mainLayout); + + // Keyboard shortcuts + auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); + connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); + + auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); + connect(nextTabShortcut, &QShortcut::activated, this, [this] { + int next = (currentTabIndex + 1) % tabButtons.size(); + setActiveTab(next); + }); + + auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); + connect(prevTabShortcut, &QShortcut::activated, this, [this] { + int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); + setActiveTab(prev); + }); + + // Initialize to first tab + setActiveTab(0); +} + +void SettingsView::setupTabBar() +{ + tabBarWidget = new QWidget; + auto *tabLayout = new QHBoxLayout; + tabLayout->setContentsMargins(0, 0, 0, 0); + tabLayout->setSpacing(2); + + const QStringList iconResources = pageIconResources(); + + for (int i = 0; i < iconResources.size(); ++i) { + auto *tabButton = new QToolButton; + tabButton->setCheckable(true); + tabButton->setIcon(QPixmap(iconResources[i])); + tabButton->setIconSize(QSize(48, 48)); + tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + tabButton->setAutoExclusive(true); + tabButton->setMinimumHeight(85); + tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); + + tabButtons.append(tabButton); + tabLayout->addWidget(tabButton); + } + + tabBarWidget->setLayout(tabLayout); +} + +void SettingsView::buildSearchIndex() +{ + QList allEntries; + + const QStringList pageNames = translatedPageNames(); + searchDelegate->setPageNames(pageNames); + searchDelegate->setPageIcons(pageIconResources()); + + for (int i = 0; i < pages.size(); ++i) { + QList pageEntries = pages[i]->getSearchEntries(); + for (auto &entry : pageEntries) { + if (entry.pageIndex == -1) { + entry.pageIndex = i; + } + } + allEntries.append(pageEntries); + } + + searchModel->setSourceEntries(allEntries); +} + +void SettingsView::onTabClicked(int index) +{ + if (searchActive) { + switchToTabMode(); + } + setActiveTab(index); +} + +void SettingsView::setActiveTab(int index) +{ + if (index < 0 || index >= tabButtons.size()) { + return; + } + + currentTabIndex = index; + pagesWidget->setCurrentIndex(index); + + for (int i = 0; i < tabButtons.size(); ++i) { + tabButtons[i]->setChecked(i == index); + } + + // Style active tab with a thick accent border + subtle background tint + for (int i = 0; i < tabButtons.size(); ++i) { + if (i == index) { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: palette(window); padding-bottom: 1px; }"); + } else { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: transparent; }"); + } + } +} + +void SettingsView::flashWidget(QWidget *widget) +{ + auto *overlay = new QWidget(widget); + overlay->setGeometry(widget->rect()); + overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + QPalette pal = overlay->palette(); + QColor flashColor = pal.color(QPalette::Highlight); + flashColor.setAlpha(100); + pal.setBrush(QPalette::Window, flashColor); + overlay->setPalette(pal); + overlay->setAutoFillBackground(true); + + auto *effect = new QGraphicsOpacityEffect(overlay); + effect->setOpacity(0.0); + overlay->setGraphicsEffect(effect); + overlay->show(); + overlay->raise(); + + auto *flashIn = new QPropertyAnimation(effect, "opacity"); + flashIn->setDuration(120); + flashIn->setStartValue(0.0); + flashIn->setEndValue(0.6); + flashIn->setEasingCurve(QEasingCurve::OutCubic); + + auto *fadeOut = new QPropertyAnimation(effect, "opacity"); + fadeOut->setDuration(900); + fadeOut->setStartValue(0.6); + fadeOut->setEndValue(0.0); + fadeOut->setEasingCurve(QEasingCurve::InCubic); + + auto *group = new QSequentialAnimationGroup(overlay); + group->addAnimation(flashIn); + group->addAnimation(fadeOut); + + connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); + + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void SettingsView::onSearchTextChanged(const QString &text) +{ + searchModel->setFilterString(text); + + if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { + if (!searchActive) { + switchToSearchMode(); + } + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } + } else if (searchActive) { + switchToTabMode(); + } +} + +void SettingsView::switchToSearchMode() +{ + searchActive = true; + tabBarWidget->setVisible(false); + pagesWidget->setVisible(false); + searchResultsView->setVisible(true); + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } +} + +void SettingsView::switchToTabMode() +{ + searchActive = false; + tabBarWidget->setVisible(true); + pagesWidget->setVisible(true); + searchResultsView->setVisible(false); + searchEdit->blockSignals(true); + searchEdit->clear(); + searchEdit->blockSignals(false); + setActiveTab(currentTabIndex); +} + +void SettingsView::onSearchResultClicked(const QModelIndex &index) +{ + navigateToSearchResult(index); +} + +void SettingsView::navigateToSearchResult(const QModelIndex &index) +{ + SettingsSearchEntry entry = searchModel->entryForIndex(index); + if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { + return; + } + + // Switch to the page + switchToTabMode(); + setActiveTab(entry.pageIndex); + + // Scroll to the widget, focus it, and flash to highlight it + if (entry.widget) { + QWidget *widget = entry.widget; + while (widget) { + if (auto *scrollArea = qobject_cast(widget)) { + scrollArea->ensureWidgetVisible(entry.widget); + break; + } + widget = widget->parentWidget(); + } + entry.widget->setFocus(); + flashWidget(entry.widget); + } +} + +void SettingsView::setTab(int index) +{ + if (index >= 0 && index < tabButtons.size()) { + setActiveTab(index); + } +} + +AbstractSettingsPage *SettingsView::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + +bool SettingsView::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == searchEdit && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Escape) { + if (searchActive) { + switchToTabMode(); + return true; + } + } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { + if (searchActive) { + if (searchResultsView->currentIndex().isValid()) { + navigateToSearchResult(searchResultsView->currentIndex()); + } + return true; + } + } else if (keyEvent->key() == Qt::Key_Down) { + if (searchActive) { + int nextRow = searchResultsView->currentIndex().row() + 1; + if (nextRow >= searchModel->rowCount()) { + nextRow = 0; + } + searchResultsView->setCurrentIndex(searchModel->index(nextRow)); + searchResultsView->scrollTo(searchModel->index(nextRow)); + return true; + } + } else if (keyEvent->key() == Qt::Key_Up) { + if (searchActive) { + int prevRow = searchResultsView->currentIndex().row() - 1; + if (prevRow < 0) { + prevRow = searchModel->rowCount() - 1; + } + if (prevRow >= 0) { + searchResultsView->setCurrentIndex(searchModel->index(prevRow)); + searchResultsView->scrollTo(searchModel->index(prevRow)); + } + return true; + } + } + } + return QWidget::eventFilter(watched, event); +} + +void SettingsView::retranslateUi() +{ + retranslateTabNames(); + + // NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings + // before the view was extracted, and keeping the context preserves existing translations. + searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings...")); + + // Rebuild search index for translated text + buildSearchIndex(); +} + +QStringList SettingsView::translatedPageNames() +{ + // NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi). + return {QCoreApplication::translate("DlgSettings", "General"), + QCoreApplication::translate("DlgSettings", "Appearance"), + QCoreApplication::translate("DlgSettings", "User Interface"), + QCoreApplication::translate("DlgSettings", "Card Sources"), + QCoreApplication::translate("DlgSettings", "Storage"), + QCoreApplication::translate("DlgSettings", "Chat"), + QCoreApplication::translate("DlgSettings", "Sound"), + QCoreApplication::translate("DlgSettings", "Shortcuts")}; +} + +void SettingsView::retranslateTabNames() +{ + const QStringList tabLabels = translatedPageNames(); + + for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { + tabButtons[i]->setText(tabLabels[i]); + } +} diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h new file mode 100644 index 000000000..fe1b925d8 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.h @@ -0,0 +1,98 @@ +/** + * @file settings_view.h + * @brief Settings content widget hosted by the settings dialog (and, later, the settings tab) + * @ingroup Dialogs + */ +#ifndef SETTINGS_VIEW_H +#define SETTINGS_VIEW_H + +#include +#include +#include +#include +#include + +class QModelIndex; +class QObject; +class QEvent; + +class QStackedWidget; +class QToolButton; +class QListView; +class QLineEdit; +class AbstractSettingsPage; +class SettingsSearchModel; +class SettingsSearchDelegate; + +/** + * @brief Settings pages with tabbed navigation and search, without any dialog chrome + * + * Owns the search bar, the tab bar, the stacked settings pages and the search + * results view. Dialog/tab hosts only add their own frame (buttons, titles). + */ +class SettingsView : public QWidget +{ + Q_OBJECT +public: + /** + * @brief Page order in the tab bar, matching the order pages are added in setupUi() + * + * Use these values instead of raw indices so reordering pages never silently + * breaks external callers like tab_room.cpp. + */ + enum SettingsPage + { + GeneralPage = 0, + AppearancePage, + UserInterfacePage, + DeckEditorPage, + StoragePage, + MessagesPage, + SoundPage, + ShortcutsPage, + NumPages + }; + + explicit SettingsView(QWidget *parent = nullptr); + void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; + +private slots: + void onTabClicked(int index); + void onSearchTextChanged(const QString &text); + void onSearchResultClicked(const QModelIndex &index); + +private: + // UI elements + QLineEdit *searchEdit; ///< Search bar for filtering settings + QWidget *tabBarWidget; ///< Container widget for the tab buttons + QList tabButtons; ///< Navigation tab buttons + QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages + QListView *searchResultsView; ///< Search results list view + QWidget *pagesContainer; ///< Container stacking pages and search results + + // Data + QList pages; ///< All settings page instances + SettingsSearchModel *searchModel; ///< Model for search results + SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering + int currentTabIndex; ///< Currently active tab index + bool searchActive; ///< Whether search mode is active + + void setupUi(); + void setupTabBar(); + void buildSearchIndex(); + void switchToTabMode(); + void switchToSearchMode(); + void navigateToSearchResult(const QModelIndex &index); + void setActiveTab(int index); + static void flashWidget(QWidget *widget); + static QStringList translatedPageNames(); + + void retranslateUi(); + void retranslateTabNames(); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; +}; + +#endif // SETTINGS_VIEW_H From 84ad1185b67b7f46dc867fc44a26df040613f564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 13:03:35 +0200 Subject: [PATCH 13/13] [Client] Settings: match CI clang-format-18 line breaking --- cockatrice/src/interface/widgets/dialogs/settings_view.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 46b964e7e..c2d9860c3 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -93,8 +93,7 @@ SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0 { setupUi(); - connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, - &SettingsView::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi); retranslateUi(); searchEdit->setFocus();