mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 02:53:25 -08:00
Compare commits
18 commits
1a9949452c
...
da56a37506
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da56a37506 | ||
|
|
3a4018773a | ||
|
|
a09954ca8d | ||
|
|
723ef76dfb | ||
|
|
b9d37b9b1e | ||
|
|
84ad1185b6 | ||
|
|
232b302c73 | ||
|
|
a139084ad6 | ||
|
|
155f7b0143 | ||
|
|
ce51e7147d | ||
|
|
c99113ab0e | ||
|
|
05dcde69db | ||
|
|
17d2e8d86e | ||
|
|
be38e8d429 | ||
|
|
6c660d6b21 | ||
|
|
711386574d | ||
|
|
b11b096828 | ||
|
|
bbe438b09a |
78 changed files with 5628 additions and 729 deletions
|
|
@ -15,8 +15,17 @@ Var ReinstallMode
|
|||
Var AllowForceClose
|
||||
Var GuardDir
|
||||
Var OldUninstallString
|
||||
Var OldInstallDir
|
||||
Var DataBackupDir
|
||||
Var DataBackupDone
|
||||
Var DataBackupForDir
|
||||
Var LooseDecksBackupDone
|
||||
Var InstallSubdirsBackupDone
|
||||
Var LooseDeckNoticeShown
|
||||
Var StaleFindHandle
|
||||
Var StaleFindName
|
||||
Var DataBackupFindHandle
|
||||
Var DataBackupFindName
|
||||
Var IsUpgrade
|
||||
Var AbortReason
|
||||
Var WaitPid
|
||||
|
|
@ -125,6 +134,17 @@ FunctionEnd
|
|||
Function .onInit
|
||||
|
||||
StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log"
|
||||
; No backup has been taken yet; RestoreUserDataAfterUninstall must be a no-op
|
||||
; until BackupUserDataBeforeUninstall sets this after actually copying something.
|
||||
StrCpy $DataBackupDir ""
|
||||
StrCpy $DataBackupDone ""
|
||||
StrCpy $DataBackupForDir ""
|
||||
StrCpy $LooseDecksBackupDone ""
|
||||
StrCpy $InstallSubdirsBackupDone ""
|
||||
StrCpy $LooseDeckNoticeShown ""
|
||||
; No old install location resolved yet; Backup/Restore fall back to $INSTDIR
|
||||
; when this is empty (the /R path where $INSTDIR already is the old dir).
|
||||
StrCpy $OldInstallDir ""
|
||||
; Start each run from a clean file so the log that gets sent back is the last
|
||||
; run, not every past run glued together.
|
||||
FileOpen $LogHandle "$InstallLogPath" w
|
||||
|
|
@ -314,10 +334,15 @@ SetShellVarContext all
|
|||
SetRegView 32
|
||||
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
|
||||
|
||||
!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'"
|
||||
; On the /R path the updater passes /D=<appDir>, so $INSTDIR already is the old
|
||||
; install dir. Publish it for the backup/restore pair below.
|
||||
StrCpy $OldInstallDir $INSTDIR
|
||||
!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$OldInstallDir'"
|
||||
StrCmp $R0 "" done32
|
||||
DetailPrint "Removing previous version (32-bit)..."
|
||||
ExecWait '$R0 _?=$INSTDIR'
|
||||
Call BackupUserDataBeforeUninstall
|
||||
ExecWait '$R0 _?=$OldInstallDir'
|
||||
Call RestoreUserDataAfterUninstall
|
||||
|
||||
done32:
|
||||
|
||||
|
|
@ -326,16 +351,312 @@ ${If} ${RunningX64}
|
|||
SetRegView 64
|
||||
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
|
||||
|
||||
!insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'"
|
||||
StrCpy $OldInstallDir $INSTDIR
|
||||
!insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'"
|
||||
StrCmp $R0 "" done64
|
||||
DetailPrint "Removing previous version (64-bit)..."
|
||||
ExecWait '$R0 _?=$INSTDIR'
|
||||
Call BackupUserDataBeforeUninstall
|
||||
ExecWait '$R0 _?=$OldInstallDir'
|
||||
Call RestoreUserDataAfterUninstall
|
||||
|
||||
done64:
|
||||
${EndIf}
|
||||
|
||||
FunctionEnd
|
||||
|
||||
; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall
|
||||
;
|
||||
; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR".
|
||||
; For a portable install that deletes $INSTDIR\data (decks, settings, pictures)
|
||||
; along with the payload, and for a normal install it deletes anything the user
|
||||
; pointed into $INSTDIR. The fixed uninstaller preserves foreign files, but the
|
||||
; destructive one is already on disk on affected machines, so stash a copy
|
||||
; outside $INSTDIR before invoking it and put it back afterwards. Best effort
|
||||
; only: a failed backup must never abort the install.
|
||||
;
|
||||
; The backup is left in %TEMP% afterwards on a best-effort basis only: the next
|
||||
; installer run that takes a new backup wipes it first (see below), so it is not
|
||||
; a durable second copy. Anything this backup does not cover - e.g. loose files
|
||||
; with other extensions - relies on the client's startup safety backup
|
||||
; (warnAboutDataPathsInProgramDir), which only exists after the first launch of
|
||||
; the new client.
|
||||
;
|
||||
; The 32-bit and 64-bit uninstall blocks below share $TEMP\CockatriceDataBackup.
|
||||
; When both are registered with different InstallLocations, backing up the second
|
||||
; directory into the first one's backup would restore the first dir's files into
|
||||
; the second one. BackupUserDataBeforeUninstall therefore starts over when
|
||||
; $OldInstallDir changes: the previous pass already restored its own backup, so
|
||||
; only its leftover %TEMP% copy is lost. RelocateRestoredDataToInstDir only sees
|
||||
; the last pass, so with two different old dirs plus a new target dir the first
|
||||
; dir's restored data stays in place instead of moving along - no loss, but the
|
||||
; user has to copy it manually.
|
||||
;
|
||||
; The portable-data branch below is defensive: both callers (AutoUninstallIfNeeded
|
||||
; on /R, componentsPagePre interactively) only run for non-portable installs, and
|
||||
; portable installs never write the registry uninstall keys that trigger them.
|
||||
; It fires only when $OldInstallDir nevertheless contains portable.dat - e.g. a
|
||||
; stale registry InstallLocation pointing at a portable dir, or portable.dat added
|
||||
; to a normal install dir. The loose-decks and install-subdirs branches are the
|
||||
; live ones for normal installs.
|
||||
Function BackupUserDataBeforeUninstall
|
||||
; $OldInstallDir is the directory being uninstalled: on the /R path it equals
|
||||
; $INSTDIR (updater passes /D=<appDir>), on the interactive path componentsPagePre
|
||||
; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is
|
||||
; still the default there (directory page comes later).
|
||||
${If} $OldInstallDir == ""
|
||||
StrCpy $OldInstallDir $INSTDIR
|
||||
${EndIf}
|
||||
; %TEMP% survives across installer runs, so a previous run's safety copy would
|
||||
; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files
|
||||
; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B
|
||||
; from run 2) or restoring stale content after a failed copy. Wipe once per
|
||||
; installer run - $DataBackupDir is empty until the first backup of this run,
|
||||
; so the second call in sequence (32-bit then 64-bit uninstall) still merges.
|
||||
${If} $DataBackupDir == ""
|
||||
RMDir /r "$TEMP\CockatriceDataBackup"
|
||||
${EndIf}
|
||||
; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second
|
||||
; block points at a different directory, merging would restore the first dir's
|
||||
; files into the second one, so start over instead. The previous pass already
|
||||
; restored its own backup; only its leftover %TEMP% copy is discarded.
|
||||
; StrCmp is case-sensitive while Windows paths are not, so a casing-only
|
||||
; difference triggers a harmless redundant re-backup of the same content.
|
||||
${If} $DataBackupDir != ""
|
||||
${AndIf} $DataBackupForDir != ""
|
||||
${AndIf} $OldInstallDir != $DataBackupForDir
|
||||
!insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup"
|
||||
RMDir /r "$TEMP\CockatriceDataBackup"
|
||||
StrCpy $DataBackupDir ""
|
||||
StrCpy $DataBackupDone ""
|
||||
StrCpy $LooseDecksBackupDone ""
|
||||
StrCpy $InstallSubdirsBackupDone ""
|
||||
${EndIf}
|
||||
${If} ${FileExists} "$OldInstallDir\portable.dat"
|
||||
${AndIf} ${FileExists} "$OldInstallDir\data"
|
||||
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
|
||||
StrCpy $DataBackupDone "1"
|
||||
!insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)"
|
||||
DetailPrint "Backing up portable user data before removing previous version..."
|
||||
CreateDirectory "$DataBackupDir\data"
|
||||
; Copy every top-level entry except pics (downloaded pictures are large and
|
||||
; re-downloadable): copying everything first and deleting pics afterwards
|
||||
; would duplicate gigabytes into %TEMP% just to throw them away.
|
||||
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*"
|
||||
${If} $DataBackupFindHandle != ""
|
||||
backup_data_loop:
|
||||
${If} $DataBackupFindName != "."
|
||||
${AndIf} $DataBackupFindName != ".."
|
||||
${AndIf} $DataBackupFindName != "pics"
|
||||
${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*"
|
||||
CreateDirectory "$DataBackupDir\data\$DataBackupFindName"
|
||||
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\"
|
||||
${Else}
|
||||
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
ClearErrors
|
||||
FindNext $DataBackupFindHandle $DataBackupFindName
|
||||
IfErrors 0 backup_data_loop
|
||||
FindClose $DataBackupFindHandle
|
||||
${EndIf}
|
||||
; Keep user-made custom pics (data\pics\CUSTOM).
|
||||
${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM"
|
||||
CreateDirectory "$DataBackupDir\data\pics\CUSTOM"
|
||||
CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\"
|
||||
${EndIf}
|
||||
DetailPrint "Portable user data backup complete."
|
||||
${EndIf}
|
||||
; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a
|
||||
; blanket *.txt copy would also grab payload-adjacent text files, while the
|
||||
; deck-specific extensions are unambiguous user content.
|
||||
${If} ${FileExists} "$OldInstallDir\*.cod"
|
||||
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
|
||||
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
|
||||
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
|
||||
${If} $DataBackupDir == ""
|
||||
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
|
||||
${EndIf}
|
||||
StrCpy $LooseDecksBackupDone "1"
|
||||
!insertmacro CockLog "pre-uninstall backup: copying loose deck files"
|
||||
DetailPrint "Backing up deck files stored in the install directory..."
|
||||
CreateDirectory "$DataBackupDir\loose_decks"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\"
|
||||
${EndIf}
|
||||
; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the
|
||||
; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the
|
||||
; client's startup backup has never run yet, so stash every top-level directory
|
||||
; that is not part of the payload. Payload dirs stay in place: they are rewritten
|
||||
; by the section below, and restoring them could put stale DLLs next to the new
|
||||
; executables. "data" is skipped once the portable branch above already took it;
|
||||
; without portable.dat it is user content like any other unknown dir.
|
||||
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*"
|
||||
${If} $DataBackupFindHandle != ""
|
||||
backup_subdirs_loop:
|
||||
${If} $DataBackupFindName != "."
|
||||
${AndIf} $DataBackupFindName != ".."
|
||||
${AndIf} $DataBackupFindName != "plugins"
|
||||
${AndIf} $DataBackupFindName != "Plugins"
|
||||
${AndIf} $DataBackupFindName != "translations"
|
||||
${AndIf} $DataBackupFindName != "sounds"
|
||||
${AndIf} $DataBackupFindName != "themes"
|
||||
${If} $DataBackupFindName == "data"
|
||||
${AndIf} $DataBackupDone == "1"
|
||||
Goto backup_subdirs_next
|
||||
${EndIf}
|
||||
${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
|
||||
${If} $DataBackupDir == ""
|
||||
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
|
||||
${EndIf}
|
||||
StrCpy $InstallSubdirsBackupDone "1"
|
||||
!insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName"
|
||||
DetailPrint "Backing up $DataBackupFindName stored in the install directory..."
|
||||
CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName"
|
||||
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
backup_subdirs_next:
|
||||
ClearErrors
|
||||
FindNext $DataBackupFindHandle $DataBackupFindName
|
||||
IfErrors 0 backup_subdirs_loop
|
||||
FindClose $DataBackupFindHandle
|
||||
${EndIf}
|
||||
; Remember which directory the current backup belongs to, so a later pass for a
|
||||
; different directory starts over instead of merging (see above).
|
||||
${If} $DataBackupDir != ""
|
||||
StrCpy $DataBackupForDir $OldInstallDir
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function RestoreUserDataAfterUninstall
|
||||
StrCmp $DataBackupDir "" done
|
||||
${If} $OldInstallDir == ""
|
||||
StrCpy $OldInstallDir $INSTDIR
|
||||
${EndIf}
|
||||
${If} $DataBackupDone == "1"
|
||||
${AndIf} ${FileExists} "$DataBackupDir\data"
|
||||
${If} ${FileExists} "$OldInstallDir\data"
|
||||
; Existence-only: a partially-deleted data dir (locked files during the old
|
||||
; uninstall) looks the same as a survived one, so the backup is always kept.
|
||||
; Merge the backup back over it to repair partial deletes; CopyFiles only
|
||||
; adds/overwrites, so a fully-survived dir is unaffected.
|
||||
!insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it"
|
||||
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
|
||||
DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data."
|
||||
${Else}
|
||||
!insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data"
|
||||
CreateDirectory "$OldInstallDir\data"
|
||||
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
|
||||
DetailPrint "Restored portable user data from pre-uninstall backup."
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
${If} $LooseDecksBackupDone == "1"
|
||||
${AndIf} ${FileExists} "$DataBackupDir\loose_decks"
|
||||
!insertmacro CockLog "post-uninstall restore: restoring loose deck files"
|
||||
CreateDirectory "$OldInstallDir"
|
||||
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\"
|
||||
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\"
|
||||
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\"
|
||||
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\"
|
||||
DetailPrint "Restored deck files stored in the install directory."
|
||||
${EndIf}
|
||||
${If} $InstallSubdirsBackupDone == "1"
|
||||
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
|
||||
!insertmacro CockLog "post-uninstall restore: restoring install subdirectories"
|
||||
CreateDirectory "$OldInstallDir"
|
||||
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
|
||||
${If} $DataBackupFindHandle != ""
|
||||
restore_subdirs_loop:
|
||||
${If} $DataBackupFindName != "."
|
||||
${AndIf} $DataBackupFindName != ".."
|
||||
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*"
|
||||
!insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName"
|
||||
CreateDirectory "$OldInstallDir\$DataBackupFindName"
|
||||
CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\"
|
||||
${EndIf}
|
||||
ClearErrors
|
||||
FindNext $DataBackupFindHandle $DataBackupFindName
|
||||
IfErrors 0 restore_subdirs_loop
|
||||
FindClose $DataBackupFindHandle
|
||||
${EndIf}
|
||||
DetailPrint "Restored folders stored in the install directory."
|
||||
${EndIf}
|
||||
; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the
|
||||
; notice a single time per installer run, covering both loose decks and
|
||||
; restored subdirectories.
|
||||
${IfNot} ${Silent}
|
||||
${AndIf} $LooseDeckNoticeShown != "1"
|
||||
${If} $LooseDecksBackupDone == "1"
|
||||
${OrIf} $InstallSubdirsBackupDone == "1"
|
||||
StrCpy $LooseDeckNoticeShown "1"
|
||||
MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again."
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
; RelocateRestoredDataToInstDir
|
||||
;
|
||||
; componentsPagePre backs up and restores around the old uninstaller before the
|
||||
; directory page, so $OldInstallDir (registry InstallLocation) is authoritative
|
||||
; there while $INSTDIR is still the default. When the user then picks a different
|
||||
; target directory, the restored user data would otherwise stay orphaned in the
|
||||
; old directory while the new install starts empty. Copy it across when they differ
|
||||
; (a copy, not a move: the source is user data and is deliberately left in place).
|
||||
; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup
|
||||
; comment above); with two different old dirs the first one's data stays restored
|
||||
; in place instead of moving along.
|
||||
; StrCmp is case-sensitive while Windows paths are not; a casing-only difference
|
||||
; triggers a harmless redundant copy onto itself.
|
||||
Function RelocateRestoredDataToInstDir
|
||||
StrCmp $DataBackupDir "" done
|
||||
StrCmp $OldInstallDir "" done
|
||||
StrCmp $OldInstallDir $INSTDIR done
|
||||
${If} $DataBackupDone == "1"
|
||||
${AndIf} ${FileExists} "$OldInstallDir\data"
|
||||
!insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data"
|
||||
CreateDirectory "$INSTDIR\data"
|
||||
CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\"
|
||||
DetailPrint "Copied restored user data to the new install folder."
|
||||
${EndIf}
|
||||
${If} $LooseDecksBackupDone == "1"
|
||||
${If} ${FileExists} "$OldInstallDir\*.cod"
|
||||
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
|
||||
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
|
||||
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
|
||||
!insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR"
|
||||
CreateDirectory "$INSTDIR"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\"
|
||||
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\"
|
||||
DetailPrint "Copied restored deck files to the new install folder."
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
${If} $InstallSubdirsBackupDone == "1"
|
||||
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
|
||||
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
|
||||
${If} $DataBackupFindHandle != ""
|
||||
relocate_subdirs_loop:
|
||||
${If} $DataBackupFindName != "."
|
||||
${AndIf} $DataBackupFindName != ".."
|
||||
${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
|
||||
!insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName"
|
||||
CreateDirectory "$INSTDIR\$DataBackupFindName"
|
||||
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\"
|
||||
DetailPrint "Copied restored folder $DataBackupFindName to the new install folder."
|
||||
${EndIf}
|
||||
ClearErrors
|
||||
FindNext $DataBackupFindHandle $DataBackupFindName
|
||||
IfErrors 0 relocate_subdirs_loop
|
||||
FindClose $DataBackupFindHandle
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
; --- Running instance handling ---
|
||||
; Cockatrice, Oracle and Servatrice must not be running while files are
|
||||
; replaced or deleted. A still-running process holds locks on its .exe and
|
||||
|
|
@ -1037,11 +1358,17 @@ ${If} $PortableMode = 0
|
|||
; instance of the application is still running out of the *old* install
|
||||
; directory before letting it delete anything. The uninstall string is
|
||||
; stashed away first, the check works on $R0.
|
||||
; $INSTDIR is still the default here (directory page comes later), so the
|
||||
; backup/restore dir comes from the registry InstallLocation via $GuardDir,
|
||||
; not from $INSTDIR - otherwise a custom-location install is unprotected.
|
||||
StrCpy $OldUninstallString $R0
|
||||
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'"
|
||||
Call EnsureOldInstallNotRunning
|
||||
StrCpy $OldInstallDir $GuardDir
|
||||
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
|
||||
ClearErrors
|
||||
ExecWait "$OldUninstallString _?=$INSTDIR"
|
||||
Call BackupUserDataBeforeUninstall
|
||||
ExecWait "$OldUninstallString _?=$OldInstallDir"
|
||||
Call RestoreUserDataAfterUninstall
|
||||
|
||||
done32:
|
||||
|
||||
|
|
@ -1061,10 +1388,13 @@ ${If} $PortableMode = 0
|
|||
|
||||
uninst64:
|
||||
StrCpy $OldUninstallString $R0
|
||||
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'"
|
||||
Call EnsureOldInstallNotRunning
|
||||
StrCpy $OldInstallDir $GuardDir
|
||||
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
|
||||
ClearErrors
|
||||
ExecWait "$OldUninstallString _?=$INSTDIR"
|
||||
Call BackupUserDataBeforeUninstall
|
||||
ExecWait "$OldUninstallString _?=$OldInstallDir"
|
||||
Call RestoreUserDataAfterUninstall
|
||||
|
||||
done64:
|
||||
${EndIf}
|
||||
|
|
@ -1188,6 +1518,11 @@ ${EndIf}
|
|||
!insertmacro CockLog "section: writing payload to $INSTDIR"
|
||||
@CPACK_NSIS_FULL_INSTALL@
|
||||
!insertmacro CockLog "section: payload written"
|
||||
; The pre-uninstall backup/restore above runs before the directory page, so a
|
||||
; user picking a different target folder would otherwise leave the restored data
|
||||
; orphaned in the old directory. Move it across when they differ (no-op on the
|
||||
; /R path where they are equal).
|
||||
Call RelocateRestoredDataToInstDir
|
||||
!insertmacro CockLogListing "final" "Qt6*.dll"
|
||||
|
||||
${If} $PortableMode = 0
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||
src/interface/widgets/dialogs/override_printing_warning.cpp
|
||||
src/interface/widgets/dialogs/path_safety_warning.cpp
|
||||
src/interface/widgets/dialogs/settings_view.cpp
|
||||
src/interface/widgets/dialogs/tip_of_the_day.cpp
|
||||
src/interface/widgets/deck_share/deck_share_utils.cpp
|
||||
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
|
||||
|
|
@ -141,6 +142,8 @@ set(cockatrice_SOURCES
|
|||
src/game_graphics/zones/view_zone.cpp
|
||||
src/game_graphics/zones/view_zone_widget.cpp
|
||||
src/game_graphics/board/abstract_graphics_item.cpp
|
||||
src/interface/at_risk_data_warning.cpp
|
||||
src/interface/at_risk_data_warning.h
|
||||
src/interface/card_database_update/card_database_update_status_bar.cpp
|
||||
src/interface/card_database_update/card_database_update_status_bar.h
|
||||
src/interface/card_database_update/card_update_progress.cpp
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ void ConnectionController::wireClientSignals()
|
|||
|
||||
connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this,
|
||||
&ConnectionController::onPromptForgotPasswordChallenge);
|
||||
|
||||
connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this,
|
||||
&ConnectionController::onPasswordVerifierReady);
|
||||
}
|
||||
|
||||
void ConnectionController::connectToServer()
|
||||
|
|
@ -103,16 +106,32 @@ void ConnectionController::connectToServer()
|
|||
connect(dlgConnect, &DlgConnect::sigStartForgotPasswordRequest, this, &ConnectionController::forgotPasswordRequest);
|
||||
|
||||
if (dlgConnect->exec()) {
|
||||
pendingSaveName = dlgConnect->getSaveName();
|
||||
pendingSavePassword = dlgConnect->getSavePassword();
|
||||
remoteClient->setStoredVerifier(dlgConnect->getStoredVerifier());
|
||||
remoteClient->connectToServer(dlgConnect->getHost(), static_cast<unsigned int>(dlgConnect->getPort()),
|
||||
dlgConnect->getPlayerName(), dlgConnect->getPassword());
|
||||
}
|
||||
}
|
||||
|
||||
void ConnectionController::onPasswordVerifierReady(const QString &verifier)
|
||||
{
|
||||
if (pendingSavePassword) {
|
||||
SettingsCache::instance().servers().setServerPassword(pendingSaveName, verifier);
|
||||
}
|
||||
}
|
||||
|
||||
void ConnectionController::connectToServerDirect(const QString &host,
|
||||
unsigned int port,
|
||||
const QString &playerName,
|
||||
const QString &password)
|
||||
const QString &password,
|
||||
const QString &storedVerifier,
|
||||
const QString &saveName,
|
||||
bool savePassword)
|
||||
{
|
||||
pendingSaveName = saveName;
|
||||
pendingSavePassword = savePassword;
|
||||
remoteClient->setStoredVerifier(storedVerifier);
|
||||
remoteClient->connectToServer(host, port, playerName, password);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,13 @@ public:
|
|||
void registerToServer();
|
||||
void forgotPasswordRequest();
|
||||
void connectToServer();
|
||||
void
|
||||
connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, const QString &password);
|
||||
void connectToServerDirect(const QString &host,
|
||||
unsigned int port,
|
||||
const QString &playerName,
|
||||
const QString &password,
|
||||
const QString &storedVerifier = QString(),
|
||||
const QString &saveName = QString(),
|
||||
bool savePassword = false);
|
||||
void disconnectFromServer();
|
||||
|
||||
void refreshWindowTitle()
|
||||
|
|
@ -86,6 +91,9 @@ private slots:
|
|||
void onPromptForgotPasswordReset();
|
||||
void onPromptForgotPasswordChallenge();
|
||||
|
||||
// Persists the derived scrypt verifier after a successful challenge-response login
|
||||
void onPasswordVerifierReady(const QString &verifier);
|
||||
|
||||
private:
|
||||
void wireClientSignals();
|
||||
void updateWindowTitle();
|
||||
|
|
@ -102,6 +110,10 @@ private:
|
|||
|
||||
// Kept as a member so the forgot-password signal can be wired to it
|
||||
DlgConnect *dlgConnect{nullptr};
|
||||
|
||||
// Captured from the connect dialog when a connection is initiated
|
||||
QString pendingSaveName;
|
||||
bool pendingSavePassword{false};
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H
|
||||
|
|
|
|||
|
|
@ -354,6 +354,19 @@ void DeckViewContainer::resolveAndSendPlaymat()
|
|||
pp->set_vertical_offset(resolved.params.verticalOffset);
|
||||
pp->set_zoom(resolved.params.zoom);
|
||||
PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd);
|
||||
const QString sentName = resolved.card.name;
|
||||
connect(playmatPend, &PendingCommand::finished, this, [this, sentName](const Response &r) {
|
||||
// Only RespFunctionNotAllowed signals a card art DENY rule. Older servers that predate
|
||||
// playmats answer RespInvalidCommand and strip the mat from DeckSelect, which must stay silent.
|
||||
if (r.response_code() == Response::RespFunctionNotAllowed) {
|
||||
if (!sentName.isEmpty()) {
|
||||
notifyPlaymatBlacklisted(
|
||||
sentName, tr("The selected playmat card is blacklisted on this server, so it was not applied."));
|
||||
}
|
||||
} else if (r.response_code() == Response::RespOk && !sentName.isEmpty()) {
|
||||
lastBlacklistedPlaymatNotice.clear();
|
||||
}
|
||||
});
|
||||
parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId);
|
||||
}
|
||||
|
||||
|
|
@ -412,6 +425,15 @@ void DeckViewContainer::deckSelectFinished(const Response &r)
|
|||
switchToDeckLoadedView();
|
||||
}
|
||||
|
||||
void DeckViewContainer::notifyPlaymatBlacklisted(const QString &cardName, const QString &message)
|
||||
{
|
||||
if (cardName.isEmpty() || cardName == lastBlacklistedPlaymatNotice) {
|
||||
return;
|
||||
}
|
||||
lastBlacklistedPlaymatNotice = cardName;
|
||||
QMessageBox::critical(this, tr("Error"), message);
|
||||
}
|
||||
|
||||
void DeckViewContainer::readyStart()
|
||||
{
|
||||
sendReadyStartCommand(!readyStartButton->getState());
|
||||
|
|
|
|||
|
|
@ -60,12 +60,14 @@ private:
|
|||
VisualDeckStorageWidget *visualDeckStorageWidget;
|
||||
TabGame *parentGame;
|
||||
int playerId;
|
||||
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
|
||||
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
|
||||
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
|
||||
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
|
||||
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
|
||||
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
|
||||
QString lastBlacklistedPlaymatNotice; ///< Card name already reported as blacklisted, to avoid repeat popups.
|
||||
|
||||
void tryCreateVisualDeckStorageWidget();
|
||||
void sendReadyStartCommand(bool ready);
|
||||
void notifyPlaymatBlacklisted(const QString &cardName, const QString &message);
|
||||
private slots:
|
||||
void switchToDeckSelectView();
|
||||
void switchToDeckLoadedView();
|
||||
|
|
|
|||
215
cockatrice/src/interface/at_risk_data_warning.cpp
Normal file
215
cockatrice/src/interface/at_risk_data_warning.cpp
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
#include "at_risk_data_warning.h"
|
||||
|
||||
#include "../client/settings/cache_settings.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMessageBox>
|
||||
#include <QWidget>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/utility/user_data_backup.h>
|
||||
|
||||
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);
|
||||
}
|
||||
50
cockatrice/src/interface/at_risk_data_warning.h
Normal file
50
cockatrice/src/interface/at_risk_data_warning.h
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#ifndef AT_RISK_DATA_WARNING_H
|
||||
#define AT_RISK_DATA_WARNING_H
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
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
|
||||
|
|
@ -289,9 +289,15 @@ void DlgConnect::updateDisplayInfo(const QString &saveName)
|
|||
playernameEdit->setText(_data.at(3));
|
||||
playernameEdit->setFocus();
|
||||
savePasswordCheckBox->setChecked(savePasswordStatus);
|
||||
storedVerifier.clear();
|
||||
|
||||
if (savePasswordStatus) {
|
||||
passwordEdit->setText(_data.at(4));
|
||||
const QString stored = _data.at(4);
|
||||
if (stored.startsWith("$")) {
|
||||
storedVerifier = stored;
|
||||
} else {
|
||||
passwordEdit->setText(stored);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_data.at(6).isEmpty()) {
|
||||
|
|
@ -317,6 +323,7 @@ void DlgConnect::newHostSelected(bool state)
|
|||
portEdit->setDisabled(false);
|
||||
playernameEdit->clear();
|
||||
passwordEdit->clear();
|
||||
storedVerifier.clear();
|
||||
saveEdit->clear();
|
||||
saveEdit->setPlaceholderText(tr("Unique Server Name"));
|
||||
saveEdit->setDisabled(false);
|
||||
|
|
@ -342,6 +349,11 @@ void DlgConnect::actOk()
|
|||
{
|
||||
ServersSettings &servers = SettingsCache::instance().servers();
|
||||
|
||||
// Never write a newly typed plaintext password to disk when a verifier is already stored:
|
||||
// the typed value is used for this connection and a fresh verifier is persisted after a
|
||||
// successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior.
|
||||
const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier;
|
||||
|
||||
if (newHostButton->isChecked()) {
|
||||
if (saveEdit->text().isEmpty()) {
|
||||
QMessageBox::critical(this, tr("Connection Warning"), tr("You need to name your new connection profile."));
|
||||
|
|
@ -349,10 +361,10 @@ void DlgConnect::actOk()
|
|||
}
|
||||
|
||||
servers.addNewServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
|
||||
playernameEdit->text().trimmed(), passwordEdit->text(), savePasswordCheckBox->isChecked());
|
||||
playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked());
|
||||
} else {
|
||||
servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
|
||||
playernameEdit->text().trimmed(), passwordEdit->text(),
|
||||
playernameEdit->text().trimmed(), passwordToSave,
|
||||
savePasswordCheckBox->isChecked());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#ifndef DLG_CONNECT_H
|
||||
#define DLG_CONNECT_H
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QMap>
|
||||
|
|
@ -51,6 +52,22 @@ public:
|
|||
{
|
||||
return passwordEdit->text();
|
||||
}
|
||||
//! \brief Stored "$scrypt$..." verifier for challenge-response servers (never the plaintext password).
|
||||
[[nodiscard]] QString getStoredVerifier() const
|
||||
{
|
||||
return storedVerifier;
|
||||
}
|
||||
[[nodiscard]] QString getSaveName() const
|
||||
{
|
||||
// Trim so the profile lookup key always matches the name DlgConnect::actOk
|
||||
// stored (it saves under saveEdit->text().trimmed()); otherwise a trailing
|
||||
// space here misses the index and silently drops the saved verifier.
|
||||
return saveEdit->text().trimmed();
|
||||
}
|
||||
[[nodiscard]] bool getSavePassword() const
|
||||
{
|
||||
return savePasswordCheckBox->isChecked();
|
||||
}
|
||||
|
||||
public slots:
|
||||
void downloadThePublicServers();
|
||||
|
|
@ -81,6 +98,7 @@ private:
|
|||
QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer;
|
||||
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
|
||||
HandlePublicServers *hps;
|
||||
QString storedVerifier;
|
||||
const QString placeHolderText = tr("Downloading...");
|
||||
};
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent)
|
|||
|
||||
auto &servers = SettingsCache::instance().servers();
|
||||
if (servers.getSavePassword()) {
|
||||
oldPasswordEdit->setText(servers.getPassword());
|
||||
const QString stored = servers.getPassword();
|
||||
if (!stored.startsWith("$")) {
|
||||
oldPasswordEdit->setText(stored);
|
||||
}
|
||||
}
|
||||
|
||||
oldPasswordLabel->setBuddy(oldPasswordEdit);
|
||||
|
|
|
|||
|
|
@ -1,205 +1,81 @@
|
|||
/**
|
||||
* @file dlg_settings.cpp
|
||||
* @brief Implementation of the main settings dialog
|
||||
* @brief Implementation of the main settings dialog (thin frame around SettingsView)
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#include "dlg_settings.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../pixel_map_generator.h"
|
||||
#include "../main.h"
|
||||
#include "../settings_page/abstract_settings_page.h"
|
||||
#include "../settings_page/appearance_settings_page.h"
|
||||
#include "../settings_page/deck_editor_settings_page.h"
|
||||
#include "../settings_page/general_settings_page.h"
|
||||
#include "../settings_page/messages_settings_page.h"
|
||||
#include "../settings_page/settings_search_delegate.h"
|
||||
#include "../settings_page/settings_search_model.h"
|
||||
#include "../settings_page/shortcut_settings_page.h"
|
||||
#include "../settings_page/sound_settings_page.h"
|
||||
#include "../settings_page/storage_settings_page.h"
|
||||
#include "../settings_page/user_interface_settings_page.h"
|
||||
#include "libcockatrice/card/database/card_database.h"
|
||||
#include "libcockatrice/card/database/card_database_loader.h"
|
||||
#include "libcockatrice/card/database/card_database_manager.h"
|
||||
|
||||
#include <QAbstractAnimation>
|
||||
#include <QAbstractItemView>
|
||||
#include <QApplication>
|
||||
#include <QBrush>
|
||||
#include <QByteArray>
|
||||
#include <QCloseEvent>
|
||||
#include <QColor>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QEasingCurve>
|
||||
#include <QEvent>
|
||||
#include <QFlags>
|
||||
#include <QFrame>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QKeyEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QMessageBox>
|
||||
#include <QMessageLogger>
|
||||
#include <QModelIndex>
|
||||
#include <QObject>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QPushButton>
|
||||
#include <QRect>
|
||||
#include <QScreen>
|
||||
#include <QScrollArea>
|
||||
#include <QScrollBar>
|
||||
#include <QSequentialAnimationGroup>
|
||||
#include <QShortcut>
|
||||
#include <QSize>
|
||||
#include <QSizePolicy>
|
||||
#include <QStackedLayout>
|
||||
#include <QStackedWidget>
|
||||
#include <QStringLiteral>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <qnamespace.h>
|
||||
|
||||
class QModelIndex;
|
||||
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 Wraps a widget in a scroll area for long settings pages
|
||||
* @param widget The widget to wrap
|
||||
* @return The scroll area containing the widget
|
||||
* @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 QScrollArea *makeScrollable(QWidget *widget)
|
||||
static QSize calculateDialogSize(const QSize &hostSize, const QSize &availableSize, const QSize &minimumSize)
|
||||
{
|
||||
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
|
||||
|
||||
auto *scrollArea = new QScrollArea;
|
||||
scrollArea->setWidgetResizable(true);
|
||||
scrollArea->setContentsMargins(0, 0, 0, 0);
|
||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
scrollArea->horizontalScrollBar()->setEnabled(false);
|
||||
scrollArea->setWidget(widget);
|
||||
return scrollArea;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
|
||||
*/
|
||||
static QStringList pageIconResources()
|
||||
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
|
||||
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
|
||||
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
|
||||
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
|
||||
}
|
||||
// Fill the main window with small margins so multi-group pages (e.g. Deck Editor) are not buried in a scroll.
|
||||
// 90% of the host window leaves ~5% margins on each side; falls back to 90% of the screen when parentless.
|
||||
QWidget *hostWindow = parent ? parent->window() : nullptr;
|
||||
QScreen *screen = nullptr;
|
||||
QSize hostSize;
|
||||
if (hostWindow) {
|
||||
hostSize = hostWindow->size();
|
||||
screen = QGuiApplication::screenAt(hostWindow->geometry().center());
|
||||
}
|
||||
if (!screen) {
|
||||
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()));
|
||||
setMinimumSize(minimum);
|
||||
|
||||
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
|
||||
{
|
||||
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
|
||||
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
|
||||
resize(calculateDialogSize(hostSize, available.size(), minimum));
|
||||
setSizeGripEnabled(true);
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
|
||||
|
||||
setupUi();
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
|
||||
retranslateUi();
|
||||
|
||||
searchEdit->setFocus();
|
||||
|
||||
adjustSize();
|
||||
}
|
||||
|
||||
void DlgSettings::setupUi()
|
||||
{
|
||||
// Search bar
|
||||
searchEdit = new QLineEdit;
|
||||
searchEdit->setClearButtonEnabled(true);
|
||||
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
|
||||
searchEdit->installEventFilter(this);
|
||||
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
|
||||
|
||||
auto *searchLayout = new QHBoxLayout;
|
||||
searchLayout->addWidget(searchEdit);
|
||||
|
||||
// Tab bar (built in setupTabBar)
|
||||
setupTabBar();
|
||||
|
||||
// Pages stacked widget
|
||||
pagesWidget = new QStackedWidget;
|
||||
|
||||
auto *generalPage = new GeneralSettingsPage;
|
||||
auto *appearancePage = new AppearanceSettingsPage;
|
||||
auto *userInterfacePage = new UserInterfaceSettingsPage;
|
||||
auto *deckEditorPage = new DeckEditorSettingsPage;
|
||||
auto *storagePage = new StorageSettingsPage;
|
||||
auto *messagesPage = new MessagesSettingsPage;
|
||||
auto *soundPage = new SoundSettingsPage;
|
||||
auto *shortcutsPage = new ShortcutSettingsPage;
|
||||
|
||||
pages.append(generalPage);
|
||||
pages.append(appearancePage);
|
||||
pages.append(userInterfacePage);
|
||||
pages.append(deckEditorPage);
|
||||
pages.append(storagePage);
|
||||
pages.append(messagesPage);
|
||||
pages.append(soundPage);
|
||||
pages.append(shortcutsPage);
|
||||
|
||||
pagesWidget->addWidget(makeScrollable(generalPage));
|
||||
pagesWidget->addWidget(makeScrollable(appearancePage));
|
||||
pagesWidget->addWidget(makeScrollable(userInterfacePage));
|
||||
pagesWidget->addWidget(makeScrollable(deckEditorPage));
|
||||
pagesWidget->addWidget(makeScrollable(storagePage));
|
||||
pagesWidget->addWidget(makeScrollable(messagesPage));
|
||||
pagesWidget->addWidget(soundPage);
|
||||
pagesWidget->addWidget(shortcutsPage);
|
||||
|
||||
Q_ASSERT(pages.size() == NumPages);
|
||||
|
||||
// Search results view (hidden by default)
|
||||
searchResultsView = new QListView;
|
||||
searchResultsView->setUniformItemSizes(false);
|
||||
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
searchResultsView->setVisible(false);
|
||||
searchResultsView->setStyleSheet(
|
||||
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
|
||||
|
||||
searchModel = new SettingsSearchModel(this);
|
||||
searchDelegate = new SettingsSearchDelegate(this);
|
||||
searchResultsView->setModel(searchModel);
|
||||
searchResultsView->setItemDelegate(searchDelegate);
|
||||
connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked);
|
||||
|
||||
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
|
||||
const QStringList icons = pageIconResources();
|
||||
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
|
||||
tabButtons[i]->setIcon(QPixmap(icons[i]));
|
||||
}
|
||||
searchDelegate->setPageIcons(icons);
|
||||
searchResultsView->viewport()->update();
|
||||
});
|
||||
|
||||
// Build search index after pages are created
|
||||
buildSearchIndex();
|
||||
|
||||
// Pages container (stacked widget + search results overlay)
|
||||
pagesContainer = new QWidget;
|
||||
auto *containerLayout = new QStackedLayout;
|
||||
containerLayout->setStackingMode(QStackedLayout::StackAll);
|
||||
containerLayout->addWidget(pagesWidget);
|
||||
containerLayout->addWidget(searchResultsView);
|
||||
pagesContainer->setLayout(containerLayout);
|
||||
settingsView = new SettingsView(this);
|
||||
|
||||
// Bottom buttons
|
||||
auto *buttonBox = new QHBoxLayout;
|
||||
|
|
@ -211,241 +87,23 @@ void DlgSettings::setupUi()
|
|||
|
||||
// Main layout
|
||||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addLayout(searchLayout);
|
||||
mainLayout->addWidget(tabBarWidget);
|
||||
auto *separator = new QFrame;
|
||||
separator->setFrameShape(QFrame::HLine);
|
||||
separator->setFrameShadow(QFrame::Sunken);
|
||||
mainLayout->addWidget(separator);
|
||||
mainLayout->addWidget(pagesContainer);
|
||||
mainLayout->addWidget(settingsView);
|
||||
mainLayout->addSpacing(4);
|
||||
mainLayout->addLayout(buttonBox);
|
||||
setLayout(mainLayout);
|
||||
|
||||
// Keyboard shortcuts
|
||||
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
|
||||
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
|
||||
|
||||
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
|
||||
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
|
||||
int next = (currentTabIndex + 1) % tabButtons.size();
|
||||
setActiveTab(next);
|
||||
});
|
||||
|
||||
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
|
||||
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
|
||||
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
|
||||
setActiveTab(prev);
|
||||
});
|
||||
|
||||
// Initialize to first tab
|
||||
setActiveTab(0);
|
||||
}
|
||||
|
||||
void DlgSettings::setupTabBar()
|
||||
{
|
||||
tabBarWidget = new QWidget;
|
||||
auto *tabLayout = new QHBoxLayout;
|
||||
tabLayout->setContentsMargins(0, 0, 0, 0);
|
||||
tabLayout->setSpacing(2);
|
||||
|
||||
const QStringList iconResources = pageIconResources();
|
||||
|
||||
for (int i = 0; i < iconResources.size(); ++i) {
|
||||
auto *tabButton = new QToolButton;
|
||||
tabButton->setCheckable(true);
|
||||
tabButton->setIcon(QPixmap(iconResources[i]));
|
||||
tabButton->setIconSize(QSize(48, 48));
|
||||
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
||||
tabButton->setAutoExclusive(true);
|
||||
tabButton->setMinimumHeight(85);
|
||||
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||
|
||||
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
|
||||
|
||||
tabButtons.append(tabButton);
|
||||
tabLayout->addWidget(tabButton);
|
||||
}
|
||||
|
||||
tabBarWidget->setLayout(tabLayout);
|
||||
}
|
||||
|
||||
void DlgSettings::buildSearchIndex()
|
||||
{
|
||||
QList<SettingsSearchEntry> allEntries;
|
||||
|
||||
const QStringList pageNames = translatedPageNames();
|
||||
searchDelegate->setPageNames(pageNames);
|
||||
searchDelegate->setPageIcons(pageIconResources());
|
||||
|
||||
for (int i = 0; i < pages.size(); ++i) {
|
||||
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
|
||||
for (auto &entry : pageEntries) {
|
||||
if (entry.pageIndex == -1) {
|
||||
entry.pageIndex = i;
|
||||
}
|
||||
}
|
||||
allEntries.append(pageEntries);
|
||||
}
|
||||
|
||||
searchModel->setSourceEntries(allEntries);
|
||||
}
|
||||
|
||||
void DlgSettings::onTabClicked(int index)
|
||||
{
|
||||
if (searchActive) {
|
||||
switchToTabMode();
|
||||
}
|
||||
setActiveTab(index);
|
||||
}
|
||||
|
||||
void DlgSettings::setActiveTab(int index)
|
||||
{
|
||||
if (index < 0 || index >= tabButtons.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTabIndex = index;
|
||||
pagesWidget->setCurrentIndex(index);
|
||||
|
||||
for (int i = 0; i < tabButtons.size(); ++i) {
|
||||
tabButtons[i]->setChecked(i == index);
|
||||
}
|
||||
|
||||
// Style active tab with a thick accent border + subtle background tint
|
||||
for (int i = 0; i < tabButtons.size(); ++i) {
|
||||
if (i == index) {
|
||||
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
|
||||
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
||||
"background: palette(window); padding-bottom: 1px; }");
|
||||
} else {
|
||||
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
|
||||
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
||||
"background: transparent; }");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DlgSettings::flashWidget(QWidget *widget)
|
||||
{
|
||||
auto *overlay = new QWidget(widget);
|
||||
overlay->setGeometry(widget->rect());
|
||||
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||
|
||||
QPalette pal = overlay->palette();
|
||||
QColor flashColor = pal.color(QPalette::Highlight);
|
||||
flashColor.setAlpha(100);
|
||||
pal.setBrush(QPalette::Window, flashColor);
|
||||
overlay->setPalette(pal);
|
||||
overlay->setAutoFillBackground(true);
|
||||
|
||||
auto *effect = new QGraphicsOpacityEffect(overlay);
|
||||
effect->setOpacity(0.0);
|
||||
overlay->setGraphicsEffect(effect);
|
||||
overlay->show();
|
||||
overlay->raise();
|
||||
|
||||
auto *flashIn = new QPropertyAnimation(effect, "opacity");
|
||||
flashIn->setDuration(120);
|
||||
flashIn->setStartValue(0.0);
|
||||
flashIn->setEndValue(0.6);
|
||||
flashIn->setEasingCurve(QEasingCurve::OutCubic);
|
||||
|
||||
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
|
||||
fadeOut->setDuration(900);
|
||||
fadeOut->setStartValue(0.6);
|
||||
fadeOut->setEndValue(0.0);
|
||||
fadeOut->setEasingCurve(QEasingCurve::InCubic);
|
||||
|
||||
auto *group = new QSequentialAnimationGroup(overlay);
|
||||
group->addAnimation(flashIn);
|
||||
group->addAnimation(fadeOut);
|
||||
|
||||
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
|
||||
|
||||
group->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
void DlgSettings::onSearchTextChanged(const QString &text)
|
||||
{
|
||||
searchModel->setFilterString(text);
|
||||
|
||||
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
|
||||
if (!searchActive) {
|
||||
switchToSearchMode();
|
||||
}
|
||||
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||
}
|
||||
} else if (searchActive) {
|
||||
switchToTabMode();
|
||||
}
|
||||
}
|
||||
|
||||
void DlgSettings::switchToSearchMode()
|
||||
{
|
||||
searchActive = true;
|
||||
tabBarWidget->setVisible(false);
|
||||
pagesWidget->setVisible(false);
|
||||
searchResultsView->setVisible(true);
|
||||
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||
}
|
||||
}
|
||||
|
||||
void DlgSettings::switchToTabMode()
|
||||
{
|
||||
searchActive = false;
|
||||
tabBarWidget->setVisible(true);
|
||||
pagesWidget->setVisible(true);
|
||||
searchResultsView->setVisible(false);
|
||||
searchEdit->blockSignals(true);
|
||||
searchEdit->clear();
|
||||
searchEdit->blockSignals(false);
|
||||
setActiveTab(currentTabIndex);
|
||||
}
|
||||
|
||||
void DlgSettings::onSearchResultClicked(const QModelIndex &index)
|
||||
{
|
||||
navigateToSearchResult(index);
|
||||
}
|
||||
|
||||
void DlgSettings::navigateToSearchResult(const QModelIndex &index)
|
||||
{
|
||||
SettingsSearchEntry entry = searchModel->entryForIndex(index);
|
||||
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the page
|
||||
switchToTabMode();
|
||||
setActiveTab(entry.pageIndex);
|
||||
|
||||
// Scroll to the widget, focus it, and flash to highlight it
|
||||
if (entry.widget) {
|
||||
QWidget *widget = entry.widget;
|
||||
while (widget) {
|
||||
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
|
||||
scrollArea->ensureWidgetVisible(entry.widget);
|
||||
break;
|
||||
}
|
||||
widget = widget->parentWidget();
|
||||
}
|
||||
entry.widget->setFocus();
|
||||
flashWidget(entry.widget);
|
||||
}
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void DlgSettings::setTab(int index)
|
||||
{
|
||||
if (index >= 0 && index < tabButtons.size()) {
|
||||
setActiveTab(index);
|
||||
}
|
||||
settingsView->setTab(index);
|
||||
}
|
||||
|
||||
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
|
||||
{
|
||||
return pages.value(static_cast<int>(which));
|
||||
return settingsView->page(which);
|
||||
}
|
||||
|
||||
void DlgSettings::updateLanguage()
|
||||
|
|
@ -454,49 +112,6 @@ void DlgSettings::updateLanguage()
|
|||
installNewTranslator();
|
||||
}
|
||||
|
||||
bool DlgSettings::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
|
||||
auto *keyEvent = static_cast<QKeyEvent *>(event);
|
||||
if (keyEvent->key() == Qt::Key_Escape) {
|
||||
if (searchActive) {
|
||||
switchToTabMode();
|
||||
return true;
|
||||
}
|
||||
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
|
||||
if (searchActive) {
|
||||
if (searchResultsView->currentIndex().isValid()) {
|
||||
navigateToSearchResult(searchResultsView->currentIndex());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} else if (keyEvent->key() == Qt::Key_Down) {
|
||||
if (searchActive) {
|
||||
int nextRow = searchResultsView->currentIndex().row() + 1;
|
||||
if (nextRow >= searchModel->rowCount()) {
|
||||
nextRow = 0;
|
||||
}
|
||||
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
|
||||
searchResultsView->scrollTo(searchModel->index(nextRow));
|
||||
return true;
|
||||
}
|
||||
} else if (keyEvent->key() == Qt::Key_Up) {
|
||||
if (searchActive) {
|
||||
int prevRow = searchResultsView->currentIndex().row() - 1;
|
||||
if (prevRow < 0) {
|
||||
prevRow = searchModel->rowCount() - 1;
|
||||
}
|
||||
if (prevRow >= 0) {
|
||||
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
|
||||
searchResultsView->scrollTo(searchModel->index(prevRow));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return QDialog::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
void DlgSettings::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
bool showLoadError = true;
|
||||
|
|
@ -576,26 +191,5 @@ void DlgSettings::closeEvent(QCloseEvent *event)
|
|||
void DlgSettings::retranslateUi()
|
||||
{
|
||||
setWindowTitle(tr("Settings"));
|
||||
retranslateTabNames();
|
||||
|
||||
searchEdit->setPlaceholderText(tr("Search settings..."));
|
||||
okButton->setText(tr("OK"));
|
||||
|
||||
// Rebuild search index for translated text
|
||||
buildSearchIndex();
|
||||
}
|
||||
|
||||
QStringList DlgSettings::translatedPageNames()
|
||||
{
|
||||
return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"),
|
||||
tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")};
|
||||
}
|
||||
|
||||
void DlgSettings::retranslateTabNames()
|
||||
{
|
||||
const QStringList tabLabels = translatedPageNames();
|
||||
|
||||
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
|
||||
tabButtons[i]->setText(tabLabels[i]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +1,60 @@
|
|||
/**
|
||||
* @file dlg_settings.h
|
||||
* @brief Main settings dialog for the Cockatrice client
|
||||
* @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView)
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#ifndef DLG_SETTINGS_H
|
||||
#define DLG_SETTINGS_H
|
||||
|
||||
#include "settings_view.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QList>
|
||||
#include <QLoggingCategory>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QPushButton;
|
||||
class QModelIndex;
|
||||
class QWidget;
|
||||
class AbstractSettingsPage;
|
||||
|
||||
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
|
||||
|
||||
class QStackedWidget;
|
||||
class QToolButton;
|
||||
class QListView;
|
||||
class QLineEdit;
|
||||
class AbstractSettingsPage;
|
||||
class SettingsSearchModel;
|
||||
class SettingsSearchDelegate;
|
||||
|
||||
/**
|
||||
* @brief Main application settings dialog with tabbed navigation and search
|
||||
* @brief Main application settings dialog hosting the shared SettingsView
|
||||
*
|
||||
* Provides a modern settings interface organized into tabbed pages. Users can
|
||||
* either navigate by clicking tabs or search for specific settings using the
|
||||
* built-in search bar. Search results are filtered and ranked by relevance.
|
||||
* Thin QDialog frame around SettingsView: only the window title, the OK button
|
||||
* and the close validation live here. All pages, navigation and search are
|
||||
* owned by SettingsView. The dialog fills ~90% of the main window so dense
|
||||
* pages stay visible without losing the modal adjust-and-return flow.
|
||||
*/
|
||||
class DlgSettings : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/**
|
||||
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
|
||||
*
|
||||
* Use these values instead of raw indices so reordering pages never silently
|
||||
* breaks external callers like tab_room.cpp.
|
||||
*/
|
||||
enum SettingsPage
|
||||
{
|
||||
GeneralPage = 0,
|
||||
AppearancePage,
|
||||
UserInterfacePage,
|
||||
DeckEditorPage,
|
||||
StoragePage,
|
||||
MessagesPage,
|
||||
SoundPage,
|
||||
ShortcutsPage,
|
||||
NumPages
|
||||
};
|
||||
using SettingsPage = SettingsView::SettingsPage;
|
||||
static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage;
|
||||
static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage;
|
||||
static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage;
|
||||
static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage;
|
||||
static constexpr SettingsPage StoragePage = SettingsView::StoragePage;
|
||||
static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage;
|
||||
static constexpr SettingsPage SoundPage = SettingsView::SoundPage;
|
||||
static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage;
|
||||
static constexpr SettingsPage NumPages = SettingsView::NumPages;
|
||||
|
||||
explicit DlgSettings(QWidget *parent = nullptr);
|
||||
void setTab(int index);
|
||||
AbstractSettingsPage *page(SettingsPage which) const;
|
||||
|
||||
private slots:
|
||||
void onTabClicked(int index);
|
||||
void onSearchTextChanged(const QString &text);
|
||||
void onSearchResultClicked(const QModelIndex &index);
|
||||
void updateLanguage();
|
||||
|
||||
private:
|
||||
// UI elements
|
||||
QLineEdit *searchEdit; ///< Search bar for filtering settings
|
||||
QWidget *tabBarWidget; ///< Container widget for the tab buttons
|
||||
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
|
||||
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
|
||||
QListView *searchResultsView; ///< Search results list view
|
||||
QWidget *pagesContainer; ///< Container stacking pages and search results
|
||||
QPushButton *okButton; ///< Button to close the dialog
|
||||
|
||||
// Data
|
||||
QList<AbstractSettingsPage *> pages; ///< All settings page instances
|
||||
SettingsSearchModel *searchModel; ///< Model for search results
|
||||
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
|
||||
int currentTabIndex; ///< Currently active tab index
|
||||
bool searchActive; ///< Whether search mode is active
|
||||
|
||||
void setupUi();
|
||||
void setupTabBar();
|
||||
void buildSearchIndex();
|
||||
void switchToTabMode();
|
||||
void switchToSearchMode();
|
||||
void navigateToSearchResult(const QModelIndex &index);
|
||||
void setActiveTab(int index);
|
||||
static void flashWidget(QWidget *widget);
|
||||
static QStringList translatedPageNames();
|
||||
SettingsView *settingsView; ///< Shared settings content
|
||||
QPushButton *okButton; ///< Button to close the dialog
|
||||
|
||||
void retranslateUi();
|
||||
void retranslateTabNames();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
};
|
||||
|
||||
#endif // DLG_SETTINGS_H
|
||||
|
|
|
|||
523
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
523
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
/**
|
||||
* @file settings_view.cpp
|
||||
* @brief Implementation of the settings content widget
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#include "settings_view.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../pixel_map_generator.h"
|
||||
#include "../settings_page/abstract_settings_page.h"
|
||||
#include "../settings_page/appearance_settings_page.h"
|
||||
#include "../settings_page/deck_editor_settings_page.h"
|
||||
#include "../settings_page/general_settings_page.h"
|
||||
#include "../settings_page/messages_settings_page.h"
|
||||
#include "../settings_page/settings_search_delegate.h"
|
||||
#include "../settings_page/settings_search_model.h"
|
||||
#include "../settings_page/shortcut_settings_page.h"
|
||||
#include "../settings_page/sound_settings_page.h"
|
||||
#include "../settings_page/storage_settings_page.h"
|
||||
#include "../settings_page/user_interface_settings_page.h"
|
||||
|
||||
#include <QAbstractAnimation>
|
||||
#include <QAbstractItemView>
|
||||
#include <QBrush>
|
||||
#include <QColor>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QEasingCurve>
|
||||
#include <QEvent>
|
||||
#include <QFlags>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QKeyEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QMessageLogger>
|
||||
#include <QModelIndex>
|
||||
#include <QObject>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QRect>
|
||||
#include <QScreen>
|
||||
#include <QScrollArea>
|
||||
#include <QScrollBar>
|
||||
#include <QSequentialAnimationGroup>
|
||||
#include <QShortcut>
|
||||
#include <QSize>
|
||||
#include <QSizePolicy>
|
||||
#include <QStackedLayout>
|
||||
#include <QStackedWidget>
|
||||
#include <QStringLiteral>
|
||||
#include <QTreeWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <qnamespace.h>
|
||||
|
||||
/**
|
||||
* @brief Wraps a widget in a scroll area for long settings pages
|
||||
* @param widget The widget to wrap
|
||||
* @return The scroll area containing the widget
|
||||
*/
|
||||
static QScrollArea *makeScrollable(QWidget *widget)
|
||||
{
|
||||
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
|
||||
|
||||
auto *scrollArea = new QScrollArea;
|
||||
scrollArea->setWidgetResizable(true);
|
||||
scrollArea->setContentsMargins(0, 0, 0, 0);
|
||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
scrollArea->horizontalScrollBar()->setEnabled(false);
|
||||
scrollArea->setWidget(widget);
|
||||
return scrollArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
|
||||
*/
|
||||
static QStringList pageIconResources()
|
||||
{
|
||||
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
|
||||
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
|
||||
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
|
||||
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
|
||||
}
|
||||
|
||||
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
|
||||
{
|
||||
setupUi();
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
|
||||
retranslateUi();
|
||||
|
||||
searchEdit->setFocus();
|
||||
}
|
||||
|
||||
void SettingsView::setupUi()
|
||||
{
|
||||
// Search bar
|
||||
searchEdit = new QLineEdit;
|
||||
searchEdit->setClearButtonEnabled(true);
|
||||
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
|
||||
searchEdit->installEventFilter(this);
|
||||
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
|
||||
|
||||
auto *searchLayout = new QHBoxLayout;
|
||||
searchLayout->addWidget(searchEdit);
|
||||
|
||||
// Navigation tree (built in setupNavTree)
|
||||
setupNavTree();
|
||||
|
||||
// Pages stacked widget
|
||||
pagesWidget = new QStackedWidget;
|
||||
|
||||
auto *generalPage = new GeneralSettingsPage;
|
||||
auto *appearancePage = new AppearanceSettingsPage;
|
||||
auto *userInterfacePage = new UserInterfaceSettingsPage;
|
||||
auto *deckEditorPage = new DeckEditorSettingsPage;
|
||||
auto *storagePage = new StorageSettingsPage;
|
||||
auto *messagesPage = new MessagesSettingsPage;
|
||||
auto *soundPage = new SoundSettingsPage;
|
||||
auto *shortcutsPage = new ShortcutSettingsPage;
|
||||
|
||||
pages.append(generalPage);
|
||||
pages.append(appearancePage);
|
||||
pages.append(userInterfacePage);
|
||||
pages.append(deckEditorPage);
|
||||
pages.append(storagePage);
|
||||
pages.append(messagesPage);
|
||||
pages.append(soundPage);
|
||||
pages.append(shortcutsPage);
|
||||
|
||||
pagesWidget->addWidget(makeScrollable(generalPage));
|
||||
pagesWidget->addWidget(makeScrollable(appearancePage));
|
||||
pagesWidget->addWidget(makeScrollable(userInterfacePage));
|
||||
pagesWidget->addWidget(makeScrollable(deckEditorPage));
|
||||
pagesWidget->addWidget(makeScrollable(storagePage));
|
||||
pagesWidget->addWidget(makeScrollable(messagesPage));
|
||||
pagesWidget->addWidget(soundPage);
|
||||
pagesWidget->addWidget(shortcutsPage);
|
||||
|
||||
Q_ASSERT(pages.size() == NumPages);
|
||||
|
||||
// Search results view (hidden by default)
|
||||
searchResultsView = new QListView;
|
||||
searchResultsView->setUniformItemSizes(false);
|
||||
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
searchResultsView->setVisible(false);
|
||||
searchResultsView->setStyleSheet(
|
||||
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
|
||||
|
||||
searchModel = new SettingsSearchModel(this);
|
||||
searchDelegate = new SettingsSearchDelegate(this);
|
||||
searchResultsView->setModel(searchModel);
|
||||
searchResultsView->setItemDelegate(searchDelegate);
|
||||
connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked);
|
||||
|
||||
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
|
||||
const QStringList icons = pageIconResources();
|
||||
for (int i = 0; i < pageItems.size() && i < icons.size(); ++i) {
|
||||
if (pageItems[i]) {
|
||||
pageItems[i]->setIcon(0, QPixmap(icons[i]));
|
||||
}
|
||||
}
|
||||
searchDelegate->setPageIcons(icons);
|
||||
searchResultsView->viewport()->update();
|
||||
});
|
||||
|
||||
// Build search index after pages are created
|
||||
buildSearchIndex();
|
||||
|
||||
// Pages container (stacked widget + search results overlay)
|
||||
pagesContainer = new QWidget;
|
||||
auto *containerLayout = new QStackedLayout;
|
||||
containerLayout->setStackingMode(QStackedLayout::StackAll);
|
||||
containerLayout->addWidget(pagesWidget);
|
||||
containerLayout->addWidget(searchResultsView);
|
||||
pagesContainer->setLayout(containerLayout);
|
||||
|
||||
// Main layout: search on top, navigation + pages side by side below
|
||||
auto *contentLayout = new QHBoxLayout;
|
||||
contentLayout->addWidget(navTree);
|
||||
contentLayout->addWidget(pagesContainer, 1);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addLayout(searchLayout);
|
||||
mainLayout->addLayout(contentLayout);
|
||||
setLayout(mainLayout);
|
||||
|
||||
// Keyboard shortcuts
|
||||
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
|
||||
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
|
||||
|
||||
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
|
||||
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
|
||||
int next = (currentTabIndex + 1) % pages.size();
|
||||
setActiveTab(next);
|
||||
});
|
||||
|
||||
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
|
||||
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
|
||||
int prev = (currentTabIndex - 1 + pages.size()) % pages.size();
|
||||
setActiveTab(prev);
|
||||
});
|
||||
|
||||
// Initialize to first tab
|
||||
setActiveTab(0);
|
||||
}
|
||||
|
||||
void SettingsView::setupNavTree()
|
||||
{
|
||||
navTree = new QTreeWidget;
|
||||
navTree->setHeaderHidden(true);
|
||||
navTree->setRootIsDecorated(true);
|
||||
navTree->setIconSize(QSize(24, 24));
|
||||
navTree->setMinimumWidth(230);
|
||||
navTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
navTree->setStyleSheet(
|
||||
"QTreeWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
|
||||
|
||||
// Groups of SettingsPage values, in navigation order
|
||||
static const QList<QList<SettingsPage>> groupedPages = {
|
||||
{GeneralPage, AppearancePage, UserInterfacePage},
|
||||
{DeckEditorPage, StoragePage},
|
||||
{MessagesPage, SoundPage},
|
||||
{ShortcutsPage},
|
||||
};
|
||||
|
||||
const QStringList groupNames = translatedGroupNames();
|
||||
const QStringList pageNames = translatedPageNames();
|
||||
const QStringList iconResources = pageIconResources();
|
||||
|
||||
pageItems.resize(NumPages);
|
||||
for (int i = 0; i < groupedPages.size(); ++i) {
|
||||
auto *group = new QTreeWidgetItem(navTree, {groupNames[i]});
|
||||
group->setFlags(Qt::ItemIsEnabled); // header row: expandable, not selectable
|
||||
group->setExpanded(true);
|
||||
groupItems.append(group);
|
||||
for (SettingsPage page : groupedPages[i]) {
|
||||
auto *item = new QTreeWidgetItem(group, {pageNames[page]});
|
||||
item->setIcon(0, QPixmap(iconResources[page]));
|
||||
item->setData(0, Qt::UserRole, static_cast<int>(page));
|
||||
pageItems[page] = item;
|
||||
}
|
||||
}
|
||||
|
||||
Q_ASSERT(groupItems.size() == groupedPages.size());
|
||||
Q_ASSERT(!pageItems.contains(nullptr));
|
||||
|
||||
connect(navTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current) {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
bool ok = false;
|
||||
int index = current->data(0, Qt::UserRole).toInt(&ok);
|
||||
if (ok) {
|
||||
onTabClicked(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SettingsView::buildSearchIndex()
|
||||
{
|
||||
QList<SettingsSearchEntry> allEntries;
|
||||
|
||||
const QStringList pageNames = translatedPageNames();
|
||||
searchDelegate->setPageNames(pageNames);
|
||||
searchDelegate->setPageIcons(pageIconResources());
|
||||
|
||||
for (int i = 0; i < pages.size(); ++i) {
|
||||
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
|
||||
for (auto &entry : pageEntries) {
|
||||
if (entry.pageIndex == -1) {
|
||||
entry.pageIndex = i;
|
||||
}
|
||||
}
|
||||
allEntries.append(pageEntries);
|
||||
}
|
||||
|
||||
searchModel->setSourceEntries(allEntries);
|
||||
}
|
||||
|
||||
void SettingsView::onTabClicked(int index)
|
||||
{
|
||||
if (searchActive) {
|
||||
switchToTabMode();
|
||||
} else if (index == currentTabIndex) {
|
||||
return;
|
||||
}
|
||||
setActiveTab(index);
|
||||
}
|
||||
|
||||
void SettingsView::setActiveTab(int index)
|
||||
{
|
||||
if (index < 0 || index >= pageItems.size() || !pageItems[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTabIndex = index;
|
||||
pagesWidget->setCurrentIndex(index);
|
||||
|
||||
// Selecting the item also expands a collapsed group; selecting the already
|
||||
// current item emits nothing, so the currentItemChanged handler cannot loop.
|
||||
if (QTreeWidgetItem *parent = pageItems[index]->parent()) {
|
||||
parent->setExpanded(true);
|
||||
}
|
||||
navTree->setCurrentItem(pageItems[index]);
|
||||
}
|
||||
|
||||
void SettingsView::flashWidget(QWidget *widget)
|
||||
{
|
||||
auto *overlay = new QWidget(widget);
|
||||
overlay->setGeometry(widget->rect());
|
||||
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||
|
||||
QPalette pal = overlay->palette();
|
||||
QColor flashColor = pal.color(QPalette::Highlight);
|
||||
flashColor.setAlpha(100);
|
||||
pal.setBrush(QPalette::Window, flashColor);
|
||||
overlay->setPalette(pal);
|
||||
overlay->setAutoFillBackground(true);
|
||||
|
||||
auto *effect = new QGraphicsOpacityEffect(overlay);
|
||||
effect->setOpacity(0.0);
|
||||
overlay->setGraphicsEffect(effect);
|
||||
overlay->show();
|
||||
overlay->raise();
|
||||
|
||||
auto *flashIn = new QPropertyAnimation(effect, "opacity");
|
||||
flashIn->setDuration(120);
|
||||
flashIn->setStartValue(0.0);
|
||||
flashIn->setEndValue(0.6);
|
||||
flashIn->setEasingCurve(QEasingCurve::OutCubic);
|
||||
|
||||
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
|
||||
fadeOut->setDuration(900);
|
||||
fadeOut->setStartValue(0.6);
|
||||
fadeOut->setEndValue(0.0);
|
||||
fadeOut->setEasingCurve(QEasingCurve::InCubic);
|
||||
|
||||
auto *group = new QSequentialAnimationGroup(overlay);
|
||||
group->addAnimation(flashIn);
|
||||
group->addAnimation(fadeOut);
|
||||
|
||||
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
|
||||
|
||||
group->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
void SettingsView::onSearchTextChanged(const QString &text)
|
||||
{
|
||||
searchModel->setFilterString(text);
|
||||
|
||||
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
|
||||
if (!searchActive) {
|
||||
switchToSearchMode();
|
||||
}
|
||||
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||
}
|
||||
} else if (searchActive) {
|
||||
switchToTabMode();
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsView::switchToSearchMode()
|
||||
{
|
||||
searchActive = true;
|
||||
navTree->setVisible(false);
|
||||
pagesWidget->setVisible(false);
|
||||
searchResultsView->setVisible(true);
|
||||
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsView::switchToTabMode()
|
||||
{
|
||||
searchActive = false;
|
||||
navTree->setVisible(true);
|
||||
pagesWidget->setVisible(true);
|
||||
searchResultsView->setVisible(false);
|
||||
searchEdit->blockSignals(true);
|
||||
searchEdit->clear();
|
||||
searchEdit->blockSignals(false);
|
||||
setActiveTab(currentTabIndex);
|
||||
}
|
||||
|
||||
void SettingsView::onSearchResultClicked(const QModelIndex &index)
|
||||
{
|
||||
navigateToSearchResult(index);
|
||||
}
|
||||
|
||||
void SettingsView::navigateToSearchResult(const QModelIndex &index)
|
||||
{
|
||||
SettingsSearchEntry entry = searchModel->entryForIndex(index);
|
||||
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the page
|
||||
switchToTabMode();
|
||||
setActiveTab(entry.pageIndex);
|
||||
|
||||
// Scroll to the widget, focus it, and flash to highlight it
|
||||
if (entry.widget) {
|
||||
QWidget *widget = entry.widget;
|
||||
while (widget) {
|
||||
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
|
||||
scrollArea->ensureWidgetVisible(entry.widget);
|
||||
break;
|
||||
}
|
||||
widget = widget->parentWidget();
|
||||
}
|
||||
entry.widget->setFocus();
|
||||
flashWidget(entry.widget);
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsView::setTab(int index)
|
||||
{
|
||||
if (index >= 0 && index < pageItems.size()) {
|
||||
setActiveTab(index);
|
||||
}
|
||||
}
|
||||
|
||||
AbstractSettingsPage *SettingsView::page(SettingsPage which) const
|
||||
{
|
||||
return pages.value(static_cast<int>(which));
|
||||
}
|
||||
|
||||
bool SettingsView::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
|
||||
auto *keyEvent = static_cast<QKeyEvent *>(event);
|
||||
if (keyEvent->key() == Qt::Key_Escape) {
|
||||
if (searchActive) {
|
||||
switchToTabMode();
|
||||
return true;
|
||||
}
|
||||
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
|
||||
if (searchActive) {
|
||||
if (searchResultsView->currentIndex().isValid()) {
|
||||
navigateToSearchResult(searchResultsView->currentIndex());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} else if (keyEvent->key() == Qt::Key_Down) {
|
||||
if (searchActive) {
|
||||
int nextRow = searchResultsView->currentIndex().row() + 1;
|
||||
if (nextRow >= searchModel->rowCount()) {
|
||||
nextRow = 0;
|
||||
}
|
||||
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
|
||||
searchResultsView->scrollTo(searchModel->index(nextRow));
|
||||
return true;
|
||||
}
|
||||
} else if (keyEvent->key() == Qt::Key_Up) {
|
||||
if (searchActive) {
|
||||
int prevRow = searchResultsView->currentIndex().row() - 1;
|
||||
if (prevRow < 0) {
|
||||
prevRow = searchModel->rowCount() - 1;
|
||||
}
|
||||
if (prevRow >= 0) {
|
||||
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
|
||||
searchResultsView->scrollTo(searchModel->index(prevRow));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
void SettingsView::retranslateUi()
|
||||
{
|
||||
retranslateNavNames();
|
||||
|
||||
// NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings
|
||||
// before the view was extracted, and keeping the context preserves existing translations.
|
||||
searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings..."));
|
||||
|
||||
// Rebuild search index for translated text
|
||||
buildSearchIndex();
|
||||
}
|
||||
|
||||
QStringList SettingsView::translatedPageNames()
|
||||
{
|
||||
// NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi).
|
||||
return {QCoreApplication::translate("DlgSettings", "General"),
|
||||
QCoreApplication::translate("DlgSettings", "Appearance"),
|
||||
QCoreApplication::translate("DlgSettings", "User Interface"),
|
||||
QCoreApplication::translate("DlgSettings", "Card Sources"),
|
||||
QCoreApplication::translate("DlgSettings", "Storage"),
|
||||
QCoreApplication::translate("DlgSettings", "Chat"),
|
||||
QCoreApplication::translate("DlgSettings", "Sound"),
|
||||
QCoreApplication::translate("DlgSettings", "Shortcuts")};
|
||||
}
|
||||
|
||||
void SettingsView::retranslateNavNames()
|
||||
{
|
||||
const QStringList groupNames = translatedGroupNames();
|
||||
for (int i = 0; i < groupItems.size() && i < groupNames.size(); ++i) {
|
||||
groupItems[i]->setText(0, groupNames[i]);
|
||||
}
|
||||
|
||||
const QStringList pageNames = translatedPageNames();
|
||||
for (int i = 0; i < pageItems.size() && i < pageNames.size(); ++i) {
|
||||
if (pageItems[i]) {
|
||||
pageItems[i]->setText(0, pageNames[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QStringList SettingsView::translatedGroupNames()
|
||||
{
|
||||
return {tr("Application"), tr("Cards & Data"), tr("Chat & Sound"), tr("Advanced")};
|
||||
}
|
||||
101
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
101
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* @file settings_view.h
|
||||
* @brief Settings content widget hosted by the settings dialog
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#ifndef SETTINGS_VIEW_H
|
||||
#define SETTINGS_VIEW_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QWidget>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QModelIndex;
|
||||
class QObject;
|
||||
class QEvent;
|
||||
|
||||
class QStackedWidget;
|
||||
class QTreeWidget;
|
||||
class QTreeWidgetItem;
|
||||
class QListView;
|
||||
class QLineEdit;
|
||||
class AbstractSettingsPage;
|
||||
class SettingsSearchModel;
|
||||
class SettingsSearchDelegate;
|
||||
|
||||
/**
|
||||
* @brief Settings pages with grouped left navigation and search, without any dialog chrome
|
||||
*
|
||||
* Owns the search bar, the navigation tree, the stacked settings pages and the search
|
||||
* results view. The settings dialog only adds its own frame (buttons, titles).
|
||||
*/
|
||||
class SettingsView : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/**
|
||||
* @brief Page order in the navigation, matching the order pages are added in setupUi()
|
||||
*
|
||||
* Use these values instead of raw indices so reordering pages never silently
|
||||
* breaks external callers like tab_room.cpp.
|
||||
*/
|
||||
enum SettingsPage
|
||||
{
|
||||
GeneralPage = 0,
|
||||
AppearancePage,
|
||||
UserInterfacePage,
|
||||
DeckEditorPage,
|
||||
StoragePage,
|
||||
MessagesPage,
|
||||
SoundPage,
|
||||
ShortcutsPage,
|
||||
NumPages
|
||||
};
|
||||
|
||||
explicit SettingsView(QWidget *parent = nullptr);
|
||||
void setTab(int index);
|
||||
AbstractSettingsPage *page(SettingsPage which) const;
|
||||
|
||||
private slots:
|
||||
void onTabClicked(int index);
|
||||
void onSearchTextChanged(const QString &text);
|
||||
void onSearchResultClicked(const QModelIndex &index);
|
||||
|
||||
private:
|
||||
// UI elements
|
||||
QLineEdit *searchEdit; ///< Search bar for filtering settings
|
||||
QTreeWidget *navTree; ///< Grouped navigation tree for the pages
|
||||
QList<QTreeWidgetItem *> groupItems; ///< Group header rows, indexed by group
|
||||
QList<QTreeWidgetItem *> pageItems; ///< Page rows, indexed by SettingsPage
|
||||
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
|
||||
QListView *searchResultsView; ///< Search results list view
|
||||
QWidget *pagesContainer; ///< Container stacking pages and search results
|
||||
|
||||
// Data
|
||||
QList<AbstractSettingsPage *> pages; ///< All settings page instances
|
||||
SettingsSearchModel *searchModel; ///< Model for search results
|
||||
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
|
||||
int currentTabIndex; ///< Currently active tab index
|
||||
bool searchActive; ///< Whether search mode is active
|
||||
|
||||
void setupUi();
|
||||
void setupNavTree();
|
||||
void buildSearchIndex();
|
||||
void switchToTabMode();
|
||||
void switchToSearchMode();
|
||||
void navigateToSearchResult(const QModelIndex &index);
|
||||
void setActiveTab(int index);
|
||||
static void flashWidget(QWidget *widget);
|
||||
static QStringList translatedPageNames();
|
||||
static QStringList translatedGroupNames();
|
||||
|
||||
void retranslateUi();
|
||||
void retranslateNavNames();
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
};
|
||||
|
||||
#endif // SETTINGS_VIEW_H
|
||||
|
|
@ -301,7 +301,9 @@ void UserInfoBox::changePassword(const QString &oldPassword, const QString &newP
|
|||
{
|
||||
Command_AccountPassword cmd;
|
||||
cmd.set_old_password(oldPassword.toStdString());
|
||||
if (client->getServerSupportsPasswordHash()) {
|
||||
if (client->getServerSupportsChallengeResponse()) {
|
||||
cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString());
|
||||
} else if (client->getServerSupportsPasswordHash()) {
|
||||
auto passwordSalt = PasswordHasher::generateRandomSalt();
|
||||
QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt);
|
||||
cmd.set_hashed_new_password(hashedPassword.toStdString());
|
||||
|
|
|
|||
|
|
@ -118,6 +118,26 @@ MessagesSettingsPage::MessagesSettingsPage()
|
|||
highlightGroupBox = new QGroupBox;
|
||||
highlightGroupBox->setLayout(highlightNotice);
|
||||
|
||||
channelUnreadColor = new QLineEdit();
|
||||
channelUnreadColor->setText(SettingsCache::instance().chat().getChannelUnreadColor());
|
||||
updateChannelUnreadPreview();
|
||||
connect(channelUnreadColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelUnreadColor);
|
||||
|
||||
channelMentionColor = new QLineEdit();
|
||||
channelMentionColor->setText(SettingsCache::instance().chat().getChannelMentionColor());
|
||||
updateChannelMentionPreview();
|
||||
connect(channelMentionColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelMentionColor);
|
||||
|
||||
auto *channelGrid = new QGridLayout;
|
||||
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
|
||||
channelGrid->addWidget(channelUnreadColor, 0, 1);
|
||||
channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2);
|
||||
channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
|
||||
channelGrid->addWidget(channelMentionColor, 1, 1);
|
||||
channelGrid->addWidget(&hexChannelMentionLabel, 1, 2);
|
||||
channelGroupBox = new QGroupBox;
|
||||
channelGroupBox->setLayout(channelGrid);
|
||||
|
||||
messageList = new QListWidget;
|
||||
|
||||
int count = SettingsCache::instance().messages().getCount();
|
||||
|
|
@ -159,6 +179,7 @@ MessagesSettingsPage::MessagesSettingsPage()
|
|||
mainLayout->addWidget(messageGroupBox);
|
||||
mainLayout->addWidget(chatGroupBox);
|
||||
mainLayout->addWidget(highlightGroupBox);
|
||||
mainLayout->addWidget(channelGroupBox);
|
||||
|
||||
setLayout(mainLayout);
|
||||
|
||||
|
|
@ -195,6 +216,34 @@ void MessagesSettingsPage::updateHighlightColor(const QString &value)
|
|||
}
|
||||
}
|
||||
|
||||
void MessagesSettingsPage::updateChannelUnreadColor(const QString &value)
|
||||
{
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0))
|
||||
QColor colorToSet = QColor::fromString("#" + value);
|
||||
#else
|
||||
QColor colorToSet;
|
||||
colorToSet.setNamedColor("#" + value);
|
||||
#endif
|
||||
if (colorToSet.isValid()) {
|
||||
SettingsCache::instance().chat().setChannelUnreadColor(value);
|
||||
updateChannelUnreadPreview();
|
||||
}
|
||||
}
|
||||
|
||||
void MessagesSettingsPage::updateChannelMentionColor(const QString &value)
|
||||
{
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0))
|
||||
QColor colorToSet = QColor::fromString("#" + value);
|
||||
#else
|
||||
QColor colorToSet;
|
||||
colorToSet.setNamedColor("#" + value);
|
||||
#endif
|
||||
if (colorToSet.isValid()) {
|
||||
SettingsCache::instance().chat().setChannelMentionColor(value);
|
||||
updateChannelMentionPreview();
|
||||
}
|
||||
}
|
||||
|
||||
void MessagesSettingsPage::updateTextColor(QT_STATE_CHANGED_T value)
|
||||
{
|
||||
SettingsCache::instance().chat().setChatMentionForeground(value);
|
||||
|
|
@ -221,6 +270,18 @@ void MessagesSettingsPage::updateHighlightPreview()
|
|||
";color: " + (SettingsCache::instance().chat().getChatHighlightForeground() ? "white" : "black") + ";}");
|
||||
}
|
||||
|
||||
void MessagesSettingsPage::updateChannelUnreadPreview()
|
||||
{
|
||||
channelUnreadColor->setStyleSheet("QLineEdit{background:#" +
|
||||
SettingsCache::instance().chat().getChannelUnreadColor() + ";}");
|
||||
}
|
||||
|
||||
void MessagesSettingsPage::updateChannelMentionPreview()
|
||||
{
|
||||
channelMentionColor->setStyleSheet("QLineEdit{background:#" +
|
||||
SettingsCache::instance().chat().getChannelMentionColor() + ";}");
|
||||
}
|
||||
|
||||
void MessagesSettingsPage::storeSettings()
|
||||
{
|
||||
SettingsCache::instance().messages().setCount(messageList->count());
|
||||
|
|
@ -267,6 +328,9 @@ void MessagesSettingsPage::retranslateUi()
|
|||
{
|
||||
chatGroupBox->setTitle(tr("Chat settings"));
|
||||
highlightGroupBox->setTitle(tr("Custom alert words"));
|
||||
channelGroupBox->setTitle(tr("Room channel tabs"));
|
||||
channelUnreadColorLabel.setText(tr("Unread messages color"));
|
||||
channelMentionColorLabel.setText(tr("Mentioned messages color"));
|
||||
chatMentionCheckBox.setText(tr("Enable chat mentions"));
|
||||
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
|
||||
messageGroupBox->setTitle(tr("In-game message macros"));
|
||||
|
|
@ -284,6 +348,8 @@ void MessagesSettingsPage::retranslateUi()
|
|||
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
|
||||
hexLabel.setText(tr("(Color is hexadecimal)"));
|
||||
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
|
||||
hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)"));
|
||||
hexChannelMentionLabel.setText(tr("(Color is hexadecimal)"));
|
||||
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
|
||||
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
|
||||
aAdd->setText(tr("Add New Message"));
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ private slots:
|
|||
void actRemove();
|
||||
void updateColor(const QString &value);
|
||||
void updateHighlightColor(const QString &value);
|
||||
void updateChannelUnreadColor(const QString &value);
|
||||
void updateChannelMentionColor(const QString &value);
|
||||
void updateTextColor(QT_STATE_CHANGED_T value);
|
||||
void updateTextHighlightColor(QT_STATE_CHANGED_T value);
|
||||
|
||||
|
|
@ -49,18 +51,27 @@ private:
|
|||
QCheckBox useGameTimeCheckBox;
|
||||
QGroupBox *chatGroupBox;
|
||||
QGroupBox *highlightGroupBox;
|
||||
QGroupBox *channelGroupBox;
|
||||
QGroupBox *messageGroupBox;
|
||||
QLineEdit *mentionColor;
|
||||
QLineEdit *highlightColor;
|
||||
QLineEdit *channelUnreadColor;
|
||||
QLineEdit *channelMentionColor;
|
||||
QLineEdit *customAlertString;
|
||||
QLabel hexLabel;
|
||||
QLabel hexHighlightLabel;
|
||||
QLabel channelUnreadColorLabel;
|
||||
QLabel channelMentionColorLabel;
|
||||
QLabel hexChannelUnreadLabel;
|
||||
QLabel hexChannelMentionLabel;
|
||||
QLabel customAlertStringLabel;
|
||||
QLabel explainMessagesLabel;
|
||||
|
||||
void storeSettings();
|
||||
void updateMentionPreview();
|
||||
void updateHighlightPreview();
|
||||
void updateChannelUnreadPreview();
|
||||
void updateChannelMentionPreview();
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_MESSAGES_SETTINGS_PAGE_H
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can
|
|||
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
|
||||
{
|
||||
roomTable = new QTableWidget();
|
||||
roomTable->setColumnCount(6);
|
||||
roomTable->setColumnCount(7);
|
||||
roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
roomTable->setHorizontalHeaderLabels(
|
||||
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";"));
|
||||
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName;Channel")).split(";"));
|
||||
|
||||
gameTable = new QTableWidget();
|
||||
gameTable->setColumnCount(6);
|
||||
|
|
@ -317,7 +317,8 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
|
|||
|
||||
for (int i = 0; i < response.log_message_size(); ++i) {
|
||||
ServerInfo_ChatMessage message = response.log_message(i);
|
||||
if (QString::fromStdString(message.target_type()) == "room") {
|
||||
const QString targetType = QString::fromStdString(message.target_type());
|
||||
if (targetType == "room" || targetType == "room_channel") {
|
||||
roomTable->insertRow(roomCounter);
|
||||
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
||||
roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
||||
|
|
@ -325,10 +326,11 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
|
|||
roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
|
||||
roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
|
||||
roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
|
||||
roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel())));
|
||||
++roomCounter;
|
||||
}
|
||||
|
||||
if (QString::fromStdString(message.target_type()) == "game") {
|
||||
if (targetType == "game") {
|
||||
gameTable->insertRow(gameCounter);
|
||||
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
||||
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
||||
|
|
@ -339,7 +341,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
|
|||
++gameCounter;
|
||||
}
|
||||
|
||||
if (QString::fromStdString(message.target_type()) == "chat") {
|
||||
if (targetType == "chat") {
|
||||
chatTable->insertRow(chatCounter);
|
||||
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
||||
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QCloseEvent>
|
||||
#include <QColor>
|
||||
#include <QCompleter>
|
||||
#include <QDateTime>
|
||||
#include <QFlag>
|
||||
|
|
@ -38,9 +39,11 @@
|
|||
#include <QMenu>
|
||||
#include <QRegularExpression>
|
||||
#include <QSplitter>
|
||||
#include <QStackedWidget>
|
||||
#include <QStringListModel>
|
||||
#include <QStringLiteral>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QTabBar>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
|
@ -52,6 +55,7 @@
|
|||
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_list_games.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_remove_messages.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
|
||||
|
|
@ -61,12 +65,45 @@
|
|||
#include <qnamespace.h>
|
||||
#include <string>
|
||||
|
||||
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<int>(ServerInfo_RoomChannel::Developer);
|
||||
}
|
||||
if (userLevel & ServerInfo_User::IsAdmin) {
|
||||
return static_cast<int>(ServerInfo_RoomChannel::Admin);
|
||||
}
|
||||
if (userLevel & ServerInfo_User::IsModerator) {
|
||||
return static_cast<int>(ServerInfo_RoomChannel::Moderator);
|
||||
}
|
||||
if (userLevel & ServerInfo_User::IsJudge) {
|
||||
return static_cast<int>(ServerInfo_RoomChannel::Judge);
|
||||
}
|
||||
if (userLevel & ServerInfo_User::IsRegistered) {
|
||||
return static_cast<int>(ServerInfo_RoomChannel::Registered);
|
||||
}
|
||||
return static_cast<int>(ServerInfo_RoomChannel::Public);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||
AbstractClient *_client,
|
||||
ServerInfo_User *_ownUser,
|
||||
const ServerInfo_Room &info)
|
||||
: Tab(_tabSupervisor), client(_client), roomId(info.room_id()), roomName(QString::fromStdString(info.name())),
|
||||
ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager())
|
||||
ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager()), activeChannelId(MAIN_CHANNEL),
|
||||
chatCollapsed(false)
|
||||
{
|
||||
const int gameTypeListSize = info.gametype_list_size();
|
||||
for (int i = 0; i < gameTypeListSize; ++i) {
|
||||
|
|
@ -86,14 +123,61 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
|||
const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); };
|
||||
userList->setGameInviteLinkProvider(gameInviteLinkProvider);
|
||||
|
||||
chatView = new ChatView(tabSupervisor, nullptr, true, this);
|
||||
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
|
||||
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
|
||||
connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog);
|
||||
connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated);
|
||||
connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup);
|
||||
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup);
|
||||
connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag);
|
||||
chatViewStack = new QStackedWidget;
|
||||
channelTabBar = new QTabBar;
|
||||
channelTabBar->setShape(QTabBar::RoundedSouth);
|
||||
channelTabBar->setExpanding(true);
|
||||
channelTabBar->setUsesScrollButtons(true);
|
||||
|
||||
channelViews.insert(MAIN_CHANNEL, makeChatView());
|
||||
channelIds << MAIN_CHANNEL;
|
||||
channelDisplayNames.insert(MAIN_CHANNEL, tr("Main"));
|
||||
channelUnreadCounts.insert(MAIN_CHANNEL, 0);
|
||||
channelMentioned.insert(MAIN_CHANNEL, false);
|
||||
channelWritable.insert(MAIN_CHANNEL, true);
|
||||
channelTabBar->addTab(channelDisplayNames.value(MAIN_CHANNEL));
|
||||
chatViewStack->addWidget(channelViews.value(MAIN_CHANNEL));
|
||||
|
||||
const uint32_t ownLevel = ownUser != nullptr ? ownUser->user_level() : 0;
|
||||
const int ownRank = userPrivilegeRank(ownLevel);
|
||||
|
||||
const int channelCount = info.channel_list_size();
|
||||
for (int i = 0; i < channelCount; ++i) {
|
||||
const ServerInfo_RoomChannel &channel = info.channel_list(i);
|
||||
const QString channelId = QString::fromStdString(channel.id());
|
||||
if (channelId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
QString displayName = QString::fromStdString(channel.display_name());
|
||||
if (displayName.isEmpty()) {
|
||||
displayName = channelId;
|
||||
}
|
||||
const int writeLevel = channel.has_write_access_level() ? static_cast<int>(channel.write_access_level())
|
||||
: static_cast<int>(channel.access_level());
|
||||
ChatView *channelView = makeChatView();
|
||||
const QString banner = QString::fromStdString(channel.banner_message());
|
||||
if (!banner.isEmpty()) {
|
||||
channelView->appendMessage(banner);
|
||||
}
|
||||
channelViews.insert(channelId, channelView);
|
||||
channelIds << channelId;
|
||||
channelDisplayNames.insert(channelId, displayName);
|
||||
channelUnreadCounts.insert(channelId, 0);
|
||||
channelMentioned.insert(channelId, false);
|
||||
channelWritable.insert(channelId, ownRank >= writeLevel);
|
||||
channelTabBar->addTab(displayName);
|
||||
chatViewStack->addWidget(channelViews.value(channelId));
|
||||
}
|
||||
|
||||
chatView = channelViews.value(MAIN_CHANNEL);
|
||||
chatViewStack->setCurrentWidget(chatView);
|
||||
channelTabBar->setCurrentIndex(0);
|
||||
// Old servers never send a channel catalog, so the tab bar would only show
|
||||
// a single "Main" tab where a misclick hides the chat. Only show it when
|
||||
// there is more than one channel to switch between.
|
||||
channelTabBar->setVisible(channelIds.size() > 1);
|
||||
connect(channelTabBar, &QTabBar::currentChanged, this, &TabRoom::onChannelChanged);
|
||||
connect(channelTabBar, &QTabBar::tabBarClicked, this, &TabRoom::onChannelTabClicked);
|
||||
connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this,
|
||||
&TabRoom::actCompleterChanged);
|
||||
sayLabel = new QLabel;
|
||||
|
|
@ -101,6 +185,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
|||
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
|
||||
sayLabel->setBuddy(sayEdit);
|
||||
connect(sayEdit, &LineEditCompleter::returnPressed, this, &TabRoom::sendMessage);
|
||||
updateSayEnabled();
|
||||
|
||||
auto *chatSettingsMenu = new QMenu(this);
|
||||
|
||||
|
|
@ -122,19 +207,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
|||
sayHbox->addWidget(sayEdit);
|
||||
sayHbox->addWidget(chatSettingsButton);
|
||||
|
||||
sayWidget = new QWidget;
|
||||
sayWidget->setLayout(sayHbox);
|
||||
|
||||
auto *chatVbox = new QVBoxLayout;
|
||||
chatVbox->addWidget(chatView);
|
||||
chatVbox->addLayout(sayHbox);
|
||||
chatVbox->addWidget(chatViewStack);
|
||||
chatVbox->addWidget(sayWidget);
|
||||
chatVbox->addWidget(channelTabBar);
|
||||
|
||||
chatGroupBox = new QGroupBox;
|
||||
chatGroupBox->setLayout(chatVbox);
|
||||
|
||||
auto *splitter = new QSplitter(Qt::Vertical);
|
||||
splitter->addWidget(gameSelector);
|
||||
splitter->addWidget(chatGroupBox);
|
||||
chatSplitter = new QSplitter(Qt::Vertical);
|
||||
chatSplitter->addWidget(gameSelector);
|
||||
chatSplitter->addWidget(chatGroupBox);
|
||||
// Splitter children are collapsible by default, so a zero size would hide
|
||||
// the whole chat group box including the tab bar. Keep it clamped to its
|
||||
// minimum size (title + tab bar) when collapsed.
|
||||
chatSplitter->setCollapsible(1, false);
|
||||
|
||||
auto *hbox = new QHBoxLayout;
|
||||
hbox->addWidget(splitter, 3);
|
||||
hbox->addWidget(chatSplitter, 3);
|
||||
hbox->addWidget(userListPanel, 1);
|
||||
|
||||
aLeaveRoom = new QAction(this);
|
||||
|
|
@ -189,7 +282,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
|||
void TabRoom::retranslateUi()
|
||||
{
|
||||
gameSelector->retranslateUi();
|
||||
chatView->retranslateUi();
|
||||
for (auto it = channelViews.constBegin(); it != channelViews.constEnd(); ++it) {
|
||||
it.value()->retranslateUi();
|
||||
}
|
||||
userListPanel->retranslateUi();
|
||||
sayLabel->setText(tr("&Say:"));
|
||||
chatGroupBox->setTitle(tr("Chat"));
|
||||
|
|
@ -197,6 +292,11 @@ void TabRoom::retranslateUi()
|
|||
aLeaveRoom->setText(tr("&Leave room"));
|
||||
aClearChat->setText(tr("&Clear chat"));
|
||||
aOpenChatSettings->setText(tr("Chat Settings..."));
|
||||
channelDisplayNames.insert(MAIN_CHANNEL, tr("Main"));
|
||||
for (const QString &channelId : channelIds) {
|
||||
updateChannelTab(channelId);
|
||||
}
|
||||
updateSayEnabled();
|
||||
}
|
||||
|
||||
void TabRoom::focusTab()
|
||||
|
|
@ -233,6 +333,11 @@ void TabRoom::tabActivated()
|
|||
if (!sayEdit->hasFocus()) {
|
||||
sayEdit->setFocus();
|
||||
}
|
||||
if (!chatCollapsed) {
|
||||
channelUnreadCounts[activeChannelId] = 0;
|
||||
channelMentioned[activeChannelId] = false;
|
||||
updateChannelTab(activeChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
QString TabRoom::sanitizeHtml(QString dirty) const
|
||||
|
|
@ -240,29 +345,232 @@ QString TabRoom::sanitizeHtml(QString dirty) const
|
|||
return dirty.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
void TabRoom::updateSayEnabled()
|
||||
{
|
||||
const bool writable = channelWritable.value(activeChannelId, true);
|
||||
sayLabel->setEnabled(writable);
|
||||
sayEdit->setEnabled(writable);
|
||||
sayEdit->setPlaceholderText(writable ? QString() : tr("This channel is read-only."));
|
||||
}
|
||||
|
||||
void TabRoom::sendMessage()
|
||||
{
|
||||
if (!channelWritable.value(activeChannelId, true)) {
|
||||
return;
|
||||
}
|
||||
if (sayEdit->text().isEmpty()) {
|
||||
return;
|
||||
} else if (sayEdit->hasVisibleCompleterPopup()) {
|
||||
sayEdit->hideCompleterPopups();
|
||||
return;
|
||||
} else {
|
||||
Command_RoomSay cmd;
|
||||
cmd.set_message(sayEdit->text().toStdString());
|
||||
ChatView *targetView = channelViews.value(activeChannelId, chatView);
|
||||
auto onFinished = [this, targetView](const Response &response) {
|
||||
if (response.response_code() == Response::RespChatFlood) {
|
||||
targetView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds."));
|
||||
} else if (response.response_code() == Response::RespFunctionNotAllowed ||
|
||||
response.response_code() == Response::RespInvalidCommand) {
|
||||
targetView->appendMessage(tr("Your message could not be sent: the channel is not available or you "
|
||||
"are not allowed to talk there."));
|
||||
}
|
||||
};
|
||||
|
||||
PendingCommand *pend = prepareRoomCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &TabRoom::sayFinished);
|
||||
sendRoomCommand(pend);
|
||||
if (activeChannelId.isEmpty()) {
|
||||
Command_RoomSay cmd;
|
||||
cmd.set_message(sayEdit->text().toStdString());
|
||||
|
||||
PendingCommand *pend = prepareRoomCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, onFinished);
|
||||
sendRoomCommand(pend);
|
||||
} else {
|
||||
Command_RoomChannelSay cmd;
|
||||
cmd.set_channel_id(activeChannelId.toStdString());
|
||||
cmd.set_message(sayEdit->text().toStdString());
|
||||
|
||||
PendingCommand *pend = prepareRoomCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, onFinished);
|
||||
sendRoomCommand(pend);
|
||||
}
|
||||
sayEdit->clear();
|
||||
}
|
||||
}
|
||||
|
||||
void TabRoom::sayFinished(const Response &response)
|
||||
void TabRoom::onChannelChanged(int index)
|
||||
{
|
||||
if (response.response_code() == Response::RespChatFlood) {
|
||||
chatView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds."));
|
||||
if (index < 0 || index >= channelIds.size()) {
|
||||
return;
|
||||
}
|
||||
activeChannelId = channelIds.at(index);
|
||||
ChatView *view = channelViews.value(activeChannelId);
|
||||
if (view) {
|
||||
chatView = view;
|
||||
chatViewStack->setCurrentWidget(view);
|
||||
}
|
||||
// Viewing a channel clears its unread state
|
||||
channelUnreadCounts[activeChannelId] = 0;
|
||||
channelMentioned[activeChannelId] = false;
|
||||
updateChannelTab(activeChannelId);
|
||||
updateSayEnabled();
|
||||
if (chatCollapsed) {
|
||||
setChatCollapsed(false);
|
||||
}
|
||||
}
|
||||
|
||||
void TabRoom::onChannelTabClicked(int index)
|
||||
{
|
||||
if (index < 0 || index >= channelIds.size()) {
|
||||
return;
|
||||
}
|
||||
// Clicking the active tab toggles collapse; clicking another tab switches to it
|
||||
if (channelTabBar->currentIndex() == index && channelIds.at(index) == activeChannelId) {
|
||||
setChatCollapsed(!chatCollapsed);
|
||||
}
|
||||
}
|
||||
|
||||
void TabRoom::setChatCollapsed(bool collapsed)
|
||||
{
|
||||
chatCollapsed = collapsed;
|
||||
chatViewStack->setVisible(!collapsed);
|
||||
sayWidget->setVisible(!collapsed);
|
||||
if (collapsed) {
|
||||
// Minimize the chat section of the splitter down to just the tab bar
|
||||
savedSplitterSizes = chatSplitter->sizes();
|
||||
chatSplitter->setSizes({1, 0});
|
||||
} else {
|
||||
// Expanding onto the active channel marks it read
|
||||
channelUnreadCounts[activeChannelId] = 0;
|
||||
channelMentioned[activeChannelId] = false;
|
||||
updateChannelTab(activeChannelId);
|
||||
chatViewStack->setCurrentWidget(chatView);
|
||||
if (!savedSplitterSizes.isEmpty()) {
|
||||
chatSplitter->setSizes(savedSplitterSizes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QColor TabRoom::parseColorHex(const QString &hex, const QColor &fallback)
|
||||
{
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0))
|
||||
QColor color = QColor::fromString("#" + hex);
|
||||
#else
|
||||
QColor color;
|
||||
color.setNamedColor("#" + hex);
|
||||
#endif
|
||||
return color.isValid() ? color : fallback;
|
||||
}
|
||||
|
||||
void TabRoom::updateChannelTab(const QString &channelId)
|
||||
{
|
||||
int index = channelIds.indexOf(channelId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
QString baseName = channelDisplayNames.value(channelId, channelId.isEmpty() ? tr("Main") : channelId);
|
||||
int unread = channelUnreadCounts.value(channelId, 0);
|
||||
bool mentioned = channelMentioned.value(channelId, false);
|
||||
|
||||
QString label = baseName;
|
||||
if (unread > 0) {
|
||||
label += tr(" (%1)").arg(unread);
|
||||
}
|
||||
channelTabBar->setTabText(index, label);
|
||||
|
||||
if (mentioned) {
|
||||
channelTabBar->setTabTextColor(
|
||||
index, parseColorHex(SettingsCache::instance().chat().getChannelMentionColor(), QColor(194, 31, 47)));
|
||||
} else if (unread > 0) {
|
||||
channelTabBar->setTabTextColor(
|
||||
index, parseColorHex(SettingsCache::instance().chat().getChannelUnreadColor(), QColor(26, 123, 0)));
|
||||
} else {
|
||||
channelTabBar->setTabTextColor(index, QColor());
|
||||
}
|
||||
}
|
||||
|
||||
bool TabRoom::messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const
|
||||
{
|
||||
if (ownUser == nullptr) {
|
||||
return false;
|
||||
}
|
||||
QString ownName = QString::fromStdString(ownUser->name());
|
||||
if (ownName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// Match ChatView::checkMention / checkWord: whole-word, case-insensitive.
|
||||
// "@zach" must not match "@zachary", and highlight words match full words.
|
||||
auto containsWholeWord = [](const QString &haystack, const QString &needle) {
|
||||
if (needle.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
QRegularExpression re(QStringLiteral("\\b%1\\b").arg(QRegularExpression::escape(needle)),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
return re.match(haystack).hasMatch();
|
||||
};
|
||||
|
||||
if (containsWholeWord(message, "@" + ownName)) {
|
||||
return true;
|
||||
}
|
||||
// ChatView only highlights @all for moderator/admin senders
|
||||
// (ChatView::isModeratorSendingGlobal). Gating here keeps the tab
|
||||
// mention color from drifting ahead of the message view.
|
||||
const uint32_t senderLevel = senderInfo.user_level();
|
||||
const bool senderIsPrivileged =
|
||||
(senderLevel & ServerInfo_User::IsModerator) || (senderLevel & ServerInfo_User::IsAdmin);
|
||||
if (senderIsPrivileged && containsWholeWord(message, "@all")) {
|
||||
return true;
|
||||
}
|
||||
const QStringList words = SettingsCache::instance().chat().getHighlightWords().split(' ', Qt::SkipEmptyParts);
|
||||
for (const QString &word : words) {
|
||||
if (containsWholeWord(message, word)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void TabRoom::markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo)
|
||||
{
|
||||
bool roomVisible = (tabSupervisor->currentWidget() == this);
|
||||
bool viewing = (channelId == activeChannelId) && !chatCollapsed && roomVisible;
|
||||
if (viewing) {
|
||||
return;
|
||||
}
|
||||
channelUnreadCounts[channelId] = channelUnreadCounts.value(channelId, 0) + 1;
|
||||
if (messageContainsMention(message, senderInfo)) {
|
||||
channelMentioned[channelId] = true;
|
||||
}
|
||||
updateChannelTab(channelId);
|
||||
}
|
||||
|
||||
bool TabRoom::isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const
|
||||
{
|
||||
if (userListProxy->isUserIgnored(senderName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
UserListTWI *twi = userList->getUsers().value(senderName);
|
||||
if (twi) {
|
||||
userInfoOut = twi->getUserInfo();
|
||||
if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() &&
|
||||
!UserLevelFlags(userInfoOut.user_level()).testFlag(ServerInfo_User::IsRegistered)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
userInfoOut = ServerInfo_User{};
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatView *TabRoom::makeChatView()
|
||||
{
|
||||
ChatView *view = new ChatView(tabSupervisor, nullptr, true, this);
|
||||
connect(view, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
|
||||
connect(view, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
|
||||
connect(view, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog);
|
||||
connect(view, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated);
|
||||
connect(view, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup);
|
||||
connect(view, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup);
|
||||
connect(view, &ChatView::addMentionTag, this, &TabRoom::addMentionTag);
|
||||
return view;
|
||||
}
|
||||
|
||||
void TabRoom::actClearChat()
|
||||
|
|
@ -298,6 +606,9 @@ void TabRoom::processRoomEvent(const RoomEvent &event)
|
|||
case RoomEvent::ROOM_SAY:
|
||||
processRoomSayEvent(event.GetExtension(Event_RoomSay::ext));
|
||||
break;
|
||||
case RoomEvent::ROOM_CHANNEL_SAY:
|
||||
processRoomChannelSayEvent(event.GetExtension(Event_RoomChannelSay::ext));
|
||||
break;
|
||||
case RoomEvent::REMOVE_MESSAGES:
|
||||
processRemoveMessagesEvent(event.GetExtension(Event_RemoveMessages::ext));
|
||||
break;
|
||||
|
|
@ -335,18 +646,9 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event)
|
|||
QString senderName = QString::fromStdString(event.name());
|
||||
QString message = QString::fromStdString(event.message());
|
||||
|
||||
if (userListProxy->isUserIgnored(senderName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
UserListTWI *twi = userList->getUsers().value(senderName);
|
||||
ServerInfo_User userInfo = {};
|
||||
if (twi) {
|
||||
userInfo = twi->getUserInfo();
|
||||
if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() &&
|
||||
!UserLevelFlags(userInfo.user_level()).testFlag(ServerInfo_User::IsRegistered)) {
|
||||
return;
|
||||
}
|
||||
if (isRoomMessageIgnored(senderName, userInfo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) {
|
||||
|
|
@ -360,7 +662,49 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event)
|
|||
"] " + message;
|
||||
}
|
||||
|
||||
chatView->appendMessage(message, event.message_type(), userInfo, true);
|
||||
ChatView *mainView = channelViews.value(MAIN_CHANNEL);
|
||||
if (mainView == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainView->appendMessage(message, event.message_type(), userInfo, true);
|
||||
if (event.message_type() != Event_RoomSay::ChatHistory) {
|
||||
markChannelMessage(MAIN_CHANNEL, message, userInfo);
|
||||
}
|
||||
emit userEvent(false);
|
||||
}
|
||||
|
||||
void TabRoom::processRoomChannelSayEvent(const Event_RoomChannelSay &event)
|
||||
{
|
||||
const QString channelId = QString::fromStdString(event.channel_id());
|
||||
ChatView *view = channelViews.value(channelId);
|
||||
if (view == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString senderName = QString::fromStdString(event.name());
|
||||
QString message = QString::fromStdString(event.message());
|
||||
|
||||
ServerInfo_User userInfo = {};
|
||||
if (isRoomMessageIgnored(senderName, userInfo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (senderName.isEmpty()) {
|
||||
if (!SettingsCache::instance().chat().getRoomHistory()) {
|
||||
return;
|
||||
}
|
||||
message =
|
||||
"[" +
|
||||
QString(QDateTime::fromMSecsSinceEpoch(event.time_of()).toLocalTime().toString("d MMM yyyy HH:mm:ss")) +
|
||||
"] " + message;
|
||||
}
|
||||
|
||||
view->appendMessage(message, {}, userInfo, true);
|
||||
if (!senderName.isEmpty()) {
|
||||
// Empty senders mark replayed history, which must not count as unread
|
||||
markChannelMessage(channelId, message, userInfo);
|
||||
}
|
||||
emit userEvent(false);
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +712,9 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event)
|
|||
{
|
||||
QString userName = QString::fromStdString(event.name());
|
||||
int amount = event.amount();
|
||||
chatView->redactMessages(userName, amount);
|
||||
for (ChatView *view : channelViews) {
|
||||
view->redactMessages(userName, amount);
|
||||
}
|
||||
}
|
||||
|
||||
void TabRoom::refreshShortcuts()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
#include "tab.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
|
|
@ -18,8 +19,12 @@
|
|||
|
||||
class UserListProxy;
|
||||
class QAction;
|
||||
class QTabBar;
|
||||
class QWidget;
|
||||
class QGroupBox;
|
||||
class QSplitter;
|
||||
class QMenu;
|
||||
class QStackedWidget;
|
||||
class QStringListModel;
|
||||
class TabSupervisor;
|
||||
|
||||
|
|
@ -42,6 +47,7 @@ class Event_ListGames;
|
|||
class Event_JoinRoom;
|
||||
class Event_LeaveRoom;
|
||||
class Event_RoomSay;
|
||||
class Event_RoomChannelSay;
|
||||
class Event_RemoveMessages;
|
||||
class GameSelector;
|
||||
class Response;
|
||||
|
|
@ -64,10 +70,23 @@ private:
|
|||
UserListWidget *userList;
|
||||
const UserListProxy *userListProxy;
|
||||
ChatView *chatView;
|
||||
QStackedWidget *chatViewStack;
|
||||
QTabBar *channelTabBar;
|
||||
QWidget *sayWidget;
|
||||
QMap<QString, ChatView *> channelViews;
|
||||
QMap<QString, QString> channelDisplayNames;
|
||||
QMap<QString, int> channelUnreadCounts;
|
||||
QMap<QString, bool> channelMentioned;
|
||||
QMap<QString, bool> channelWritable;
|
||||
QStringList channelIds;
|
||||
QString activeChannelId;
|
||||
bool chatCollapsed;
|
||||
QLabel *sayLabel;
|
||||
LineEditCompleter *sayEdit;
|
||||
QStringListModel *mentionModel;
|
||||
QGroupBox *chatGroupBox;
|
||||
QSplitter *chatSplitter;
|
||||
QList<int> savedSplitterSizes;
|
||||
|
||||
QMenu *roomMenu;
|
||||
QAction *aLeaveRoom;
|
||||
|
|
@ -85,7 +104,9 @@ signals:
|
|||
void gameListUpdated();
|
||||
private slots:
|
||||
void sendMessage();
|
||||
void sayFinished(const Response &response);
|
||||
void onChannelChanged(int index);
|
||||
void onChannelTabClicked(int index);
|
||||
void setChatCollapsed(bool collapsed);
|
||||
void actClearChat();
|
||||
void actOpenChatSettings();
|
||||
void addMentionTag(QString mentionTag);
|
||||
|
|
@ -98,9 +119,18 @@ private slots:
|
|||
void processJoinRoomEvent(const Event_JoinRoom &event);
|
||||
void processLeaveRoomEvent(const Event_LeaveRoom &event);
|
||||
void processRoomSayEvent(const Event_RoomSay &event);
|
||||
void processRoomChannelSayEvent(const Event_RoomChannelSay &event);
|
||||
void processRemoveMessagesEvent(const Event_RemoveMessages &event);
|
||||
void refreshShortcuts();
|
||||
|
||||
ChatView *makeChatView();
|
||||
void updateChannelTab(const QString &channelId);
|
||||
void updateSayEnabled();
|
||||
void markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo);
|
||||
[[nodiscard]] bool messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const;
|
||||
[[nodiscard]] bool isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const;
|
||||
static QColor parseColorHex(const QString &hex, const QColor &fallback);
|
||||
|
||||
protected slots:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
|
|
|
|||
|
|
@ -133,29 +133,33 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event)
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!above) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *popup = completer->popup();
|
||||
const int rowCount = completer->completionModel()->rowCount();
|
||||
if (rowCount == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle navigation ourselves so arrow keys move the highlight without
|
||||
// rewriting the line edit. QCompleter's default handling selects with a
|
||||
// selection change that QLineEdit turns into setText(), which fights the
|
||||
// user while typing (e.g. typing "A" becomes "Axe" and cannot be edited).
|
||||
// With the popup above the text edit the rows are shown in reverse order and
|
||||
// the closest match is in the bottom-most row. The up arrow then advances
|
||||
// through the list (towards its end at the top of the popup).
|
||||
const int currentRow = popup->currentIndex().row();
|
||||
const int step = (key == Qt::Key_Up) ? -1 : 1;
|
||||
const int newRow = qBound(0, currentRow + step, rowCount - 1);
|
||||
|
||||
if (newRow != currentRow) {
|
||||
const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn());
|
||||
popup->selectionModel()->setCurrentIndex(newIndex,
|
||||
QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
|
||||
const QModelIndex current = popup->currentIndex();
|
||||
int newRow;
|
||||
if (!current.isValid()) {
|
||||
newRow = (key == Qt::Key_Down) ? 0 : rowCount - 1;
|
||||
} else {
|
||||
const int step = (key == Qt::Key_Up) ? -1 : 1;
|
||||
newRow = qBound(0, current.row() + step, rowCount - 1);
|
||||
if (newRow == current.row()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
selectWithoutRewrite(completer->completionModel()->index(newRow, completer->completionColumn()));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -218,8 +222,37 @@ void CardCompleterStyler::ensureClosestSelected()
|
|||
const int closestRow = rowCount - 1;
|
||||
|
||||
if (currentRow != closestRow) {
|
||||
popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()),
|
||||
QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
|
||||
selectWithoutRewrite(completionModel->index(closestRow, completer->completionColumn()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void CardCompleterStyler::selectWithoutRewrite(const QModelIndex &index)
|
||||
{
|
||||
auto *popup = completer->popup();
|
||||
auto *selectionModel = popup->selectionModel();
|
||||
|
||||
// Changing the popup selection emits selectionChanged, which QCompleter
|
||||
// turns into highlighted() and QLineEdit turns into setText(). That
|
||||
// rewrites what the user typed (e.g. "A" becomes "Axe") and fights further
|
||||
// typing. Block the signals so the highlight and preview move without
|
||||
// touching the line edit; Enter/click still activates via activated().
|
||||
const bool wasBlocked = selectionModel->blockSignals(true);
|
||||
if (!index.isValid()) {
|
||||
selectionModel->clear();
|
||||
} else {
|
||||
selectionModel->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
|
||||
}
|
||||
selectionModel->blockSignals(wasBlocked);
|
||||
popup->viewport()->update();
|
||||
|
||||
// currentRowChanged was suppressed above, so sync the preview manually.
|
||||
updatePreview(index);
|
||||
if (index.isValid()) {
|
||||
popup->scrollTo(index, QAbstractItemView::EnsureVisible);
|
||||
} else {
|
||||
popup->scrollToTop();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ private:
|
|||
void applyPopupWidth();
|
||||
void updateOrientation();
|
||||
void ensureClosestSelected();
|
||||
void selectWithoutRewrite(const QModelIndex &index);
|
||||
|
||||
bool handlePopupKeyPress(QKeyEvent *event);
|
||||
bool isPopupAboveWidget() const;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
#include "../interface/widgets/tabs/tab_supervisor.h"
|
||||
#include "../main.h"
|
||||
#include "abstract_client.h"
|
||||
#include "at_risk_data_warning.h"
|
||||
#include "card_database_update/card_database_update_status_bar.h"
|
||||
#include "card_database_update/card_update_progress.h"
|
||||
#include "intents/contexts/context_connect_to_server.h"
|
||||
|
|
@ -58,13 +59,12 @@
|
|||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QButtonGroup>
|
||||
#include <QCheckBox>
|
||||
#include <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDesktopServices>
|
||||
#include <QDialog>
|
||||
#include <QDir>
|
||||
#include <QEvent>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
|
|
@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent)
|
|||
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
|
||||
}
|
||||
|
||||
void MainWindow::warnAboutDataPathsInProgramDir()
|
||||
{
|
||||
auto &paths = SettingsCache::instance().paths();
|
||||
if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath());
|
||||
if (offenders.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"),
|
||||
tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
|
||||
"Cockatrice keeps its own data outside the program directory by default. Files "
|
||||
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
|
||||
"Change them in the Settings dialog - most are on the General page - to keep "
|
||||
"them safe.")
|
||||
.arg(offenders.join(QLatin1Char('\n'))),
|
||||
QMessageBox::Ok, this);
|
||||
box.setTextFormat(Qt::PlainText);
|
||||
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
|
||||
box.setCheckBox(dontShowAgain);
|
||||
box.exec();
|
||||
if (dontShowAgain->isChecked()) {
|
||||
paths.setAppDirWarningAcknowledged(true);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::startupConfigCheck()
|
||||
{
|
||||
warnAboutDataPathsInProgramDir();
|
||||
AtRiskDataWarning::warnAboutDataPathsInProgramDir(this);
|
||||
|
||||
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
|
||||
|
||||
|
|
@ -1029,7 +1000,8 @@ void MainWindow::attemptStartupAutoConnect()
|
|||
qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect...";
|
||||
DlgConnect dlg(this);
|
||||
connectionController->connectToServerDirect(dlg.getHost(), static_cast<unsigned int>(dlg.getPort()),
|
||||
dlg.getPlayerName(), dlg.getPassword());
|
||||
dlg.getPlayerName(), dlg.getPassword(), dlg.getStoredVerifier(),
|
||||
dlg.getSaveName(), dlg.getSavePassword());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,9 +149,6 @@ private:
|
|||
|
||||
void runFirstRunWizard(bool firstRun = false);
|
||||
|
||||
/** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */
|
||||
void warnAboutDataPathsInProgramDir();
|
||||
|
||||
inline QString getCardUpdaterBinaryName()
|
||||
{
|
||||
return "oracle";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ public:
|
|||
[[nodiscard]] virtual bool getChatMentionCompleter() const = 0;
|
||||
[[nodiscard]] virtual QString getChatMentionColor() const = 0;
|
||||
[[nodiscard]] virtual QString getChatHighlightColor() const = 0;
|
||||
[[nodiscard]] virtual QString getChannelUnreadColor() const = 0;
|
||||
[[nodiscard]] virtual QString getChannelMentionColor() const = 0;
|
||||
[[nodiscard]] virtual bool getChatMentionForeground() const = 0;
|
||||
[[nodiscard]] virtual bool getChatHighlightForeground() const = 0;
|
||||
[[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
#include <libcockatrice/protocol/pending_command.h>
|
||||
|
||||
AbstractClient::AbstractClient(QObject *parent)
|
||||
: QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false)
|
||||
: QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false),
|
||||
serverSupportsChallengeResponse(false)
|
||||
{
|
||||
qRegisterMetaType<QVariant>("QVariant");
|
||||
qRegisterMetaType<CommandContainer>("CommandContainer");
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ protected:
|
|||
QMap<int, PendingCommand *> pendingCommands;
|
||||
QString userName, password, email, country, realName, token;
|
||||
bool serverSupportsPasswordHash;
|
||||
bool serverSupportsChallengeResponse;
|
||||
void setStatus(ClientStatus _status);
|
||||
int getNewCmdId()
|
||||
{
|
||||
|
|
@ -166,6 +167,10 @@ public:
|
|||
{
|
||||
return serverSupportsPasswordHash;
|
||||
}
|
||||
bool getServerSupportsChallengeResponse() const
|
||||
{
|
||||
return serverSupportsChallengeResponse;
|
||||
}
|
||||
const QString &getUserName() const
|
||||
{
|
||||
return userName;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ static const unsigned int protocolVersion = 14;
|
|||
|
||||
RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider)
|
||||
: AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0),
|
||||
messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword()
|
||||
messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword(),
|
||||
passwordNeedsMigration(false)
|
||||
{
|
||||
|
||||
clearNewClientFeatures();
|
||||
|
|
@ -129,6 +130,11 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
|
|||
return;
|
||||
}
|
||||
serverSupportsPasswordHash = event.server_options() & Event_ServerIdentification::SupportsPasswordHash;
|
||||
// Challenge-response is advertised in its own field so server_options stays
|
||||
// exactly SupportsPasswordHash on the wire for already-released clients
|
||||
// (proto2 enums are closed: an unknown value parses as NoOptions and those
|
||||
// clients would fall back to plaintext passwords).
|
||||
serverSupportsChallengeResponse = event.has_supports_challenge_response() && event.supports_challenge_response();
|
||||
|
||||
if (getStatus() == StatusRequestingForgotPassword) {
|
||||
Command_ForgotPasswordRequest cmdForgotPasswordRequest;
|
||||
|
|
@ -145,7 +151,13 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
|
|||
cmdForgotPasswordReset.set_user_name(userName.toStdString());
|
||||
cmdForgotPasswordReset.set_clientid(getSrvClientID(lastHostname).toStdString());
|
||||
cmdForgotPasswordReset.set_token(token.toStdString());
|
||||
if (!password.isEmpty() && serverSupportsPasswordHash) {
|
||||
if (!password.isEmpty() && serverSupportsChallengeResponse) {
|
||||
hashedPassword = PasswordHasher::generatePasswordVerifier(password);
|
||||
// Only this branch yields a challenge-response verifier worth persisting; the
|
||||
// legacy-hash and plaintext branches below must not be saved under the password key.
|
||||
derivedVerifier = hashedPassword;
|
||||
cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString());
|
||||
} else if (!password.isEmpty() && serverSupportsPasswordHash) {
|
||||
auto passwordSalt = PasswordHasher::generateRandomSalt();
|
||||
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
||||
cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString());
|
||||
|
|
@ -173,7 +185,10 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
|
|||
if (getStatus() == StatusRegistering) {
|
||||
Command_Register cmdRegister;
|
||||
cmdRegister.set_user_name(userName.toStdString());
|
||||
if (!password.isEmpty() && serverSupportsPasswordHash) {
|
||||
if (!password.isEmpty() && serverSupportsChallengeResponse) {
|
||||
hashedPassword = PasswordHasher::generatePasswordVerifier(password);
|
||||
cmdRegister.set_hashed_password(hashedPassword.toStdString());
|
||||
} else if (!password.isEmpty() && serverSupportsPasswordHash) {
|
||||
auto passwordSalt = PasswordHasher::generateRandomSalt();
|
||||
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
||||
cmdRegister.set_hashed_password(hashedPassword.toStdString());
|
||||
|
|
@ -238,7 +253,9 @@ Command_Login RemoteClient::generateCommandLogin()
|
|||
|
||||
void RemoteClient::doLogin()
|
||||
{
|
||||
if (!password.isEmpty() && serverSupportsPasswordHash) {
|
||||
if ((!password.isEmpty() || !storedVerifier.isEmpty()) && serverSupportsChallengeResponse) {
|
||||
doRequestPasswordSalt(); // ask salt + nonce to build the challenge response
|
||||
} else if (!password.isEmpty() && serverSupportsPasswordHash) {
|
||||
//! \todo Store and log in using stored hashed password.
|
||||
if (hashedPassword.isEmpty()) {
|
||||
doRequestPasswordSalt(); // ask salt to create hashedPassword, then log in
|
||||
|
|
@ -272,6 +289,30 @@ void RemoteClient::doHashedLogin()
|
|||
sendCommand(pend);
|
||||
}
|
||||
|
||||
void RemoteClient::doSubmitPasswordVerifier()
|
||||
{
|
||||
pendingVerifier = PasswordHasher::generatePasswordVerifier(password);
|
||||
Command_SubmitPasswordVerifier cmdSubmitVerifier;
|
||||
cmdSubmitVerifier.set_password_verifier(pendingVerifier.toStdString());
|
||||
|
||||
PendingCommand *pend = prepareSessionCommand(cmdSubmitVerifier);
|
||||
connect(pend, &PendingCommand::finished, this, &RemoteClient::submitPasswordVerifierResponse);
|
||||
sendCommand(pend);
|
||||
}
|
||||
|
||||
void RemoteClient::submitPasswordVerifierResponse(const Response &response)
|
||||
{
|
||||
if (response.response_code() == Response::RespOk) {
|
||||
qCDebug(RemoteClientLog) << "Password verifier migrated successfully";
|
||||
if (!pendingVerifier.isEmpty()) {
|
||||
emit sigPasswordVerifierReady(pendingVerifier);
|
||||
pendingVerifier.clear();
|
||||
}
|
||||
} else {
|
||||
qCWarning(RemoteClientLog) << "Failed to migrate password verifier:" << response.response_code();
|
||||
}
|
||||
}
|
||||
|
||||
void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/)
|
||||
{
|
||||
doDisconnectFromServer();
|
||||
|
|
@ -284,7 +325,70 @@ void RemoteClient::passwordSaltResponse(const Response &response)
|
|||
auto passwordSalt = QString::fromStdString(resp.password_salt());
|
||||
if (passwordSalt.isEmpty()) { // the server does not recognize the user but allows them to enter unregistered
|
||||
password.clear(); // the password will not be used
|
||||
storedVerifier.clear();
|
||||
doLogin();
|
||||
} else if (serverSupportsChallengeResponse && resp.has_nonce()) {
|
||||
const QByteArray nonce = QByteArray::fromStdString(resp.nonce());
|
||||
// needs_migration is a server-controlled flag over an unauthenticated
|
||||
// transport (plain TCP by default). If we already hold a scrypt
|
||||
// verifier for this account, a server telling us to fall back to the
|
||||
// 1000-round legacy hash is a downgrade of the KDF — refuse it rather
|
||||
// than hand an attacker a weakly-keyed HMAC they can attack offline.
|
||||
if (resp.needs_migration() && PasswordHasher::parsePasswordVerifier(storedVerifier).isValid) {
|
||||
emit loginError(Response::RespClientUpdateRequired,
|
||||
QStringLiteral("Server asked to downgrade authentication for a migrated account."), 0,
|
||||
{});
|
||||
return;
|
||||
}
|
||||
QByteArray key;
|
||||
if (resp.needs_migration()) {
|
||||
// The account still uses the legacy format; the legacy full hash
|
||||
// is only derivable from the plaintext password.
|
||||
if (password.isEmpty()) {
|
||||
emit loginError(Response::RespClientUpdateRequired,
|
||||
QStringLiteral("This account must be logged in with its password once."), 0, {});
|
||||
return;
|
||||
}
|
||||
key = PasswordHasher::computeHash(password, passwordSalt).toUtf8();
|
||||
} else if (!password.isEmpty()) {
|
||||
// A hostile server must not be able to make us run or allocate for
|
||||
// unreasonable scrypt parameters.
|
||||
const int n = resp.has_n() ? resp.n() : SCRYPT_N;
|
||||
const int r = resp.has_r() ? resp.r() : SCRYPT_R;
|
||||
const int p = resp.has_p() ? resp.p() : SCRYPT_P;
|
||||
if (!PasswordHasher::costParamsAreSane(n, r, p)) {
|
||||
emit loginError(Response::RespClientUpdateRequired,
|
||||
QStringLiteral("The server requested unreasonable scrypt cost parameters."), 0, {});
|
||||
return;
|
||||
}
|
||||
// password_salt may carry a legacy backup salt for pre-challenge clients;
|
||||
// the verifier itself is always derived from scrypt_salt when present.
|
||||
const QString scryptSalt =
|
||||
resp.has_scrypt_salt() ? QString::fromStdString(resp.scrypt_salt()) : passwordSalt;
|
||||
key = PasswordHasher::deriveKey(password, QByteArray::fromBase64(scryptSalt.toUtf8()), n, r, p);
|
||||
if (key.isEmpty()) {
|
||||
emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Unable to derive verifier."), 0,
|
||||
{});
|
||||
return;
|
||||
}
|
||||
derivedVerifier =
|
||||
QString("$scrypt$%1$%2$%3$%4$%5").arg(n).arg(r).arg(p).arg(scryptSalt).arg(QString(key.toBase64()));
|
||||
} else if (!storedVerifier.isEmpty()) {
|
||||
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedVerifier);
|
||||
if (!verifier.isValid) {
|
||||
emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Stored verifier is invalid."),
|
||||
0, {});
|
||||
return;
|
||||
}
|
||||
key = verifier.verifier;
|
||||
} else {
|
||||
emit loginError(Response::RespLoginNeeded, {}, 0, {});
|
||||
return;
|
||||
}
|
||||
passwordNeedsMigration = resp.needs_migration();
|
||||
const QByteArray responseBytes = PasswordHasher::computeResponse(key, nonce);
|
||||
hashedPassword = "$challenge$" + QString(nonce.toBase64()) + "$" + QString(responseBytes.toBase64());
|
||||
doHashedLogin();
|
||||
} else {
|
||||
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
||||
doHashedLogin();
|
||||
|
|
@ -309,6 +413,14 @@ void RemoteClient::loginResponse(const Response &response)
|
|||
setStatus(StatusLoggedIn);
|
||||
emit userInfoChanged(resp.user_info());
|
||||
|
||||
if (passwordNeedsMigration) {
|
||||
// The account still used the legacy password format; upgrade it to scrypt.
|
||||
doSubmitPasswordVerifier();
|
||||
} else if (!derivedVerifier.isEmpty()) {
|
||||
emit sigPasswordVerifierReady(derivedVerifier);
|
||||
derivedVerifier.clear();
|
||||
}
|
||||
|
||||
QList<ServerInfo_User> buddyList;
|
||||
for (int i = resp.buddy_list_size() - 1; i >= 0; --i) {
|
||||
buddyList.append(resp.buddy_list(i));
|
||||
|
|
@ -565,6 +677,8 @@ void RemoteClient::doDisconnectFromServer()
|
|||
websocket->close();
|
||||
}
|
||||
socket->close();
|
||||
derivedVerifier.clear();
|
||||
pendingVerifier.clear();
|
||||
}
|
||||
|
||||
void RemoteClient::ping()
|
||||
|
|
@ -736,6 +850,12 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response)
|
|||
{
|
||||
if (response.response_code() == Response::RespOk) {
|
||||
emit sigForgotPasswordSuccess();
|
||||
// Persist only a real scrypt verifier; a legacy hash must not be stored under
|
||||
// the password key, where it would break future challenge-response logins.
|
||||
if (!derivedVerifier.isEmpty()) {
|
||||
emit sigPasswordVerifierReady(derivedVerifier);
|
||||
derivedVerifier.clear();
|
||||
}
|
||||
} else {
|
||||
emit sigForgotPasswordError();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ signals:
|
|||
unsigned int port,
|
||||
const QString &_userName,
|
||||
const QString &_email);
|
||||
//! \brief Emitted once a scrypt verifier for the given account is known and
|
||||
//! can be persisted instead of the plaintext password. The receiving side
|
||||
//! should only store it for the connection it is currently negotiating, so
|
||||
//! the hostname and user name are intentionally not part of the signal.
|
||||
void sigPasswordVerifierReady(const QString &verifier);
|
||||
private slots:
|
||||
void slotConnected();
|
||||
void readData();
|
||||
|
|
@ -92,6 +97,8 @@ private slots:
|
|||
void doLogin();
|
||||
void doHashedLogin();
|
||||
Command_Login generateCommandLogin();
|
||||
void doSubmitPasswordVerifier();
|
||||
void submitPasswordVerifierResponse(const Response &response);
|
||||
void doDisconnectFromServer();
|
||||
void doActivateToServer(const QString &_token);
|
||||
void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName);
|
||||
|
|
@ -123,6 +130,14 @@ private:
|
|||
QString lastHostname;
|
||||
unsigned int lastPort;
|
||||
QString hashedPassword;
|
||||
bool passwordNeedsMigration;
|
||||
//! \brief A previously stored "$scrypt$..." verifier used to authenticate
|
||||
//! without the plaintext password.
|
||||
QString storedVerifier;
|
||||
//! \brief Verifier derived during the current login, persisted after success.
|
||||
QString derivedVerifier;
|
||||
//! \brief Verifier sent for migration, persisted once the server accepts it.
|
||||
QString pendingVerifier;
|
||||
|
||||
QString getSrvClientID(const QString &_hostname);
|
||||
bool newMissingFeatureFound(const QString &_serversMissingFeatures);
|
||||
|
|
@ -161,6 +176,12 @@ public:
|
|||
}
|
||||
void
|
||||
connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password);
|
||||
//! \brief Provide a stored "$scrypt$..." verifier so the client can
|
||||
//! authenticate without the plaintext password.
|
||||
void setStoredVerifier(const QString &verifier)
|
||||
{
|
||||
storedVerifier = verifier;
|
||||
}
|
||||
void registerToServer(const QString &hostname,
|
||||
unsigned int port,
|
||||
const QString &_userName,
|
||||
|
|
|
|||
|
|
@ -257,6 +257,19 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r
|
|||
return Response::RespInternalError;
|
||||
}
|
||||
|
||||
// Card art rules apply to playmats as well: never broadcast a DENY-listed
|
||||
// printing as a playmat. Stripping keeps the deck selectable while hiding
|
||||
// the banned art from all clients.
|
||||
const auto deckPlaymat = newDeck->getPlaymat();
|
||||
if (!deckPlaymat.card.name.isEmpty()) {
|
||||
auto *db = game->getRoom()->getServer()->getDatabaseInterface();
|
||||
// Check the truncated values actually broadcast below; the rule columns are varchar(255).
|
||||
if (db && !db->isCardArtAllowed(deckPlaymat.card.name.left(MAX_NAME_LENGTH),
|
||||
deckPlaymat.card.providerId.left(MAX_NAME_LENGTH))) {
|
||||
newDeck->setPlaymat({});
|
||||
}
|
||||
}
|
||||
|
||||
delete deck;
|
||||
deck = newDeck;
|
||||
sideboardLocked = true;
|
||||
|
|
@ -631,6 +644,14 @@ Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &r
|
|||
if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) {
|
||||
return Response::RespInvalidData;
|
||||
}
|
||||
// Card art rules apply to playmats as well: reject DENY-listed art, like
|
||||
// cmdSetCardArtParams does. Empty clears the playmat and is always allowed.
|
||||
if (!rawName.isEmpty()) {
|
||||
auto *db = game->getRoom()->getServer()->getDatabaseInterface();
|
||||
if (db && !db->isCardArtAllowed(rawName, rawProviderId)) {
|
||||
return Response::RespFunctionNotAllowed;
|
||||
}
|
||||
}
|
||||
PlaymatInfo playmat;
|
||||
playmat.card.name = rawName;
|
||||
playmat.card.providerId = rawProviderId;
|
||||
|
|
|
|||
|
|
@ -441,7 +441,7 @@ void Server::externalRoomUserLeft(int roomId, const QString &userName)
|
|||
room->removeExternalUser(userName);
|
||||
}
|
||||
|
||||
void Server::externalRoomSay(int roomId, const QString &userName, const QString &message)
|
||||
void Server::externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId)
|
||||
{
|
||||
// This function is always called from the main thread via signal/slot.
|
||||
QReadLocker locker(&roomsLock);
|
||||
|
|
@ -451,10 +451,22 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString
|
|||
qDebug() << "externalRoomSay: room id=" << roomId << "not found";
|
||||
return;
|
||||
}
|
||||
room->say(userName, message, false);
|
||||
if (!channelId.isEmpty() && !Server_Room::isMainChannel(channelId) && room->findChannel(channelId) == nullptr) {
|
||||
// The receiver does not know this channel; say() would drop it, so do
|
||||
// not log what was never delivered.
|
||||
qDebug() << "externalRoomSay: unknown channel" << channelId << "for room id=" << roomId << "dropping";
|
||||
return;
|
||||
}
|
||||
room->say(userName, message, channelId, false);
|
||||
|
||||
getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom,
|
||||
room->getId(), room->getName());
|
||||
if (channelId.isEmpty()) {
|
||||
getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom,
|
||||
room->getId(), room->getName());
|
||||
} else {
|
||||
getDatabaseInterface()->logMessage(0, userName, "ISL", message,
|
||||
Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(),
|
||||
room->getName(), channelId);
|
||||
}
|
||||
}
|
||||
|
||||
void Server::externalRoomRemoveMessages(int roomId, const QString &userName, int amount)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ public:
|
|||
{
|
||||
return QMap<QString, bool>();
|
||||
}
|
||||
/** @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<QString> getOnlineModeratorList() const;
|
||||
|
|
@ -265,7 +270,7 @@ protected slots:
|
|||
void externalUserLeft(const QString &userName);
|
||||
void externalRoomUserJoined(int roomId, const ServerInfo_User &userInfo);
|
||||
void externalRoomUserLeft(int roomId, const QString &userName);
|
||||
void externalRoomSay(int roomId, const QString &userName, const QString &message);
|
||||
void externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId);
|
||||
void externalRoomRemoveMessages(int roomId, const QString &userName, int amount);
|
||||
void externalRoomGameListChanged(int roomId, const ServerInfo_Game &gameInfo);
|
||||
void
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ public:
|
|||
{
|
||||
return {};
|
||||
}
|
||||
virtual QString getUserPasswordData(const QString & /* user */)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
virtual bool submitPasswordVerifier(const QString & /* user */, const QString & /* passwordVerifier */)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
virtual QMap<QString, ServerInfo_User> getBuddyList(const QString & /* name */)
|
||||
{
|
||||
return QMap<QString, ServerInfo_User>();
|
||||
|
|
@ -151,7 +159,8 @@ public:
|
|||
MessageTargetRoom,
|
||||
MessageTargetGame,
|
||||
MessageTargetChat,
|
||||
MessageTargetIslRoom
|
||||
MessageTargetIslRoom,
|
||||
MessageTargetRoomChannel
|
||||
};
|
||||
virtual void logMessage(const int /* senderId */,
|
||||
const QString & /* senderName */,
|
||||
|
|
@ -159,7 +168,8 @@ public:
|
|||
const QString & /* logMessage */,
|
||||
LogMessage_TargetType /* targetType */,
|
||||
const int /* targetId */,
|
||||
const QString & /* targetName */)
|
||||
const QString & /* targetName */,
|
||||
const QString & /* channel */ = QString())
|
||||
{
|
||||
}
|
||||
virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */,
|
||||
|
|
@ -188,6 +198,16 @@ public:
|
|||
virtual void setForcePasswordChange(const QString & /* user */, bool /* force */)
|
||||
{
|
||||
}
|
||||
/**
|
||||
* @brief Whether the given card art may be shown (card art rules).
|
||||
*
|
||||
* Default allows everything; the real server overrides this with a lookup
|
||||
* in {prefix}_card_art_name_rules. Empty names are always allowed (clear path).
|
||||
*/
|
||||
virtual bool isCardArtAllowed(const QString & /* cardName */, const QString & /* providerId */)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
#include "server_protocolhandler.h"
|
||||
|
||||
// Challenge-response nonces are valid for at most one minute from issuance.
|
||||
static constexpr qint64 kAuthNonceLifetimeSeconds = 60;
|
||||
|
||||
#include "game/game_config.h"
|
||||
#include "game/server_abstract_participant.h"
|
||||
#include "game/server_game.h"
|
||||
|
|
@ -13,6 +16,7 @@
|
|||
#include "libcockatrice/protocol/pb/server_message.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_game.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_room_channel.pb.h"
|
||||
#include "libcockatrice/protocol/pb/session_commands.pb.h"
|
||||
#include "libcockatrice/protocol/pb/session_event.pb.h"
|
||||
#include "server_database_interface.h"
|
||||
|
|
@ -40,6 +44,7 @@
|
|||
#include <libcockatrice/protocol/pb/commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_list_rooms.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_notify_user.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_user_message.pb.h>
|
||||
|
|
@ -70,6 +75,28 @@ Server_ProtocolHandler::~Server_ProtocolHandler()
|
|||
{
|
||||
}
|
||||
|
||||
void Server_ProtocolHandler::setAuthNonce(const QByteArray &nonce, const QString &userName)
|
||||
{
|
||||
authNonce = nonce;
|
||||
authNonceUser = userName;
|
||||
authNonceCreated = QDateTime::currentDateTimeUtc();
|
||||
}
|
||||
|
||||
bool Server_ProtocolHandler::isAuthNonceValid(const QByteArray &nonce, const QString &userName) const
|
||||
{
|
||||
// secsTo is signed, so a wall-clock step backwards (NTP correction, VM resume)
|
||||
// must not make the elapsed time negative and re-validate an old nonce.
|
||||
const qint64 elapsed = authNonceCreated.secsTo(QDateTime::currentDateTimeUtc());
|
||||
return !authNonce.isEmpty() && authNonce == nonce && authNonceUser == userName && authNonceCreated.isValid() &&
|
||||
elapsed >= 0 && elapsed < kAuthNonceLifetimeSeconds;
|
||||
}
|
||||
|
||||
void Server_ProtocolHandler::clearAuthNonce()
|
||||
{
|
||||
authNonce.clear();
|
||||
authNonceUser.clear();
|
||||
}
|
||||
|
||||
// This function must only be called from the thread this object lives in.
|
||||
// Except when the server is shutting down.
|
||||
// The thread must not hold any server locks when calling this (e.g. clientsLock, roomsLock).
|
||||
|
|
@ -252,6 +279,9 @@ Response::ResponseCode Server_ProtocolHandler::processRoomCommandContainer(const
|
|||
case RoomCommand::ROOM_SAY:
|
||||
resp = cmdRoomSay(sc.GetExtension(Command_RoomSay::ext), room, rc);
|
||||
break;
|
||||
case RoomCommand::ROOM_CHANNEL_SAY:
|
||||
resp = cmdRoomChannelSay(sc.GetExtension(Command_RoomChannelSay::ext), room, rc);
|
||||
break;
|
||||
case RoomCommand::CREATE_GAME:
|
||||
resp = cmdCreateGame(sc.GetExtension(Command_CreateGame::ext), room, rc);
|
||||
break;
|
||||
|
|
@ -563,6 +593,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
|||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
// In strict mode only challenge-response logins are accepted, but only for
|
||||
// registered SQL accounts: guests (no DB row) and non-SQL authentication
|
||||
// methods have no stored verifier to challenge against. userExists is false
|
||||
// for both (it requires AuthenticationSql), so a single check covers them.
|
||||
const bool isRegisteredAccount = databaseInterface != nullptr && databaseInterface->userExists(userName);
|
||||
if (server->requiresChallengeResponseAuth() && isRegisteredAccount &&
|
||||
(!cmd.has_hashed_password() || cmd.hashed_password().rfind("$challenge$", 0) != 0)) {
|
||||
auto *re = new Response_Login;
|
||||
re->set_denied_reason_str("Client upgrade required");
|
||||
re->add_missing_features("challenge_response_auth");
|
||||
rc.setResponseExtension(re);
|
||||
return Response::RespClientUpdateRequired;
|
||||
}
|
||||
|
||||
// check client feature set against server feature set
|
||||
FeatureSet features;
|
||||
QMap<QString, bool> receivedClientFeatures;
|
||||
|
|
@ -764,7 +808,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetGamesOfUser(const Command_G
|
|||
while (roomIterator.hasNext()) {
|
||||
Server_Room *room = roomIterator.next().value();
|
||||
room->gamesLock.lockForRead();
|
||||
room->getInfo(*re->add_room_list(), false, true);
|
||||
room->getInfo(*re->add_room_list(), false, true, true, userInfo);
|
||||
QListIterator<ServerInfo_Game> gameIterator(room->getGamesOfUser(nameFromStdString(cmd.user_name())));
|
||||
while (gameIterator.hasNext()) {
|
||||
re->add_game_list()->CopyFrom(gameIterator.next());
|
||||
|
|
@ -822,7 +866,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdListRooms(const Command_ListRo
|
|||
Event_ListRooms event;
|
||||
QMapIterator<int, Server_Room *> roomIterator(server->getRooms());
|
||||
while (roomIterator.hasNext()) {
|
||||
roomIterator.next().value()->getInfo(*event.add_room_list(), false);
|
||||
roomIterator.next().value()->getInfo(*event.add_room_list(), false, false, true, userInfo);
|
||||
}
|
||||
rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event));
|
||||
|
||||
|
|
@ -856,10 +900,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo
|
|||
rooms.insert(room->getId(), room);
|
||||
|
||||
QReadLocker chatHistoryLocker(&room->historyLock);
|
||||
QList<ServerInfo_ChatMessage> chatHistory = room->getChatHistory();
|
||||
ServerInfo_ChatMessage chatMessage;
|
||||
for (int i = 0; i < chatHistory.size(); ++i) {
|
||||
chatMessage = chatHistory.at(i);
|
||||
const QList<ServerInfo_ChatMessage> mainChatHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY);
|
||||
for (const ServerInfo_ChatMessage &chatMessage : mainChatHistory) {
|
||||
Event_RoomSay roomChatHistory;
|
||||
roomChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message());
|
||||
roomChatHistory.set_message_type(Event_RoomSay::ChatHistory);
|
||||
|
|
@ -868,13 +910,31 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo
|
|||
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory));
|
||||
}
|
||||
|
||||
// Replay the history of every catalog channel the joining user may see.
|
||||
// Public channels are replayed to everyone; restricted channels only to
|
||||
// members whose privilege level meets the channel's access level.
|
||||
for (const ServerInfo_RoomChannel &channel : room->getChannels()) {
|
||||
if (!room->mayReadChannel(*userInfo, channel)) {
|
||||
continue;
|
||||
}
|
||||
const QList<ServerInfo_ChatMessage> channelHistory = room->getChatHistory(QString::fromStdString(channel.id()));
|
||||
for (const ServerInfo_ChatMessage &chatMessage : channelHistory) {
|
||||
Event_RoomChannelSay channelChatHistory;
|
||||
channelChatHistory.set_channel_id(channel.id());
|
||||
channelChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message());
|
||||
channelChatHistory.set_time_of(
|
||||
QDateTime::fromString(QString::fromStdString(chatMessage.time())).toMSecsSinceEpoch());
|
||||
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(channelChatHistory));
|
||||
}
|
||||
}
|
||||
|
||||
Event_RoomSay joinMessageEvent;
|
||||
joinMessageEvent.set_message(room->getJoinMessage().toStdString());
|
||||
joinMessageEvent.set_message_type(Event_RoomSay::Welcome);
|
||||
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(joinMessageEvent));
|
||||
|
||||
auto *re = new Response_JoinRoom;
|
||||
room->getInfo(*re->mutable_room_info(), true);
|
||||
room->getInfo(*re->mutable_room_info(), true, false, true, userInfo);
|
||||
|
||||
rc.setResponseExtension(re);
|
||||
return Response::RespOk;
|
||||
|
|
@ -959,6 +1019,36 @@ Server_ProtocolHandler::cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room
|
|||
return Response::RespOk;
|
||||
}
|
||||
|
||||
Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_RoomChannelSay &cmd,
|
||||
Server_Room *room,
|
||||
ResponseContainer & /*rc*/)
|
||||
{
|
||||
const QString channelId = QString::fromStdString(cmd.channel_id());
|
||||
const ServerInfo_RoomChannel *channel = room->findChannel(channelId);
|
||||
if (channel == nullptr) {
|
||||
// The client only ever offers catalog ids; a crafted or stale id is rejected.
|
||||
return Response::RespInvalidCommand;
|
||||
}
|
||||
if (!room->mayWriteChannel(*userInfo, *channel)) {
|
||||
return Response::RespFunctionNotAllowed;
|
||||
}
|
||||
|
||||
if (!addSaidMessageSize(static_cast<int>(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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
#include "server.h"
|
||||
#include "server_abstractuserinterface.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
|
|
@ -30,6 +32,7 @@ class Command_ListRooms;
|
|||
class Command_JoinRoom;
|
||||
class Command_LeaveRoom;
|
||||
class Command_RoomSay;
|
||||
class Command_RoomChannelSay;
|
||||
class Command_CreateGame;
|
||||
class Command_JoinGame;
|
||||
|
||||
|
|
@ -46,6 +49,9 @@ protected:
|
|||
bool acceptsUserListChanges;
|
||||
bool acceptsRoomListChanges;
|
||||
bool idleClientWarningSent;
|
||||
QByteArray authNonce;
|
||||
QString authNonceUser;
|
||||
QDateTime authNonceCreated;
|
||||
virtual void logDebugMessage(const QString & /* message */)
|
||||
{
|
||||
}
|
||||
|
|
@ -66,6 +72,8 @@ private:
|
|||
Response::ResponseCode cmdListUsers(const Command_ListUsers &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdLeaveRoom(const Command_LeaveRoom &cmd, Server_Room *room, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room, ResponseContainer &rc);
|
||||
Response::ResponseCode
|
||||
cmdRoomChannelSay(const Command_RoomChannelSay &cmd, Server_Room *room, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdJoinGame(const Command_JoinGame &cmd, Server_Room *room, ResponseContainer &rc);
|
||||
|
||||
|
|
@ -121,6 +129,13 @@ public:
|
|||
return databaseInterface;
|
||||
}
|
||||
|
||||
/** @brief Store a fresh challenge nonce bound to @p userName for the next challenge-response login attempt. */
|
||||
void setAuthNonce(const QByteArray &nonce, const QString &userName);
|
||||
/** @brief True if nonce matches the pending one, was issued for @p userName, and is less than 60 seconds old. */
|
||||
bool isAuthNonceValid(const QByteArray &nonce, const QString &userName) const;
|
||||
/** @brief Invalidate the pending nonce (single-use). */
|
||||
void clearAuthNonce();
|
||||
|
||||
int getLastCommandTime() const
|
||||
{
|
||||
return timeRunning - lastDataReceived;
|
||||
|
|
|
|||
|
|
@ -26,16 +26,20 @@
|
|||
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_list_games.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_remove_messages.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
#include <qlogging.h>
|
||||
#include <qnamespace.h>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
const QString Server_Room::MAIN_CHANNEL_KEY = QStringLiteral("__main__");
|
||||
|
||||
Server_Room::Server_Room(int _id,
|
||||
int _chatHistorySize,
|
||||
const QString &_name,
|
||||
|
|
@ -45,10 +49,11 @@ Server_Room::Server_Room(int _id,
|
|||
bool _autoJoin,
|
||||
const QString &_joinMessage,
|
||||
const QStringList &_gameTypes,
|
||||
Server *parent)
|
||||
Server *parent,
|
||||
const QList<ServerInfo_RoomChannel> &_channels)
|
||||
: QObject(parent), id(_id), chatHistorySize(_chatHistorySize), name(_name), description(_description),
|
||||
permissionLevel(_permissionLevel), privilegeLevel(_privilegeLevel), autoJoin(_autoJoin),
|
||||
joinMessage(_joinMessage), gameTypes(_gameTypes), gamesLock(QReadWriteLock::Recursive)
|
||||
joinMessage(_joinMessage), gameTypes(_gameTypes), channels(_channels), gamesLock(QReadWriteLock::Recursive)
|
||||
{
|
||||
connect(
|
||||
this, &Server_Room::gameListChanged, this, [this](auto gameInfo) { broadcastGameListUpdate(gameInfo); },
|
||||
|
|
@ -102,8 +107,11 @@ Server *Server_Room::getServer() const
|
|||
return static_cast<Server *>(parent());
|
||||
}
|
||||
|
||||
const ServerInfo_Room &
|
||||
Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, bool includeExternalData) const
|
||||
const ServerInfo_Room &Server_Room::getInfo(ServerInfo_Room &result,
|
||||
bool complete,
|
||||
bool showGameTypes,
|
||||
bool includeExternalData,
|
||||
const ServerInfo_User *forUser) const
|
||||
{
|
||||
result.set_room_id(id);
|
||||
result.set_name(name.toStdString());
|
||||
|
|
@ -152,9 +160,73 @@ Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes,
|
|||
}
|
||||
}
|
||||
|
||||
for (const ServerInfo_RoomChannel &channel : channels) {
|
||||
if (forUser != nullptr && !mayReadChannel(*forUser, channel)) {
|
||||
continue;
|
||||
}
|
||||
result.add_channel_list()->CopyFrom(channel);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Server_Room::isMainChannel(const QString &channelId)
|
||||
{
|
||||
return channelId.isEmpty() || channelId == MAIN_CHANNEL_KEY;
|
||||
}
|
||||
|
||||
const ServerInfo_RoomChannel *Server_Room::findChannel(const QString &channelId) const
|
||||
{
|
||||
for (const ServerInfo_RoomChannel &channel : channels) {
|
||||
if (QString::fromStdString(channel.id()) == channelId) {
|
||||
return &channel;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool Server_Room::hasChannelPrivilege(const ServerInfo_User &userInfo,
|
||||
ServerInfo_RoomChannel::AccessLevel requiredLevel)
|
||||
{
|
||||
// User flags are independent bits, not a ladder: holding IsDeveloper does
|
||||
// not imply IsModerator or IsAdmin. The database grants admins the admin
|
||||
// and the moderator bit together, so admins still reach moderator (and
|
||||
// judge) channels; moderators likewise reach judge channels.
|
||||
const uint32_t userLevel = userInfo.user_level();
|
||||
switch (requiredLevel) {
|
||||
case ServerInfo_RoomChannel::Public:
|
||||
return true;
|
||||
case ServerInfo_RoomChannel::Registered:
|
||||
return userLevel & ServerInfo_User::IsRegistered;
|
||||
case ServerInfo_RoomChannel::Judge:
|
||||
return userLevel & (ServerInfo_User::IsJudge | ServerInfo_User::IsModerator);
|
||||
case ServerInfo_RoomChannel::Moderator:
|
||||
return userLevel & ServerInfo_User::IsModerator;
|
||||
case ServerInfo_RoomChannel::Admin:
|
||||
return userLevel & ServerInfo_User::IsAdmin;
|
||||
case ServerInfo_RoomChannel::Developer:
|
||||
return userLevel & (ServerInfo_User::IsDeveloper | ServerInfo_User::IsAdmin);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Server_Room::mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel)
|
||||
{
|
||||
return hasChannelPrivilege(userInfo, channel.access_level());
|
||||
}
|
||||
|
||||
bool Server_Room::mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel)
|
||||
{
|
||||
const auto writeLevel = channel.has_write_access_level() ? channel.write_access_level() : channel.access_level();
|
||||
return mayReadChannel(userInfo, channel) && hasChannelPrivilege(userInfo, writeLevel);
|
||||
}
|
||||
|
||||
QList<ServerInfo_ChatMessage> Server_Room::getChatHistory(const QString &channelId) const
|
||||
{
|
||||
return channelChatHistory.value(isMainChannel(channelId) ? MAIN_CHANNEL_KEY : channelId);
|
||||
}
|
||||
|
||||
RoomEvent *Server_Room::prepareRoomEvent(const ::google::protobuf::Message &roomEvent)
|
||||
{
|
||||
auto *event = new RoomEvent;
|
||||
|
|
@ -310,12 +382,35 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam
|
|||
return result;
|
||||
}
|
||||
|
||||
void Server_Room::say(const QString &userName, const QString &userMessage, bool sendToIsl)
|
||||
void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl)
|
||||
{
|
||||
Event_RoomSay event;
|
||||
event.set_name(userName.toStdString());
|
||||
event.set_message(userMessage.toStdString());
|
||||
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
|
||||
const bool isMain = isMainChannel(channelId);
|
||||
ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public;
|
||||
if (!isMain) {
|
||||
const ServerInfo_RoomChannel *channel = findChannel(channelId);
|
||||
if (channel == nullptr) {
|
||||
// The channel was not whitelisted against the catalog; drop the message.
|
||||
return;
|
||||
}
|
||||
accessLevel = channel->access_level();
|
||||
// V1 does not relay restricted channels across ISL (plan N5).
|
||||
if (accessLevel != ServerInfo_RoomChannel::Public) {
|
||||
sendToIsl = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain) {
|
||||
Event_RoomSay event;
|
||||
event.set_name(userName.toStdString());
|
||||
event.set_message(userMessage.toStdString());
|
||||
sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel);
|
||||
} else {
|
||||
Event_RoomChannelSay event;
|
||||
event.set_channel_id(channelId.toStdString());
|
||||
event.set_name(userName.toStdString());
|
||||
event.set_message(userMessage.toStdString());
|
||||
sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel);
|
||||
}
|
||||
|
||||
if (chatHistorySize != 0) {
|
||||
ServerInfo_ChatMessage chatMessage;
|
||||
|
|
@ -324,13 +419,18 @@ void Server_Room::say(const QString &userName, const QString &userMessage, bool
|
|||
chatMessage.set_time(dateTimeString.toStdString());
|
||||
chatMessage.set_sender_name(userName.toStdString());
|
||||
chatMessage.set_message(userMessage.simplified().toStdString());
|
||||
|
||||
historyLock.lockForWrite();
|
||||
if (chatHistory.size() >= chatHistorySize) {
|
||||
chatHistory.removeAt(0);
|
||||
if (!isMain) {
|
||||
chatMessage.set_channel(channelId.toStdString());
|
||||
}
|
||||
|
||||
chatHistory.push_back(std::move(chatMessage));
|
||||
const QString storageKey = isMain ? MAIN_CHANNEL_KEY : channelId;
|
||||
historyLock.lockForWrite();
|
||||
QList<ServerInfo_ChatMessage> &history = channelChatHistory[storageKey];
|
||||
if (history.size() >= chatHistorySize) {
|
||||
history.removeAt(0);
|
||||
}
|
||||
|
||||
history.push_back(std::move(chatMessage));
|
||||
historyLock.unlock();
|
||||
}
|
||||
}
|
||||
|
|
@ -344,26 +444,34 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s
|
|||
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
|
||||
|
||||
if (chatHistorySize != 0) {
|
||||
int removed = 0;
|
||||
historyLock.lockForWrite();
|
||||
// redact [amount] of the most recent messages from this user from history
|
||||
for (auto message = chatHistory.rbegin(); message != chatHistory.rend() && removed != amount; ++message) {
|
||||
if (message->sender_name() == stdStringUserName) {
|
||||
message->clear_message();
|
||||
++removed;
|
||||
// redact [amount] of the most recent messages from this user from every channel history,
|
||||
// so removed content does not resurface for late joiners on any stream
|
||||
for (auto historyIt = channelChatHistory.begin(); historyIt != channelChatHistory.end(); ++historyIt) {
|
||||
QList<ServerInfo_ChatMessage> &history = historyIt.value();
|
||||
int removed = 0;
|
||||
for (auto message = history.rbegin(); message != history.rend() && removed != amount; ++message) {
|
||||
if (message->sender_name() == stdStringUserName) {
|
||||
message->clear_message();
|
||||
++removed;
|
||||
}
|
||||
}
|
||||
}
|
||||
historyLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl)
|
||||
void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl, ServerInfo_RoomChannel::AccessLevel accessLevel)
|
||||
{
|
||||
usersLock.lockForRead();
|
||||
{
|
||||
QMapIterator<QString, Server_ProtocolHandler *> userIterator(users);
|
||||
while (userIterator.hasNext()) {
|
||||
userIterator.next().value()->sendProtocolItem(*event);
|
||||
Server_ProtocolHandler *user = userIterator.next().value();
|
||||
if (!hasChannelPrivilege(*user->getUserInfo(), accessLevel)) {
|
||||
continue;
|
||||
}
|
||||
user->sendProtocolItem(*event);
|
||||
}
|
||||
}
|
||||
usersLock.unlock();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <QStringList>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class Server_ProtocolHandler;
|
||||
|
|
@ -47,15 +48,18 @@ private:
|
|||
bool autoJoin;
|
||||
QString joinMessage;
|
||||
QStringList gameTypes;
|
||||
QList<ServerInfo_RoomChannel> channels;
|
||||
QMap<int, Server_Game *> games;
|
||||
QMap<int, ServerInfo_Game> externalGames;
|
||||
QMap<QString, Server_ProtocolHandler *> users;
|
||||
QMap<QString, ServerInfo_User_Container> externalUsers;
|
||||
QList<ServerInfo_ChatMessage> chatHistory;
|
||||
QMap<QString, QList<ServerInfo_ChatMessage>> channelChatHistory;
|
||||
private slots:
|
||||
void broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool sendToIsl = true);
|
||||
|
||||
public:
|
||||
static const QString MAIN_CHANNEL_KEY;
|
||||
|
||||
mutable QReadWriteLock usersLock;
|
||||
mutable QReadWriteLock gamesLock;
|
||||
mutable QReadWriteLock historyLock;
|
||||
|
|
@ -68,7 +72,8 @@ public:
|
|||
bool _autoJoin,
|
||||
const QString &_joinMessage,
|
||||
const QStringList &_gameTypes,
|
||||
Server *parent);
|
||||
Server *parent,
|
||||
const QList<ServerInfo_RoomChannel> &_channels = {});
|
||||
~Server_Room() override;
|
||||
int getId() const
|
||||
{
|
||||
|
|
@ -103,6 +108,35 @@ public:
|
|||
{
|
||||
return gameTypes;
|
||||
}
|
||||
const QList<ServerInfo_RoomChannel> &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<int, Server_Game *> &getGames() const
|
||||
{
|
||||
return games;
|
||||
|
|
@ -112,14 +146,14 @@ public:
|
|||
return externalGames;
|
||||
}
|
||||
Server *getServer() const;
|
||||
const ServerInfo_Room &
|
||||
getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const;
|
||||
const ServerInfo_Room &getInfo(ServerInfo_Room &result,
|
||||
bool complete,
|
||||
bool showGameTypes = false,
|
||||
bool includeExternalData = true,
|
||||
const ServerInfo_User *forUser = nullptr) const;
|
||||
int getGamesCreatedByUser(const QString &name) const;
|
||||
QList<ServerInfo_Game> getGamesOfUser(const QString &name) const;
|
||||
QList<ServerInfo_ChatMessage> &getChatHistory()
|
||||
{
|
||||
return chatHistory;
|
||||
}
|
||||
QList<ServerInfo_ChatMessage> getChatHistory(const QString &channelId) const;
|
||||
|
||||
void addClient(Server_ProtocolHandler *client);
|
||||
void removeClient(Server_ProtocolHandler *client);
|
||||
|
|
@ -136,13 +170,15 @@ public:
|
|||
ResponseContainer &rc,
|
||||
Server_AbstractUserInterface *userInterface);
|
||||
|
||||
void say(const QString &userName, const QString &s, bool sendToIsl = true);
|
||||
void say(const QString &userName, const QString &s, const QString &channelId = QString(), bool sendToIsl = true);
|
||||
void removeSaidMessages(const QString &userName, int amount, bool sendToIsl = true);
|
||||
|
||||
void addGame(Server_Game *game);
|
||||
void removeGame(Server_Game *game);
|
||||
|
||||
void sendRoomEvent(RoomEvent *event, bool sendToIsl = true);
|
||||
void sendRoomEvent(RoomEvent *event,
|
||||
bool sendToIsl = true,
|
||||
ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public);
|
||||
RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ void FeatureSet::initalizeFeatureList(QMap<QString, bool> &_featureList)
|
|||
_featureList.insert("idle_client", false);
|
||||
_featureList.insert("forgot_password", false);
|
||||
_featureList.insert("websocket", false);
|
||||
// featureList.insert("hashed_password_login", false);
|
||||
_featureList.insert("hashed_password_login", false);
|
||||
_featureList.insert("challenge_response_auth", false);
|
||||
// These are temp to force users onto a newer client
|
||||
_featureList.insert("2.7.0_min_version", false);
|
||||
_featureList.insert("2.8.0_min_version", false);
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ set(PROTO_FILES
|
|||
event_reveal_cards.proto
|
||||
event_reverse_turn.proto
|
||||
event_roll_die.proto
|
||||
event_room_channel_say.proto
|
||||
event_room_say.proto
|
||||
event_server_complete_list.proto
|
||||
event_server_identification.proto
|
||||
|
|
@ -201,6 +202,7 @@ set(PROTO_FILES
|
|||
serverinfo_replay_match.proto
|
||||
serverinfo_report.proto
|
||||
serverinfo_room.proto
|
||||
serverinfo_room_channel.proto
|
||||
serverinfo_user.proto
|
||||
serverinfo_user_alt.proto
|
||||
serverinfo_user_session.proto
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
syntax = "proto2";
|
||||
import "room_event.proto";
|
||||
|
||||
message Event_RoomChannelSay {
|
||||
extend RoomEvent {
|
||||
optional Event_RoomChannelSay ext = 1005; // next free RoomEvent extension
|
||||
}
|
||||
optional string channel_id = 1;
|
||||
optional string name = 2;
|
||||
optional string message = 3;
|
||||
optional uint64 time_of = 4;
|
||||
}
|
||||
|
|
@ -13,4 +13,10 @@ message Event_ServerIdentification {
|
|||
optional string server_version = 2;
|
||||
optional uint32 protocol_version = 3;
|
||||
optional ServerOptions server_options = 4 [default = NoOptions];
|
||||
// Advertises challenge-response authentication separately from server_options.
|
||||
// server_options must stay exactly SupportsPasswordHash on the wire: it is a
|
||||
// proto2 enum (closed), so already-released clients compiled without any new
|
||||
// enumerator parse an unknown value as NoOptions and fall back to sending
|
||||
// the plaintext password. New clients check this field instead.
|
||||
optional bool supports_challenge_response = 5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,23 @@ message Response_PasswordSalt {
|
|||
extend Response {
|
||||
optional Response_PasswordSalt ext = 1017;
|
||||
}
|
||||
// Salt for the legacy password hash. Pre-challenge clients derive and send
|
||||
// that hash from it. For migrated accounts holding a fresh legacy backup
|
||||
// this carries the backup's salt so those clients can still log in; clients
|
||||
// that speak challenge-response must use scrypt_salt instead when present.
|
||||
optional string password_salt = 1;
|
||||
// scrypt cost parameters for the verifier. Absent/zero for legacy accounts.
|
||||
optional int32 n = 2;
|
||||
optional int32 r = 3;
|
||||
optional int32 p = 4;
|
||||
// Server-generated challenge. When present the client must authenticate
|
||||
// with a challenge-response instead of transmitting the password hash.
|
||||
optional bytes nonce = 5;
|
||||
// True when the account still uses the legacy password format and should
|
||||
// be migrated to the scrypt format after a successful login.
|
||||
optional bool needs_migration = 6;
|
||||
// Salt of the stored scrypt verifier. Present for migrated accounts;
|
||||
// challenge-response clients derive the verifier from this rather than
|
||||
// from password_salt, which may carry a legacy backup salt.
|
||||
optional string scrypt_salt = 7;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ message RoomCommand {
|
|||
ROOM_SAY = 1001;
|
||||
CREATE_GAME = 1002;
|
||||
JOIN_GAME = 1003;
|
||||
ROOM_CHANNEL_SAY = 1004;
|
||||
}
|
||||
extensions 100 to max;
|
||||
}
|
||||
|
|
@ -22,6 +23,14 @@ message Command_RoomSay {
|
|||
optional string message = 1;
|
||||
}
|
||||
|
||||
message Command_RoomChannelSay {
|
||||
extend RoomCommand {
|
||||
optional Command_RoomChannelSay ext = 1004;
|
||||
}
|
||||
optional string channel_id = 1;
|
||||
optional string message = 2;
|
||||
}
|
||||
|
||||
// Create a new game in the room
|
||||
message Command_CreateGame {
|
||||
extend RoomCommand {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ message RoomEvent {
|
|||
ROOM_SAY = 1002;
|
||||
LIST_GAMES = 1003;
|
||||
REMOVE_MESSAGES = 1004;
|
||||
ROOM_CHANNEL_SAY = 1005;
|
||||
}
|
||||
optional sint32 room_id = 1;
|
||||
extensions 100 to max;
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ message ServerInfo_ChatMessage {
|
|||
optional string target_type = 6; // target type (room,game,chat)
|
||||
optional string target_id = 7; // id of target
|
||||
optional string target_name = 8; // name of target
|
||||
optional string channel = 9; // channel id, for room_channel targets
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ syntax = "proto2";
|
|||
import "serverinfo_game.proto";
|
||||
import "serverinfo_user.proto";
|
||||
import "serverinfo_gametype.proto";
|
||||
import "serverinfo_room_channel.proto";
|
||||
|
||||
message ServerInfo_Room {
|
||||
optional sint32 room_id = 1;
|
||||
|
|
@ -15,4 +16,5 @@ message ServerInfo_Room {
|
|||
repeated ServerInfo_GameType gametype_list = 9;
|
||||
optional string permissionlevel = 10;
|
||||
optional string privilegelevel = 11;
|
||||
repeated ServerInfo_RoomChannel channel_list = 12;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
syntax = "proto2";
|
||||
|
||||
message ServerInfo_RoomChannel {
|
||||
enum AccessLevel {
|
||||
// Required user flag set, evaluated per level (not as a ladder):
|
||||
// Registered needs IsRegistered; Judge needs IsJudge or IsModerator;
|
||||
// Moderator needs IsModerator (admins hold it alongside IsAdmin);
|
||||
// Admin needs IsAdmin; Developer needs IsDeveloper or IsAdmin.
|
||||
Public = 0;
|
||||
Registered = 1;
|
||||
Judge = 2;
|
||||
Moderator = 3;
|
||||
Admin = 4;
|
||||
Developer = 5;
|
||||
}
|
||||
optional string id = 1; // stable ASCII id, e.g. "deck-discussion"
|
||||
optional string display_name = 2; // server-provided, localizable only server-side
|
||||
optional AccessLevel access_level = 3 [default = Public]; // minimum privilege level required to read the channel
|
||||
// Minimum privilege level required to write to the channel. When unset, writing requires the same level as
|
||||
// reading. This allows read-mostly channels such as a developer news feed: readable by everyone, writable
|
||||
// only by developers.
|
||||
optional AccessLevel write_access_level = 4;
|
||||
// Static banner shown at the top of the channel, analogous to the room join message on Main. Empty = none.
|
||||
optional string banner_message = 5;
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ message SessionCommand {
|
|||
DECK_DOWNLOAD_PUBLIC = 1031;
|
||||
DECK_SHARE_LIST_MINE = 1032;
|
||||
DECK_SHARE_REMOVE = 1033;
|
||||
SUBMIT_PASSWORD_VERIFIER = 1034;
|
||||
REPLAY_LIST = 1100;
|
||||
REPLAY_DOWNLOAD = 1101;
|
||||
REPLAY_MODIFY_MATCH = 1102;
|
||||
|
|
@ -231,3 +232,14 @@ message Command_SetCardArtParams {
|
|||
optional double vertical_offset = 5;
|
||||
optional double zoom = 6;
|
||||
}
|
||||
|
||||
// Client uploads the new password verifier to migrate a legacy account
|
||||
// after a successful challenge-response login. Idempotent; only applies
|
||||
// to accounts still using the legacy password format.
|
||||
message Command_SubmitPasswordVerifier {
|
||||
extend SessionCommand {
|
||||
optional Command_SubmitPasswordVerifier ext = 1034;
|
||||
}
|
||||
// Full verifier string to store, e.g. "$scrypt$32768$8$1$<salt>$<verifier>"
|
||||
required string password_verifier = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,16 @@ QString ChatSettings::getChatHighlightColor() const
|
|||
return getValue("highlightColor", QString(), QString(), "A6120D").toString();
|
||||
}
|
||||
|
||||
QString ChatSettings::getChannelUnreadColor() const
|
||||
{
|
||||
return getValue("channelUnreadColor", QString(), QString(), "1A7B00").toString();
|
||||
}
|
||||
|
||||
QString ChatSettings::getChannelMentionColor() const
|
||||
{
|
||||
return getValue("channelMentionColor", QString(), QString(), "A6120D").toString();
|
||||
}
|
||||
|
||||
bool ChatSettings::getChatMentionForeground() const
|
||||
{
|
||||
return getValue("mentionForeground", QString(), QString(), true).toBool();
|
||||
|
|
@ -107,6 +117,16 @@ void ChatSettings::setChatHighlightColor(const QString &_chatHighlightColor)
|
|||
setValue(_chatHighlightColor, "highlightColor");
|
||||
}
|
||||
|
||||
void ChatSettings::setChannelUnreadColor(const QString &_channelUnreadColor)
|
||||
{
|
||||
setValue(_channelUnreadColor, "channelUnreadColor");
|
||||
}
|
||||
|
||||
void ChatSettings::setChannelMentionColor(const QString &_channelMentionColor)
|
||||
{
|
||||
setValue(_channelMentionColor, "channelMentionColor");
|
||||
}
|
||||
|
||||
void ChatSettings::setChatMentionForeground(bool _chatMentionForeground)
|
||||
{
|
||||
setValue(_chatMentionForeground, "mentionForeground");
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public:
|
|||
[[nodiscard]] bool getChatMentionCompleter() const override;
|
||||
[[nodiscard]] QString getChatMentionColor() const override;
|
||||
[[nodiscard]] QString getChatHighlightColor() const override;
|
||||
[[nodiscard]] QString getChannelUnreadColor() const override;
|
||||
[[nodiscard]] QString getChannelMentionColor() const override;
|
||||
[[nodiscard]] bool getChatMentionForeground() const override;
|
||||
[[nodiscard]] bool getChatHighlightForeground() const override;
|
||||
[[nodiscard]] bool getIgnoreUnregisteredUsers() const override;
|
||||
|
|
@ -35,6 +37,8 @@ public:
|
|||
void setChatMentionCompleter(bool _chatMentionCompleter);
|
||||
void setChatMentionColor(const QString &_chatMentionColor);
|
||||
void setChatHighlightColor(const QString &_chatHighlightColor);
|
||||
void setChannelUnreadColor(const QString &_channelUnreadColor);
|
||||
void setChannelMentionColor(const QString &_channelMentionColor);
|
||||
void setChatMentionForeground(bool _chatMentionForeground);
|
||||
void setChatHighlightForeground(bool _chatHighlightForeground);
|
||||
void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers);
|
||||
|
|
|
|||
|
|
@ -155,6 +155,19 @@ void ServersSettings::setFPPlayerName(QString playerName)
|
|||
setValue(playerName, "fpPlayerName");
|
||||
}
|
||||
|
||||
void ServersSettings::setServerPassword(const QString &saveName, const QString &password)
|
||||
{
|
||||
const int index = getPrevioushostindex(saveName);
|
||||
if (index >= 0) {
|
||||
setValue(password, QString("password%1").arg(index), "server", "server_details");
|
||||
} else {
|
||||
// A credential write that silently no-ops is hard to diagnose from a
|
||||
// bug report; surface a mismatched profile name instead.
|
||||
qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName
|
||||
<< "- password not saved";
|
||||
}
|
||||
}
|
||||
|
||||
QString ServersSettings::getFPPlayerName(QString defaultName) const
|
||||
{
|
||||
QVariant name = getValue("fpPlayerName");
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ public:
|
|||
void setFPHostName(QString hostname);
|
||||
void setFPPort(QString port);
|
||||
void setFPPlayerName(QString playerName);
|
||||
//! \brief Store a password (or a "$scrypt$..." verifier) for the given saved server.
|
||||
void setServerPassword(const QString &saveName, const QString &password);
|
||||
void addNewServer(const QString &saveName,
|
||||
const QString &serv,
|
||||
const QString &port,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ set(UTILITY_SOURCES
|
|||
libcockatrice/utility/report_categories.cpp
|
||||
libcockatrice/utility/server_rate_limiter.cpp
|
||||
libcockatrice/utility/translation_loader.cpp
|
||||
libcockatrice/utility/user_data_backup.cpp
|
||||
libcockatrice/utility/warning_categories.cpp
|
||||
)
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ set(UTILITY_HEADERS
|
|||
libcockatrice/utility/days_years_between.h
|
||||
libcockatrice/utility/server_rate_limiter.h
|
||||
libcockatrice/utility/translation_loader.h
|
||||
libcockatrice/utility/user_data_backup.h
|
||||
libcockatrice/utility/warning_categories.h
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
#include "cryptoutil.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <qlogging.h>
|
||||
|
||||
// Sealed secret layout: "enc1:<base64 nonce>:<base64 ciphertext+tag>".
|
||||
static constexpr int SECRET_KEY_LENGTH = 32;
|
||||
static constexpr int SECRET_NONCE_LENGTH = 12;
|
||||
static constexpr int SECRET_TAG_LENGTH = 16;
|
||||
static const char *SECRET_FORMAT_PREFIX = "enc1";
|
||||
|
||||
namespace CryptoUtil
|
||||
{
|
||||
QByteArray randomBytes(int count)
|
||||
|
|
@ -23,4 +31,99 @@ quint64 randomUInt64()
|
|||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
QString encryptSecret(const QByteArray &plaintext, const QByteArray &key, const QByteArray &aad)
|
||||
{
|
||||
if (key.size() != SECRET_KEY_LENGTH) {
|
||||
return {};
|
||||
}
|
||||
const QByteArray nonce = randomBytes(SECRET_NONCE_LENGTH);
|
||||
QByteArray ciphertext(plaintext.size() + SECRET_TAG_LENGTH, Qt::Uninitialized);
|
||||
QByteArray tag(SECRET_TAG_LENGTH, Qt::Uninitialized);
|
||||
|
||||
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
|
||||
if (ctx == nullptr) {
|
||||
return {};
|
||||
}
|
||||
int written = 0;
|
||||
int finalWritten = 0;
|
||||
bool ok = EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 &&
|
||||
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, nonce.size(), nullptr) == 1 &&
|
||||
EVP_EncryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast<const unsigned char *>(key.constData()),
|
||||
reinterpret_cast<const unsigned char *>(nonce.constData())) == 1;
|
||||
if (ok && !aad.isEmpty()) {
|
||||
ok = EVP_EncryptUpdate(ctx, nullptr, &written, reinterpret_cast<const unsigned char *>(aad.constData()),
|
||||
aad.size()) == 1;
|
||||
}
|
||||
if (ok) {
|
||||
ok = EVP_EncryptUpdate(ctx, reinterpret_cast<unsigned char *>(ciphertext.data()), &written,
|
||||
reinterpret_cast<const unsigned char *>(plaintext.constData()), plaintext.size()) == 1;
|
||||
}
|
||||
if (ok) {
|
||||
ok = EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(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<const unsigned char *>(key.constData()),
|
||||
reinterpret_cast<const unsigned char *>(nonce.constData())) == 1;
|
||||
if (ok && !aad.isEmpty()) {
|
||||
ok = EVP_DecryptUpdate(ctx, nullptr, &written, reinterpret_cast<const unsigned char *>(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<unsigned char *>(plaintext.data()), &written,
|
||||
reinterpret_cast<const unsigned char *>(body.constData()), body.size()) == 1;
|
||||
}
|
||||
if (ok) {
|
||||
ok =
|
||||
EVP_DecryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(plaintext.data() + written), &finalWritten) == 1;
|
||||
}
|
||||
EVP_CIPHER_CTX_free(ctx);
|
||||
if (!ok) {
|
||||
return {};
|
||||
}
|
||||
plaintext.truncate(written + finalWritten);
|
||||
return plaintext;
|
||||
}
|
||||
} // namespace CryptoUtil
|
||||
|
|
|
|||
|
|
@ -2,12 +2,21 @@
|
|||
#define CRYPTOUTIL_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace CryptoUtil
|
||||
{
|
||||
QByteArray randomBytes(int count);
|
||||
quint64 randomUInt64();
|
||||
/** @brief Seal a secret with AES-256-GCM as "enc1:<b64 nonce>:<b64 ciphertext+tag>".
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
#include <QChar>
|
||||
#include <QCryptographicHash>
|
||||
#include <libcockatrice/utility/cryptoutil.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/hmac.h>
|
||||
|
||||
QString PasswordHasher::computeHash(const QString &password, const QString &salt)
|
||||
{
|
||||
|
|
@ -54,3 +57,114 @@ QString PasswordHasher::generateActivationToken()
|
|||
{
|
||||
return QString(CryptoUtil::randomBytes(16).toBase64().left(16));
|
||||
}
|
||||
|
||||
QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p)
|
||||
{
|
||||
QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0');
|
||||
const QByteArray passwordUtf8 = password.toUtf8();
|
||||
// EVP_PBE_scrypt aborts unless maxmem covers the required working memory,
|
||||
// which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array).
|
||||
const auto maxmem = static_cast<quint64>(128) * n * r + static_cast<quint64>(128) * r * p + 4096;
|
||||
if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(),
|
||||
reinterpret_cast<const unsigned char *>(salt.constData()), salt.size(), n, r, p, maxmem,
|
||||
reinterpret_cast<unsigned char *>(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<const unsigned char *>(nonce.constData()),
|
||||
nonce.size(), reinterpret_cast<unsigned char *>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,58 @@
|
|||
#ifndef PASSWORDHASHER_H
|
||||
#define PASSWORDHASHER_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
// 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$<n>$<r>$<p>$<salt>$<verifier>" 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
|
||||
|
|
|
|||
405
libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp
Normal file
405
libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
#include "user_data_backup.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QStandardPaths>
|
||||
|
||||
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<qint64>(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<qint64>(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
|
||||
132
libcockatrice_utility/libcockatrice/utility/user_data_backup.h
Normal file
132
libcockatrice_utility/libcockatrice/utility/user_data_backup.h
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* @file user_data_backup.h
|
||||
* @ingroup Utility
|
||||
* @brief Best-effort safety copies of user data that lives inside the install directory.
|
||||
*
|
||||
* Background: Windows uninstallers shipped in 3.1.0/3.1.1 ran a blanket
|
||||
* "RMDir /r $INSTDIR". For a portable install that deletes $INSTDIR\data
|
||||
* (decks, settings, pictures) along with the payload, and for a normal install
|
||||
* it deletes anything the user pointed into $INSTDIR. The fixed uninstaller
|
||||
* preserves foreign files, but the destructive one is already on disk on
|
||||
* affected machines, so the client keeps a safety copy outside the install
|
||||
* directory before warning the user. Detection of at-risk paths lives in
|
||||
* PathsSettings::pathsInsideDir; this helper only copies.
|
||||
*/
|
||||
|
||||
#ifndef USER_DATA_BACKUP_H
|
||||
#define USER_DATA_BACKUP_H
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
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
|
||||
29
servatrice/migrations/servatrice_0037_to_0038.sql
Normal file
29
servatrice/migrations/servatrice_0037_to_0038.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- Servatrice db migration from version 37 to version 38
|
||||
|
||||
-- Room chat channels.
|
||||
--
|
||||
-- A room now offers a fixed catalog of named message streams in addition to the
|
||||
-- implicit Main channel. Public channels are broadcast to every room member;
|
||||
-- restricted channels are only delivered to (and sendable by) members whose
|
||||
-- privilege level meets the channel's access level.
|
||||
|
||||
-- 1. Catalog table, mirroring the config-driven channels of no-DB servers.
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` (
|
||||
`id_room` int(7) unsigned NOT NULL,
|
||||
`id_server` tinyint(3) NOT NULL DEFAULT 1,
|
||||
`channel_id` varchar(50) NOT NULL,
|
||||
`display_name` varchar(50) NOT NULL,
|
||||
`access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public',
|
||||
PRIMARY KEY (`id_room`, `id_server`, `channel_id`),
|
||||
FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. Audit log: room_channel target type plus a channel column so moderators
|
||||
-- can tell which stream a logged message belongs to.
|
||||
ALTER TABLE `cockatrice_log`
|
||||
MODIFY `target_type` ENUM('room', 'game', 'chat', 'room_channel');
|
||||
|
||||
ALTER TABLE `cockatrice_log`
|
||||
ADD COLUMN `channel` varchar(50) NULL AFTER `target_name`;
|
||||
|
||||
UPDATE cockatrice_schema_version SET version=38 WHERE version=37;
|
||||
19
servatrice/migrations/servatrice_0038_to_0039.sql
Normal file
19
servatrice/migrations/servatrice_0038_to_0039.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Servatrice db migration from version 38 to version 39
|
||||
|
||||
-- Per-channel read/write split and banner messages.
|
||||
--
|
||||
-- write_access_level gates sending: NULL (the default) means writing requires
|
||||
-- the same level as reading (access_level), preserving the previous
|
||||
-- single-level behavior. Set it higher than access_level for read-mostly
|
||||
-- channels (e.g. a developer news feed readable by everyone but writable only
|
||||
-- by developers).
|
||||
--
|
||||
-- banner_message is a static notice shown at the top of the channel,
|
||||
-- analogous to the room join message on Main. Empty = none.
|
||||
|
||||
ALTER TABLE `cockatrice_rooms_channels`
|
||||
MODIFY `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public',
|
||||
ADD COLUMN `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL,
|
||||
ADD COLUMN `banner_message` varchar(255) NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE cockatrice_schema_version SET version=39 WHERE version=38;
|
||||
17
servatrice/migrations/servatrice_0039_to_0040.sql
Normal file
17
servatrice/migrations/servatrice_0039_to_0040.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Servatrice db migration from version 39 to version 40
|
||||
|
||||
-- The column must hold "$scrypt$<n>$<r>$<p>$<salt>$<verifier>" (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;
|
||||
|
|
@ -332,6 +332,40 @@ roomlist\1\game_types\1\name="GameType1"
|
|||
roomlist\1\game_types\2\name="GameType2"
|
||||
roomlist\1\game_types\3\name="GameType3"
|
||||
|
||||
; Optional chat channels for room number 1, in addition to the implicit Main
|
||||
; channel every room has. Each channel needs a stable ASCII id, a display name
|
||||
; (server data, like the room name) and an access level. Supported levels, from
|
||||
; least to most restrictive, are: public, registered, judge, moderator, admin
|
||||
; and developer. Access is evaluated against the member's user flags, not as a
|
||||
; ladder: a moderator channel admits moderators (admins hold the moderator
|
||||
; flag alongside the admin flag, so they are admitted too), an admin channel
|
||||
; admits only admins, and a developer channel admits only developers and
|
||||
; admins. In particular, developers cannot read moderator or admin channels.
|
||||
; A judge channel additionally admits moderators.
|
||||
; Public channels are visible to every room member.
|
||||
;
|
||||
; Write access defaults to the read level, so by default a channel is readable
|
||||
; and writable by the same members. Set writeaccess higher for read-mostly
|
||||
; channels (e.g. a developer news feed readable by everyone but writable only
|
||||
; by developers). Members who can read but not write see the channel with a
|
||||
; disabled message input.
|
||||
;
|
||||
; banner is an optional static notice shown at the top of the channel, similar
|
||||
; to the room joinmessage on Main. Empty or unset = none.
|
||||
roomlist\1\channels\size=3
|
||||
roomlist\1\channels\1\id=looking-for-players
|
||||
roomlist\1\channels\1\displayname="Looking for players"
|
||||
roomlist\1\channels\1\access=public
|
||||
roomlist\1\channels\1\banner="Find opponents here. Be friendly!"
|
||||
roomlist\1\channels\2\id=moderators
|
||||
roomlist\1\channels\2\displayname="Moderators"
|
||||
roomlist\1\channels\2\access=moderator
|
||||
roomlist\1\channels\3\id=developer-news
|
||||
roomlist\1\channels\3\displayname="Developer news"
|
||||
roomlist\1\channels\3\access=public
|
||||
roomlist\1\channels\3\writeaccess=developer
|
||||
roomlist\1\channels\3\banner="Official announcements from the developers."
|
||||
|
||||
|
||||
[game]
|
||||
|
||||
|
|
@ -386,6 +420,48 @@ max_forgot_password_requests_per_ip=3
|
|||
|
||||
; Length in seconds of the forgot-password window. Default is 3600 (1 hour).
|
||||
forgot_password_window_seconds=3600
|
||||
; How strictly new authentication features are enforced. Possible values:
|
||||
; * legacy: accounts that still use the legacy 1000-round SHA-512 hash keep logging in with it
|
||||
; (they are served the legacy salt, never a challenge-response nonce) and are never
|
||||
; auto-migrated to scrypt. Already-migrated scrypt rows keep logging in via
|
||||
; challenge-response. New credentials may use either format;
|
||||
; * mixed: accept both legacy hashes and challenge-response authentication (default);
|
||||
; * strict: only accept challenge-response authentication from clients that support it,
|
||||
; and reject plain password submissions. Legacy accounts are migrated to scrypt
|
||||
; automatically on their next successful login. Strict only constrains
|
||||
; registered SQL accounts: guest/unregistered logins and servers with
|
||||
; authentication/method set to none or password are unaffected, since
|
||||
; they have no stored verifier to challenge against.
|
||||
;
|
||||
; Note: anyone holding only the legacy salt+hash of a not-yet-migrated account
|
||||
; (e.g. from a wire capture of an old client, or an old database dump) can log in
|
||||
; with it and then set a brand new password of their choosing. Before
|
||||
; challenge-response a captured hash yielded a session, but changing the password
|
||||
; still required the plaintext old password. There is no way around this without
|
||||
; a plaintext proof at migration time, so not-yet-migrated rows should be treated
|
||||
; accordingly.
|
||||
;
|
||||
; Challenge-response verifiers are scrypt (RFC 7914, N=32768, r=8, p=1) stored as
|
||||
; "$scrypt$<n>$<r>$<p>$<salt>$<verifier>". 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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
|
|||
PRIMARY KEY (`version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO cockatrice_schema_version VALUES(37);
|
||||
INSERT INTO cockatrice_schema_version VALUES(40);
|
||||
|
||||
-- users and user data tables
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
||||
|
|
@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
|||
`admin` tinyint(1) NOT NULL,
|
||||
`name` varchar(35) NOT NULL,
|
||||
`realname` varchar(255) NOT NULL,
|
||||
`password_sha512` char(120) NOT NULL,
|
||||
`password_sha512` varchar(255) NOT NULL,
|
||||
`password_legacy_backup` varchar(255) NOT NULL DEFAULT '',
|
||||
`password_legacy_backup_used_at` datetime DEFAULT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`country` char(2) NOT NULL,
|
||||
`avatar_bmp` mediumblob NOT NULL,
|
||||
|
|
@ -158,6 +160,18 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_gametypes` (
|
|||
FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` (
|
||||
`id_room` int(7) unsigned NOT NULL,
|
||||
`id_server` tinyint(3) NOT NULL DEFAULT 1,
|
||||
`channel_id` varchar(50) NOT NULL,
|
||||
`display_name` varchar(50) NOT NULL,
|
||||
`access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public',
|
||||
`write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL,
|
||||
`banner_message` varchar(255) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (`id_room`, `id_server`, `channel_id`),
|
||||
FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- games
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_games` (
|
||||
`room_name` varchar(255) NOT NULL,
|
||||
|
|
@ -329,9 +343,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_log` (
|
|||
`sender_name` varchar(35) NOT NULL,
|
||||
`sender_ip` varchar(45) NOT NULL,
|
||||
`log_message` text NOT NULL,
|
||||
`target_type` ENUM('room', 'game', 'chat'),
|
||||
`target_type` ENUM('room', 'game', 'chat', 'room_channel'),
|
||||
`target_id` int(7) NULL,
|
||||
`target_name` varchar(50) NOT NULL,
|
||||
`channel` varchar(50) NULL,
|
||||
KEY `sender_name` (`sender_name`),
|
||||
KEY `sender_ip` (`sender_ip`),
|
||||
KEY `target_id` (`target_id`),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_list_games.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_remove_messages.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_server_complete_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_user_joined.pb.h>
|
||||
|
|
@ -383,7 +384,14 @@ void IslInterface::roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event)
|
|||
|
||||
void IslInterface::roomEvent_Say(int roomId, const Event_RoomSay &event)
|
||||
{
|
||||
emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()));
|
||||
emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()),
|
||||
QString());
|
||||
}
|
||||
|
||||
void IslInterface::roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event)
|
||||
{
|
||||
emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()),
|
||||
QString::fromStdString(event.channel_id()));
|
||||
}
|
||||
|
||||
void IslInterface::roomEvent_ListGames(int roomId, const Event_ListGames &event)
|
||||
|
|
@ -459,6 +467,9 @@ void IslInterface::processRoomEvent(const RoomEvent &event)
|
|||
case RoomEvent::ROOM_SAY:
|
||||
roomEvent_Say(event.room_id(), event.GetExtension(Event_RoomSay::ext));
|
||||
break;
|
||||
case RoomEvent::ROOM_CHANNEL_SAY:
|
||||
roomEvent_ChannelSay(event.room_id(), event.GetExtension(Event_RoomChannelSay::ext));
|
||||
break;
|
||||
case RoomEvent::LIST_GAMES:
|
||||
roomEvent_ListGames(event.room_id(), event.GetExtension(Event_ListGames::ext));
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class Event_UserLeft;
|
|||
class Event_JoinRoom;
|
||||
class Event_LeaveRoom;
|
||||
class Event_RoomSay;
|
||||
class Event_RoomChannelSay;
|
||||
class Event_ListGames;
|
||||
class Event_RemoveMessages;
|
||||
class Command_JoinGame;
|
||||
|
|
@ -49,7 +50,7 @@ signals:
|
|||
void externalUserLeft(QString userName);
|
||||
void externalRoomUserJoined(int roomId, ServerInfo_User userInfo);
|
||||
void externalRoomUserLeft(int roomId, QString userName);
|
||||
void externalRoomSay(int roomId, QString userName, QString message);
|
||||
void externalRoomSay(int roomId, QString userName, QString message, QString channelId);
|
||||
void externalRoomGameListChanged(int roomId, ServerInfo_Game gameInfo);
|
||||
void externalRoomRemoveMessages(int roomId, QString userName, int amount);
|
||||
void joinGameCommandReceived(const Command_JoinGame &cmd, int cmdId, int roomId, int serverId, qint64 sessionId);
|
||||
|
|
@ -79,6 +80,7 @@ private:
|
|||
void roomEvent_UserJoined(int roomId, const Event_JoinRoom &event);
|
||||
void roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event);
|
||||
void roomEvent_Say(int roomId, const Event_RoomSay &event);
|
||||
void roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event);
|
||||
void roomEvent_ListGames(int roomId, const Event_ListGames &event);
|
||||
void roomEvent_RemoveMessages(int roomId, const Event_RemoveMessages &event);
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@
|
|||
#include <QObject>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QReadLocker>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QSharedPointer>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
|
|
@ -65,7 +67,9 @@
|
|||
#include <libcockatrice/protocol/pb/event_connection_closed.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_server_shutdown.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
||||
#include <new>
|
||||
#include <optional>
|
||||
#include <qlogging.h>
|
||||
#include <qnamespace.h>
|
||||
#include <server_room.h>
|
||||
|
|
@ -73,6 +77,10 @@
|
|||
|
||||
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
|
||||
|
||||
// Expired legacy password backups are also purged on first use, but idle accounts
|
||||
// need this sweep so credentials that are never touched still age out of the database.
|
||||
static constexpr qint64 kLegacyBackupSweepIntervalMs = 6 * 60 * 60 * 1000;
|
||||
|
||||
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
|
||||
int _numberPools,
|
||||
const QSqlDatabase &_sqlDatabase,
|
||||
|
|
@ -249,6 +257,37 @@ Servatrice::~Servatrice()
|
|||
prepareDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Maps a configured channel access level (database column or ini value)
|
||||
* onto the ServerInfo_RoomChannel privilege ladder.
|
||||
*
|
||||
* Returns nullopt for unknown keywords so callers can fail closed instead of
|
||||
* silently downgrading a restricted channel to Public.
|
||||
*/
|
||||
static std::optional<ServerInfo_RoomChannel::AccessLevel> roomChannelAccessFromKey(const QString &key)
|
||||
{
|
||||
const QString level = key.trimmed().toLower();
|
||||
if (level == "developer") {
|
||||
return ServerInfo_RoomChannel::Developer;
|
||||
}
|
||||
if (level == "admin") {
|
||||
return ServerInfo_RoomChannel::Admin;
|
||||
}
|
||||
if (level == "moderator") {
|
||||
return ServerInfo_RoomChannel::Moderator;
|
||||
}
|
||||
if (level == "judge") {
|
||||
return ServerInfo_RoomChannel::Judge;
|
||||
}
|
||||
if (level == "registered") {
|
||||
return ServerInfo_RoomChannel::Registered;
|
||||
}
|
||||
if (level == "public") {
|
||||
return ServerInfo_RoomChannel::Public;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool Servatrice::initServer()
|
||||
{
|
||||
|
||||
|
|
@ -360,10 +399,65 @@ bool Servatrice::initServer()
|
|||
while (query2->next()) {
|
||||
gameTypes.append(query2->value(0).toString());
|
||||
}
|
||||
|
||||
QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery(
|
||||
"select channel_id, display_name, access_level, write_access_level, banner_message from "
|
||||
"{prefix}_rooms_channels where id_room = :id_room "
|
||||
"AND id_server = :id_server order by channel_id asc");
|
||||
query3->bindValue(":id_server", serverId);
|
||||
query3->bindValue(":id_room", query->value(0).toInt());
|
||||
servatriceDatabaseInterface->execSqlQuery(query3);
|
||||
QList<ServerInfo_RoomChannel> channels;
|
||||
QSet<QString> 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::AccessLevel>(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<bool>(query->value(5).toInt()),
|
||||
query->value(6).toString(), gameTypes, this));
|
||||
query->value(6).toString(), gameTypes, this, channels));
|
||||
}
|
||||
} else {
|
||||
int size = settingsCache->beginReadArray("rooms/roomlist");
|
||||
|
|
@ -376,12 +470,61 @@ bool Servatrice::initServer()
|
|||
gameTypes.append(settingsCache->value("name").toString());
|
||||
}
|
||||
settingsCache->endArray();
|
||||
QList<ServerInfo_RoomChannel> channels;
|
||||
QSet<QString> 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::AccessLevel>(ServerInfo_RoomChannel::Public)
|
||||
: roomChannelAccessFromKey(accessKey);
|
||||
if (!accessLevel.has_value()) {
|
||||
qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id"
|
||||
<< channelId;
|
||||
continue;
|
||||
}
|
||||
ServerInfo_RoomChannel channel;
|
||||
channel.set_id(channelId.toStdString());
|
||||
channel.set_display_name(settingsCache->value("displayname").toString().toStdString());
|
||||
channel.set_access_level(accessLevel.value());
|
||||
if (settingsCache->contains("writeaccess")) {
|
||||
const QString writeKey = settingsCache->value("writeaccess").toString();
|
||||
if (!writeKey.trimmed().isEmpty()) {
|
||||
auto writeLevel = roomChannelAccessFromKey(writeKey);
|
||||
if (!writeLevel.has_value()) {
|
||||
qWarning() << "Skipping room channel with unknown write access level" << writeKey
|
||||
<< "for id" << channelId;
|
||||
continue;
|
||||
}
|
||||
channel.set_write_access_level(writeLevel.value());
|
||||
}
|
||||
}
|
||||
channel.set_banner_message(settingsCache->value("banner").toString().toStdString());
|
||||
channels.append(channel);
|
||||
}
|
||||
settingsCache->endArray();
|
||||
Server_Room *newRoom = new Server_Room(
|
||||
i, settingsCache->value("chathistorysize").toInt(), settingsCache->value("name").toString(),
|
||||
settingsCache->value("description").toString(),
|
||||
settingsCache->value("permissionlevel").toString().toLower(),
|
||||
settingsCache->value("privilegelevel").toString().toLower(), settingsCache->value("autojoin").toBool(),
|
||||
settingsCache->value("joinmessage").toString(), gameTypes, this);
|
||||
settingsCache->value("joinmessage").toString(), gameTypes, this, channels);
|
||||
addRoom(newRoom);
|
||||
}
|
||||
|
||||
|
|
@ -475,6 +618,10 @@ bool Servatrice::initServer()
|
|||
deckShareCleanupClock->start(deckShareCleanupInterval);
|
||||
}
|
||||
|
||||
legacyBackupSweepClock = new QTimer(this);
|
||||
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
|
||||
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
|
||||
|
||||
// SOCKET SERVER
|
||||
if (getNumberOfTCPPools() > 0) {
|
||||
gameServer =
|
||||
|
|
@ -864,6 +1011,14 @@ SessionEvent *Servatrice::getLoginSessionEvent() const
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
void Servatrice::sweepLegacyBackups()
|
||||
{
|
||||
if (!servatriceDatabaseInterface->checkSql()) {
|
||||
return;
|
||||
}
|
||||
servatriceDatabaseInterface->purgeExpiredLegacyBackups();
|
||||
}
|
||||
|
||||
void Servatrice::scheduleShutdown(const QString &reason, int minutes)
|
||||
{
|
||||
shutdownStateMutex.lock();
|
||||
|
|
@ -940,8 +1095,8 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface)
|
|||
connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this,
|
||||
SLOT(externalRoomUserJoined(int, ServerInfo_User)));
|
||||
connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString)));
|
||||
connect(interface, SIGNAL(externalRoomSay(int, QString, QString)), this,
|
||||
SLOT(externalRoomSay(int, QString, QString)));
|
||||
connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this,
|
||||
SLOT(externalRoomSay(int, QString, QString, QString)));
|
||||
connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this,
|
||||
SLOT(externalRoomRemoveMessages(int, QString, int)));
|
||||
connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this,
|
||||
|
|
@ -1056,6 +1211,42 @@ QString Servatrice::getRequiredFeatures() const
|
|||
return settingsCache->value("server/requiredfeatures", "").toString();
|
||||
}
|
||||
|
||||
Servatrice::AuthenticationStrictness Servatrice::getAuthenticationStrictness() const
|
||||
{
|
||||
const QString strictness = settingsCache->value("security/authentication_strictness", "mixed").toString();
|
||||
if (strictness == "strict") {
|
||||
return AuthenticationStrict;
|
||||
}
|
||||
if (strictness == "legacy") {
|
||||
return AuthenticationLegacy;
|
||||
}
|
||||
return AuthenticationMixed;
|
||||
}
|
||||
|
||||
QByteArray Servatrice::getLegacyBackupKey() const
|
||||
{
|
||||
const QString keyHex = settingsCache->value("security/legacy_backup_key").toString().trimmed();
|
||||
if (keyHex.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
static const QRegularExpression validHexKey(QStringLiteral("^[0-9a-fA-F]{64}$"));
|
||||
if (!validHexKey.match(keyHex).hasMatch()) {
|
||||
qDebug() << "security/legacy_backup_key is not 64 hex characters;" << "legacy password backups are disabled";
|
||||
return {};
|
||||
}
|
||||
return QByteArray::fromHex(keyHex.toUtf8());
|
||||
}
|
||||
|
||||
int Servatrice::getLegacyBackupTtlDays() const
|
||||
{
|
||||
bool ok = false;
|
||||
const int days = settingsCache->value("security/legacy_backup_ttl_days", 180).toInt(&ok);
|
||||
if (!ok || days < 1 || days > 3650) {
|
||||
return 180;
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
QString Servatrice::getDBTypeString() const
|
||||
{
|
||||
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
||||
|
|
|
|||
|
|
@ -155,8 +155,15 @@ public:
|
|||
AuthenticationSql,
|
||||
AuthenticationPassword
|
||||
};
|
||||
enum AuthenticationStrictness
|
||||
{
|
||||
AuthenticationLegacy,
|
||||
AuthenticationMixed,
|
||||
AuthenticationStrict
|
||||
};
|
||||
private slots:
|
||||
void statusUpdate();
|
||||
void sweepLegacyBackups();
|
||||
void shutdownTimeout();
|
||||
void cleanupExpiredDeckShares();
|
||||
|
||||
|
|
@ -173,6 +180,7 @@ private:
|
|||
DatabaseType databaseType;
|
||||
QTimer *pingClock, *statusUpdateClock;
|
||||
QTimer *deckShareCleanupClock;
|
||||
QTimer *legacyBackupSweepClock;
|
||||
Servatrice_GameServer *gameServer;
|
||||
Servatrice_WebsocketGameServer *websocketGameServer;
|
||||
Servatrice_IslServer *islServer;
|
||||
|
|
@ -251,6 +259,14 @@ public:
|
|||
{
|
||||
return serverRequiredFeatureList;
|
||||
}
|
||||
bool requiresChallengeResponseAuth() const override
|
||||
{
|
||||
return getAuthenticationStrictness() == AuthenticationStrict;
|
||||
}
|
||||
/** @brief Key sealing legacy password backups, or empty when they are disabled. */
|
||||
QByteArray getLegacyBackupKey() const;
|
||||
/** @brief Days a legacy password backup may go unused before it is purged. */
|
||||
int getLegacyBackupTtlDays() const;
|
||||
QString getServerName() const;
|
||||
QString getLoginMessage() const override
|
||||
{
|
||||
|
|
@ -272,6 +288,7 @@ public:
|
|||
{
|
||||
return authenticationMethod;
|
||||
}
|
||||
AuthenticationStrictness getAuthenticationStrictness() const;
|
||||
bool permitUnregisteredUsers() const override
|
||||
{
|
||||
return authenticationMethod != AuthenticationNone;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/protocol/pb/game_replay.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/utility/cryptoutil.h>
|
||||
#include <libcockatrice/utility/passwordhasher.h>
|
||||
#include <qalgorithms.h>
|
||||
#include <qnamespace.h>
|
||||
|
|
@ -44,6 +45,17 @@
|
|||
|
||||
inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface");
|
||||
|
||||
// Oldest password_legacy_backup_used_at that is still inside the TTL. The value is
|
||||
// compared against (and stored as) a UTC "yyyy-MM-dd HH:mm:ss" string, which sorts
|
||||
// lexicographically, so no timezone-aware parsing is needed on either side.
|
||||
static QString legacyBackupExpiryCutoff(int ttlDays)
|
||||
{
|
||||
return QDateTime::currentDateTimeUtc().addDays(-ttlDays).toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
|
||||
}
|
||||
|
||||
// Width of the password_legacy_backup column; a sealed backup must fit inside it.
|
||||
static constexpr int MAX_LEGACY_BACKUP_LENGTH = 255;
|
||||
|
||||
Servatrice_DatabaseInterface::Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server)
|
||||
: instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server)
|
||||
{
|
||||
|
|
@ -429,13 +441,104 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
|
|||
qCWarning(DatabaseInterfaceLog) << "Login denied: user not active";
|
||||
return UserIsInactive;
|
||||
}
|
||||
|
||||
// Fail closed on an absent stored credential: an empty key would
|
||||
// otherwise authenticate anyone who can compute HMAC("", nonce).
|
||||
if (correctPasswordSha512.isEmpty()) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Login denied: empty stored credential";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
if (password.startsWith("$challenge$")) {
|
||||
// Challenge-response login: verify HMAC(stored_key, nonce) without
|
||||
// ever transmitting the stored credential or password hash.
|
||||
const QStringList parts = password.split("$");
|
||||
if (parts.size() != 4) {
|
||||
return NotLoggedIn;
|
||||
}
|
||||
const QByteArray nonce = QByteArray::fromBase64(parts.at(2).toUtf8());
|
||||
const QByteArray response = QByteArray::fromBase64(parts.at(3).toUtf8());
|
||||
if (nonce.isEmpty() || response.isEmpty() || !handler->isAuthNonceValid(nonce, user)) {
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
QByteArray key;
|
||||
if (PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
|
||||
key = correctPasswordSha512.toUtf8();
|
||||
} else {
|
||||
// Design note: the stored scrypt verifier IS the challenge-response key, so a
|
||||
// database dump yields credentials that can answer a login challenge directly.
|
||||
// We deliberately accept this trade: it removes plaintext passwords from the
|
||||
// client config and from the wire, but does not protect against DB compromise.
|
||||
// A password-equivalent proof scheme (e.g. SRP-6a/OPAQUE) would be the proper
|
||||
// escalation and is out of scope here.
|
||||
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(correctPasswordSha512);
|
||||
if (!verifier.isValid) {
|
||||
return NotLoggedIn;
|
||||
}
|
||||
key = verifier.verifier;
|
||||
}
|
||||
|
||||
const QByteArray expected = PasswordHasher::computeResponse(key, nonce);
|
||||
handler->clearAuthNonce();
|
||||
if (PasswordHasher::constantTimeEquals(expected, response)) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right";
|
||||
return PasswordRight;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login denied: challenge-response password wrong";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
// A migrated (scrypt) row distinguishes how the credential arrived:
|
||||
// a plaintext password (passwordNeedsHash) is verified directly against
|
||||
// the scrypt verifier, so email changes (cmdAccountEdit) and old clients
|
||||
// that still send `password` keep working without a legacy backup key.
|
||||
// A pre-hashed legacy value is accepted against the encrypted legacy
|
||||
// backup while that backup is fresh. Without one there is no downgrade
|
||||
// path. The backup window is refreshed only when the backup is what
|
||||
// actually matched, so a plaintext proof never keeps the
|
||||
// fast-crackable hash alive.
|
||||
if (!PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
|
||||
if (passwordNeedsHash) {
|
||||
// Cost is bounded by costParamsAreSane inside parsePasswordVerifier.
|
||||
if (PasswordHasher::verifyPassword(password, correctPasswordSha512)) {
|
||||
if (forceChange) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
||||
return PasswordChangeRequired;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
|
||||
return PasswordRight;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
const QString backupCredential = getUsableLegacyBackup(user);
|
||||
if (backupCredential.isEmpty()) {
|
||||
qCDebug(DatabaseInterfaceLog)
|
||||
<< "Login denied: migrated account without a usable legacy backup";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
if (PasswordHasher::constantTimeEquals(backupCredential.toUtf8(), password.toUtf8())) {
|
||||
touchLegacyBackup(user);
|
||||
if (forceChange) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
||||
return PasswordChangeRequired;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
|
||||
return PasswordRight;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
QString storedCredential = correctPasswordSha512;
|
||||
QString hashedPassword;
|
||||
if (passwordNeedsHash) {
|
||||
hashedPassword = PasswordHasher::computeHash(password, correctPasswordSha512.left(16));
|
||||
hashedPassword = PasswordHasher::computeHash(password, storedCredential.left(16));
|
||||
} else {
|
||||
hashedPassword = password;
|
||||
}
|
||||
if (correctPasswordSha512 == hashedPassword) {
|
||||
if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) {
|
||||
if (forceChange) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
||||
return PasswordChangeRequired;
|
||||
|
|
@ -631,6 +734,185 @@ QString Servatrice_DatabaseInterface::getUserSalt(const QString &user)
|
|||
return {};
|
||||
}
|
||||
|
||||
QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user)
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return {};
|
||||
}
|
||||
|
||||
checkSql();
|
||||
|
||||
QSqlQuery *query = prepareQuery("SELECT password_sha512 FROM {prefix}_users WHERE name = :name");
|
||||
query->bindValue(":name", user);
|
||||
if (!execSqlQuery(query)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!query->next()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return query->value(0).toString();
|
||||
}
|
||||
|
||||
bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier)
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return false;
|
||||
}
|
||||
|
||||
checkSql();
|
||||
|
||||
// Read the credential that is about to be replaced: while it is still in the
|
||||
// legacy format it becomes the encrypted downgrade backup, sealed before the
|
||||
// row is overwritten with the scrypt verifier.
|
||||
QSqlQuery *currentQuery = prepareQuery("SELECT name, password_sha512 FROM {prefix}_users WHERE name = :name");
|
||||
currentQuery->bindValue(":name", user);
|
||||
if (!execSqlQuery(currentQuery) || !currentQuery->next()) {
|
||||
return false;
|
||||
}
|
||||
// Use the stored row name (not the caller-supplied one) as the AAD: name is
|
||||
// utf8mb4_unicode_ci, so `alice` logs in as `Alice` and the SELECT still finds
|
||||
// the row while a client-typed name would fail the GCM tag check on open.
|
||||
const QString storedName = currentQuery->value(0).toString();
|
||||
const QString currentCredential = currentQuery->value(1).toString();
|
||||
|
||||
QString sealedBackup;
|
||||
QDateTime backupUsedAt;
|
||||
if (PasswordHasher::isLegacyFormat(currentCredential)) {
|
||||
const QByteArray key = server->getLegacyBackupKey();
|
||||
if (!key.isEmpty()) {
|
||||
const QByteArray aad = storedName.simplified().toUtf8();
|
||||
sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad);
|
||||
if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) {
|
||||
qCWarning(DatabaseInterfaceLog)
|
||||
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
|
||||
sealedBackup.clear();
|
||||
} else {
|
||||
backupUsedAt = QDateTime::currentDateTimeUtc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only migrate accounts that still use the credential this backup was taken from;
|
||||
// the guard makes a re-migration (or a raced second submission) a no-op.
|
||||
QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512 = :verifier, "
|
||||
"password_legacy_backup = :backup, password_legacy_backup_used_at = :used_at "
|
||||
"where name = :user and password_sha512 = :expected and password_sha512 not like "
|
||||
"'$%'");
|
||||
query->bindValue(":verifier", passwordVerifier);
|
||||
query->bindValue(":backup", sealedBackup);
|
||||
query->bindValue(":used_at", backupUsedAt.isValid()
|
||||
? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")))
|
||||
: QVariant());
|
||||
query->bindValue(":user", user);
|
||||
query->bindValue(":expected", currentCredential);
|
||||
if (!execSqlQuery(query)) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Failed to submit password verifier for user" << user << query->lastError();
|
||||
return false;
|
||||
}
|
||||
// The guard makes a re-migration a no-op; only report success when a row was actually updated.
|
||||
return query->numRowsAffected() > 0;
|
||||
}
|
||||
|
||||
QString Servatrice_DatabaseInterface::getUsableLegacyBackup(const QString &user)
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Without a configured key the backup cannot be sealed or opened; do not touch
|
||||
// (and never purge on this path) so a temporarily missing key cannot destroy it.
|
||||
const QByteArray key = server->getLegacyBackupKey();
|
||||
if (key.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
checkSql();
|
||||
|
||||
QSqlQuery *query = prepareQuery(
|
||||
"SELECT name, password_legacy_backup, password_legacy_backup_used_at FROM {prefix}_users WHERE name = :name");
|
||||
query->bindValue(":name", user);
|
||||
if (!execSqlQuery(query) || !query->next()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Open with the stored row name as the AAD: sealing used the canonical DB name
|
||||
// while the caller passes the client-typed name, and the two differ whenever
|
||||
// the login case does not match the stored case (utf8mb4_unicode_ci).
|
||||
const QString storedName = query->value(0).toString();
|
||||
const QString sealed = query->value(1).toString();
|
||||
const QString usedAt = query->value(2).toString();
|
||||
if (sealed.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Age out: a backup unused for the whole TTL is purged on sight, so a database
|
||||
// dump taken after the window closes never carries a crackable legacy hash.
|
||||
if (usedAt.isEmpty() || usedAt < legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Purging expired legacy backup for user" << user;
|
||||
clearLegacyBackup(user);
|
||||
return {};
|
||||
}
|
||||
|
||||
const QByteArray legacyCredential = CryptoUtil::decryptSecret(sealed, key, storedName.simplified().toUtf8());
|
||||
if (legacyCredential.isEmpty()) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Legacy backup for user" << user << "failed to decrypt"
|
||||
<< "- check security/legacy_backup_key";
|
||||
return {};
|
||||
}
|
||||
return QString::fromUtf8(legacyCredential);
|
||||
}
|
||||
|
||||
void Servatrice_DatabaseInterface::touchLegacyBackup(const QString &user)
|
||||
{
|
||||
if (!checkSql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup_used_at = UTC_TIMESTAMP() "
|
||||
"WHERE name = :name AND password_legacy_backup <> ''");
|
||||
query->bindValue(":name", user);
|
||||
execSqlQuery(query);
|
||||
}
|
||||
|
||||
void Servatrice_DatabaseInterface::clearLegacyBackup(const QString &user)
|
||||
{
|
||||
if (!checkSql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
|
||||
"password_legacy_backup_used_at = NULL WHERE name = :name");
|
||||
query->bindValue(":name", user);
|
||||
execSqlQuery(query);
|
||||
}
|
||||
|
||||
void Servatrice_DatabaseInterface::purgeExpiredLegacyBackups()
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkSql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
|
||||
"password_legacy_backup_used_at = NULL "
|
||||
"WHERE password_legacy_backup <> '' AND "
|
||||
"(password_legacy_backup_used_at IS NULL OR password_legacy_backup_used_at < "
|
||||
":cutoff)");
|
||||
query->bindValue(":cutoff", legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays()));
|
||||
if (!execSqlQuery(query)) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Legacy backup sweep failed:" << query->lastError();
|
||||
return;
|
||||
}
|
||||
if (query->numRowsAffected() > 0) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Purged" << query->numRowsAffected() << "expired legacy password backups";
|
||||
}
|
||||
}
|
||||
|
||||
int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name)
|
||||
{
|
||||
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
||||
|
|
@ -1286,7 +1568,8 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
|||
const QString &logMessage,
|
||||
LogMessage_TargetType targetType,
|
||||
const int targetId,
|
||||
const QString &targetName)
|
||||
const QString &targetName,
|
||||
const QString &channel)
|
||||
{
|
||||
QString targetTypeString;
|
||||
switch (targetType) {
|
||||
|
|
@ -1296,6 +1579,12 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
|||
}
|
||||
targetTypeString = "room";
|
||||
break;
|
||||
case MessageTargetRoomChannel:
|
||||
if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) {
|
||||
return;
|
||||
}
|
||||
targetTypeString = "room_channel";
|
||||
break;
|
||||
case MessageTargetGame:
|
||||
if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) {
|
||||
return;
|
||||
|
|
@ -1319,8 +1608,9 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
|||
}
|
||||
|
||||
QSqlQuery *query = prepareQuery("insert into {prefix}_log (log_time, sender_id, sender_name, sender_ip, "
|
||||
"log_message, target_type, target_id, target_name) values (now(), :sender_id, "
|
||||
":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)");
|
||||
"log_message, target_type, target_id, target_name, channel) values (now(), "
|
||||
":sender_id, :sender_name, :sender_ip, :log_message, :target_type, :target_id, "
|
||||
":target_name, :channel)");
|
||||
query->bindValue(":sender_id", senderId < 1 ? QVariant() : senderId);
|
||||
query->bindValue(":sender_name", senderName);
|
||||
query->bindValue(":sender_ip", senderIp);
|
||||
|
|
@ -1328,6 +1618,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
|||
query->bindValue(":target_type", targetTypeString);
|
||||
query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId);
|
||||
query->bindValue(":target_name", targetName);
|
||||
query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel);
|
||||
execSqlQuery(query);
|
||||
}
|
||||
|
||||
|
|
@ -1340,7 +1631,10 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
|||
passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt());
|
||||
}
|
||||
|
||||
// The backup authenticates the credential it was taken from; a new credential
|
||||
// invalidates it (the old password is dead, and the new one has no legacy form).
|
||||
QSqlQuery *passwordQuery = prepareQuery("update {prefix}_users set password_sha512=:password, "
|
||||
"password_legacy_backup='', password_legacy_backup_used_at=NULL, "
|
||||
"passwordLastChangedDate = NOW() where name = :name");
|
||||
passwordQuery->bindValue(":password", passwordSha512);
|
||||
passwordQuery->bindValue(":name", user);
|
||||
|
|
@ -1393,13 +1687,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
|||
return false;
|
||||
}
|
||||
|
||||
const QString correctPasswordSha512 = passwordQuery->value(0).toString();
|
||||
QString oldPasswordSha512 = oldPassword;
|
||||
if (oldPasswordNeedsHash) {
|
||||
QString salt = correctPasswordSha512.left(16);
|
||||
oldPasswordSha512 = PasswordHasher::computeHash(oldPassword, salt);
|
||||
}
|
||||
if (correctPasswordSha512 != oldPasswordSha512) {
|
||||
const QString storedPassword = passwordQuery->value(0).toString();
|
||||
// oldPasswordNeedsHash means the client sent the old password in plaintext. Verify it
|
||||
// against whatever is stored: legacy salt+hash rows or already-migrated scrypt rows
|
||||
// (which must NOT be re-hashed with a salt torn out of the "$scrypt$..." string).
|
||||
const bool oldPasswordMatches = oldPasswordNeedsHash ? PasswordHasher::verifyPassword(oldPassword, storedPassword)
|
||||
: (oldPassword == storedPassword);
|
||||
if (!oldPasswordMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1846,9 +2140,9 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
|||
|
||||
if (room) {
|
||||
if (game || chat) {
|
||||
queryString.append(" OR `target_type` = 'room'");
|
||||
queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'");
|
||||
} else {
|
||||
queryString.append("`target_type` = 'room'");
|
||||
queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'");
|
||||
}
|
||||
}
|
||||
queryString.append(")");
|
||||
|
|
@ -1899,6 +2193,7 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
|||
chatMessage.set_target_type(QString(query->value(5).toString()).toStdString());
|
||||
chatMessage.set_target_id(QString(query->value(6).toString()).toStdString());
|
||||
chatMessage.set_target_name(QString(query->value(7).toString()).toStdString());
|
||||
chatMessage.set_channel(QString(query->value(8).toString()).toStdString());
|
||||
results << chatMessage;
|
||||
}
|
||||
|
||||
|
|
@ -2068,3 +2363,25 @@ void Servatrice_DatabaseInterface::addAuditRecord(const QString &user,
|
|||
query->bindValue(":details", details);
|
||||
execSqlQuery(query);
|
||||
}
|
||||
|
||||
bool Servatrice_DatabaseInterface::isCardArtAllowed(const QString &cardName, const QString &cardProviderId)
|
||||
{
|
||||
if (cardName.isEmpty() || !checkSql()) {
|
||||
return true;
|
||||
}
|
||||
QSqlQuery *q = prepareQuery(
|
||||
"SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider");
|
||||
q->bindValue(":name", cardName);
|
||||
q->bindValue(":provider", cardProviderId);
|
||||
|
||||
if (!execSqlQuery(q)) {
|
||||
qWarning() << "Card art rule lookup failed; failing open for" << cardName;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!q->next()) {
|
||||
return true; // default allow
|
||||
}
|
||||
|
||||
return q->value(0).toString() != "DENY";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
class QSqlQuery;
|
||||
|
||||
#define DATABASE_SCHEMA_VERSION 37
|
||||
#define DATABASE_SCHEMA_VERSION 40
|
||||
|
||||
class Servatrice;
|
||||
|
||||
|
|
@ -96,6 +96,18 @@ public:
|
|||
bool activeUserExists(const QString &user) override;
|
||||
bool userExists(const QString &user) override;
|
||||
QString getUserSalt(const QString &user) override;
|
||||
QString getUserPasswordData(const QString &user) override;
|
||||
bool submitPasswordVerifier(const QString &user, const QString &passwordVerifier) override;
|
||||
/** @brief Decrypt the account's legacy password backup when it exists, is decryptable
|
||||
* with the configured key and has not aged out; purges it when it has. Returns the
|
||||
* legacy credential in the stored "salt + hash" format, or an empty string. */
|
||||
QString getUsableLegacyBackup(const QString &user);
|
||||
/** @brief Refresh the age window of the account's legacy password backup. */
|
||||
void touchLegacyBackup(const QString &user);
|
||||
/** @brief Drop the account's legacy password backup. */
|
||||
void clearLegacyBackup(const QString &user);
|
||||
/** @brief Drop every legacy password backup that has not been used within the TTL. */
|
||||
void purgeExpiredLegacyBackups();
|
||||
int getUserIdInDB(const QString &name);
|
||||
QMap<QString, ServerInfo_User> getBuddyList(const QString &name) override;
|
||||
QMap<QString, ServerInfo_User> getIgnoreList(const QString &name) override;
|
||||
|
|
@ -177,7 +189,8 @@ public:
|
|||
const QString &logMessage,
|
||||
LogMessage_TargetType targetType,
|
||||
const int targetId,
|
||||
const QString &targetName) override;
|
||||
const QString &targetName,
|
||||
const QString &channel = QString()) override;
|
||||
bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override;
|
||||
bool changeUserPassword(const QString &user,
|
||||
const QString &oldPassword,
|
||||
|
|
@ -218,6 +231,7 @@ public:
|
|||
};
|
||||
UptimeSnapshot getLatestUptimeSnapshot(int serverId);
|
||||
bool removeUserAvatar(const QString &userName);
|
||||
bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override;
|
||||
bool addForgotPassword(const QString &user);
|
||||
bool removeForgotPassword(const QString &user) override;
|
||||
bool doesForgotPasswordExist(const QString &user);
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@
|
|||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
|
||||
#include <libcockatrice/utility/cryptoutil.h>
|
||||
#include <libcockatrice/utility/passwordhasher.h>
|
||||
#include <libcockatrice/utility/report_categories.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
|
|
@ -204,7 +205,15 @@ bool AbstractServerSocketInterface::initSession()
|
|||
identEvent.set_server_version(VERSION_STRING);
|
||||
identEvent.set_protocol_version(protocolVersion);
|
||||
if (servatrice->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
||||
// Challenge-response is advertised in every strictness mode: legacy accounts keep
|
||||
// logging in with the legacy hash, but already-migrated scrypt rows are always
|
||||
// served challenge-response (authentication_strictness only governs NEW credentials).
|
||||
// server_options stays exactly SupportsPasswordHash on the wire: already-released
|
||||
// clients parse an unknown proto2 enum value as NoOptions and would fall back to
|
||||
// plaintext passwords. Challenge support is advertised in the separate
|
||||
// supports_challenge_response field, which old clients simply ignore.
|
||||
identEvent.set_server_options(Event_ServerIdentification::SupportsPasswordHash);
|
||||
identEvent.set_supports_challenge_response(true);
|
||||
}
|
||||
SessionEvent *identSe = prepareSessionEvent(identEvent);
|
||||
sendProtocolItem(*identSe);
|
||||
|
|
@ -409,6 +418,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
|
|||
return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc);
|
||||
case SessionCommand::REPORT_DETAILS:
|
||||
return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc);
|
||||
case SessionCommand::SUBMIT_PASSWORD_VERIFIER:
|
||||
return cmdSubmitPasswordVerifier(cmd.GetExtension(Command_SubmitPasswordVerifier::ext), rc);
|
||||
default:
|
||||
return Response::RespFunctionNotAllowed;
|
||||
}
|
||||
|
|
@ -3298,6 +3309,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
|
|||
password = QString::fromStdString(cmd.hashed_password());
|
||||
}
|
||||
|
||||
// Reject credential formats the configured authentication strictness does not accept.
|
||||
if (!acceptsCredentialFormat(passwordNeedsHash, password)) {
|
||||
return Response::RespClientUpdateRequired;
|
||||
}
|
||||
|
||||
bool requireEmailActivation = settingsCache->value("registration/requireemailactivation", true).toBool();
|
||||
bool regSucceeded = sqlInterface->registerUser(userName, realName, password, passwordNeedsHash, parsedEmailAddress,
|
||||
country, !requireEmailActivation);
|
||||
|
|
@ -3342,6 +3358,30 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i
|
|||
return servatrice->recordRegistrationAttempt(ipAddress);
|
||||
}
|
||||
|
||||
bool AbstractServerSocketInterface::acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const
|
||||
{
|
||||
// An empty credential must never reach the database: it would be accepted
|
||||
// as a legacy format and stored as '' (fail-open on login, see the empty
|
||||
// stored-credential guard in Servatrice_DatabaseInterface).
|
||||
if (password.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// "scryptFormat" means the client sent a derived verifier rather than a
|
||||
// password to hash ourselves. parsePasswordVerifier enforces the sane-cost
|
||||
// clamp, so nothing starting with '$' reaches the database unparsed.
|
||||
const bool scryptFormat = !passwordNeedsHash && PasswordHasher::parsePasswordVerifier(password).isValid;
|
||||
|
||||
// The strictness mode governs how existing legacy accounts are served, not which new-credential
|
||||
// formats are tolerated: a legacy-mode server must still accept scrypt verifiers, because clients
|
||||
// derive them whenever challenge-response is advertised (and it must be, so already-migrated
|
||||
// scrypt rows keep logging in). strict is the only mode that rejects legacy formats.
|
||||
if (servatrice->getAuthenticationStrictness() == Servatrice::AuthenticationStrict) {
|
||||
return scryptFormat;
|
||||
}
|
||||
// legacy and mixed accept a valid scrypt verifier or a genuine legacy salt+hash.
|
||||
return scryptFormat || PasswordHasher::isLegacyFormat(password);
|
||||
}
|
||||
|
||||
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
|
||||
ResponseContainer & /*rc*/)
|
||||
{
|
||||
|
|
@ -3481,26 +3521,6 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm
|
|||
return Response::RespOk;
|
||||
}
|
||||
|
||||
bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName, const QString &cardProviderId)
|
||||
{
|
||||
QSqlQuery *q = sqlInterface->prepareQuery(
|
||||
"SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider");
|
||||
|
||||
q->bindValue(":name", cardName);
|
||||
q->bindValue(":provider", cardProviderId);
|
||||
|
||||
if (!sqlInterface->execSqlQuery(q)) {
|
||||
qWarning() << "Card art rule lookup failed; failing open for" << cardName;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!q->next()) {
|
||||
return true; // default allow
|
||||
}
|
||||
|
||||
return q->value(0).toString() != "DENY";
|
||||
}
|
||||
|
||||
Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd,
|
||||
ResponseContainer & /* rc */)
|
||||
{
|
||||
|
|
@ -3527,7 +3547,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const
|
|||
return Response::RespOk;
|
||||
}
|
||||
|
||||
if (!isCardNameAllowed(cardName, cardProviderId)) {
|
||||
if (!sqlInterface->isCardArtAllowed(cardName, cardProviderId)) {
|
||||
return Response::RespFunctionNotAllowed;
|
||||
}
|
||||
|
||||
|
|
@ -3676,6 +3696,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C
|
|||
newPassword = QString::fromStdString(cmd.hashed_new_password());
|
||||
}
|
||||
|
||||
// Reject new credential formats the configured authentication strictness does not accept.
|
||||
if (!acceptsCredentialFormat(newPasswordNeedsHash, newPassword)) {
|
||||
return Response::RespClientUpdateRequired;
|
||||
}
|
||||
|
||||
QString userName = QString::fromStdString(userInfo->name());
|
||||
if (!databaseInterface->changeUserPassword(userName, oldPassword, true, newPassword, newPasswordNeedsHash)) {
|
||||
return Response::RespWrongPassword;
|
||||
|
|
@ -3822,6 +3847,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con
|
|||
password = QString::fromStdString(cmd.hashed_new_password());
|
||||
}
|
||||
|
||||
// Reject new credential formats the configured authentication strictness does not accept.
|
||||
if (!acceptsCredentialFormat(passwordNeedsHash, password)) {
|
||||
return Response::RespClientUpdateRequired;
|
||||
}
|
||||
|
||||
if (sqlInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) {
|
||||
if (servatrice->getEnableForgotPasswordAudit()) {
|
||||
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
|
||||
|
|
@ -3889,9 +3919,11 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa
|
|||
Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd,
|
||||
ResponseContainer &rc)
|
||||
{
|
||||
const QString userName = nameFromStdString(cmd.user_name());
|
||||
QString passwordSalt = sqlInterface->getUserSalt(userName);
|
||||
if (passwordSalt.isEmpty()) {
|
||||
// Simplified exactly like cmdLogin, so the nonce bound to userName here validates
|
||||
// against the same normalized name during the login's challenge-response check.
|
||||
const QString userName = nameFromStdString(cmd.user_name()).simplified();
|
||||
const QString storedPasswordData = sqlInterface->getUserPasswordData(userName);
|
||||
if (storedPasswordData.isEmpty()) {
|
||||
if (server->getRegOnlyServerEnabled()) {
|
||||
return Response::RespRegistrationRequired;
|
||||
} else {
|
||||
|
|
@ -3899,8 +3931,46 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
|
|||
return Response::RespOk;
|
||||
}
|
||||
}
|
||||
|
||||
auto *re = new Response_PasswordSalt;
|
||||
re->set_password_salt(passwordSalt.toStdString());
|
||||
if (PasswordHasher::isLegacyFormat(storedPasswordData)) {
|
||||
re->set_password_salt(storedPasswordData.left(16).toStdString());
|
||||
re->set_needs_migration(true);
|
||||
// Legacy rows get a challenge-response nonce only outside legacy mode (there the client
|
||||
// logs in with the legacy hash and the account is migrated). In legacy mode the row is
|
||||
// served the legacy salt, since legacy mode only governs what NEW credentials are accepted.
|
||||
if (servatrice->getAuthenticationStrictness() != Servatrice::AuthenticationLegacy) {
|
||||
const QByteArray nonce = CryptoUtil::randomBytes(32);
|
||||
setAuthNonce(nonce, userName);
|
||||
re->set_nonce(nonce.constData(), nonce.size());
|
||||
}
|
||||
} else {
|
||||
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedPasswordData);
|
||||
if (!verifier.isValid) {
|
||||
delete re;
|
||||
return Response::RespContextError;
|
||||
}
|
||||
const QString scryptSalt = QString(verifier.salt.toBase64());
|
||||
re->set_password_salt(scryptSalt.toStdString());
|
||||
re->set_scrypt_salt(scryptSalt.toStdString());
|
||||
re->set_n(verifier.n);
|
||||
re->set_r(verifier.r);
|
||||
re->set_p(verifier.p);
|
||||
re->set_needs_migration(false);
|
||||
// Pre-challenge clients derive the legacy hash from password_salt. While a fresh
|
||||
// encrypted legacy backup exists, serve its salt there so they can still log in;
|
||||
// challenge-response clients must use scrypt_salt, which always carries the
|
||||
// verifier salt.
|
||||
const QString legacyBackup = sqlInterface->getUsableLegacyBackup(userName);
|
||||
if (!legacyBackup.isEmpty()) {
|
||||
re->set_password_salt(legacyBackup.left(16).toStdString());
|
||||
}
|
||||
// scrypt rows are served challenge-response in every mode so migrated accounts never lock out.
|
||||
const QByteArray nonce = CryptoUtil::randomBytes(32);
|
||||
setAuthNonce(nonce, userName);
|
||||
re->set_nonce(nonce.constData(), nonce.size());
|
||||
}
|
||||
|
||||
rc.setResponseExtension(re);
|
||||
return Response::RespOk;
|
||||
}
|
||||
|
|
@ -3998,6 +4068,39 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Re
|
|||
return Response::RespOk;
|
||||
}
|
||||
|
||||
Response::ResponseCode
|
||||
AbstractServerSocketInterface::cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd,
|
||||
ResponseContainer & /*rc*/)
|
||||
{
|
||||
if (authState != PasswordRight) {
|
||||
return Response::RespLoginNeeded;
|
||||
}
|
||||
|
||||
// Limit to the size of the database column (password_sha512 varchar(255)).
|
||||
constexpr int MAX_PASSWORD_VERIFIER_LENGTH = 255;
|
||||
const QString passwordVerifier = QString::fromStdString(cmd.password_verifier());
|
||||
if (passwordVerifier.isEmpty() || passwordVerifier.length() > MAX_PASSWORD_VERIFIER_LENGTH ||
|
||||
PasswordHasher::isLegacyFormat(passwordVerifier)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
// Reject unparseable or hostile cost parameters before they reach the database.
|
||||
const PasswordVerifier parsedVerifier = PasswordHasher::parsePasswordVerifier(passwordVerifier);
|
||||
if (!parsedVerifier.isValid) {
|
||||
qCWarning(AbstractServerSocketInterfaceLog)
|
||||
<< "Rejecting password verifier submission with invalid or insane cost parameters";
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
if (!sqlInterface->submitPasswordVerifier(QString::fromStdString(userInfo->name()), passwordVerifier)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
qCDebug(AbstractServerSocketInterfaceLog)
|
||||
<< "Password verifier migrated for user" << QString::fromStdString(userInfo->name());
|
||||
return Response::RespOk;
|
||||
}
|
||||
|
||||
// ADMIN FUNCTIONS.
|
||||
// Permission is checked by the calling function.
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ class Command_ReportUserInfo;
|
|||
class Command_RequestPasswordSalt;
|
||||
class Command_ResetUserPassword;
|
||||
class Command_SetCardArtParams;
|
||||
class Command_SubmitPasswordVerifier;
|
||||
class Command_UpdateAdminNotes;
|
||||
class Command_ViewLogHistory;
|
||||
class Command_WarnUser;
|
||||
|
|
@ -124,6 +125,7 @@ protected:
|
|||
void logDebugMessage(const QString &message) override;
|
||||
bool tooManyRegistrationAttempts(const QString &ipAddress);
|
||||
void processCommandContainer(const CommandContainer &cont) override;
|
||||
bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const;
|
||||
|
||||
virtual void writeToSocket(QByteArray &data) = 0;
|
||||
virtual void flushSocket() = 0;
|
||||
|
|
@ -205,6 +207,7 @@ private:
|
|||
Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode
|
||||
processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override;
|
||||
Response::ResponseCode
|
||||
|
|
@ -216,7 +219,6 @@ private:
|
|||
|
||||
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc);
|
||||
bool isCardNameAllowed(const QString &cardName, const QString &cardProviderId);
|
||||
Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &);
|
||||
Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &);
|
||||
Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &);
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ endfunction()
|
|||
cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
|
||||
|
|
@ -273,6 +274,17 @@ cockatrice_add_test(
|
|||
LABELS
|
||||
server
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
server_room_channels_test
|
||||
SOURCES
|
||||
server_room_channels_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network_server_remote
|
||||
libcockatrice_rng
|
||||
LABELS
|
||||
server
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
latency_tracker_test
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
#include "gtest/gtest.h"
|
||||
#include <QChar>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <cstring>
|
||||
#include <libcockatrice/utility/cryptoutil.h>
|
||||
#include <libcockatrice/utility/passwordhasher.h>
|
||||
#include <string>
|
||||
|
||||
|
|
@ -39,6 +41,204 @@ TEST(PasswordHashTest, TokenHasExpectedLength)
|
|||
const QString token = PasswordHasher::generateActivationToken();
|
||||
ASSERT_EQ(token.size(), 16);
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, DeriveKeyMatchesKnownVector)
|
||||
{
|
||||
// RFC 7914 scrypt test vector, P="password", S="NaCl", N=1024, r=8, p=16
|
||||
const QByteArray expected = QByteArray::fromHex("fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b"
|
||||
"3731622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cb"
|
||||
"dfa2cc0640");
|
||||
const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16);
|
||||
ASSERT_EQ(derived.toHex(), expected.toHex());
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, PasswordVerifierRoundTrip)
|
||||
{
|
||||
const QString stored = PasswordHasher::generatePasswordVerifier("hunter2");
|
||||
ASSERT_FALSE(stored.isEmpty());
|
||||
ASSERT_TRUE(stored.startsWith("$scrypt$"));
|
||||
|
||||
const PasswordVerifier parsed = PasswordHasher::parsePasswordVerifier(stored);
|
||||
ASSERT_TRUE(parsed.isValid);
|
||||
ASSERT_EQ(parsed.format, PasswordFormat::Scrypt);
|
||||
ASSERT_EQ(parsed.n, SCRYPT_N);
|
||||
ASSERT_EQ(parsed.r, SCRYPT_R);
|
||||
ASSERT_EQ(parsed.p, SCRYPT_P);
|
||||
ASSERT_EQ(parsed.salt.size(), SCRYPT_SALT_LENGTH);
|
||||
ASSERT_EQ(parsed.verifier.size(), SCRYPT_VERIFIER_LENGTH);
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, PasswordVerifierInvalidInput)
|
||||
{
|
||||
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("garbage").isValid);
|
||||
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$not-an-int$8$1$AAAA$BBBB").isValid);
|
||||
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$1024$8$1$AAAA$too-short").isValid);
|
||||
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$pbkdf2-sha512$1000$AAAA$BBBB").isValid);
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, LegacyFormatDetection)
|
||||
{
|
||||
ASSERT_TRUE(PasswordHasher::isLegacyFormat("salt+hash"));
|
||||
ASSERT_FALSE(PasswordHasher::isLegacyFormat(PasswordHasher::generatePasswordVerifier("password")));
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, DeriveKeyDependsOnCostParameters)
|
||||
{
|
||||
const QByteArray keyA = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16);
|
||||
const QByteArray keyB = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 2048, 8, 16);
|
||||
const QByteArray keyC = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 1);
|
||||
ASSERT_NE(keyA, keyB);
|
||||
ASSERT_NE(keyA, keyC);
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, ComputeResponseIsDeterministic)
|
||||
{
|
||||
const QByteArray nonce = QByteArray("a nonce value");
|
||||
const QByteArray key = QByteArray("the verifier bytes");
|
||||
const QByteArray r1 = PasswordHasher::computeResponse(key, nonce);
|
||||
const QByteArray r2 = PasswordHasher::computeResponse(key, nonce);
|
||||
const QByteArray r3 = PasswordHasher::computeResponse(QByteArray("a different key"), nonce);
|
||||
ASSERT_EQ(r1, r2);
|
||||
ASSERT_NE(r1, r3);
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, ConstantTimeEquals)
|
||||
{
|
||||
ASSERT_TRUE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("same")));
|
||||
ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("diff")));
|
||||
ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("short"), QByteArray("longer")));
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, CostParamsAreSane)
|
||||
{
|
||||
// Accept the recommended interactive parameters and the RFC 7914 test vector's.
|
||||
ASSERT_TRUE(PasswordHasher::costParamsAreSane(SCRYPT_N, SCRYPT_R, SCRYPT_P));
|
||||
ASSERT_TRUE(PasswordHasher::costParamsAreSane(1024, 8, 16));
|
||||
|
||||
// n must be in [1024, 2**20] and a power of two.
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(512, 8, 1));
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1 << 21, 8, 1));
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1025, 8, 1));
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(0, 8, 1));
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(-1024, 8, 1));
|
||||
|
||||
// r in [1, 32], p in [1, 16].
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 0, 1));
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 33, 1));
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 0));
|
||||
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 17));
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, ParsePasswordVerifierRejectsHostileCostParams)
|
||||
{
|
||||
// 16 bytes of salt and 64 bytes of verifier, base64 encoded.
|
||||
const QString saltB64 = QLatin1String("c2FsdHNhbHRzYWx0c2FsdA==");
|
||||
const QString verifierB64 = QString(QByteArray(SCRYPT_VERIFIER_LENGTH, '\x42').toBase64());
|
||||
|
||||
ASSERT_TRUE(
|
||||
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
|
||||
|
||||
// n not a power of two, below 1024, or above 2**20.
|
||||
ASSERT_FALSE(
|
||||
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1025$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
|
||||
ASSERT_FALSE(
|
||||
PasswordHasher::parsePasswordVerifier(QString("$scrypt$512$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
|
||||
ASSERT_FALSE(
|
||||
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1073741824$8$1$%1$%2").arg(saltB64).arg(verifierB64))
|
||||
.isValid);
|
||||
|
||||
// r and p out of range.
|
||||
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$33$1$%1$%2").arg(saltB64).arg(verifierB64))
|
||||
.isValid);
|
||||
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$17$%1$%2").arg(saltB64).arg(verifierB64))
|
||||
.isValid);
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, VerifyPasswordLegacyRow)
|
||||
{
|
||||
const QString salt = PasswordHasher::generateRandomSalt();
|
||||
const QString legacyStored = PasswordHasher::computeHash("correct horse", salt);
|
||||
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", legacyStored));
|
||||
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", legacyStored));
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, VerifyPasswordScryptRow)
|
||||
{
|
||||
const QString scryptStored = PasswordHasher::generatePasswordVerifier("correct horse");
|
||||
// Regression for the changeUserPassword bug that re-hashed the old password with
|
||||
// a 16-char salt torn out of the "$scrypt$..." string, which could never match.
|
||||
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", scryptStored));
|
||||
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", scryptStored));
|
||||
ASSERT_FALSE(PasswordHasher::verifyPassword("correct horse", "garbage"));
|
||||
}
|
||||
|
||||
TEST(CryptoUtilTest, SecretRoundTrip)
|
||||
{
|
||||
const QByteArray key = CryptoUtil::randomBytes(32);
|
||||
const QByteArray plaintext = PasswordHasher::computeHash("correct horse", "saltsaltsaltsalt").toUtf8();
|
||||
const QByteArray aad = "alice";
|
||||
|
||||
const QString sealed = CryptoUtil::encryptSecret(plaintext, key, aad);
|
||||
ASSERT_FALSE(sealed.isEmpty());
|
||||
ASSERT_TRUE(sealed.startsWith("enc1:"));
|
||||
ASSERT_EQ(CryptoUtil::decryptSecret(sealed, key, aad), plaintext);
|
||||
}
|
||||
|
||||
TEST(CryptoUtilTest, SealIsNonDeterministic)
|
||||
{
|
||||
const QByteArray key = CryptoUtil::randomBytes(32);
|
||||
const QByteArray plaintext = "same secret";
|
||||
// Fresh random nonce per seal: identical inputs must not produce identical blobs.
|
||||
ASSERT_NE(CryptoUtil::encryptSecret(plaintext, key), CryptoUtil::encryptSecret(plaintext, key));
|
||||
}
|
||||
|
||||
TEST(CryptoUtilTest, RejectsWrongKey)
|
||||
{
|
||||
const QByteArray key = CryptoUtil::randomBytes(32);
|
||||
const QByteArray otherKey = CryptoUtil::randomBytes(32);
|
||||
const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice");
|
||||
ASSERT_FALSE(sealed.isEmpty());
|
||||
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, otherKey, "alice").isEmpty());
|
||||
}
|
||||
|
||||
TEST(CryptoUtilTest, RejectsWrongAad)
|
||||
{
|
||||
const QByteArray key = CryptoUtil::randomBytes(32);
|
||||
const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice");
|
||||
ASSERT_FALSE(sealed.isEmpty());
|
||||
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key, "bob").isEmpty());
|
||||
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key).isEmpty());
|
||||
}
|
||||
|
||||
TEST(CryptoUtilTest, RejectsTampering)
|
||||
{
|
||||
const QByteArray key = CryptoUtil::randomBytes(32);
|
||||
const QString sealed = CryptoUtil::encryptSecret("secret", key);
|
||||
ASSERT_FALSE(sealed.isEmpty());
|
||||
|
||||
// Flip a character inside the ciphertext segment (keep it valid base64).
|
||||
QStringList parts = sealed.split(':');
|
||||
ASSERT_EQ(parts.size(), 3);
|
||||
QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8());
|
||||
ciphertext[0] = static_cast<char>(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)
|
||||
|
|
|
|||
550
tests/server_room_channels_test.cpp
Normal file
550
tests/server_room_channels_test.cpp
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
/** @file server_room_channels_test.cpp
|
||||
* @brief Tests for room chat channels: fan-out, privilege gating, whitelist, history replay and audit logging.
|
||||
* @ingroup Tests
|
||||
*/
|
||||
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
#include "libcockatrice/protocol/pb/server_message.pb.h"
|
||||
#include "server.h"
|
||||
#include "server_database_interface.h"
|
||||
#include "server_protocolhandler.h"
|
||||
#include "server_room.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/protocol/get_pb_extension.h>
|
||||
#include <libcockatrice/protocol/pb/commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
|
||||
#include <libcockatrice/protocol/pb/isl_message.pb.h>
|
||||
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/room_event.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/protocol/pb/session_commands.pb.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <string>
|
||||
|
||||
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<LogEntry> 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<RoomEvent> 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<ServerInfo_RoomChannel> 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<RoomEvent> channelSayEvents(const TestRoomHandler &handler)
|
||||
{
|
||||
QList<RoomEvent> result;
|
||||
for (const RoomEvent &event : handler.receivedRoomEvents) {
|
||||
if (static_cast<RoomEvent::RoomEventType>(getPbExtension(event)) == RoomEvent::ROOM_CHANNEL_SAY) {
|
||||
result.append(event);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static QList<RoomEvent> roomSayEvents(const TestRoomHandler &handler)
|
||||
{
|
||||
QList<RoomEvent> result;
|
||||
for (const RoomEvent &event : handler.receivedRoomEvents) {
|
||||
if (static_cast<RoomEvent::RoomEventType>(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<ServerInfo_ChatMessage> 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<ServerInfo_ChatMessage> 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<ServerInfo_ChatMessage> 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<RoomEvent> 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<RoomEvent> 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<RoomEvent> 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<IslMessage> 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<RoomEvent::RoomEventType>(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();
|
||||
}
|
||||
|
|
@ -285,6 +285,32 @@ TEST_F(SettingsDefaultsTest, Chat_HighlightColor_Default)
|
|||
ASSERT_EQ(s.getChatHighlightColor(), QString("A6120D"));
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_Default)
|
||||
{
|
||||
ChatSettings s(settingsPath, nullptr);
|
||||
ASSERT_EQ(s.getChannelUnreadColor(), QString("1A7B00"));
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_SetAndGet)
|
||||
{
|
||||
ChatSettings s(settingsPath, nullptr);
|
||||
s.setChannelUnreadColor(QString("0000FF"));
|
||||
ASSERT_EQ(s.getChannelUnreadColor(), QString("0000FF"));
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_Default)
|
||||
{
|
||||
ChatSettings s(settingsPath, nullptr);
|
||||
ASSERT_EQ(s.getChannelMentionColor(), QString("A6120D"));
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_SetAndGet)
|
||||
{
|
||||
ChatSettings s(settingsPath, nullptr);
|
||||
s.setChannelMentionColor(QString("FF0000"));
|
||||
ASSERT_EQ(s.getChannelMentionColor(), QString("FF0000"));
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Chat_MentionForeground_Default)
|
||||
{
|
||||
ChatSettings s(settingsPath, nullptr);
|
||||
|
|
|
|||
581
tests/user_data_backup_test.cpp
Normal file
581
tests/user_data_backup_test.cpp
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
#include "gtest/gtest.h"
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <libcockatrice/utility/user_data_backup.h>
|
||||
|
||||
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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_card_database/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_card_database/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue