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..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 @@ -141,6 +142,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/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/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(); 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/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/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..c2d9860c3 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -0,0 +1,497 @@ +/** + * @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 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/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_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/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/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; diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 57d6e8b15..d23ce254c 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; @@ -1029,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/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_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_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/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.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 73b13e23e..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, 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..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; @@ -265,7 +270,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/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index e0be7990b..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(); @@ -151,7 +159,8 @@ public: MessageTargetRoom, MessageTargetGame, MessageTargetChat, - MessageTargetIslRoom + MessageTargetIslRoom, + MessageTargetRoomChannel }; virtual void logMessage(const int /* senderId */, const QString & /* senderName */, @@ -159,7 +168,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 */, @@ -188,6 +198,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/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index fbca9589a..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" @@ -13,6 +16,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 +44,7 @@ #include #include #include +#include #include #include #include @@ -70,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). @@ -252,6 +279,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; @@ -563,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; @@ -764,7 +808,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()); @@ -822,7 +866,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)); @@ -856,10 +900,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,13 +910,31 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory)); } + // 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; + 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); 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; @@ -959,6 +1019,36 @@ 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 (!room->mayWriteChannel(*userInfo, *channel)) { + return Response::RespFunctionNotAllowed; + } + + 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); + + databaseInterface->logMessage( + userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()), msg, + Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(), room->getName(), 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..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 @@ -30,6 +32,7 @@ class Command_ListRooms; class Command_JoinRoom; class Command_LeaveRoom; class Command_RoomSay; +class Command_RoomChannelSay; class Command_CreateGame; class Command_JoinGame; @@ -46,6 +49,9 @@ protected: bool acceptsUserListChanges; bool acceptsRoomListChanges; bool idleClientWarningSent; + QByteArray authNonce; + QString authNonceUser; + QDateTime authNonceCreated; virtual void logDebugMessage(const QString & /* message */) { } @@ -66,6 +72,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); @@ -121,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_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 614de8d63..68b137cd4 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); }, @@ -102,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()); @@ -152,9 +160,73 @@ 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); + } + 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; +} + +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); +} + RoomEvent *Server_Room::prepareRoomEvent(const ::google::protobuf::Message &roomEvent) { auto *event = new RoomEvent; @@ -310,12 +382,35 @@ 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); + 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, 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, accessLevel); + } if (chatHistorySize != 0) { ServerInfo_ChatMessage chatMessage; @@ -324,13 +419,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,26 +444,34 @@ 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(); } } -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 5933894d5..b6e6f269a 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,35 @@ 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); + /** + * @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; @@ -112,14 +146,14 @@ 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() - { - return chatHistory; - } + QList getChatHistory(const QString &channelId) const; void addClient(Server_ProtocolHandler *client); void removeClient(Server_ProtocolHandler *client); @@ -136,13 +170,15 @@ 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); 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/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/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/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/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/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..0f3a9ccad --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto @@ -0,0 +1,25 @@ +syntax = "proto2"; + +message ServerInfo_RoomChannel { + enum AccessLevel { + // 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; + 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; +} 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/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/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/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/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/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/servatrice/migrations/servatrice_0037_to_0038.sql b/servatrice/migrations/servatrice_0037_to_0038.sql new file mode 100644 index 000000000..ad6477c46 --- /dev/null +++ b/servatrice/migrations/servatrice_0037_to_0038.sql @@ -0,0 +1,29 @@ +-- 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; + +-- 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/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/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 f802f0244..68478810b 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -332,6 +332,40 @@ 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. 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. +; +; 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] @@ -386,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 4fbc1d8bd..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(37); +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, @@ -158,6 +160,18 @@ 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', + `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; + -- games CREATE TABLE IF NOT EXISTS `cockatrice_games` ( `room_name` varchar(255) NOT NULL, @@ -329,9 +343,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/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 a832c1652..a306de523 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -45,6 +45,8 @@ #include #include #include +#include +#include #include #include #include @@ -65,7 +67,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -73,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, @@ -249,6 +257,37 @@ Servatrice::~Servatrice() prepareDestroy(); } +/** + * @brief Maps a configured channel access level (database column or ini value) + * onto the ServerInfo_RoomChannel privilege ladder. + * + * Returns nullopt for unknown keywords so callers can fail closed instead of + * silently downgrading a restricted channel to Public. + */ +static std::optional 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; + } + if (level == "public") { + return ServerInfo_RoomChannel::Public; + } + return std::nullopt; +} + bool Servatrice::initServer() { @@ -360,10 +399,65 @@ bool Servatrice::initServer() while (query2->next()) { gameTypes.append(query2->value(0).toString()); } + + QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery( + "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()); + 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); + 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(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); + } + 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 +470,61 @@ 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); + 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(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(); 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); } @@ -475,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 = @@ -864,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(); @@ -940,8 +1095,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, @@ -1056,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 ac3a19c8e..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) { @@ -1286,7 +1568,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 +1579,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 +1608,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 +1618,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); } @@ -1340,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); @@ -1393,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; } @@ -1846,9 +2140,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 +2193,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; } @@ -2068,3 +2363,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..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 37 +#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; @@ -177,7 +189,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, @@ -218,6 +231,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..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*/) { @@ -3481,26 +3521,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 +3547,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const return Response::RespOk; } - if (!isCardNameAllowed(cardName, cardProviderId)) { + if (!sqlInterface->isCardArtAllowed(cardName, cardProviderId)) { return Response::RespFunctionNotAllowed; } @@ -3676,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; @@ -3822,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(), @@ -3889,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 { @@ -3899,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; } @@ -3998,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 7de742c66..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 @@ -216,7 +219,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 &); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 784aac3be..b58942f3f 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) @@ -273,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/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) 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 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); 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(); +}