diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 811a3cb7f..b6125238f 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,17 +15,8 @@ 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 @@ -134,17 +125,6 @@ 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 @@ -334,15 +314,10 @@ SetShellVarContext all SetRegView 32 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" -; 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'" +!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." -Call BackupUserDataBeforeUninstall -ExecWait '$R0 _?=$OldInstallDir' -Call RestoreUserDataAfterUninstall +ExecWait '$R0 _?=$INSTDIR' done32: @@ -351,312 +326,16 @@ ${If} ${RunningX64} SetRegView 64 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" - StrCpy $OldInstallDir $INSTDIR - !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" + !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." - Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$OldInstallDir' - Call RestoreUserDataAfterUninstall + ExecWait '$R0 _?=$INSTDIR' 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 @@ -1358,17 +1037,11 @@ ${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 - Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" - Call RestoreUserDataAfterUninstall + ExecWait "$OldUninstallString _?=$INSTDIR" done32: @@ -1388,13 +1061,10 @@ ${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 - Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" - Call RestoreUserDataAfterUninstall + ExecWait "$OldUninstallString _?=$INSTDIR" done64: ${EndIf} @@ -1518,11 +1188,6 @@ ${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 5baf10b1e..282f5a3c6 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -142,8 +142,6 @@ 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 037565c6a..9b985073d 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -95,9 +95,6 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this, &ConnectionController::onPromptForgotPasswordChallenge); - - connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this, - &ConnectionController::onPasswordVerifierReady); } void ConnectionController::connectToServer() @@ -106,32 +103,16 @@ 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 &storedVerifier, - const QString &saveName, - bool savePassword) + const QString &password) { - 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 150ec9e5b..d4a832c75 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -40,13 +40,8 @@ public: void registerToServer(); void forgotPasswordRequest(); void connectToServer(); - 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 + connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, const QString &password); void disconnectFromServer(); void refreshWindowTitle() @@ -91,9 +86,6 @@ 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(); @@ -110,10 +102,6 @@ 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 a6e541fca..c97767ec7 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -354,19 +354,6 @@ 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); } @@ -425,15 +412,6 @@ 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 c064a5b5a..80b82b6e5 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -60,14 +60,12 @@ 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. - QString lastBlacklistedPlaymatNotice; ///< Card name already reported as blacklisted, to avoid repeat popups. + 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. 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 deleted file mode 100644 index 5145d2ebb..000000000 --- a/cockatrice/src/interface/at_risk_data_warning.cpp +++ /dev/null @@ -1,215 +0,0 @@ -#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 deleted file mode 100644 index 22cad86fa..000000000 --- a/cockatrice/src/interface/at_risk_data_warning.h +++ /dev/null @@ -1,50 +0,0 @@ -#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 43b40bf30..7a7e31a58 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -289,15 +289,9 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) playernameEdit->setText(_data.at(3)); playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); - storedVerifier.clear(); if (savePasswordStatus) { - const QString stored = _data.at(4); - if (stored.startsWith("$")) { - storedVerifier = stored; - } else { - passwordEdit->setText(stored); - } + passwordEdit->setText(_data.at(4)); } if (!_data.at(6).isEmpty()) { @@ -323,7 +317,6 @@ 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); @@ -349,11 +342,6 @@ 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.")); @@ -361,10 +349,10 @@ void DlgConnect::actOk() } servers.addNewServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(), - playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked()); + playernameEdit->text().trimmed(), passwordEdit->text(), savePasswordCheckBox->isChecked()); } else { servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(), - playernameEdit->text().trimmed(), passwordToSave, + playernameEdit->text().trimmed(), passwordEdit->text(), savePasswordCheckBox->isChecked()); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index bef5f136e..10cb5366a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -7,7 +7,6 @@ #ifndef DLG_CONNECT_H #define DLG_CONNECT_H -#include #include #include #include @@ -52,22 +51,6 @@ 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(); @@ -98,7 +81,6 @@ 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 979a4519c..59f80c79c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp @@ -23,10 +23,7 @@ DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent) auto &servers = SettingsCache::instance().servers(); if (servers.getSavePassword()) { - const QString stored = servers.getPassword(); - if (!stored.startsWith("$")) { - oldPasswordEdit->setText(stored); - } + oldPasswordEdit->setText(servers.getPassword()); } oldPasswordLabel->setBuddy(oldPasswordEdit); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ab870a2c8..62b8a4909 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -29,29 +29,6 @@ #include #include -static constexpr int MIN_DIALOG_WIDTH = 750; -static constexpr int MIN_DIALOG_HEIGHT = 700; -static constexpr int MAX_DIALOG_WIDTH = 1280; -static constexpr double DIALOG_SIZE_RATIO = 0.9; - -/** - * @brief Calculates the initial dialog size from the host window or screen - * @param hostSize Size of the hosting main window, or empty when parentless - * @param availableSize Available screen area the dialog must fit into - * @param minimumSize Minimum dialog size already applied via setMinimumSize - * @return Target size at ~90% of the host with small margins, clamped to fit - */ -static QSize calculateDialogSize(const QSize &hostSize, const QSize &availableSize, const QSize &minimumSize) -{ - QSize target = !hostSize.isEmpty() ? QSize(qRound(hostSize.width() * DIALOG_SIZE_RATIO), - qRound(hostSize.height() * DIALOG_SIZE_RATIO)) - : QSize(qRound(availableSize.width() * DIALOG_SIZE_RATIO), - qRound(availableSize.height() * DIALOG_SIZE_RATIO)); - // Evenly-spaced columns leave unnatural dead space on very wide windows, so clamp the width. - target.setWidth(qMin(target.width(), MAX_DIALOG_WIDTH)); - return target.expandedTo(minimumSize).boundedTo(availableSize); -} - DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) { // Fill the main window with small margins so multi-group pages (e.g. Deck Editor) are not buried in a scroll. @@ -67,10 +44,16 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) screen = QGuiApplication::primaryScreen(); } const QRect available = screen ? screen->availableGeometry() : QRect(0, 0, 1280, 800); - const QSize minimum(qMin(MIN_DIALOG_WIDTH, available.width()), qMin(MIN_DIALOG_HEIGHT, available.height())); + const QSize minimum(qMin(750, available.width()), qMin(700, available.height())); setMinimumSize(minimum); - resize(calculateDialogSize(hostSize, available.size(), minimum)); + QSize target = !hostSize.isEmpty() ? QSize(qRound(hostSize.width() * 0.9), qRound(hostSize.height() * 0.9)) + : QSize(qRound(available.width() * 0.9), qRound(available.height() * 0.9)); + // Evenly-spaced columns leave unnatural dead space on very wide windows; clamp the width per review. + static constexpr int MAX_DIALOG_WIDTH = 1280; + target.setWidth(qMin(target.width(), MAX_DIALOG_WIDTH)); + target = target.expandedTo(minimum).boundedTo(available.size()); + resize(target); setSizeGripEnabled(true); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); 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 60bc97edc..ff3d9a224 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -301,9 +301,7 @@ void UserInfoBox::changePassword(const QString &oldPassword, const QString &newP { Command_AccountPassword cmd; cmd.set_old_password(oldPassword.toStdString()); - if (client->getServerSupportsChallengeResponse()) { - cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); - } else if (client->getServerSupportsPasswordHash()) { + 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 6128a7830..9494155af 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -118,26 +118,6 @@ 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(); @@ -179,7 +159,6 @@ MessagesSettingsPage::MessagesSettingsPage() mainLayout->addWidget(messageGroupBox); mainLayout->addWidget(chatGroupBox); mainLayout->addWidget(highlightGroupBox); - mainLayout->addWidget(channelGroupBox); setLayout(mainLayout); @@ -216,34 +195,6 @@ 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); @@ -270,18 +221,6 @@ 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()); @@ -328,9 +267,6 @@ 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")); @@ -348,8 +284,6 @@ 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 112ff4e29..e85e6cca7 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -27,8 +27,6 @@ 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); @@ -51,27 +49,18 @@ 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 67e076983..9f17afea2 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(7); + roomTable->setColumnCount(6); roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); roomTable->setHorizontalHeaderLabels( - QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName;Channel")).split(";")); + QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";")); gameTable = new QTableWidget(); gameTable->setColumnCount(6); @@ -317,8 +317,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) for (int i = 0; i < response.log_message_size(); ++i) { ServerInfo_ChatMessage message = response.log_message(i); - const QString targetType = QString::fromStdString(message.target_type()); - if (targetType == "room" || targetType == "room_channel") { + if (QString::fromStdString(message.target_type()) == "room") { roomTable->insertRow(roomCounter); roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); @@ -326,11 +325,10 @@ 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 (targetType == "game") { + if (QString::fromStdString(message.target_type()) == "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()))); @@ -341,7 +339,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) ++gameCounter; } - if (targetType == "chat") { + if (QString::fromStdString(message.target_type()) == "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 e5f2020ca..67106ba34 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -29,7 +29,6 @@ #include #include #include -#include #include #include #include @@ -39,11 +38,9 @@ #include #include #include -#include #include #include #include -#include #include #include #include @@ -55,7 +52,6 @@ #include #include #include -#include #include #include #include @@ -65,45 +61,12 @@ #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()), activeChannelId(MAIN_CHANNEL), - chatCollapsed(false) + ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager()) { const int gameTypeListSize = info.gametype_list_size(); for (int i = 0; i < gameTypeListSize; ++i) { @@ -123,61 +86,14 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; userList->setGameInviteLinkProvider(gameInviteLinkProvider); - 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); + 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); connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this, &TabRoom::actCompleterChanged); sayLabel = new QLabel; @@ -185,7 +101,6 @@ 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); @@ -207,27 +122,19 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, sayHbox->addWidget(sayEdit); sayHbox->addWidget(chatSettingsButton); - sayWidget = new QWidget; - sayWidget->setLayout(sayHbox); - auto *chatVbox = new QVBoxLayout; - chatVbox->addWidget(chatViewStack); - chatVbox->addWidget(sayWidget); - chatVbox->addWidget(channelTabBar); + chatVbox->addWidget(chatView); + chatVbox->addLayout(sayHbox); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatVbox); - 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 *splitter = new QSplitter(Qt::Vertical); + splitter->addWidget(gameSelector); + splitter->addWidget(chatGroupBox); auto *hbox = new QHBoxLayout; - hbox->addWidget(chatSplitter, 3); + hbox->addWidget(splitter, 3); hbox->addWidget(userListPanel, 1); aLeaveRoom = new QAction(this); @@ -282,9 +189,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, void TabRoom::retranslateUi() { gameSelector->retranslateUi(); - for (auto it = channelViews.constBegin(); it != channelViews.constEnd(); ++it) { - it.value()->retranslateUi(); - } + chatView->retranslateUi(); userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); @@ -292,11 +197,6 @@ 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() @@ -333,11 +233,6 @@ void TabRoom::tabActivated() if (!sayEdit->hasFocus()) { sayEdit->setFocus(); } - if (!chatCollapsed) { - channelUnreadCounts[activeChannelId] = 0; - channelMentioned[activeChannelId] = false; - updateChannelTab(activeChannelId); - } } QString TabRoom::sanitizeHtml(QString dirty) const @@ -345,232 +240,29 @@ 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 { - 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.")); - } - }; + Command_RoomSay cmd; + cmd.set_message(sayEdit->text().toStdString()); - 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); - } + PendingCommand *pend = prepareRoomCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabRoom::sayFinished); + sendRoomCommand(pend); sayEdit->clear(); } } -void TabRoom::onChannelChanged(int index) +void TabRoom::sayFinished(const Response &response) { - if (index < 0 || index >= channelIds.size()) { - return; + if (response.response_code() == Response::RespChatFlood) { + chatView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds.")); } - 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() @@ -606,9 +298,6 @@ 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; @@ -646,11 +335,20 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) QString senderName = QString::fromStdString(event.name()); QString message = QString::fromStdString(event.message()); - ServerInfo_User userInfo = {}; - if (isRoomMessageIgnored(senderName, userInfo)) { + 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 (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) { return; } @@ -662,49 +360,7 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) "] " + message; } - 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); - } + chatView->appendMessage(message, event.message_type(), userInfo, true); emit userEvent(false); } @@ -712,9 +368,7 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event) { QString userName = QString::fromStdString(event.name()); int amount = event.amount(); - for (ChatView *view : channelViews) { - view->redactMessages(userName, amount); - } + chatView->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 8f7c8f918..031056145 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -10,7 +10,6 @@ #include "tab.h" -#include #include #include #include @@ -19,12 +18,8 @@ class UserListProxy; class QAction; -class QTabBar; -class QWidget; class QGroupBox; -class QSplitter; class QMenu; -class QStackedWidget; class QStringListModel; class TabSupervisor; @@ -47,7 +42,6 @@ class Event_ListGames; class Event_JoinRoom; class Event_LeaveRoom; class Event_RoomSay; -class Event_RoomChannelSay; class Event_RemoveMessages; class GameSelector; class Response; @@ -70,23 +64,10 @@ 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; @@ -104,9 +85,7 @@ signals: void gameListUpdated(); private slots: void sendMessage(); - void onChannelChanged(int index); - void onChannelTabClicked(int index); - void setChatCollapsed(bool collapsed); + void sayFinished(const Response &response); void actClearChat(); void actOpenChatSettings(); void addMentionTag(QString mentionTag); @@ -119,18 +98,9 @@ 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 5b33303a0..8aa32556f 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -133,32 +133,28 @@ 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 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; - } - } + const int currentRow = popup->currentIndex().row(); + const int step = (key == Qt::Key_Up) ? -1 : 1; + const int newRow = qBound(0, currentRow + step, rowCount - 1); - selectWithoutRewrite(completer->completionModel()->index(newRow, completer->completionColumn())); + if (newRow != currentRow) { + const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn()); + popup->selectionModel()->setCurrentIndex(newIndex, + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } return true; } @@ -222,37 +218,8 @@ void CardCompleterStyler::ensureClosestSelected() const int closestRow = rowCount - 1; if (currentRow != closestRow) { - 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(); + popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()), + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); } } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.h b/cockatrice/src/interface/widgets/utility/card_completer_styler.h index 04310e676..2912f7312 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.h @@ -55,7 +55,6 @@ 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 d23ce254c..57d6e8b15 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -37,7 +37,6 @@ #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" @@ -59,12 +58,13 @@ #include #include #include +#include #include +#include #include #include #include #include -#include #include #include #include @@ -606,9 +606,38 @@ 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() { - AtRiskDataWarning::warnAboutDataPathsInProgramDir(this); + warnAboutDataPathsInProgramDir(); const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; @@ -1000,8 +1029,7 @@ void MainWindow::attemptStartupAutoConnect() qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; DlgConnect dlg(this); connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword(), dlg.getStoredVerifier(), - dlg.getSaveName(), dlg.getSavePassword()); + dlg.getPlayerName(), dlg.getPassword()); } } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 4b3da716d..2b6ed5633 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -149,6 +149,9 @@ 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 03f98f7bd..04dad9243 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h @@ -12,8 +12,6 @@ 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 0c523d776..d66653fb1 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -41,8 +41,7 @@ #include AbstractClient::AbstractClient(QObject *parent) - : QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false), - serverSupportsChallengeResponse(false) + : QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(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 7efd67972..442e96eb2 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -133,7 +133,6 @@ protected: QMap pendingCommands; QString userName, password, email, country, realName, token; bool serverSupportsPasswordHash; - bool serverSupportsChallengeResponse; void setStatus(ClientStatus _status); int getNewCmdId() { @@ -167,10 +166,6 @@ 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 cbf7dbda2..feda9e3f4 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -42,8 +42,7 @@ 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(), - passwordNeedsMigration(false) + messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword() { clearNewClientFeatures(); @@ -130,11 +129,6 @@ 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; @@ -151,13 +145,7 @@ 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() && 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) { + if (!password.isEmpty() && serverSupportsPasswordHash) { auto passwordSalt = PasswordHasher::generateRandomSalt(); hashedPassword = PasswordHasher::computeHash(password, passwordSalt); cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString()); @@ -185,10 +173,7 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica if (getStatus() == StatusRegistering) { Command_Register cmdRegister; cmdRegister.set_user_name(userName.toStdString()); - if (!password.isEmpty() && serverSupportsChallengeResponse) { - hashedPassword = PasswordHasher::generatePasswordVerifier(password); - cmdRegister.set_hashed_password(hashedPassword.toStdString()); - } else if (!password.isEmpty() && serverSupportsPasswordHash) { + if (!password.isEmpty() && serverSupportsPasswordHash) { auto passwordSalt = PasswordHasher::generateRandomSalt(); hashedPassword = PasswordHasher::computeHash(password, passwordSalt); cmdRegister.set_hashed_password(hashedPassword.toStdString()); @@ -253,9 +238,7 @@ Command_Login RemoteClient::generateCommandLogin() void RemoteClient::doLogin() { - if ((!password.isEmpty() || !storedVerifier.isEmpty()) && serverSupportsChallengeResponse) { - doRequestPasswordSalt(); // ask salt + nonce to build the challenge response - } else if (!password.isEmpty() && serverSupportsPasswordHash) { + 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 @@ -289,30 +272,6 @@ 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(); @@ -325,70 +284,7 @@ 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(); @@ -413,14 +309,6 @@ 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)); @@ -677,8 +565,6 @@ void RemoteClient::doDisconnectFromServer() websocket->close(); } socket->close(); - derivedVerifier.clear(); - pendingVerifier.clear(); } void RemoteClient::ping() @@ -850,12 +736,6 @@ 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 dd91c4c6d..4d8c24fd2 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -66,11 +66,6 @@ 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(); @@ -97,8 +92,6 @@ 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); @@ -130,14 +123,6 @@ 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); @@ -176,12 +161,6 @@ 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 949d04b4b..a4c904c01 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -257,19 +257,6 @@ 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; @@ -644,14 +631,6 @@ 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 63989bbfa..73b13e23e 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, const QString &channelId) +void Server::externalRoomSay(int roomId, const QString &userName, const QString &message) { // This function is always called from the main thread via signal/slot. QReadLocker locker(&roomsLock); @@ -451,22 +451,10 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString qDebug() << "externalRoomSay: room id=" << roomId << "not found"; return; } - 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); + room->say(userName, message, false); - 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); - } + getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom, + room->getId(), room->getName()); } 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 9e05750ac..932a136ff 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -87,11 +87,6 @@ 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; @@ -270,7 +265,7 @@ protected slots: void externalUserLeft(const QString &userName); void externalRoomUserJoined(int roomId, const ServerInfo_User &userInfo); void externalRoomUserLeft(int roomId, const QString &userName); - void externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId); + void externalRoomSay(int roomId, const QString &userName, const QString &message); 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 936c48880..e0be7990b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -53,14 +53,6 @@ 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(); @@ -159,8 +151,7 @@ public: MessageTargetRoom, MessageTargetGame, MessageTargetChat, - MessageTargetIslRoom, - MessageTargetRoomChannel + MessageTargetIslRoom }; virtual void logMessage(const int /* senderId */, const QString & /* senderName */, @@ -168,8 +159,7 @@ public: const QString & /* logMessage */, LogMessage_TargetType /* targetType */, const int /* targetId */, - const QString & /* targetName */, - const QString & /* channel */ = QString()) + const QString & /* targetName */) { } virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */, @@ -198,16 +188,6 @@ 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 eb2b2fc1c..fbca9589a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1,8 +1,5 @@ #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" @@ -16,7 +13,6 @@ static constexpr qint64 kAuthNonceLifetimeSeconds = 60; #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" @@ -44,7 +40,6 @@ static constexpr qint64 kAuthNonceLifetimeSeconds = 60; #include #include #include -#include #include #include #include @@ -75,28 +70,6 @@ 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). @@ -279,9 +252,6 @@ 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; @@ -593,20 +563,6 @@ 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; @@ -808,7 +764,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, true, userInfo); + room->getInfo(*re->add_room_list(), false, true); QListIterator gameIterator(room->getGamesOfUser(nameFromStdString(cmd.user_name()))); while (gameIterator.hasNext()) { re->add_game_list()->CopyFrom(gameIterator.next()); @@ -866,7 +822,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, false, true, userInfo); + roomIterator.next().value()->getInfo(*event.add_room_list(), false); } rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); @@ -900,8 +856,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo rooms.insert(room->getId(), room); QReadLocker chatHistoryLocker(&room->historyLock); - const QList mainChatHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY); - for (const ServerInfo_ChatMessage &chatMessage : mainChatHistory) { + QList chatHistory = room->getChatHistory(); + ServerInfo_ChatMessage chatMessage; + for (int i = 0; i < chatHistory.size(); ++i) { + chatMessage = chatHistory.at(i); Event_RoomSay roomChatHistory; roomChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message()); roomChatHistory.set_message_type(Event_RoomSay::ChatHistory); @@ -910,31 +868,13 @@ 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, false, true, userInfo); + room->getInfo(*re->mutable_room_info(), true); rc.setResponseExtension(re); return Response::RespOk; @@ -1019,36 +959,6 @@ 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 c0a1ffe9a..a12706757 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -4,8 +4,6 @@ #include "server.h" #include "server_abstractuserinterface.h" -#include -#include #include #include #include @@ -32,7 +30,6 @@ class Command_ListRooms; class Command_JoinRoom; class Command_LeaveRoom; class Command_RoomSay; -class Command_RoomChannelSay; class Command_CreateGame; class Command_JoinGame; @@ -49,9 +46,6 @@ protected: bool acceptsUserListChanges; bool acceptsRoomListChanges; bool idleClientWarningSent; - QByteArray authNonce; - QString authNonceUser; - QDateTime authNonceCreated; virtual void logDebugMessage(const QString & /* message */) { } @@ -72,8 +66,6 @@ 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); @@ -129,13 +121,6 @@ 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 68b137cd4..614de8d63 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -26,20 +26,16 @@ #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, @@ -49,11 +45,10 @@ Server_Room::Server_Room(int _id, bool _autoJoin, const QString &_joinMessage, const QStringList &_gameTypes, - Server *parent, - const QList &_channels) + Server *parent) : QObject(parent), id(_id), chatHistorySize(_chatHistorySize), name(_name), description(_description), permissionLevel(_permissionLevel), privilegeLevel(_privilegeLevel), autoJoin(_autoJoin), - joinMessage(_joinMessage), gameTypes(_gameTypes), channels(_channels), gamesLock(QReadWriteLock::Recursive) + joinMessage(_joinMessage), gameTypes(_gameTypes), gamesLock(QReadWriteLock::Recursive) { connect( this, &Server_Room::gameListChanged, this, [this](auto gameInfo) { broadcastGameListUpdate(gameInfo); }, @@ -107,11 +102,8 @@ 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 ServerInfo_User *forUser) const +const ServerInfo_Room & +Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, bool includeExternalData) const { result.set_room_id(id); result.set_name(name.toStdString()); @@ -160,73 +152,9 @@ const ServerInfo_Room &Server_Room::getInfo(ServerInfo_Room &result, } } - 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; @@ -382,35 +310,12 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam return result; } -void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl) +void Server_Room::say(const QString &userName, const QString &userMessage, bool sendToIsl) { - const bool isMain = isMainChannel(channelId); - ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public; - if (!isMain) { - const ServerInfo_RoomChannel *channel = findChannel(channelId); - if (channel == nullptr) { - // The channel was not whitelisted against the catalog; drop the message. - return; - } - accessLevel = channel->access_level(); - // V1 does not relay restricted channels across ISL (plan N5). - if (accessLevel != ServerInfo_RoomChannel::Public) { - sendToIsl = false; - } - } - - if (isMain) { - Event_RoomSay event; - event.set_name(userName.toStdString()); - event.set_message(userMessage.toStdString()); - sendRoomEvent(prepareRoomEvent(event), sendToIsl, 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); - } + Event_RoomSay event; + event.set_name(userName.toStdString()); + event.set_message(userMessage.toStdString()); + sendRoomEvent(prepareRoomEvent(event), sendToIsl); if (chatHistorySize != 0) { ServerInfo_ChatMessage chatMessage; @@ -419,18 +324,13 @@ void Server_Room::say(const QString &userName, const QString &userMessage, const chatMessage.set_time(dateTimeString.toStdString()); chatMessage.set_sender_name(userName.toStdString()); chatMessage.set_message(userMessage.simplified().toStdString()); - if (!isMain) { - chatMessage.set_channel(channelId.toStdString()); - } - const QString storageKey = isMain ? MAIN_CHANNEL_KEY : channelId; historyLock.lockForWrite(); - QList &history = channelChatHistory[storageKey]; - if (history.size() >= chatHistorySize) { - history.removeAt(0); + if (chatHistory.size() >= chatHistorySize) { + chatHistory.removeAt(0); } - history.push_back(std::move(chatMessage)); + chatHistory.push_back(std::move(chatMessage)); historyLock.unlock(); } } @@ -444,34 +344,26 @@ 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 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; - } + // 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; } } historyLock.unlock(); } } -void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl, ServerInfo_RoomChannel::AccessLevel accessLevel) +void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl) { usersLock.lockForRead(); { QMapIterator userIterator(users); while (userIterator.hasNext()) { - Server_ProtocolHandler *user = userIterator.next().value(); - if (!hasChannelPrivilege(*user->getUserInfo(), accessLevel)) { - continue; - } - user->sendProtocolItem(*event); + userIterator.next().value()->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 b6e6f269a..5933894d5 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -9,7 +9,6 @@ #include #include #include -#include #include class Server_ProtocolHandler; @@ -48,18 +47,15 @@ private: bool autoJoin; QString joinMessage; QStringList gameTypes; - QList channels; QMap games; QMap externalGames; QMap users; QMap externalUsers; - QMap> channelChatHistory; + QList chatHistory; 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; @@ -72,8 +68,7 @@ public: bool _autoJoin, const QString &_joinMessage, const QStringList &_gameTypes, - Server *parent, - const QList &_channels = {}); + Server *parent); ~Server_Room() override; int getId() const { @@ -108,35 +103,6 @@ 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; @@ -146,14 +112,14 @@ public: return externalGames; } Server *getServer() const; - const ServerInfo_Room &getInfo(ServerInfo_Room &result, - bool complete, - bool showGameTypes = false, - bool includeExternalData = true, - const ServerInfo_User *forUser = nullptr) const; + const ServerInfo_Room & + getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const; int getGamesCreatedByUser(const QString &name) const; QList getGamesOfUser(const QString &name) const; - QList getChatHistory(const QString &channelId) const; + QList &getChatHistory() + { + return chatHistory; + } void addClient(Server_ProtocolHandler *client); void removeClient(Server_ProtocolHandler *client); @@ -170,15 +136,13 @@ public: ResponseContainer &rc, Server_AbstractUserInterface *userInterface); - void say(const QString &userName, const QString &s, const QString &channelId = QString(), bool sendToIsl = true); + void say(const QString &userName, const QString &s, 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, - ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public); + void sendRoomEvent(RoomEvent *event, bool sendToIsl = true); RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent); }; diff --git a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp index b0d67c00d..0f8fd4210 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp @@ -26,8 +26,7 @@ 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("challenge_response_auth", false); + // featureList.insert("hashed_password_login", 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 bd0b5bda2..4e4f0e190 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -117,7 +117,6 @@ 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 @@ -202,7 +201,6 @@ 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 deleted file mode 100644 index c8de1a799..000000000 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_room_channel_say.proto +++ /dev/null @@ -1,12 +0,0 @@ -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 1f1775063..987ab20d1 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_server_identification.proto @@ -13,10 +13,4 @@ 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 aeb3eade2..3fc228530 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_password_salt.proto @@ -5,23 +5,5 @@ 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 9d9c6d277..a8c90ec6c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -5,7 +5,6 @@ message RoomCommand { ROOM_SAY = 1001; CREATE_GAME = 1002; JOIN_GAME = 1003; - ROOM_CHANNEL_SAY = 1004; } extensions 100 to max; } @@ -23,14 +22,6 @@ 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 ec6c4f636..9fa70e7e1 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_event.proto @@ -6,7 +6,6 @@ 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 b6d35a32d..d47ec4fc7 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_chat_message.proto @@ -13,5 +13,4 @@ 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 8e99de48f..c1c2e363b 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room.proto @@ -2,7 +2,6 @@ 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; @@ -16,5 +15,4 @@ 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 deleted file mode 100644 index 0f3a9ccad..000000000 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_room_channel.proto +++ /dev/null @@ -1,25 +0,0 @@ -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 10aefe7b5..4b7fe9c85 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -36,7 +36,6 @@ 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; @@ -232,14 +231,3 @@ 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 10567f2ff..d9ae0ddac 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp @@ -31,16 +31,6 @@ 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(); @@ -117,16 +107,6 @@ 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 d107dfb67..e00da4527 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h @@ -19,8 +19,6 @@ 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; @@ -37,8 +35,6 @@ 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 4595da2a2..c5c4b2592 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -155,19 +155,6 @@ 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 38bb02876..40648baa9 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -51,8 +51,6 @@ 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 9b21a071f..5e6fa986d 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -13,7 +13,6 @@ 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 ) @@ -36,7 +35,6 @@ 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 871ee1357..fb59022de 100644 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -1,16 +1,8 @@ #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) @@ -31,99 +23,4 @@ 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 d12200bc7..dba9dc37d 100644 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h @@ -2,21 +2,12 @@ #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 a7106aadc..b4a412fa6 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -4,9 +4,6 @@ #include #include #include -#include -#include -#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -57,114 +54,3 @@ 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 f46d2f21a..8ad83f089 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.h +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.h @@ -1,58 +1,14 @@ #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 deleted file mode 100644 index aa7095545..000000000 --- a/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp +++ /dev/null @@ -1,405 +0,0 @@ -#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 deleted file mode 100644 index 4a4dc1d7f..000000000 --- a/libcockatrice_utility/libcockatrice/utility/user_data_backup.h +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @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 deleted file mode 100644 index ad6477c46..000000000 --- a/servatrice/migrations/servatrice_0037_to_0038.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 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 deleted file mode 100644 index 0cad5f494..000000000 --- a/servatrice/migrations/servatrice_0038_to_0039.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 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 deleted file mode 100644 index 8298d03f9..000000000 --- a/servatrice/migrations/servatrice_0039_to_0040.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 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 68478810b..f802f0244 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -332,40 +332,6 @@ 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] @@ -420,48 +386,6 @@ 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 e942e4e91..4fbc1d8bd 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(40); +INSERT INTO cockatrice_schema_version VALUES(37); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -31,9 +31,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `admin` tinyint(1) NOT NULL, `name` varchar(35) NOT NULL, `realname` varchar(255) NOT NULL, - `password_sha512` varchar(255) NOT NULL, - `password_legacy_backup` varchar(255) NOT NULL DEFAULT '', - `password_legacy_backup_used_at` datetime DEFAULT NULL, + `password_sha512` char(120) NOT NULL, `email` varchar(255) NOT NULL, `country` char(2) NOT NULL, `avatar_bmp` mediumblob NOT NULL, @@ -160,18 +158,6 @@ 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, @@ -343,10 +329,9 @@ 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', 'room_channel'), + `target_type` ENUM('room', 'game', 'chat'), `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 8733c9a40..a78ee9e72 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -36,7 +36,6 @@ #include #include #include -#include #include #include #include @@ -384,14 +383,7 @@ 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()), - 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())); + emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message())); } void IslInterface::roomEvent_ListGames(int roomId, const Event_ListGames &event) @@ -467,9 +459,6 @@ 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 c1d9334a5..f7680c0a2 100644 --- a/servatrice/src/isl_interface.h +++ b/servatrice/src/isl_interface.h @@ -26,7 +26,6 @@ class Event_UserLeft; class Event_JoinRoom; class Event_LeaveRoom; class Event_RoomSay; -class Event_RoomChannelSay; class Event_ListGames; class Event_RemoveMessages; class Command_JoinGame; @@ -50,7 +49,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, QString channelId); + void externalRoomSay(int roomId, QString userName, QString message); 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); @@ -80,7 +79,6 @@ 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 a306de523..a832c1652 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -45,8 +45,6 @@ #include #include #include -#include -#include #include #include #include @@ -67,9 +65,7 @@ #include #include #include -#include #include -#include #include #include #include @@ -77,10 +73,6 @@ 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, @@ -257,37 +249,6 @@ 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() { @@ -399,65 +360,10 @@ 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, channels)); + query->value(6).toString(), gameTypes, this)); } } else { int size = settingsCache->beginReadArray("rooms/roomlist"); @@ -470,61 +376,12 @@ 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, channels); + settingsCache->value("joinmessage").toString(), gameTypes, this); addRoom(newRoom); } @@ -618,10 +475,6 @@ 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 = @@ -1011,14 +864,6 @@ 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(); @@ -1095,8 +940,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, QString)), this, - SLOT(externalRoomSay(int, QString, QString, QString))); + connect(interface, SIGNAL(externalRoomSay(int, QString, QString)), this, + SLOT(externalRoomSay(int, QString, QString))); connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this, SLOT(externalRoomRemoveMessages(int, QString, int))); connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this, @@ -1211,42 +1056,6 @@ 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 119adc8b2..d5414f810 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -155,15 +155,8 @@ public: AuthenticationSql, AuthenticationPassword }; - enum AuthenticationStrictness - { - AuthenticationLegacy, - AuthenticationMixed, - AuthenticationStrict - }; private slots: void statusUpdate(); - void sweepLegacyBackups(); void shutdownTimeout(); void cleanupExpiredDeckShares(); @@ -180,7 +173,6 @@ private: DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; QTimer *deckShareCleanupClock; - QTimer *legacyBackupSweepClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -259,14 +251,6 @@ 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 { @@ -288,7 +272,6 @@ 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 04f6047fa..ac3a19c8e 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -37,7 +37,6 @@ #include #include #include -#include #include #include #include @@ -45,17 +44,6 @@ 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) { @@ -441,104 +429,13 @@ 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, storedCredential.left(16)); + hashedPassword = PasswordHasher::computeHash(password, correctPasswordSha512.left(16)); } else { hashedPassword = password; } - if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) { + if (correctPasswordSha512 == hashedPassword) { if (forceChange) { qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; return PasswordChangeRequired; @@ -734,185 +631,6 @@ 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) { @@ -1568,8 +1286,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName, - const QString &channel) + const QString &targetName) { QString targetTypeString; switch (targetType) { @@ -1579,12 +1296,6 @@ 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; @@ -1608,9 +1319,8 @@ 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, channel) values (now(), " - ":sender_id, :sender_name, :sender_ip, :log_message, :target_type, :target_id, " - ":target_name, :channel)"); + "log_message, target_type, target_id, target_name) values (now(), :sender_id, " + ":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)"); query->bindValue(":sender_id", senderId < 1 ? QVariant() : senderId); query->bindValue(":sender_name", senderName); query->bindValue(":sender_ip", senderIp); @@ -1618,7 +1328,6 @@ 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); } @@ -1631,10 +1340,7 @@ 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); @@ -1687,13 +1393,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, return false; } - 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) { + 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) { return false; } @@ -2140,9 +1846,9 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory if (room) { if (game || chat) { - queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'"); + queryString.append(" OR `target_type` = 'room'"); } else { - queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'"); + queryString.append("`target_type` = 'room'"); } } queryString.append(")"); @@ -2193,7 +1899,6 @@ 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; } @@ -2363,25 +2068,3 @@ 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 822accada..0509d1a03 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 40 +#define DATABASE_SCHEMA_VERSION 37 class Servatrice; @@ -96,18 +96,6 @@ 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; @@ -189,8 +177,7 @@ public: const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName, - const QString &channel = QString()) override; + const QString &targetName) override; bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override; bool changeUserPassword(const QString &user, const QString &oldPassword, @@ -231,7 +218,6 @@ 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 b24126d93..bf8d02fc7 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -161,7 +161,6 @@ #include #include #include -#include #include #include #include @@ -205,15 +204,7 @@ 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); @@ -418,8 +409,6 @@ 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; } @@ -3309,11 +3298,6 @@ 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); @@ -3358,30 +3342,6 @@ 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*/) { @@ -3521,6 +3481,26 @@ 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 */) { @@ -3547,7 +3527,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const return Response::RespOk; } - if (!sqlInterface->isCardArtAllowed(cardName, cardProviderId)) { + if (!isCardNameAllowed(cardName, cardProviderId)) { return Response::RespFunctionNotAllowed; } @@ -3696,11 +3676,6 @@ 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; @@ -3847,11 +3822,6 @@ 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(), @@ -3919,11 +3889,9 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd, ResponseContainer &rc) { - // 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()) { + const QString userName = nameFromStdString(cmd.user_name()); + QString passwordSalt = sqlInterface->getUserSalt(userName); + if (passwordSalt.isEmpty()) { if (server->getRegOnlyServerEnabled()) { return Response::RespRegistrationRequired; } else { @@ -3931,46 +3899,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con return Response::RespOk; } } - auto *re = new Response_PasswordSalt; - 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()); - } - + re->set_password_salt(passwordSalt.toStdString()); rc.setResponseExtension(re); return Response::RespOk; } @@ -4068,39 +3998,6 @@ 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 dd9d3f2bb..7de742c66 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -103,7 +103,6 @@ class Command_ReportUserInfo; class Command_RequestPasswordSalt; class Command_ResetUserPassword; class Command_SetCardArtParams; -class Command_SubmitPasswordVerifier; class Command_UpdateAdminNotes; class Command_ViewLogHistory; class Command_WarnUser; @@ -125,7 +124,6 @@ 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; @@ -207,7 +205,6 @@ 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 @@ -219,6 +216,7 @@ 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 b58942f3f..784aac3be 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,7 +174,6 @@ 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) @@ -274,17 +273,6 @@ 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 99f2e1400..2a504b81c 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,9 +1,7 @@ #include "gtest/gtest.h" #include #include -#include #include -#include #include #include @@ -41,204 +39,6 @@ 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 deleted file mode 100644 index 006c9fa39..000000000 --- a/tests/server_room_channels_test.cpp +++ /dev/null @@ -1,550 +0,0 @@ -/** @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 0d4cdbafe..2ba9f3fbb 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -285,32 +285,6 @@ 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 deleted file mode 100644 index b74b26fa3..000000000 --- a/tests/user_data_backup_test.cpp +++ /dev/null @@ -1,581 +0,0 @@ -#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(); -}