mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-09 03:23:24 -08:00
Compare commits
13 commits
b7253efefb
...
84ad1185b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84ad1185b6 | ||
|
|
232b302c73 | ||
|
|
a139084ad6 | ||
|
|
155f7b0143 | ||
|
|
ce51e7147d | ||
|
|
c99113ab0e | ||
|
|
05dcde69db | ||
|
|
17d2e8d86e | ||
|
|
be38e8d429 | ||
|
|
6c660d6b21 | ||
|
|
711386574d | ||
|
|
b11b096828 | ||
|
|
bbe438b09a |
78 changed files with 5565 additions and 734 deletions
|
|
@ -15,8 +15,17 @@ Var ReinstallMode
|
||||||
Var AllowForceClose
|
Var AllowForceClose
|
||||||
Var GuardDir
|
Var GuardDir
|
||||||
Var OldUninstallString
|
Var OldUninstallString
|
||||||
|
Var OldInstallDir
|
||||||
|
Var DataBackupDir
|
||||||
|
Var DataBackupDone
|
||||||
|
Var DataBackupForDir
|
||||||
|
Var LooseDecksBackupDone
|
||||||
|
Var InstallSubdirsBackupDone
|
||||||
|
Var LooseDeckNoticeShown
|
||||||
Var StaleFindHandle
|
Var StaleFindHandle
|
||||||
Var StaleFindName
|
Var StaleFindName
|
||||||
|
Var DataBackupFindHandle
|
||||||
|
Var DataBackupFindName
|
||||||
Var IsUpgrade
|
Var IsUpgrade
|
||||||
Var AbortReason
|
Var AbortReason
|
||||||
Var WaitPid
|
Var WaitPid
|
||||||
|
|
@ -125,6 +134,17 @@ FunctionEnd
|
||||||
Function .onInit
|
Function .onInit
|
||||||
|
|
||||||
StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log"
|
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
|
; Start each run from a clean file so the log that gets sent back is the last
|
||||||
; run, not every past run glued together.
|
; run, not every past run glued together.
|
||||||
FileOpen $LogHandle "$InstallLogPath" w
|
FileOpen $LogHandle "$InstallLogPath" w
|
||||||
|
|
@ -314,10 +334,15 @@ SetShellVarContext all
|
||||||
SetRegView 32
|
SetRegView 32
|
||||||
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
|
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
|
StrCmp $R0 "" done32
|
||||||
DetailPrint "Removing previous version (32-bit)..."
|
DetailPrint "Removing previous version (32-bit)..."
|
||||||
ExecWait '$R0 _?=$INSTDIR'
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait '$R0 _?=$OldInstallDir'
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done32:
|
done32:
|
||||||
|
|
||||||
|
|
@ -326,16 +351,312 @@ ${If} ${RunningX64}
|
||||||
SetRegView 64
|
SetRegView 64
|
||||||
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
|
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
|
StrCmp $R0 "" done64
|
||||||
DetailPrint "Removing previous version (64-bit)..."
|
DetailPrint "Removing previous version (64-bit)..."
|
||||||
ExecWait '$R0 _?=$INSTDIR'
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait '$R0 _?=$OldInstallDir'
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done64:
|
done64:
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
FunctionEnd
|
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 ---
|
; --- Running instance handling ---
|
||||||
; Cockatrice, Oracle and Servatrice must not be running while files are
|
; Cockatrice, Oracle and Servatrice must not be running while files are
|
||||||
; replaced or deleted. A still-running process holds locks on its .exe and
|
; 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
|
; instance of the application is still running out of the *old* install
|
||||||
; directory before letting it delete anything. The uninstall string is
|
; directory before letting it delete anything. The uninstall string is
|
||||||
; stashed away first, the check works on $R0.
|
; 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
|
StrCpy $OldUninstallString $R0
|
||||||
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'"
|
|
||||||
Call EnsureOldInstallNotRunning
|
Call EnsureOldInstallNotRunning
|
||||||
|
StrCpy $OldInstallDir $GuardDir
|
||||||
|
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
|
||||||
ClearErrors
|
ClearErrors
|
||||||
ExecWait "$OldUninstallString _?=$INSTDIR"
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait "$OldUninstallString _?=$OldInstallDir"
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done32:
|
done32:
|
||||||
|
|
||||||
|
|
@ -1061,10 +1388,13 @@ ${If} $PortableMode = 0
|
||||||
|
|
||||||
uninst64:
|
uninst64:
|
||||||
StrCpy $OldUninstallString $R0
|
StrCpy $OldUninstallString $R0
|
||||||
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'"
|
|
||||||
Call EnsureOldInstallNotRunning
|
Call EnsureOldInstallNotRunning
|
||||||
|
StrCpy $OldInstallDir $GuardDir
|
||||||
|
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
|
||||||
ClearErrors
|
ClearErrors
|
||||||
ExecWait "$OldUninstallString _?=$INSTDIR"
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait "$OldUninstallString _?=$OldInstallDir"
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done64:
|
done64:
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
@ -1188,6 +1518,11 @@ ${EndIf}
|
||||||
!insertmacro CockLog "section: writing payload to $INSTDIR"
|
!insertmacro CockLog "section: writing payload to $INSTDIR"
|
||||||
@CPACK_NSIS_FULL_INSTALL@
|
@CPACK_NSIS_FULL_INSTALL@
|
||||||
!insertmacro CockLog "section: payload written"
|
!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"
|
!insertmacro CockLogListing "final" "Qt6*.dll"
|
||||||
|
|
||||||
${If} $PortableMode = 0
|
${If} $PortableMode = 0
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||||
src/interface/widgets/dialogs/override_printing_warning.cpp
|
src/interface/widgets/dialogs/override_printing_warning.cpp
|
||||||
src/interface/widgets/dialogs/path_safety_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/dialogs/tip_of_the_day.cpp
|
||||||
src/interface/widgets/deck_share/deck_share_utils.cpp
|
src/interface/widgets/deck_share/deck_share_utils.cpp
|
||||||
src/interface/widgets/deck_share/shared_deck_preview_widget.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.cpp
|
||||||
src/game_graphics/zones/view_zone_widget.cpp
|
src/game_graphics/zones/view_zone_widget.cpp
|
||||||
src/game_graphics/board/abstract_graphics_item.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.cpp
|
||||||
src/interface/card_database_update/card_database_update_status_bar.h
|
src/interface/card_database_update/card_database_update_status_bar.h
|
||||||
src/interface/card_database_update/card_update_progress.cpp
|
src/interface/card_database_update/card_update_progress.cpp
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ void ConnectionController::wireClientSignals()
|
||||||
|
|
||||||
connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this,
|
connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this,
|
||||||
&ConnectionController::onPromptForgotPasswordChallenge);
|
&ConnectionController::onPromptForgotPasswordChallenge);
|
||||||
|
|
||||||
|
connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this,
|
||||||
|
&ConnectionController::onPasswordVerifierReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConnectionController::connectToServer()
|
void ConnectionController::connectToServer()
|
||||||
|
|
@ -103,16 +106,32 @@ void ConnectionController::connectToServer()
|
||||||
connect(dlgConnect, &DlgConnect::sigStartForgotPasswordRequest, this, &ConnectionController::forgotPasswordRequest);
|
connect(dlgConnect, &DlgConnect::sigStartForgotPasswordRequest, this, &ConnectionController::forgotPasswordRequest);
|
||||||
|
|
||||||
if (dlgConnect->exec()) {
|
if (dlgConnect->exec()) {
|
||||||
|
pendingSaveName = dlgConnect->getSaveName();
|
||||||
|
pendingSavePassword = dlgConnect->getSavePassword();
|
||||||
|
remoteClient->setStoredVerifier(dlgConnect->getStoredVerifier());
|
||||||
remoteClient->connectToServer(dlgConnect->getHost(), static_cast<unsigned int>(dlgConnect->getPort()),
|
remoteClient->connectToServer(dlgConnect->getHost(), static_cast<unsigned int>(dlgConnect->getPort()),
|
||||||
dlgConnect->getPlayerName(), dlgConnect->getPassword());
|
dlgConnect->getPlayerName(), dlgConnect->getPassword());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConnectionController::onPasswordVerifierReady(const QString &verifier)
|
||||||
|
{
|
||||||
|
if (pendingSavePassword) {
|
||||||
|
SettingsCache::instance().servers().setServerPassword(pendingSaveName, verifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ConnectionController::connectToServerDirect(const QString &host,
|
void ConnectionController::connectToServerDirect(const QString &host,
|
||||||
unsigned int port,
|
unsigned int port,
|
||||||
const QString &playerName,
|
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);
|
remoteClient->connectToServer(host, port, playerName, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,13 @@ public:
|
||||||
void registerToServer();
|
void registerToServer();
|
||||||
void forgotPasswordRequest();
|
void forgotPasswordRequest();
|
||||||
void connectToServer();
|
void connectToServer();
|
||||||
void
|
void connectToServerDirect(const QString &host,
|
||||||
connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, const QString &password);
|
unsigned int port,
|
||||||
|
const QString &playerName,
|
||||||
|
const QString &password,
|
||||||
|
const QString &storedVerifier = QString(),
|
||||||
|
const QString &saveName = QString(),
|
||||||
|
bool savePassword = false);
|
||||||
void disconnectFromServer();
|
void disconnectFromServer();
|
||||||
|
|
||||||
void refreshWindowTitle()
|
void refreshWindowTitle()
|
||||||
|
|
@ -86,6 +91,9 @@ private slots:
|
||||||
void onPromptForgotPasswordReset();
|
void onPromptForgotPasswordReset();
|
||||||
void onPromptForgotPasswordChallenge();
|
void onPromptForgotPasswordChallenge();
|
||||||
|
|
||||||
|
// Persists the derived scrypt verifier after a successful challenge-response login
|
||||||
|
void onPasswordVerifierReady(const QString &verifier);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void wireClientSignals();
|
void wireClientSignals();
|
||||||
void updateWindowTitle();
|
void updateWindowTitle();
|
||||||
|
|
@ -102,6 +110,10 @@ private:
|
||||||
|
|
||||||
// Kept as a member so the forgot-password signal can be wired to it
|
// Kept as a member so the forgot-password signal can be wired to it
|
||||||
DlgConnect *dlgConnect{nullptr};
|
DlgConnect *dlgConnect{nullptr};
|
||||||
|
|
||||||
|
// Captured from the connect dialog when a connection is initiated
|
||||||
|
QString pendingSaveName;
|
||||||
|
bool pendingSavePassword{false};
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H
|
#endif // COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,19 @@ void DeckViewContainer::resolveAndSendPlaymat()
|
||||||
pp->set_vertical_offset(resolved.params.verticalOffset);
|
pp->set_vertical_offset(resolved.params.verticalOffset);
|
||||||
pp->set_zoom(resolved.params.zoom);
|
pp->set_zoom(resolved.params.zoom);
|
||||||
PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd);
|
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);
|
parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -412,6 +425,15 @@ void DeckViewContainer::deckSelectFinished(const Response &r)
|
||||||
switchToDeckLoadedView();
|
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()
|
void DeckViewContainer::readyStart()
|
||||||
{
|
{
|
||||||
sendReadyStartCommand(!readyStartButton->getState());
|
sendReadyStartCommand(!readyStartButton->getState());
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,14 @@ private:
|
||||||
VisualDeckStorageWidget *visualDeckStorageWidget;
|
VisualDeckStorageWidget *visualDeckStorageWidget;
|
||||||
TabGame *parentGame;
|
TabGame *parentGame;
|
||||||
int playerId;
|
int playerId;
|
||||||
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
|
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
|
||||||
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
|
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
|
||||||
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
|
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 tryCreateVisualDeckStorageWidget();
|
||||||
void sendReadyStartCommand(bool ready);
|
void sendReadyStartCommand(bool ready);
|
||||||
|
void notifyPlaymatBlacklisted(const QString &cardName, const QString &message);
|
||||||
private slots:
|
private slots:
|
||||||
void switchToDeckSelectView();
|
void switchToDeckSelectView();
|
||||||
void switchToDeckLoadedView();
|
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->setText(_data.at(3));
|
||||||
playernameEdit->setFocus();
|
playernameEdit->setFocus();
|
||||||
savePasswordCheckBox->setChecked(savePasswordStatus);
|
savePasswordCheckBox->setChecked(savePasswordStatus);
|
||||||
|
storedVerifier.clear();
|
||||||
|
|
||||||
if (savePasswordStatus) {
|
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()) {
|
if (!_data.at(6).isEmpty()) {
|
||||||
|
|
@ -317,6 +323,7 @@ void DlgConnect::newHostSelected(bool state)
|
||||||
portEdit->setDisabled(false);
|
portEdit->setDisabled(false);
|
||||||
playernameEdit->clear();
|
playernameEdit->clear();
|
||||||
passwordEdit->clear();
|
passwordEdit->clear();
|
||||||
|
storedVerifier.clear();
|
||||||
saveEdit->clear();
|
saveEdit->clear();
|
||||||
saveEdit->setPlaceholderText(tr("Unique Server Name"));
|
saveEdit->setPlaceholderText(tr("Unique Server Name"));
|
||||||
saveEdit->setDisabled(false);
|
saveEdit->setDisabled(false);
|
||||||
|
|
@ -342,6 +349,11 @@ void DlgConnect::actOk()
|
||||||
{
|
{
|
||||||
ServersSettings &servers = SettingsCache::instance().servers();
|
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 (newHostButton->isChecked()) {
|
||||||
if (saveEdit->text().isEmpty()) {
|
if (saveEdit->text().isEmpty()) {
|
||||||
QMessageBox::critical(this, tr("Connection Warning"), tr("You need to name your new connection profile."));
|
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(),
|
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 {
|
} else {
|
||||||
servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
|
servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
|
||||||
playernameEdit->text().trimmed(), passwordEdit->text(),
|
playernameEdit->text().trimmed(), passwordToSave,
|
||||||
savePasswordCheckBox->isChecked());
|
savePasswordCheckBox->isChecked());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#ifndef DLG_CONNECT_H
|
#ifndef DLG_CONNECT_H
|
||||||
#define DLG_CONNECT_H
|
#define DLG_CONNECT_H
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
|
|
@ -51,6 +52,22 @@ public:
|
||||||
{
|
{
|
||||||
return passwordEdit->text();
|
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:
|
public slots:
|
||||||
void downloadThePublicServers();
|
void downloadThePublicServers();
|
||||||
|
|
@ -81,6 +98,7 @@ private:
|
||||||
QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer;
|
QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer;
|
||||||
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
|
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
|
||||||
HandlePublicServers *hps;
|
HandlePublicServers *hps;
|
||||||
|
QString storedVerifier;
|
||||||
const QString placeHolderText = tr("Downloading...");
|
const QString placeHolderText = tr("Downloading...");
|
||||||
};
|
};
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent)
|
||||||
|
|
||||||
auto &servers = SettingsCache::instance().servers();
|
auto &servers = SettingsCache::instance().servers();
|
||||||
if (servers.getSavePassword()) {
|
if (servers.getSavePassword()) {
|
||||||
oldPasswordEdit->setText(servers.getPassword());
|
const QString stored = servers.getPassword();
|
||||||
|
if (!stored.startsWith("$")) {
|
||||||
|
oldPasswordEdit->setText(stored);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oldPasswordLabel->setBuddy(oldPasswordEdit);
|
oldPasswordLabel->setBuddy(oldPasswordEdit);
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,41 @@
|
||||||
/**
|
/**
|
||||||
* @file dlg_settings.cpp
|
* @file dlg_settings.cpp
|
||||||
* @brief Implementation of the main settings dialog
|
* @brief Implementation of the main settings dialog (thin frame around SettingsView)
|
||||||
* @ingroup Dialogs
|
* @ingroup Dialogs
|
||||||
*/
|
*/
|
||||||
#include "dlg_settings.h"
|
#include "dlg_settings.h"
|
||||||
|
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
#include "../../pixel_map_generator.h"
|
|
||||||
#include "../main.h"
|
#include "../main.h"
|
||||||
#include "../settings_page/abstract_settings_page.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.h"
|
||||||
#include "libcockatrice/card/database/card_database_loader.h"
|
#include "libcockatrice/card/database/card_database_loader.h"
|
||||||
#include "libcockatrice/card/database/card_database_manager.h"
|
#include "libcockatrice/card/database/card_database_manager.h"
|
||||||
|
|
||||||
#include <QAbstractAnimation>
|
|
||||||
#include <QAbstractItemView>
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QBrush>
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
#include <QColor>
|
|
||||||
#include <QDebug>
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QEasingCurve>
|
|
||||||
#include <QEvent>
|
|
||||||
#include <QFlags>
|
|
||||||
#include <QFrame>
|
|
||||||
#include <QGraphicsOpacityEffect>
|
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QIcon>
|
|
||||||
#include <QKeyEvent>
|
|
||||||
#include <QKeySequence>
|
|
||||||
#include <QLineEdit>
|
|
||||||
#include <QListView>
|
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QMessageLogger>
|
#include <QMessageLogger>
|
||||||
#include <QModelIndex>
|
|
||||||
#include <QObject>
|
|
||||||
#include <QPalette>
|
|
||||||
#include <QPixmap>
|
|
||||||
#include <QPropertyAnimation>
|
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QRect>
|
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QScrollArea>
|
|
||||||
#include <QScrollBar>
|
|
||||||
#include <QSequentialAnimationGroup>
|
|
||||||
#include <QShortcut>
|
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
#include <QSizePolicy>
|
|
||||||
#include <QStackedLayout>
|
|
||||||
#include <QStackedWidget>
|
|
||||||
#include <QStringLiteral>
|
|
||||||
#include <QToolButton>
|
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <libcockatrice/settings/paths_settings.h>
|
#include <libcockatrice/settings/paths_settings.h>
|
||||||
#include <libcockatrice/settings/personal_settings.h>
|
#include <libcockatrice/settings/personal_settings.h>
|
||||||
#include <qnamespace.h>
|
|
||||||
|
|
||||||
class QModelIndex;
|
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Wraps a widget in a scroll area for long settings pages
|
|
||||||
* @param widget The widget to wrap
|
|
||||||
* @return The scroll area containing the widget
|
|
||||||
*/
|
|
||||||
static QScrollArea *makeScrollable(QWidget *widget)
|
|
||||||
{
|
|
||||||
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
|
|
||||||
|
|
||||||
auto *scrollArea = new QScrollArea;
|
|
||||||
scrollArea->setWidgetResizable(true);
|
|
||||||
scrollArea->setContentsMargins(0, 0, 0, 0);
|
|
||||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
||||||
scrollArea->horizontalScrollBar()->setEnabled(false);
|
|
||||||
scrollArea->setWidget(widget);
|
|
||||||
return scrollArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
|
|
||||||
*/
|
|
||||||
static QStringList pageIconResources()
|
|
||||||
{
|
|
||||||
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
|
|
||||||
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
|
|
||||||
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
|
|
||||||
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
|
|
||||||
}
|
|
||||||
|
|
||||||
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
|
|
||||||
{
|
{
|
||||||
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
|
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
|
||||||
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
|
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
|
||||||
|
|
||||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
|
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
|
||||||
|
|
||||||
setupUi();
|
settingsView = new SettingsView(this);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Bottom buttons
|
// Bottom buttons
|
||||||
auto *buttonBox = new QHBoxLayout;
|
auto *buttonBox = new QHBoxLayout;
|
||||||
|
|
@ -211,241 +47,25 @@ void DlgSettings::setupUi()
|
||||||
|
|
||||||
// Main layout
|
// Main layout
|
||||||
auto *mainLayout = new QVBoxLayout;
|
auto *mainLayout = new QVBoxLayout;
|
||||||
mainLayout->addLayout(searchLayout);
|
mainLayout->addWidget(settingsView);
|
||||||
mainLayout->addWidget(tabBarWidget);
|
|
||||||
auto *separator = new QFrame;
|
|
||||||
separator->setFrameShape(QFrame::HLine);
|
|
||||||
separator->setFrameShadow(QFrame::Sunken);
|
|
||||||
mainLayout->addWidget(separator);
|
|
||||||
mainLayout->addWidget(pagesContainer);
|
|
||||||
mainLayout->addSpacing(4);
|
mainLayout->addSpacing(4);
|
||||||
mainLayout->addLayout(buttonBox);
|
mainLayout->addLayout(buttonBox);
|
||||||
setLayout(mainLayout);
|
setLayout(mainLayout);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
|
||||||
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
|
retranslateUi();
|
||||||
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
|
|
||||||
|
|
||||||
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
|
adjustSize();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DlgSettings::setTab(int index)
|
void DlgSettings::setTab(int index)
|
||||||
{
|
{
|
||||||
if (index >= 0 && index < tabButtons.size()) {
|
settingsView->setTab(index);
|
||||||
setActiveTab(index);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
|
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
|
||||||
{
|
{
|
||||||
return pages.value(static_cast<int>(which));
|
return settingsView->page(which);
|
||||||
}
|
}
|
||||||
|
|
||||||
void DlgSettings::updateLanguage()
|
void DlgSettings::updateLanguage()
|
||||||
|
|
@ -454,49 +74,6 @@ void DlgSettings::updateLanguage()
|
||||||
installNewTranslator();
|
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)
|
void DlgSettings::closeEvent(QCloseEvent *event)
|
||||||
{
|
{
|
||||||
bool showLoadError = true;
|
bool showLoadError = true;
|
||||||
|
|
@ -576,26 +153,5 @@ void DlgSettings::closeEvent(QCloseEvent *event)
|
||||||
void DlgSettings::retranslateUi()
|
void DlgSettings::retranslateUi()
|
||||||
{
|
{
|
||||||
setWindowTitle(tr("Settings"));
|
setWindowTitle(tr("Settings"));
|
||||||
retranslateTabNames();
|
|
||||||
|
|
||||||
searchEdit->setPlaceholderText(tr("Search settings..."));
|
|
||||||
okButton->setText(tr("OK"));
|
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,59 @@
|
||||||
/**
|
/**
|
||||||
* @file dlg_settings.h
|
* @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
|
* @ingroup Dialogs
|
||||||
*/
|
*/
|
||||||
#ifndef DLG_SETTINGS_H
|
#ifndef DLG_SETTINGS_H
|
||||||
#define DLG_SETTINGS_H
|
#define DLG_SETTINGS_H
|
||||||
|
|
||||||
|
#include "settings_view.h"
|
||||||
|
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QList>
|
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <QString>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QModelIndex;
|
class AbstractSettingsPage;
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
|
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
|
* Thin QDialog frame around SettingsView: only the window title, the OK button
|
||||||
* either navigate by clicking tabs or search for specific settings using the
|
* and the close validation live here. All pages, navigation and search are
|
||||||
* built-in search bar. Search results are filtered and ranked by relevance.
|
* owned by SettingsView so other hosts (e.g. a settings tab) can reuse them.
|
||||||
*/
|
*/
|
||||||
class DlgSettings : public QDialog
|
class DlgSettings : public QDialog
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
/**
|
using SettingsPage = SettingsView::SettingsPage;
|
||||||
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
|
static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage;
|
||||||
*
|
static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage;
|
||||||
* Use these values instead of raw indices so reordering pages never silently
|
static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage;
|
||||||
* breaks external callers like tab_room.cpp.
|
static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage;
|
||||||
*/
|
static constexpr SettingsPage StoragePage = SettingsView::StoragePage;
|
||||||
enum SettingsPage
|
static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage;
|
||||||
{
|
static constexpr SettingsPage SoundPage = SettingsView::SoundPage;
|
||||||
GeneralPage = 0,
|
static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage;
|
||||||
AppearancePage,
|
static constexpr SettingsPage NumPages = SettingsView::NumPages;
|
||||||
UserInterfacePage,
|
|
||||||
DeckEditorPage,
|
|
||||||
StoragePage,
|
|
||||||
MessagesPage,
|
|
||||||
SoundPage,
|
|
||||||
ShortcutsPage,
|
|
||||||
NumPages
|
|
||||||
};
|
|
||||||
|
|
||||||
explicit DlgSettings(QWidget *parent = nullptr);
|
explicit DlgSettings(QWidget *parent = nullptr);
|
||||||
void setTab(int index);
|
void setTab(int index);
|
||||||
AbstractSettingsPage *page(SettingsPage which) const;
|
AbstractSettingsPage *page(SettingsPage which) const;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onTabClicked(int index);
|
|
||||||
void onSearchTextChanged(const QString &text);
|
|
||||||
void onSearchResultClicked(const QModelIndex &index);
|
|
||||||
void updateLanguage();
|
void updateLanguage();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// UI elements
|
SettingsView *settingsView; ///< Shared settings content
|
||||||
QLineEdit *searchEdit; ///< Search bar for filtering settings
|
QPushButton *okButton; ///< Button to close the dialog
|
||||||
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();
|
|
||||||
|
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
void retranslateTabNames();
|
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void closeEvent(QCloseEvent *event) override;
|
void closeEvent(QCloseEvent *event) override;
|
||||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // DLG_SETTINGS_H
|
#endif // DLG_SETTINGS_H
|
||||||
|
|
|
||||||
497
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
497
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
/**
|
||||||
|
* @file settings_view.cpp
|
||||||
|
* @brief Implementation of the settings content widget
|
||||||
|
* @ingroup Dialogs
|
||||||
|
*/
|
||||||
|
#include "settings_view.h"
|
||||||
|
|
||||||
|
#include "../../../client/settings/cache_settings.h"
|
||||||
|
#include "../../pixel_map_generator.h"
|
||||||
|
#include "../settings_page/abstract_settings_page.h"
|
||||||
|
#include "../settings_page/appearance_settings_page.h"
|
||||||
|
#include "../settings_page/deck_editor_settings_page.h"
|
||||||
|
#include "../settings_page/general_settings_page.h"
|
||||||
|
#include "../settings_page/messages_settings_page.h"
|
||||||
|
#include "../settings_page/settings_search_delegate.h"
|
||||||
|
#include "../settings_page/settings_search_model.h"
|
||||||
|
#include "../settings_page/shortcut_settings_page.h"
|
||||||
|
#include "../settings_page/sound_settings_page.h"
|
||||||
|
#include "../settings_page/storage_settings_page.h"
|
||||||
|
#include "../settings_page/user_interface_settings_page.h"
|
||||||
|
|
||||||
|
#include <QAbstractAnimation>
|
||||||
|
#include <QAbstractItemView>
|
||||||
|
#include <QBrush>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QEasingCurve>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFlags>
|
||||||
|
#include <QFrame>
|
||||||
|
#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 <QToolButton>
|
||||||
|
#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);
|
||||||
|
|
||||||
|
// Tab bar (built in setupTabBar)
|
||||||
|
setupTabBar();
|
||||||
|
|
||||||
|
// Pages stacked widget
|
||||||
|
pagesWidget = new QStackedWidget;
|
||||||
|
|
||||||
|
auto *generalPage = new GeneralSettingsPage;
|
||||||
|
auto *appearancePage = new AppearanceSettingsPage;
|
||||||
|
auto *userInterfacePage = new UserInterfaceSettingsPage;
|
||||||
|
auto *deckEditorPage = new DeckEditorSettingsPage;
|
||||||
|
auto *storagePage = new StorageSettingsPage;
|
||||||
|
auto *messagesPage = new MessagesSettingsPage;
|
||||||
|
auto *soundPage = new SoundSettingsPage;
|
||||||
|
auto *shortcutsPage = new ShortcutSettingsPage;
|
||||||
|
|
||||||
|
pages.append(generalPage);
|
||||||
|
pages.append(appearancePage);
|
||||||
|
pages.append(userInterfacePage);
|
||||||
|
pages.append(deckEditorPage);
|
||||||
|
pages.append(storagePage);
|
||||||
|
pages.append(messagesPage);
|
||||||
|
pages.append(soundPage);
|
||||||
|
pages.append(shortcutsPage);
|
||||||
|
|
||||||
|
pagesWidget->addWidget(makeScrollable(generalPage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(appearancePage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(userInterfacePage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(deckEditorPage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(storagePage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(messagesPage));
|
||||||
|
pagesWidget->addWidget(soundPage);
|
||||||
|
pagesWidget->addWidget(shortcutsPage);
|
||||||
|
|
||||||
|
Q_ASSERT(pages.size() == NumPages);
|
||||||
|
|
||||||
|
// Search results view (hidden by default)
|
||||||
|
searchResultsView = new QListView;
|
||||||
|
searchResultsView->setUniformItemSizes(false);
|
||||||
|
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
|
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
searchResultsView->setVisible(false);
|
||||||
|
searchResultsView->setStyleSheet(
|
||||||
|
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
|
||||||
|
|
||||||
|
searchModel = new SettingsSearchModel(this);
|
||||||
|
searchDelegate = new SettingsSearchDelegate(this);
|
||||||
|
searchResultsView->setModel(searchModel);
|
||||||
|
searchResultsView->setItemDelegate(searchDelegate);
|
||||||
|
connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked);
|
||||||
|
|
||||||
|
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
|
||||||
|
const QStringList icons = pageIconResources();
|
||||||
|
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
|
||||||
|
tabButtons[i]->setIcon(QPixmap(icons[i]));
|
||||||
|
}
|
||||||
|
searchDelegate->setPageIcons(icons);
|
||||||
|
searchResultsView->viewport()->update();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build search index after pages are created
|
||||||
|
buildSearchIndex();
|
||||||
|
|
||||||
|
// Pages container (stacked widget + search results overlay)
|
||||||
|
pagesContainer = new QWidget;
|
||||||
|
auto *containerLayout = new QStackedLayout;
|
||||||
|
containerLayout->setStackingMode(QStackedLayout::StackAll);
|
||||||
|
containerLayout->addWidget(pagesWidget);
|
||||||
|
containerLayout->addWidget(searchResultsView);
|
||||||
|
pagesContainer->setLayout(containerLayout);
|
||||||
|
|
||||||
|
// Main layout
|
||||||
|
auto *mainLayout = new QVBoxLayout;
|
||||||
|
mainLayout->addLayout(searchLayout);
|
||||||
|
mainLayout->addWidget(tabBarWidget);
|
||||||
|
auto *separator = new QFrame;
|
||||||
|
separator->setFrameShape(QFrame::HLine);
|
||||||
|
separator->setFrameShadow(QFrame::Sunken);
|
||||||
|
mainLayout->addWidget(separator);
|
||||||
|
mainLayout->addWidget(pagesContainer);
|
||||||
|
mainLayout->addSpacing(4);
|
||||||
|
setLayout(mainLayout);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
|
||||||
|
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
|
||||||
|
|
||||||
|
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
|
||||||
|
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
|
||||||
|
int next = (currentTabIndex + 1) % tabButtons.size();
|
||||||
|
setActiveTab(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
|
||||||
|
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
|
||||||
|
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
|
||||||
|
setActiveTab(prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize to first tab
|
||||||
|
setActiveTab(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::setupTabBar()
|
||||||
|
{
|
||||||
|
tabBarWidget = new QWidget;
|
||||||
|
auto *tabLayout = new QHBoxLayout;
|
||||||
|
tabLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
tabLayout->setSpacing(2);
|
||||||
|
|
||||||
|
const QStringList iconResources = pageIconResources();
|
||||||
|
|
||||||
|
for (int i = 0; i < iconResources.size(); ++i) {
|
||||||
|
auto *tabButton = new QToolButton;
|
||||||
|
tabButton->setCheckable(true);
|
||||||
|
tabButton->setIcon(QPixmap(iconResources[i]));
|
||||||
|
tabButton->setIconSize(QSize(48, 48));
|
||||||
|
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
||||||
|
tabButton->setAutoExclusive(true);
|
||||||
|
tabButton->setMinimumHeight(85);
|
||||||
|
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||||
|
|
||||||
|
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
|
||||||
|
|
||||||
|
tabButtons.append(tabButton);
|
||||||
|
tabLayout->addWidget(tabButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
tabBarWidget->setLayout(tabLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::buildSearchIndex()
|
||||||
|
{
|
||||||
|
QList<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();
|
||||||
|
}
|
||||||
|
setActiveTab(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::setActiveTab(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= tabButtons.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTabIndex = index;
|
||||||
|
pagesWidget->setCurrentIndex(index);
|
||||||
|
|
||||||
|
for (int i = 0; i < tabButtons.size(); ++i) {
|
||||||
|
tabButtons[i]->setChecked(i == index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style active tab with a thick accent border + subtle background tint
|
||||||
|
for (int i = 0; i < tabButtons.size(); ++i) {
|
||||||
|
if (i == index) {
|
||||||
|
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
|
||||||
|
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
||||||
|
"background: palette(window); padding-bottom: 1px; }");
|
||||||
|
} else {
|
||||||
|
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
|
||||||
|
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
||||||
|
"background: transparent; }");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::flashWidget(QWidget *widget)
|
||||||
|
{
|
||||||
|
auto *overlay = new QWidget(widget);
|
||||||
|
overlay->setGeometry(widget->rect());
|
||||||
|
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
|
||||||
|
QPalette pal = overlay->palette();
|
||||||
|
QColor flashColor = pal.color(QPalette::Highlight);
|
||||||
|
flashColor.setAlpha(100);
|
||||||
|
pal.setBrush(QPalette::Window, flashColor);
|
||||||
|
overlay->setPalette(pal);
|
||||||
|
overlay->setAutoFillBackground(true);
|
||||||
|
|
||||||
|
auto *effect = new QGraphicsOpacityEffect(overlay);
|
||||||
|
effect->setOpacity(0.0);
|
||||||
|
overlay->setGraphicsEffect(effect);
|
||||||
|
overlay->show();
|
||||||
|
overlay->raise();
|
||||||
|
|
||||||
|
auto *flashIn = new QPropertyAnimation(effect, "opacity");
|
||||||
|
flashIn->setDuration(120);
|
||||||
|
flashIn->setStartValue(0.0);
|
||||||
|
flashIn->setEndValue(0.6);
|
||||||
|
flashIn->setEasingCurve(QEasingCurve::OutCubic);
|
||||||
|
|
||||||
|
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
|
||||||
|
fadeOut->setDuration(900);
|
||||||
|
fadeOut->setStartValue(0.6);
|
||||||
|
fadeOut->setEndValue(0.0);
|
||||||
|
fadeOut->setEasingCurve(QEasingCurve::InCubic);
|
||||||
|
|
||||||
|
auto *group = new QSequentialAnimationGroup(overlay);
|
||||||
|
group->addAnimation(flashIn);
|
||||||
|
group->addAnimation(fadeOut);
|
||||||
|
|
||||||
|
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
|
||||||
|
|
||||||
|
group->start(QAbstractAnimation::DeleteWhenStopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::onSearchTextChanged(const QString &text)
|
||||||
|
{
|
||||||
|
searchModel->setFilterString(text);
|
||||||
|
|
||||||
|
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
|
||||||
|
if (!searchActive) {
|
||||||
|
switchToSearchMode();
|
||||||
|
}
|
||||||
|
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||||
|
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||||
|
}
|
||||||
|
} else if (searchActive) {
|
||||||
|
switchToTabMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::switchToSearchMode()
|
||||||
|
{
|
||||||
|
searchActive = true;
|
||||||
|
tabBarWidget->setVisible(false);
|
||||||
|
pagesWidget->setVisible(false);
|
||||||
|
searchResultsView->setVisible(true);
|
||||||
|
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||||
|
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::switchToTabMode()
|
||||||
|
{
|
||||||
|
searchActive = false;
|
||||||
|
tabBarWidget->setVisible(true);
|
||||||
|
pagesWidget->setVisible(true);
|
||||||
|
searchResultsView->setVisible(false);
|
||||||
|
searchEdit->blockSignals(true);
|
||||||
|
searchEdit->clear();
|
||||||
|
searchEdit->blockSignals(false);
|
||||||
|
setActiveTab(currentTabIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::onSearchResultClicked(const QModelIndex &index)
|
||||||
|
{
|
||||||
|
navigateToSearchResult(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::navigateToSearchResult(const QModelIndex &index)
|
||||||
|
{
|
||||||
|
SettingsSearchEntry entry = searchModel->entryForIndex(index);
|
||||||
|
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to the page
|
||||||
|
switchToTabMode();
|
||||||
|
setActiveTab(entry.pageIndex);
|
||||||
|
|
||||||
|
// Scroll to the widget, focus it, and flash to highlight it
|
||||||
|
if (entry.widget) {
|
||||||
|
QWidget *widget = entry.widget;
|
||||||
|
while (widget) {
|
||||||
|
if (auto *scrollArea = qobject_cast<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 < tabButtons.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()
|
||||||
|
{
|
||||||
|
retranslateTabNames();
|
||||||
|
|
||||||
|
// NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings
|
||||||
|
// before the view was extracted, and keeping the context preserves existing translations.
|
||||||
|
searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings..."));
|
||||||
|
|
||||||
|
// Rebuild search index for translated text
|
||||||
|
buildSearchIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList SettingsView::translatedPageNames()
|
||||||
|
{
|
||||||
|
// NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi).
|
||||||
|
return {QCoreApplication::translate("DlgSettings", "General"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Appearance"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "User Interface"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Card Sources"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Storage"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Chat"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Sound"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Shortcuts")};
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::retranslateTabNames()
|
||||||
|
{
|
||||||
|
const QStringList tabLabels = translatedPageNames();
|
||||||
|
|
||||||
|
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
|
||||||
|
tabButtons[i]->setText(tabLabels[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
98
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* @file settings_view.h
|
||||||
|
* @brief Settings content widget hosted by the settings dialog (and, later, the settings tab)
|
||||||
|
* @ingroup Dialogs
|
||||||
|
*/
|
||||||
|
#ifndef SETTINGS_VIEW_H
|
||||||
|
#define SETTINGS_VIEW_H
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QWidget>
|
||||||
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
class QModelIndex;
|
||||||
|
class QObject;
|
||||||
|
class QEvent;
|
||||||
|
|
||||||
|
class QStackedWidget;
|
||||||
|
class QToolButton;
|
||||||
|
class QListView;
|
||||||
|
class QLineEdit;
|
||||||
|
class AbstractSettingsPage;
|
||||||
|
class SettingsSearchModel;
|
||||||
|
class SettingsSearchDelegate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Settings pages with tabbed navigation and search, without any dialog chrome
|
||||||
|
*
|
||||||
|
* Owns the search bar, the tab bar, the stacked settings pages and the search
|
||||||
|
* results view. Dialog/tab hosts only add their own frame (buttons, titles).
|
||||||
|
*/
|
||||||
|
class SettingsView : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
|
||||||
|
*
|
||||||
|
* Use these values instead of raw indices so reordering pages never silently
|
||||||
|
* breaks external callers like tab_room.cpp.
|
||||||
|
*/
|
||||||
|
enum SettingsPage
|
||||||
|
{
|
||||||
|
GeneralPage = 0,
|
||||||
|
AppearancePage,
|
||||||
|
UserInterfacePage,
|
||||||
|
DeckEditorPage,
|
||||||
|
StoragePage,
|
||||||
|
MessagesPage,
|
||||||
|
SoundPage,
|
||||||
|
ShortcutsPage,
|
||||||
|
NumPages
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit SettingsView(QWidget *parent = nullptr);
|
||||||
|
void setTab(int index);
|
||||||
|
AbstractSettingsPage *page(SettingsPage which) const;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onTabClicked(int index);
|
||||||
|
void onSearchTextChanged(const QString &text);
|
||||||
|
void onSearchResultClicked(const QModelIndex &index);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// UI elements
|
||||||
|
QLineEdit *searchEdit; ///< Search bar for filtering settings
|
||||||
|
QWidget *tabBarWidget; ///< Container widget for the tab buttons
|
||||||
|
QList<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
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
void retranslateUi();
|
||||||
|
void retranslateTabNames();
|
||||||
|
|
||||||
|
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;
|
Command_AccountPassword cmd;
|
||||||
cmd.set_old_password(oldPassword.toStdString());
|
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();
|
auto passwordSalt = PasswordHasher::generateRandomSalt();
|
||||||
QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt);
|
QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt);
|
||||||
cmd.set_hashed_new_password(hashedPassword.toStdString());
|
cmd.set_hashed_new_password(hashedPassword.toStdString());
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,26 @@ MessagesSettingsPage::MessagesSettingsPage()
|
||||||
highlightGroupBox = new QGroupBox;
|
highlightGroupBox = new QGroupBox;
|
||||||
highlightGroupBox->setLayout(highlightNotice);
|
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;
|
messageList = new QListWidget;
|
||||||
|
|
||||||
int count = SettingsCache::instance().messages().getCount();
|
int count = SettingsCache::instance().messages().getCount();
|
||||||
|
|
@ -159,6 +179,7 @@ MessagesSettingsPage::MessagesSettingsPage()
|
||||||
mainLayout->addWidget(messageGroupBox);
|
mainLayout->addWidget(messageGroupBox);
|
||||||
mainLayout->addWidget(chatGroupBox);
|
mainLayout->addWidget(chatGroupBox);
|
||||||
mainLayout->addWidget(highlightGroupBox);
|
mainLayout->addWidget(highlightGroupBox);
|
||||||
|
mainLayout->addWidget(channelGroupBox);
|
||||||
|
|
||||||
setLayout(mainLayout);
|
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)
|
void MessagesSettingsPage::updateTextColor(QT_STATE_CHANGED_T value)
|
||||||
{
|
{
|
||||||
SettingsCache::instance().chat().setChatMentionForeground(value);
|
SettingsCache::instance().chat().setChatMentionForeground(value);
|
||||||
|
|
@ -221,6 +270,18 @@ void MessagesSettingsPage::updateHighlightPreview()
|
||||||
";color: " + (SettingsCache::instance().chat().getChatHighlightForeground() ? "white" : "black") + ";}");
|
";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()
|
void MessagesSettingsPage::storeSettings()
|
||||||
{
|
{
|
||||||
SettingsCache::instance().messages().setCount(messageList->count());
|
SettingsCache::instance().messages().setCount(messageList->count());
|
||||||
|
|
@ -267,6 +328,9 @@ void MessagesSettingsPage::retranslateUi()
|
||||||
{
|
{
|
||||||
chatGroupBox->setTitle(tr("Chat settings"));
|
chatGroupBox->setTitle(tr("Chat settings"));
|
||||||
highlightGroupBox->setTitle(tr("Custom alert words"));
|
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"));
|
chatMentionCheckBox.setText(tr("Enable chat mentions"));
|
||||||
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
|
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
|
||||||
messageGroupBox->setTitle(tr("In-game message macros"));
|
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"));
|
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
|
||||||
hexLabel.setText(tr("(Color is hexadecimal)"));
|
hexLabel.setText(tr("(Color is hexadecimal)"));
|
||||||
hexHighlightLabel.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"));
|
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
|
||||||
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
|
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
|
||||||
aAdd->setText(tr("Add New Message"));
|
aAdd->setText(tr("Add New Message"));
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ private slots:
|
||||||
void actRemove();
|
void actRemove();
|
||||||
void updateColor(const QString &value);
|
void updateColor(const QString &value);
|
||||||
void updateHighlightColor(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 updateTextColor(QT_STATE_CHANGED_T value);
|
||||||
void updateTextHighlightColor(QT_STATE_CHANGED_T value);
|
void updateTextHighlightColor(QT_STATE_CHANGED_T value);
|
||||||
|
|
||||||
|
|
@ -49,18 +51,27 @@ private:
|
||||||
QCheckBox useGameTimeCheckBox;
|
QCheckBox useGameTimeCheckBox;
|
||||||
QGroupBox *chatGroupBox;
|
QGroupBox *chatGroupBox;
|
||||||
QGroupBox *highlightGroupBox;
|
QGroupBox *highlightGroupBox;
|
||||||
|
QGroupBox *channelGroupBox;
|
||||||
QGroupBox *messageGroupBox;
|
QGroupBox *messageGroupBox;
|
||||||
QLineEdit *mentionColor;
|
QLineEdit *mentionColor;
|
||||||
QLineEdit *highlightColor;
|
QLineEdit *highlightColor;
|
||||||
|
QLineEdit *channelUnreadColor;
|
||||||
|
QLineEdit *channelMentionColor;
|
||||||
QLineEdit *customAlertString;
|
QLineEdit *customAlertString;
|
||||||
QLabel hexLabel;
|
QLabel hexLabel;
|
||||||
QLabel hexHighlightLabel;
|
QLabel hexHighlightLabel;
|
||||||
|
QLabel channelUnreadColorLabel;
|
||||||
|
QLabel channelMentionColorLabel;
|
||||||
|
QLabel hexChannelUnreadLabel;
|
||||||
|
QLabel hexChannelMentionLabel;
|
||||||
QLabel customAlertStringLabel;
|
QLabel customAlertStringLabel;
|
||||||
QLabel explainMessagesLabel;
|
QLabel explainMessagesLabel;
|
||||||
|
|
||||||
void storeSettings();
|
void storeSettings();
|
||||||
void updateMentionPreview();
|
void updateMentionPreview();
|
||||||
void updateHighlightPreview();
|
void updateHighlightPreview();
|
||||||
|
void updateChannelUnreadPreview();
|
||||||
|
void updateChannelMentionPreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_MESSAGES_SETTINGS_PAGE_H
|
#endif // COCKATRICE_MESSAGES_SETTINGS_PAGE_H
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can
|
||||||
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
|
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
|
||||||
{
|
{
|
||||||
roomTable = new QTableWidget();
|
roomTable = new QTableWidget();
|
||||||
roomTable->setColumnCount(6);
|
roomTable->setColumnCount(7);
|
||||||
roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||||
roomTable->setHorizontalHeaderLabels(
|
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 = new QTableWidget();
|
||||||
gameTable->setColumnCount(6);
|
gameTable->setColumnCount(6);
|
||||||
|
|
@ -317,7 +317,8 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
|
||||||
|
|
||||||
for (int i = 0; i < response.log_message_size(); ++i) {
|
for (int i = 0; i < response.log_message_size(); ++i) {
|
||||||
ServerInfo_ChatMessage message = response.log_message(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->insertRow(roomCounter);
|
||||||
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
||||||
roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
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, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
|
||||||
roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
|
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, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
|
||||||
|
roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel())));
|
||||||
++roomCounter;
|
++roomCounter;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (QString::fromStdString(message.target_type()) == "game") {
|
if (targetType == "game") {
|
||||||
gameTable->insertRow(gameCounter);
|
gameTable->insertRow(gameCounter);
|
||||||
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
||||||
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
||||||
|
|
@ -339,7 +341,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
|
||||||
++gameCounter;
|
++gameCounter;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (QString::fromStdString(message.target_type()) == "chat") {
|
if (targetType == "chat") {
|
||||||
chatTable->insertRow(chatCounter);
|
chatTable->insertRow(chatCounter);
|
||||||
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
||||||
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
|
#include <QColor>
|
||||||
#include <QCompleter>
|
#include <QCompleter>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QFlag>
|
#include <QFlag>
|
||||||
|
|
@ -38,9 +39,11 @@
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QSplitter>
|
#include <QSplitter>
|
||||||
|
#include <QStackedWidget>
|
||||||
#include <QStringListModel>
|
#include <QStringListModel>
|
||||||
#include <QStringLiteral>
|
#include <QStringLiteral>
|
||||||
#include <QSystemTrayIcon>
|
#include <QSystemTrayIcon>
|
||||||
|
#include <QTabBar>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
@ -52,6 +55,7 @@
|
||||||
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_list_games.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_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_room_say.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
|
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
|
||||||
|
|
@ -61,12 +65,45 @@
|
||||||
#include <qnamespace.h>
|
#include <qnamespace.h>
|
||||||
#include <string>
|
#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,
|
TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
AbstractClient *_client,
|
AbstractClient *_client,
|
||||||
ServerInfo_User *_ownUser,
|
ServerInfo_User *_ownUser,
|
||||||
const ServerInfo_Room &info)
|
const ServerInfo_Room &info)
|
||||||
: Tab(_tabSupervisor), client(_client), roomId(info.room_id()), roomName(QString::fromStdString(info.name())),
|
: 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();
|
const int gameTypeListSize = info.gametype_list_size();
|
||||||
for (int i = 0; i < gameTypeListSize; ++i) {
|
for (int i = 0; i < gameTypeListSize; ++i) {
|
||||||
|
|
@ -86,14 +123,61 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); };
|
const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); };
|
||||||
userList->setGameInviteLinkProvider(gameInviteLinkProvider);
|
userList->setGameInviteLinkProvider(gameInviteLinkProvider);
|
||||||
|
|
||||||
chatView = new ChatView(tabSupervisor, nullptr, true, this);
|
chatViewStack = new QStackedWidget;
|
||||||
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
|
channelTabBar = new QTabBar;
|
||||||
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
|
channelTabBar->setShape(QTabBar::RoundedSouth);
|
||||||
connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog);
|
channelTabBar->setExpanding(true);
|
||||||
connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated);
|
channelTabBar->setUsesScrollButtons(true);
|
||||||
connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup);
|
|
||||||
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup);
|
channelViews.insert(MAIN_CHANNEL, makeChatView());
|
||||||
connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag);
|
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,
|
connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this,
|
||||||
&TabRoom::actCompleterChanged);
|
&TabRoom::actCompleterChanged);
|
||||||
sayLabel = new QLabel;
|
sayLabel = new QLabel;
|
||||||
|
|
@ -101,6 +185,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
|
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
|
||||||
sayLabel->setBuddy(sayEdit);
|
sayLabel->setBuddy(sayEdit);
|
||||||
connect(sayEdit, &LineEditCompleter::returnPressed, this, &TabRoom::sendMessage);
|
connect(sayEdit, &LineEditCompleter::returnPressed, this, &TabRoom::sendMessage);
|
||||||
|
updateSayEnabled();
|
||||||
|
|
||||||
auto *chatSettingsMenu = new QMenu(this);
|
auto *chatSettingsMenu = new QMenu(this);
|
||||||
|
|
||||||
|
|
@ -122,19 +207,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
sayHbox->addWidget(sayEdit);
|
sayHbox->addWidget(sayEdit);
|
||||||
sayHbox->addWidget(chatSettingsButton);
|
sayHbox->addWidget(chatSettingsButton);
|
||||||
|
|
||||||
|
sayWidget = new QWidget;
|
||||||
|
sayWidget->setLayout(sayHbox);
|
||||||
|
|
||||||
auto *chatVbox = new QVBoxLayout;
|
auto *chatVbox = new QVBoxLayout;
|
||||||
chatVbox->addWidget(chatView);
|
chatVbox->addWidget(chatViewStack);
|
||||||
chatVbox->addLayout(sayHbox);
|
chatVbox->addWidget(sayWidget);
|
||||||
|
chatVbox->addWidget(channelTabBar);
|
||||||
|
|
||||||
chatGroupBox = new QGroupBox;
|
chatGroupBox = new QGroupBox;
|
||||||
chatGroupBox->setLayout(chatVbox);
|
chatGroupBox->setLayout(chatVbox);
|
||||||
|
|
||||||
auto *splitter = new QSplitter(Qt::Vertical);
|
chatSplitter = new QSplitter(Qt::Vertical);
|
||||||
splitter->addWidget(gameSelector);
|
chatSplitter->addWidget(gameSelector);
|
||||||
splitter->addWidget(chatGroupBox);
|
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;
|
auto *hbox = new QHBoxLayout;
|
||||||
hbox->addWidget(splitter, 3);
|
hbox->addWidget(chatSplitter, 3);
|
||||||
hbox->addWidget(userListPanel, 1);
|
hbox->addWidget(userListPanel, 1);
|
||||||
|
|
||||||
aLeaveRoom = new QAction(this);
|
aLeaveRoom = new QAction(this);
|
||||||
|
|
@ -189,7 +282,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
void TabRoom::retranslateUi()
|
void TabRoom::retranslateUi()
|
||||||
{
|
{
|
||||||
gameSelector->retranslateUi();
|
gameSelector->retranslateUi();
|
||||||
chatView->retranslateUi();
|
for (auto it = channelViews.constBegin(); it != channelViews.constEnd(); ++it) {
|
||||||
|
it.value()->retranslateUi();
|
||||||
|
}
|
||||||
userListPanel->retranslateUi();
|
userListPanel->retranslateUi();
|
||||||
sayLabel->setText(tr("&Say:"));
|
sayLabel->setText(tr("&Say:"));
|
||||||
chatGroupBox->setTitle(tr("Chat"));
|
chatGroupBox->setTitle(tr("Chat"));
|
||||||
|
|
@ -197,6 +292,11 @@ void TabRoom::retranslateUi()
|
||||||
aLeaveRoom->setText(tr("&Leave room"));
|
aLeaveRoom->setText(tr("&Leave room"));
|
||||||
aClearChat->setText(tr("&Clear chat"));
|
aClearChat->setText(tr("&Clear chat"));
|
||||||
aOpenChatSettings->setText(tr("Chat Settings..."));
|
aOpenChatSettings->setText(tr("Chat Settings..."));
|
||||||
|
channelDisplayNames.insert(MAIN_CHANNEL, tr("Main"));
|
||||||
|
for (const QString &channelId : channelIds) {
|
||||||
|
updateChannelTab(channelId);
|
||||||
|
}
|
||||||
|
updateSayEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabRoom::focusTab()
|
void TabRoom::focusTab()
|
||||||
|
|
@ -233,6 +333,11 @@ void TabRoom::tabActivated()
|
||||||
if (!sayEdit->hasFocus()) {
|
if (!sayEdit->hasFocus()) {
|
||||||
sayEdit->setFocus();
|
sayEdit->setFocus();
|
||||||
}
|
}
|
||||||
|
if (!chatCollapsed) {
|
||||||
|
channelUnreadCounts[activeChannelId] = 0;
|
||||||
|
channelMentioned[activeChannelId] = false;
|
||||||
|
updateChannelTab(activeChannelId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QString TabRoom::sanitizeHtml(QString dirty) const
|
QString TabRoom::sanitizeHtml(QString dirty) const
|
||||||
|
|
@ -240,29 +345,232 @@ QString TabRoom::sanitizeHtml(QString dirty) const
|
||||||
return dirty.replace("&", "&").replace("<", "<").replace(">", ">");
|
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()
|
void TabRoom::sendMessage()
|
||||||
{
|
{
|
||||||
|
if (!channelWritable.value(activeChannelId, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sayEdit->text().isEmpty()) {
|
if (sayEdit->text().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
} else if (sayEdit->hasVisibleCompleterPopup()) {
|
} else if (sayEdit->hasVisibleCompleterPopup()) {
|
||||||
sayEdit->hideCompleterPopups();
|
sayEdit->hideCompleterPopups();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
Command_RoomSay cmd;
|
ChatView *targetView = channelViews.value(activeChannelId, chatView);
|
||||||
cmd.set_message(sayEdit->text().toStdString());
|
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);
|
if (activeChannelId.isEmpty()) {
|
||||||
connect(pend, &PendingCommand::finished, this, &TabRoom::sayFinished);
|
Command_RoomSay cmd;
|
||||||
sendRoomCommand(pend);
|
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();
|
sayEdit->clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabRoom::sayFinished(const Response &response)
|
void TabRoom::onChannelChanged(int index)
|
||||||
{
|
{
|
||||||
if (response.response_code() == Response::RespChatFlood) {
|
if (index < 0 || index >= channelIds.size()) {
|
||||||
chatView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds."));
|
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()
|
void TabRoom::actClearChat()
|
||||||
|
|
@ -298,6 +606,9 @@ void TabRoom::processRoomEvent(const RoomEvent &event)
|
||||||
case RoomEvent::ROOM_SAY:
|
case RoomEvent::ROOM_SAY:
|
||||||
processRoomSayEvent(event.GetExtension(Event_RoomSay::ext));
|
processRoomSayEvent(event.GetExtension(Event_RoomSay::ext));
|
||||||
break;
|
break;
|
||||||
|
case RoomEvent::ROOM_CHANNEL_SAY:
|
||||||
|
processRoomChannelSayEvent(event.GetExtension(Event_RoomChannelSay::ext));
|
||||||
|
break;
|
||||||
case RoomEvent::REMOVE_MESSAGES:
|
case RoomEvent::REMOVE_MESSAGES:
|
||||||
processRemoveMessagesEvent(event.GetExtension(Event_RemoveMessages::ext));
|
processRemoveMessagesEvent(event.GetExtension(Event_RemoveMessages::ext));
|
||||||
break;
|
break;
|
||||||
|
|
@ -335,18 +646,9 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event)
|
||||||
QString senderName = QString::fromStdString(event.name());
|
QString senderName = QString::fromStdString(event.name());
|
||||||
QString message = QString::fromStdString(event.message());
|
QString message = QString::fromStdString(event.message());
|
||||||
|
|
||||||
if (userListProxy->isUserIgnored(senderName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
UserListTWI *twi = userList->getUsers().value(senderName);
|
|
||||||
ServerInfo_User userInfo = {};
|
ServerInfo_User userInfo = {};
|
||||||
if (twi) {
|
if (isRoomMessageIgnored(senderName, userInfo)) {
|
||||||
userInfo = twi->getUserInfo();
|
return;
|
||||||
if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() &&
|
|
||||||
!UserLevelFlags(userInfo.user_level()).testFlag(ServerInfo_User::IsRegistered)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) {
|
if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) {
|
||||||
|
|
@ -360,7 +662,49 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event)
|
||||||
"] " + message;
|
"] " + 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);
|
emit userEvent(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,7 +712,9 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event)
|
||||||
{
|
{
|
||||||
QString userName = QString::fromStdString(event.name());
|
QString userName = QString::fromStdString(event.name());
|
||||||
int amount = event.amount();
|
int amount = event.amount();
|
||||||
chatView->redactMessages(userName, amount);
|
for (ChatView *view : channelViews) {
|
||||||
|
view->redactMessages(userName, amount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabRoom::refreshShortcuts()
|
void TabRoom::refreshShortcuts()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
#include "tab.h"
|
#include "tab.h"
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
@ -18,8 +19,12 @@
|
||||||
|
|
||||||
class UserListProxy;
|
class UserListProxy;
|
||||||
class QAction;
|
class QAction;
|
||||||
|
class QTabBar;
|
||||||
|
class QWidget;
|
||||||
class QGroupBox;
|
class QGroupBox;
|
||||||
|
class QSplitter;
|
||||||
class QMenu;
|
class QMenu;
|
||||||
|
class QStackedWidget;
|
||||||
class QStringListModel;
|
class QStringListModel;
|
||||||
class TabSupervisor;
|
class TabSupervisor;
|
||||||
|
|
||||||
|
|
@ -42,6 +47,7 @@ class Event_ListGames;
|
||||||
class Event_JoinRoom;
|
class Event_JoinRoom;
|
||||||
class Event_LeaveRoom;
|
class Event_LeaveRoom;
|
||||||
class Event_RoomSay;
|
class Event_RoomSay;
|
||||||
|
class Event_RoomChannelSay;
|
||||||
class Event_RemoveMessages;
|
class Event_RemoveMessages;
|
||||||
class GameSelector;
|
class GameSelector;
|
||||||
class Response;
|
class Response;
|
||||||
|
|
@ -64,10 +70,23 @@ private:
|
||||||
UserListWidget *userList;
|
UserListWidget *userList;
|
||||||
const UserListProxy *userListProxy;
|
const UserListProxy *userListProxy;
|
||||||
ChatView *chatView;
|
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;
|
QLabel *sayLabel;
|
||||||
LineEditCompleter *sayEdit;
|
LineEditCompleter *sayEdit;
|
||||||
QStringListModel *mentionModel;
|
QStringListModel *mentionModel;
|
||||||
QGroupBox *chatGroupBox;
|
QGroupBox *chatGroupBox;
|
||||||
|
QSplitter *chatSplitter;
|
||||||
|
QList<int> savedSplitterSizes;
|
||||||
|
|
||||||
QMenu *roomMenu;
|
QMenu *roomMenu;
|
||||||
QAction *aLeaveRoom;
|
QAction *aLeaveRoom;
|
||||||
|
|
@ -85,7 +104,9 @@ signals:
|
||||||
void gameListUpdated();
|
void gameListUpdated();
|
||||||
private slots:
|
private slots:
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
void sayFinished(const Response &response);
|
void onChannelChanged(int index);
|
||||||
|
void onChannelTabClicked(int index);
|
||||||
|
void setChatCollapsed(bool collapsed);
|
||||||
void actClearChat();
|
void actClearChat();
|
||||||
void actOpenChatSettings();
|
void actOpenChatSettings();
|
||||||
void addMentionTag(QString mentionTag);
|
void addMentionTag(QString mentionTag);
|
||||||
|
|
@ -98,9 +119,18 @@ private slots:
|
||||||
void processJoinRoomEvent(const Event_JoinRoom &event);
|
void processJoinRoomEvent(const Event_JoinRoom &event);
|
||||||
void processLeaveRoomEvent(const Event_LeaveRoom &event);
|
void processLeaveRoomEvent(const Event_LeaveRoom &event);
|
||||||
void processRoomSayEvent(const Event_RoomSay &event);
|
void processRoomSayEvent(const Event_RoomSay &event);
|
||||||
|
void processRoomChannelSayEvent(const Event_RoomChannelSay &event);
|
||||||
void processRemoveMessagesEvent(const Event_RemoveMessages &event);
|
void processRemoveMessagesEvent(const Event_RemoveMessages &event);
|
||||||
void refreshShortcuts();
|
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:
|
protected slots:
|
||||||
void closeEvent(QCloseEvent *event) override;
|
void closeEvent(QCloseEvent *event) override;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,29 +133,33 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!above) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto *popup = completer->popup();
|
auto *popup = completer->popup();
|
||||||
const int rowCount = completer->completionModel()->rowCount();
|
const int rowCount = completer->completionModel()->rowCount();
|
||||||
if (rowCount == 0) {
|
if (rowCount == 0) {
|
||||||
return true;
|
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
|
// 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
|
// 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).
|
// through the list (towards its end at the top of the popup).
|
||||||
const int currentRow = popup->currentIndex().row();
|
const QModelIndex current = popup->currentIndex();
|
||||||
const int step = (key == Qt::Key_Up) ? -1 : 1;
|
int newRow;
|
||||||
const int newRow = qBound(0, currentRow + step, rowCount - 1);
|
if (!current.isValid()) {
|
||||||
|
newRow = (key == Qt::Key_Down) ? 0 : rowCount - 1;
|
||||||
if (newRow != currentRow) {
|
} else {
|
||||||
const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn());
|
const int step = (key == Qt::Key_Up) ? -1 : 1;
|
||||||
popup->selectionModel()->setCurrentIndex(newIndex,
|
newRow = qBound(0, current.row() + step, rowCount - 1);
|
||||||
QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
|
if (newRow == current.row()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectWithoutRewrite(completer->completionModel()->index(newRow, completer->completionColumn()));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,8 +222,37 @@ void CardCompleterStyler::ensureClosestSelected()
|
||||||
const int closestRow = rowCount - 1;
|
const int closestRow = rowCount - 1;
|
||||||
|
|
||||||
if (currentRow != closestRow) {
|
if (currentRow != closestRow) {
|
||||||
popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()),
|
selectWithoutRewrite(completionModel->index(closestRow, completer->completionColumn()));
|
||||||
QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 applyPopupWidth();
|
||||||
void updateOrientation();
|
void updateOrientation();
|
||||||
void ensureClosestSelected();
|
void ensureClosestSelected();
|
||||||
|
void selectWithoutRewrite(const QModelIndex &index);
|
||||||
|
|
||||||
bool handlePopupKeyPress(QKeyEvent *event);
|
bool handlePopupKeyPress(QKeyEvent *event);
|
||||||
bool isPopupAboveWidget() const;
|
bool isPopupAboveWidget() const;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
#include "../interface/widgets/tabs/tab_supervisor.h"
|
#include "../interface/widgets/tabs/tab_supervisor.h"
|
||||||
#include "../main.h"
|
#include "../main.h"
|
||||||
#include "abstract_client.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_database_update_status_bar.h"
|
||||||
#include "card_database_update/card_update_progress.h"
|
#include "card_database_update/card_update_progress.h"
|
||||||
#include "intents/contexts/context_connect_to_server.h"
|
#include "intents/contexts/context_connect_to_server.h"
|
||||||
|
|
@ -58,13 +59,12 @@
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QButtonGroup>
|
#include <QButtonGroup>
|
||||||
#include <QCheckBox>
|
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
#include <QCoreApplication>
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
|
#include <QDir>
|
||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
|
|
@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent)
|
||||||
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
|
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()
|
void MainWindow::startupConfigCheck()
|
||||||
{
|
{
|
||||||
warnAboutDataPathsInProgramDir();
|
AtRiskDataWarning::warnAboutDataPathsInProgramDir(this);
|
||||||
|
|
||||||
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
|
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
|
||||||
|
|
||||||
|
|
@ -1029,7 +1000,8 @@ void MainWindow::attemptStartupAutoConnect()
|
||||||
qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect...";
|
qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect...";
|
||||||
DlgConnect dlg(this);
|
DlgConnect dlg(this);
|
||||||
connectionController->connectToServerDirect(dlg.getHost(), static_cast<unsigned int>(dlg.getPort()),
|
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);
|
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()
|
inline QString getCardUpdaterBinaryName()
|
||||||
{
|
{
|
||||||
return "oracle";
|
return "oracle";
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ public:
|
||||||
[[nodiscard]] virtual bool getChatMentionCompleter() const = 0;
|
[[nodiscard]] virtual bool getChatMentionCompleter() const = 0;
|
||||||
[[nodiscard]] virtual QString getChatMentionColor() const = 0;
|
[[nodiscard]] virtual QString getChatMentionColor() const = 0;
|
||||||
[[nodiscard]] virtual QString getChatHighlightColor() 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 getChatMentionForeground() const = 0;
|
||||||
[[nodiscard]] virtual bool getChatHighlightForeground() const = 0;
|
[[nodiscard]] virtual bool getChatHighlightForeground() const = 0;
|
||||||
[[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0;
|
[[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@
|
||||||
#include <libcockatrice/protocol/pending_command.h>
|
#include <libcockatrice/protocol/pending_command.h>
|
||||||
|
|
||||||
AbstractClient::AbstractClient(QObject *parent)
|
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<QVariant>("QVariant");
|
||||||
qRegisterMetaType<CommandContainer>("CommandContainer");
|
qRegisterMetaType<CommandContainer>("CommandContainer");
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ protected:
|
||||||
QMap<int, PendingCommand *> pendingCommands;
|
QMap<int, PendingCommand *> pendingCommands;
|
||||||
QString userName, password, email, country, realName, token;
|
QString userName, password, email, country, realName, token;
|
||||||
bool serverSupportsPasswordHash;
|
bool serverSupportsPasswordHash;
|
||||||
|
bool serverSupportsChallengeResponse;
|
||||||
void setStatus(ClientStatus _status);
|
void setStatus(ClientStatus _status);
|
||||||
int getNewCmdId()
|
int getNewCmdId()
|
||||||
{
|
{
|
||||||
|
|
@ -166,6 +167,10 @@ public:
|
||||||
{
|
{
|
||||||
return serverSupportsPasswordHash;
|
return serverSupportsPasswordHash;
|
||||||
}
|
}
|
||||||
|
bool getServerSupportsChallengeResponse() const
|
||||||
|
{
|
||||||
|
return serverSupportsChallengeResponse;
|
||||||
|
}
|
||||||
const QString &getUserName() const
|
const QString &getUserName() const
|
||||||
{
|
{
|
||||||
return userName;
|
return userName;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ static const unsigned int protocolVersion = 14;
|
||||||
|
|
||||||
RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider)
|
RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider)
|
||||||
: AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0),
|
: 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();
|
clearNewClientFeatures();
|
||||||
|
|
@ -129,6 +130,11 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
serverSupportsPasswordHash = event.server_options() & Event_ServerIdentification::SupportsPasswordHash;
|
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) {
|
if (getStatus() == StatusRequestingForgotPassword) {
|
||||||
Command_ForgotPasswordRequest cmdForgotPasswordRequest;
|
Command_ForgotPasswordRequest cmdForgotPasswordRequest;
|
||||||
|
|
@ -145,7 +151,13 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
|
||||||
cmdForgotPasswordReset.set_user_name(userName.toStdString());
|
cmdForgotPasswordReset.set_user_name(userName.toStdString());
|
||||||
cmdForgotPasswordReset.set_clientid(getSrvClientID(lastHostname).toStdString());
|
cmdForgotPasswordReset.set_clientid(getSrvClientID(lastHostname).toStdString());
|
||||||
cmdForgotPasswordReset.set_token(token.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();
|
auto passwordSalt = PasswordHasher::generateRandomSalt();
|
||||||
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
||||||
cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString());
|
cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString());
|
||||||
|
|
@ -173,7 +185,10 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
|
||||||
if (getStatus() == StatusRegistering) {
|
if (getStatus() == StatusRegistering) {
|
||||||
Command_Register cmdRegister;
|
Command_Register cmdRegister;
|
||||||
cmdRegister.set_user_name(userName.toStdString());
|
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();
|
auto passwordSalt = PasswordHasher::generateRandomSalt();
|
||||||
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
||||||
cmdRegister.set_hashed_password(hashedPassword.toStdString());
|
cmdRegister.set_hashed_password(hashedPassword.toStdString());
|
||||||
|
|
@ -238,7 +253,9 @@ Command_Login RemoteClient::generateCommandLogin()
|
||||||
|
|
||||||
void RemoteClient::doLogin()
|
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.
|
//! \todo Store and log in using stored hashed password.
|
||||||
if (hashedPassword.isEmpty()) {
|
if (hashedPassword.isEmpty()) {
|
||||||
doRequestPasswordSalt(); // ask salt to create hashedPassword, then log in
|
doRequestPasswordSalt(); // ask salt to create hashedPassword, then log in
|
||||||
|
|
@ -272,6 +289,30 @@ void RemoteClient::doHashedLogin()
|
||||||
sendCommand(pend);
|
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*/)
|
void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/)
|
||||||
{
|
{
|
||||||
doDisconnectFromServer();
|
doDisconnectFromServer();
|
||||||
|
|
@ -284,7 +325,70 @@ void RemoteClient::passwordSaltResponse(const Response &response)
|
||||||
auto passwordSalt = QString::fromStdString(resp.password_salt());
|
auto passwordSalt = QString::fromStdString(resp.password_salt());
|
||||||
if (passwordSalt.isEmpty()) { // the server does not recognize the user but allows them to enter unregistered
|
if (passwordSalt.isEmpty()) { // the server does not recognize the user but allows them to enter unregistered
|
||||||
password.clear(); // the password will not be used
|
password.clear(); // the password will not be used
|
||||||
|
storedVerifier.clear();
|
||||||
doLogin();
|
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 {
|
} else {
|
||||||
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
|
||||||
doHashedLogin();
|
doHashedLogin();
|
||||||
|
|
@ -309,6 +413,14 @@ void RemoteClient::loginResponse(const Response &response)
|
||||||
setStatus(StatusLoggedIn);
|
setStatus(StatusLoggedIn);
|
||||||
emit userInfoChanged(resp.user_info());
|
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;
|
QList<ServerInfo_User> buddyList;
|
||||||
for (int i = resp.buddy_list_size() - 1; i >= 0; --i) {
|
for (int i = resp.buddy_list_size() - 1; i >= 0; --i) {
|
||||||
buddyList.append(resp.buddy_list(i));
|
buddyList.append(resp.buddy_list(i));
|
||||||
|
|
@ -565,6 +677,8 @@ void RemoteClient::doDisconnectFromServer()
|
||||||
websocket->close();
|
websocket->close();
|
||||||
}
|
}
|
||||||
socket->close();
|
socket->close();
|
||||||
|
derivedVerifier.clear();
|
||||||
|
pendingVerifier.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RemoteClient::ping()
|
void RemoteClient::ping()
|
||||||
|
|
@ -736,6 +850,12 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response)
|
||||||
{
|
{
|
||||||
if (response.response_code() == Response::RespOk) {
|
if (response.response_code() == Response::RespOk) {
|
||||||
emit sigForgotPasswordSuccess();
|
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 {
|
} else {
|
||||||
emit sigForgotPasswordError();
|
emit sigForgotPasswordError();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ signals:
|
||||||
unsigned int port,
|
unsigned int port,
|
||||||
const QString &_userName,
|
const QString &_userName,
|
||||||
const QString &_email);
|
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:
|
private slots:
|
||||||
void slotConnected();
|
void slotConnected();
|
||||||
void readData();
|
void readData();
|
||||||
|
|
@ -92,6 +97,8 @@ private slots:
|
||||||
void doLogin();
|
void doLogin();
|
||||||
void doHashedLogin();
|
void doHashedLogin();
|
||||||
Command_Login generateCommandLogin();
|
Command_Login generateCommandLogin();
|
||||||
|
void doSubmitPasswordVerifier();
|
||||||
|
void submitPasswordVerifierResponse(const Response &response);
|
||||||
void doDisconnectFromServer();
|
void doDisconnectFromServer();
|
||||||
void doActivateToServer(const QString &_token);
|
void doActivateToServer(const QString &_token);
|
||||||
void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName);
|
void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName);
|
||||||
|
|
@ -123,6 +130,14 @@ private:
|
||||||
QString lastHostname;
|
QString lastHostname;
|
||||||
unsigned int lastPort;
|
unsigned int lastPort;
|
||||||
QString hashedPassword;
|
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);
|
QString getSrvClientID(const QString &_hostname);
|
||||||
bool newMissingFeatureFound(const QString &_serversMissingFeatures);
|
bool newMissingFeatureFound(const QString &_serversMissingFeatures);
|
||||||
|
|
@ -161,6 +176,12 @@ public:
|
||||||
}
|
}
|
||||||
void
|
void
|
||||||
connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password);
|
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,
|
void registerToServer(const QString &hostname,
|
||||||
unsigned int port,
|
unsigned int port,
|
||||||
const QString &_userName,
|
const QString &_userName,
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,19 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r
|
||||||
return Response::RespInternalError;
|
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;
|
delete deck;
|
||||||
deck = newDeck;
|
deck = newDeck;
|
||||||
sideboardLocked = true;
|
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) {
|
if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) {
|
||||||
return Response::RespInvalidData;
|
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;
|
PlaymatInfo playmat;
|
||||||
playmat.card.name = rawName;
|
playmat.card.name = rawName;
|
||||||
playmat.card.providerId = rawProviderId;
|
playmat.card.providerId = rawProviderId;
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,7 @@ void Server::externalRoomUserLeft(int roomId, const QString &userName)
|
||||||
room->removeExternalUser(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.
|
// This function is always called from the main thread via signal/slot.
|
||||||
QReadLocker locker(&roomsLock);
|
QReadLocker locker(&roomsLock);
|
||||||
|
|
@ -451,10 +451,22 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString
|
||||||
qDebug() << "externalRoomSay: room id=" << roomId << "not found";
|
qDebug() << "externalRoomSay: room id=" << roomId << "not found";
|
||||||
return;
|
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,
|
if (channelId.isEmpty()) {
|
||||||
room->getId(), room->getName());
|
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)
|
void Server::externalRoomRemoveMessages(int roomId, const QString &userName, int amount)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@ public:
|
||||||
{
|
{
|
||||||
return QMap<QString, bool>();
|
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 addClient(Server_ProtocolHandler *player);
|
||||||
void removeClient(Server_ProtocolHandler *player);
|
void removeClient(Server_ProtocolHandler *player);
|
||||||
QList<QString> getOnlineModeratorList() const;
|
QList<QString> getOnlineModeratorList() const;
|
||||||
|
|
@ -265,7 +270,7 @@ protected slots:
|
||||||
void externalUserLeft(const QString &userName);
|
void externalUserLeft(const QString &userName);
|
||||||
void externalRoomUserJoined(int roomId, const ServerInfo_User &userInfo);
|
void externalRoomUserJoined(int roomId, const ServerInfo_User &userInfo);
|
||||||
void externalRoomUserLeft(int roomId, const QString &userName);
|
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 externalRoomRemoveMessages(int roomId, const QString &userName, int amount);
|
||||||
void externalRoomGameListChanged(int roomId, const ServerInfo_Game &gameInfo);
|
void externalRoomGameListChanged(int roomId, const ServerInfo_Game &gameInfo);
|
||||||
void
|
void
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,14 @@ public:
|
||||||
{
|
{
|
||||||
return {};
|
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 */)
|
virtual QMap<QString, ServerInfo_User> getBuddyList(const QString & /* name */)
|
||||||
{
|
{
|
||||||
return QMap<QString, ServerInfo_User>();
|
return QMap<QString, ServerInfo_User>();
|
||||||
|
|
@ -151,7 +159,8 @@ public:
|
||||||
MessageTargetRoom,
|
MessageTargetRoom,
|
||||||
MessageTargetGame,
|
MessageTargetGame,
|
||||||
MessageTargetChat,
|
MessageTargetChat,
|
||||||
MessageTargetIslRoom
|
MessageTargetIslRoom,
|
||||||
|
MessageTargetRoomChannel
|
||||||
};
|
};
|
||||||
virtual void logMessage(const int /* senderId */,
|
virtual void logMessage(const int /* senderId */,
|
||||||
const QString & /* senderName */,
|
const QString & /* senderName */,
|
||||||
|
|
@ -159,7 +168,8 @@ public:
|
||||||
const QString & /* logMessage */,
|
const QString & /* logMessage */,
|
||||||
LogMessage_TargetType /* targetType */,
|
LogMessage_TargetType /* targetType */,
|
||||||
const int /* targetId */,
|
const int /* targetId */,
|
||||||
const QString & /* targetName */)
|
const QString & /* targetName */,
|
||||||
|
const QString & /* channel */ = QString())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */,
|
virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */,
|
||||||
|
|
@ -188,6 +198,16 @@ public:
|
||||||
virtual void setForcePasswordChange(const QString & /* user */, bool /* force */)
|
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
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
#include "server_protocolhandler.h"
|
#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/game_config.h"
|
||||||
#include "game/server_abstract_participant.h"
|
#include "game/server_abstract_participant.h"
|
||||||
#include "game/server_game.h"
|
#include "game/server_game.h"
|
||||||
|
|
@ -13,6 +16,7 @@
|
||||||
#include "libcockatrice/protocol/pb/server_message.pb.h"
|
#include "libcockatrice/protocol/pb/server_message.pb.h"
|
||||||
#include "libcockatrice/protocol/pb/serverinfo_chat_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_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_commands.pb.h"
|
||||||
#include "libcockatrice/protocol/pb/session_event.pb.h"
|
#include "libcockatrice/protocol/pb/session_event.pb.h"
|
||||||
#include "server_database_interface.h"
|
#include "server_database_interface.h"
|
||||||
|
|
@ -40,6 +44,7 @@
|
||||||
#include <libcockatrice/protocol/pb/commands.pb.h>
|
#include <libcockatrice/protocol/pb/commands.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_list_rooms.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_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_room_say.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_user_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.
|
// This function must only be called from the thread this object lives in.
|
||||||
// Except when the server is shutting down.
|
// Except when the server is shutting down.
|
||||||
// The thread must not hold any server locks when calling this (e.g. clientsLock, roomsLock).
|
// 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:
|
case RoomCommand::ROOM_SAY:
|
||||||
resp = cmdRoomSay(sc.GetExtension(Command_RoomSay::ext), room, rc);
|
resp = cmdRoomSay(sc.GetExtension(Command_RoomSay::ext), room, rc);
|
||||||
break;
|
break;
|
||||||
|
case RoomCommand::ROOM_CHANNEL_SAY:
|
||||||
|
resp = cmdRoomChannelSay(sc.GetExtension(Command_RoomChannelSay::ext), room, rc);
|
||||||
|
break;
|
||||||
case RoomCommand::CREATE_GAME:
|
case RoomCommand::CREATE_GAME:
|
||||||
resp = cmdCreateGame(sc.GetExtension(Command_CreateGame::ext), room, rc);
|
resp = cmdCreateGame(sc.GetExtension(Command_CreateGame::ext), room, rc);
|
||||||
break;
|
break;
|
||||||
|
|
@ -563,6 +593,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
||||||
return Response::RespContextError;
|
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
|
// check client feature set against server feature set
|
||||||
FeatureSet features;
|
FeatureSet features;
|
||||||
QMap<QString, bool> receivedClientFeatures;
|
QMap<QString, bool> receivedClientFeatures;
|
||||||
|
|
@ -764,7 +808,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetGamesOfUser(const Command_G
|
||||||
while (roomIterator.hasNext()) {
|
while (roomIterator.hasNext()) {
|
||||||
Server_Room *room = roomIterator.next().value();
|
Server_Room *room = roomIterator.next().value();
|
||||||
room->gamesLock.lockForRead();
|
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())));
|
QListIterator<ServerInfo_Game> gameIterator(room->getGamesOfUser(nameFromStdString(cmd.user_name())));
|
||||||
while (gameIterator.hasNext()) {
|
while (gameIterator.hasNext()) {
|
||||||
re->add_game_list()->CopyFrom(gameIterator.next());
|
re->add_game_list()->CopyFrom(gameIterator.next());
|
||||||
|
|
@ -822,7 +866,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdListRooms(const Command_ListRo
|
||||||
Event_ListRooms event;
|
Event_ListRooms event;
|
||||||
QMapIterator<int, Server_Room *> roomIterator(server->getRooms());
|
QMapIterator<int, Server_Room *> roomIterator(server->getRooms());
|
||||||
while (roomIterator.hasNext()) {
|
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));
|
rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event));
|
||||||
|
|
||||||
|
|
@ -856,10 +900,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo
|
||||||
rooms.insert(room->getId(), room);
|
rooms.insert(room->getId(), room);
|
||||||
|
|
||||||
QReadLocker chatHistoryLocker(&room->historyLock);
|
QReadLocker chatHistoryLocker(&room->historyLock);
|
||||||
QList<ServerInfo_ChatMessage> chatHistory = room->getChatHistory();
|
const QList<ServerInfo_ChatMessage> mainChatHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY);
|
||||||
ServerInfo_ChatMessage chatMessage;
|
for (const ServerInfo_ChatMessage &chatMessage : mainChatHistory) {
|
||||||
for (int i = 0; i < chatHistory.size(); ++i) {
|
|
||||||
chatMessage = chatHistory.at(i);
|
|
||||||
Event_RoomSay roomChatHistory;
|
Event_RoomSay roomChatHistory;
|
||||||
roomChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message());
|
roomChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message());
|
||||||
roomChatHistory.set_message_type(Event_RoomSay::ChatHistory);
|
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));
|
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;
|
Event_RoomSay joinMessageEvent;
|
||||||
joinMessageEvent.set_message(room->getJoinMessage().toStdString());
|
joinMessageEvent.set_message(room->getJoinMessage().toStdString());
|
||||||
joinMessageEvent.set_message_type(Event_RoomSay::Welcome);
|
joinMessageEvent.set_message_type(Event_RoomSay::Welcome);
|
||||||
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(joinMessageEvent));
|
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(joinMessageEvent));
|
||||||
|
|
||||||
auto *re = new Response_JoinRoom;
|
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);
|
rc.setResponseExtension(re);
|
||||||
return Response::RespOk;
|
return Response::RespOk;
|
||||||
|
|
@ -959,6 +1019,36 @@ Server_ProtocolHandler::cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room
|
||||||
return Response::RespOk;
|
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
|
Response::ResponseCode
|
||||||
Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc)
|
Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
#include "server.h"
|
#include "server.h"
|
||||||
#include "server_abstractuserinterface.h"
|
#include "server_abstractuserinterface.h"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
|
@ -30,6 +32,7 @@ class Command_ListRooms;
|
||||||
class Command_JoinRoom;
|
class Command_JoinRoom;
|
||||||
class Command_LeaveRoom;
|
class Command_LeaveRoom;
|
||||||
class Command_RoomSay;
|
class Command_RoomSay;
|
||||||
|
class Command_RoomChannelSay;
|
||||||
class Command_CreateGame;
|
class Command_CreateGame;
|
||||||
class Command_JoinGame;
|
class Command_JoinGame;
|
||||||
|
|
||||||
|
|
@ -46,6 +49,9 @@ protected:
|
||||||
bool acceptsUserListChanges;
|
bool acceptsUserListChanges;
|
||||||
bool acceptsRoomListChanges;
|
bool acceptsRoomListChanges;
|
||||||
bool idleClientWarningSent;
|
bool idleClientWarningSent;
|
||||||
|
QByteArray authNonce;
|
||||||
|
QString authNonceUser;
|
||||||
|
QDateTime authNonceCreated;
|
||||||
virtual void logDebugMessage(const QString & /* message */)
|
virtual void logDebugMessage(const QString & /* message */)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +72,8 @@ private:
|
||||||
Response::ResponseCode cmdListUsers(const Command_ListUsers &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdListUsers(const Command_ListUsers &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdLeaveRoom(const Command_LeaveRoom &cmd, Server_Room *room, 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 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 cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdJoinGame(const Command_JoinGame &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;
|
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
|
int getLastCommandTime() const
|
||||||
{
|
{
|
||||||
return timeRunning - lastDataReceived;
|
return timeRunning - lastDataReceived;
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,20 @@
|
||||||
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_list_games.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_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_room_say.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_chat_message.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.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
||||||
#include <libcockatrice/utility/string_limits.h>
|
#include <libcockatrice/utility/string_limits.h>
|
||||||
#include <qlogging.h>
|
#include <qlogging.h>
|
||||||
#include <qnamespace.h>
|
#include <qnamespace.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
const QString Server_Room::MAIN_CHANNEL_KEY = QStringLiteral("__main__");
|
||||||
|
|
||||||
Server_Room::Server_Room(int _id,
|
Server_Room::Server_Room(int _id,
|
||||||
int _chatHistorySize,
|
int _chatHistorySize,
|
||||||
const QString &_name,
|
const QString &_name,
|
||||||
|
|
@ -45,10 +49,11 @@ Server_Room::Server_Room(int _id,
|
||||||
bool _autoJoin,
|
bool _autoJoin,
|
||||||
const QString &_joinMessage,
|
const QString &_joinMessage,
|
||||||
const QStringList &_gameTypes,
|
const QStringList &_gameTypes,
|
||||||
Server *parent)
|
Server *parent,
|
||||||
|
const QList<ServerInfo_RoomChannel> &_channels)
|
||||||
: QObject(parent), id(_id), chatHistorySize(_chatHistorySize), name(_name), description(_description),
|
: QObject(parent), id(_id), chatHistorySize(_chatHistorySize), name(_name), description(_description),
|
||||||
permissionLevel(_permissionLevel), privilegeLevel(_privilegeLevel), autoJoin(_autoJoin),
|
permissionLevel(_permissionLevel), privilegeLevel(_privilegeLevel), autoJoin(_autoJoin),
|
||||||
joinMessage(_joinMessage), gameTypes(_gameTypes), gamesLock(QReadWriteLock::Recursive)
|
joinMessage(_joinMessage), gameTypes(_gameTypes), channels(_channels), gamesLock(QReadWriteLock::Recursive)
|
||||||
{
|
{
|
||||||
connect(
|
connect(
|
||||||
this, &Server_Room::gameListChanged, this, [this](auto gameInfo) { broadcastGameListUpdate(gameInfo); },
|
this, &Server_Room::gameListChanged, this, [this](auto gameInfo) { broadcastGameListUpdate(gameInfo); },
|
||||||
|
|
@ -102,8 +107,11 @@ Server *Server_Room::getServer() const
|
||||||
return static_cast<Server *>(parent());
|
return static_cast<Server *>(parent());
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerInfo_Room &
|
const ServerInfo_Room &Server_Room::getInfo(ServerInfo_Room &result,
|
||||||
Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, bool includeExternalData) const
|
bool complete,
|
||||||
|
bool showGameTypes,
|
||||||
|
bool includeExternalData,
|
||||||
|
const ServerInfo_User *forUser) const
|
||||||
{
|
{
|
||||||
result.set_room_id(id);
|
result.set_room_id(id);
|
||||||
result.set_name(name.toStdString());
|
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;
|
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)
|
RoomEvent *Server_Room::prepareRoomEvent(const ::google::protobuf::Message &roomEvent)
|
||||||
{
|
{
|
||||||
auto *event = new RoomEvent;
|
auto *event = new RoomEvent;
|
||||||
|
|
@ -310,12 +382,35 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam
|
||||||
return result;
|
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;
|
const bool isMain = isMainChannel(channelId);
|
||||||
event.set_name(userName.toStdString());
|
ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public;
|
||||||
event.set_message(userMessage.toStdString());
|
if (!isMain) {
|
||||||
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
|
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) {
|
if (chatHistorySize != 0) {
|
||||||
ServerInfo_ChatMessage chatMessage;
|
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_time(dateTimeString.toStdString());
|
||||||
chatMessage.set_sender_name(userName.toStdString());
|
chatMessage.set_sender_name(userName.toStdString());
|
||||||
chatMessage.set_message(userMessage.simplified().toStdString());
|
chatMessage.set_message(userMessage.simplified().toStdString());
|
||||||
|
if (!isMain) {
|
||||||
historyLock.lockForWrite();
|
chatMessage.set_channel(channelId.toStdString());
|
||||||
if (chatHistory.size() >= chatHistorySize) {
|
|
||||||
chatHistory.removeAt(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
historyLock.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -344,26 +444,34 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s
|
||||||
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
|
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
|
||||||
|
|
||||||
if (chatHistorySize != 0) {
|
if (chatHistorySize != 0) {
|
||||||
int removed = 0;
|
|
||||||
historyLock.lockForWrite();
|
historyLock.lockForWrite();
|
||||||
// redact [amount] of the most recent messages from this user from history
|
// redact [amount] of the most recent messages from this user from every channel history,
|
||||||
for (auto message = chatHistory.rbegin(); message != chatHistory.rend() && removed != amount; ++message) {
|
// so removed content does not resurface for late joiners on any stream
|
||||||
if (message->sender_name() == stdStringUserName) {
|
for (auto historyIt = channelChatHistory.begin(); historyIt != channelChatHistory.end(); ++historyIt) {
|
||||||
message->clear_message();
|
QList<ServerInfo_ChatMessage> &history = historyIt.value();
|
||||||
++removed;
|
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();
|
historyLock.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl)
|
void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl, ServerInfo_RoomChannel::AccessLevel accessLevel)
|
||||||
{
|
{
|
||||||
usersLock.lockForRead();
|
usersLock.lockForRead();
|
||||||
{
|
{
|
||||||
QMapIterator<QString, Server_ProtocolHandler *> userIterator(users);
|
QMapIterator<QString, Server_ProtocolHandler *> userIterator(users);
|
||||||
while (userIterator.hasNext()) {
|
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();
|
usersLock.unlock();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
|
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
class Server_ProtocolHandler;
|
class Server_ProtocolHandler;
|
||||||
|
|
@ -47,15 +48,18 @@ private:
|
||||||
bool autoJoin;
|
bool autoJoin;
|
||||||
QString joinMessage;
|
QString joinMessage;
|
||||||
QStringList gameTypes;
|
QStringList gameTypes;
|
||||||
|
QList<ServerInfo_RoomChannel> channels;
|
||||||
QMap<int, Server_Game *> games;
|
QMap<int, Server_Game *> games;
|
||||||
QMap<int, ServerInfo_Game> externalGames;
|
QMap<int, ServerInfo_Game> externalGames;
|
||||||
QMap<QString, Server_ProtocolHandler *> users;
|
QMap<QString, Server_ProtocolHandler *> users;
|
||||||
QMap<QString, ServerInfo_User_Container> externalUsers;
|
QMap<QString, ServerInfo_User_Container> externalUsers;
|
||||||
QList<ServerInfo_ChatMessage> chatHistory;
|
QMap<QString, QList<ServerInfo_ChatMessage>> channelChatHistory;
|
||||||
private slots:
|
private slots:
|
||||||
void broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool sendToIsl = true);
|
void broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool sendToIsl = true);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
static const QString MAIN_CHANNEL_KEY;
|
||||||
|
|
||||||
mutable QReadWriteLock usersLock;
|
mutable QReadWriteLock usersLock;
|
||||||
mutable QReadWriteLock gamesLock;
|
mutable QReadWriteLock gamesLock;
|
||||||
mutable QReadWriteLock historyLock;
|
mutable QReadWriteLock historyLock;
|
||||||
|
|
@ -68,7 +72,8 @@ public:
|
||||||
bool _autoJoin,
|
bool _autoJoin,
|
||||||
const QString &_joinMessage,
|
const QString &_joinMessage,
|
||||||
const QStringList &_gameTypes,
|
const QStringList &_gameTypes,
|
||||||
Server *parent);
|
Server *parent,
|
||||||
|
const QList<ServerInfo_RoomChannel> &_channels = {});
|
||||||
~Server_Room() override;
|
~Server_Room() override;
|
||||||
int getId() const
|
int getId() const
|
||||||
{
|
{
|
||||||
|
|
@ -103,6 +108,35 @@ public:
|
||||||
{
|
{
|
||||||
return gameTypes;
|
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
|
const QMap<int, Server_Game *> &getGames() const
|
||||||
{
|
{
|
||||||
return games;
|
return games;
|
||||||
|
|
@ -112,14 +146,14 @@ public:
|
||||||
return externalGames;
|
return externalGames;
|
||||||
}
|
}
|
||||||
Server *getServer() const;
|
Server *getServer() const;
|
||||||
const ServerInfo_Room &
|
const ServerInfo_Room &getInfo(ServerInfo_Room &result,
|
||||||
getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const;
|
bool complete,
|
||||||
|
bool showGameTypes = false,
|
||||||
|
bool includeExternalData = true,
|
||||||
|
const ServerInfo_User *forUser = nullptr) const;
|
||||||
int getGamesCreatedByUser(const QString &name) const;
|
int getGamesCreatedByUser(const QString &name) const;
|
||||||
QList<ServerInfo_Game> getGamesOfUser(const QString &name) const;
|
QList<ServerInfo_Game> getGamesOfUser(const QString &name) const;
|
||||||
QList<ServerInfo_ChatMessage> &getChatHistory()
|
QList<ServerInfo_ChatMessage> getChatHistory(const QString &channelId) const;
|
||||||
{
|
|
||||||
return chatHistory;
|
|
||||||
}
|
|
||||||
|
|
||||||
void addClient(Server_ProtocolHandler *client);
|
void addClient(Server_ProtocolHandler *client);
|
||||||
void removeClient(Server_ProtocolHandler *client);
|
void removeClient(Server_ProtocolHandler *client);
|
||||||
|
|
@ -136,13 +170,15 @@ public:
|
||||||
ResponseContainer &rc,
|
ResponseContainer &rc,
|
||||||
Server_AbstractUserInterface *userInterface);
|
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 removeSaidMessages(const QString &userName, int amount, bool sendToIsl = true);
|
||||||
|
|
||||||
void addGame(Server_Game *game);
|
void addGame(Server_Game *game);
|
||||||
void removeGame(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);
|
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("idle_client", false);
|
||||||
_featureList.insert("forgot_password", false);
|
_featureList.insert("forgot_password", false);
|
||||||
_featureList.insert("websocket", 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
|
// These are temp to force users onto a newer client
|
||||||
_featureList.insert("2.7.0_min_version", false);
|
_featureList.insert("2.7.0_min_version", false);
|
||||||
_featureList.insert("2.8.0_min_version", false);
|
_featureList.insert("2.8.0_min_version", false);
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ set(PROTO_FILES
|
||||||
event_reveal_cards.proto
|
event_reveal_cards.proto
|
||||||
event_reverse_turn.proto
|
event_reverse_turn.proto
|
||||||
event_roll_die.proto
|
event_roll_die.proto
|
||||||
|
event_room_channel_say.proto
|
||||||
event_room_say.proto
|
event_room_say.proto
|
||||||
event_server_complete_list.proto
|
event_server_complete_list.proto
|
||||||
event_server_identification.proto
|
event_server_identification.proto
|
||||||
|
|
@ -201,6 +202,7 @@ set(PROTO_FILES
|
||||||
serverinfo_replay_match.proto
|
serverinfo_replay_match.proto
|
||||||
serverinfo_report.proto
|
serverinfo_report.proto
|
||||||
serverinfo_room.proto
|
serverinfo_room.proto
|
||||||
|
serverinfo_room_channel.proto
|
||||||
serverinfo_user.proto
|
serverinfo_user.proto
|
||||||
serverinfo_user_alt.proto
|
serverinfo_user_alt.proto
|
||||||
serverinfo_user_session.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 string server_version = 2;
|
||||||
optional uint32 protocol_version = 3;
|
optional uint32 protocol_version = 3;
|
||||||
optional ServerOptions server_options = 4 [default = NoOptions];
|
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 {
|
extend Response {
|
||||||
optional Response_PasswordSalt ext = 1017;
|
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;
|
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;
|
ROOM_SAY = 1001;
|
||||||
CREATE_GAME = 1002;
|
CREATE_GAME = 1002;
|
||||||
JOIN_GAME = 1003;
|
JOIN_GAME = 1003;
|
||||||
|
ROOM_CHANNEL_SAY = 1004;
|
||||||
}
|
}
|
||||||
extensions 100 to max;
|
extensions 100 to max;
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +23,14 @@ message Command_RoomSay {
|
||||||
optional string message = 1;
|
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
|
// Create a new game in the room
|
||||||
message Command_CreateGame {
|
message Command_CreateGame {
|
||||||
extend RoomCommand {
|
extend RoomCommand {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ message RoomEvent {
|
||||||
ROOM_SAY = 1002;
|
ROOM_SAY = 1002;
|
||||||
LIST_GAMES = 1003;
|
LIST_GAMES = 1003;
|
||||||
REMOVE_MESSAGES = 1004;
|
REMOVE_MESSAGES = 1004;
|
||||||
|
ROOM_CHANNEL_SAY = 1005;
|
||||||
}
|
}
|
||||||
optional sint32 room_id = 1;
|
optional sint32 room_id = 1;
|
||||||
extensions 100 to max;
|
extensions 100 to max;
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ message ServerInfo_ChatMessage {
|
||||||
optional string target_type = 6; // target type (room,game,chat)
|
optional string target_type = 6; // target type (room,game,chat)
|
||||||
optional string target_id = 7; // id of target
|
optional string target_id = 7; // id of target
|
||||||
optional string target_name = 8; // name 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_game.proto";
|
||||||
import "serverinfo_user.proto";
|
import "serverinfo_user.proto";
|
||||||
import "serverinfo_gametype.proto";
|
import "serverinfo_gametype.proto";
|
||||||
|
import "serverinfo_room_channel.proto";
|
||||||
|
|
||||||
message ServerInfo_Room {
|
message ServerInfo_Room {
|
||||||
optional sint32 room_id = 1;
|
optional sint32 room_id = 1;
|
||||||
|
|
@ -15,4 +16,5 @@ message ServerInfo_Room {
|
||||||
repeated ServerInfo_GameType gametype_list = 9;
|
repeated ServerInfo_GameType gametype_list = 9;
|
||||||
optional string permissionlevel = 10;
|
optional string permissionlevel = 10;
|
||||||
optional string privilegelevel = 11;
|
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_DOWNLOAD_PUBLIC = 1031;
|
||||||
DECK_SHARE_LIST_MINE = 1032;
|
DECK_SHARE_LIST_MINE = 1032;
|
||||||
DECK_SHARE_REMOVE = 1033;
|
DECK_SHARE_REMOVE = 1033;
|
||||||
|
SUBMIT_PASSWORD_VERIFIER = 1034;
|
||||||
REPLAY_LIST = 1100;
|
REPLAY_LIST = 1100;
|
||||||
REPLAY_DOWNLOAD = 1101;
|
REPLAY_DOWNLOAD = 1101;
|
||||||
REPLAY_MODIFY_MATCH = 1102;
|
REPLAY_MODIFY_MATCH = 1102;
|
||||||
|
|
@ -231,3 +232,14 @@ message Command_SetCardArtParams {
|
||||||
optional double vertical_offset = 5;
|
optional double vertical_offset = 5;
|
||||||
optional double zoom = 6;
|
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();
|
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
|
bool ChatSettings::getChatMentionForeground() const
|
||||||
{
|
{
|
||||||
return getValue("mentionForeground", QString(), QString(), true).toBool();
|
return getValue("mentionForeground", QString(), QString(), true).toBool();
|
||||||
|
|
@ -107,6 +117,16 @@ void ChatSettings::setChatHighlightColor(const QString &_chatHighlightColor)
|
||||||
setValue(_chatHighlightColor, "highlightColor");
|
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)
|
void ChatSettings::setChatMentionForeground(bool _chatMentionForeground)
|
||||||
{
|
{
|
||||||
setValue(_chatMentionForeground, "mentionForeground");
|
setValue(_chatMentionForeground, "mentionForeground");
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ public:
|
||||||
[[nodiscard]] bool getChatMentionCompleter() const override;
|
[[nodiscard]] bool getChatMentionCompleter() const override;
|
||||||
[[nodiscard]] QString getChatMentionColor() const override;
|
[[nodiscard]] QString getChatMentionColor() const override;
|
||||||
[[nodiscard]] QString getChatHighlightColor() const override;
|
[[nodiscard]] QString getChatHighlightColor() const override;
|
||||||
|
[[nodiscard]] QString getChannelUnreadColor() const override;
|
||||||
|
[[nodiscard]] QString getChannelMentionColor() const override;
|
||||||
[[nodiscard]] bool getChatMentionForeground() const override;
|
[[nodiscard]] bool getChatMentionForeground() const override;
|
||||||
[[nodiscard]] bool getChatHighlightForeground() const override;
|
[[nodiscard]] bool getChatHighlightForeground() const override;
|
||||||
[[nodiscard]] bool getIgnoreUnregisteredUsers() const override;
|
[[nodiscard]] bool getIgnoreUnregisteredUsers() const override;
|
||||||
|
|
@ -35,6 +37,8 @@ public:
|
||||||
void setChatMentionCompleter(bool _chatMentionCompleter);
|
void setChatMentionCompleter(bool _chatMentionCompleter);
|
||||||
void setChatMentionColor(const QString &_chatMentionColor);
|
void setChatMentionColor(const QString &_chatMentionColor);
|
||||||
void setChatHighlightColor(const QString &_chatHighlightColor);
|
void setChatHighlightColor(const QString &_chatHighlightColor);
|
||||||
|
void setChannelUnreadColor(const QString &_channelUnreadColor);
|
||||||
|
void setChannelMentionColor(const QString &_channelMentionColor);
|
||||||
void setChatMentionForeground(bool _chatMentionForeground);
|
void setChatMentionForeground(bool _chatMentionForeground);
|
||||||
void setChatHighlightForeground(bool _chatHighlightForeground);
|
void setChatHighlightForeground(bool _chatHighlightForeground);
|
||||||
void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers);
|
void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers);
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,19 @@ void ServersSettings::setFPPlayerName(QString playerName)
|
||||||
setValue(playerName, "fpPlayerName");
|
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
|
QString ServersSettings::getFPPlayerName(QString defaultName) const
|
||||||
{
|
{
|
||||||
QVariant name = getValue("fpPlayerName");
|
QVariant name = getValue("fpPlayerName");
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ public:
|
||||||
void setFPHostName(QString hostname);
|
void setFPHostName(QString hostname);
|
||||||
void setFPPort(QString port);
|
void setFPPort(QString port);
|
||||||
void setFPPlayerName(QString playerName);
|
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,
|
void addNewServer(const QString &saveName,
|
||||||
const QString &serv,
|
const QString &serv,
|
||||||
const QString &port,
|
const QString &port,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ set(UTILITY_SOURCES
|
||||||
libcockatrice/utility/report_categories.cpp
|
libcockatrice/utility/report_categories.cpp
|
||||||
libcockatrice/utility/server_rate_limiter.cpp
|
libcockatrice/utility/server_rate_limiter.cpp
|
||||||
libcockatrice/utility/translation_loader.cpp
|
libcockatrice/utility/translation_loader.cpp
|
||||||
|
libcockatrice/utility/user_data_backup.cpp
|
||||||
libcockatrice/utility/warning_categories.cpp
|
libcockatrice/utility/warning_categories.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ set(UTILITY_HEADERS
|
||||||
libcockatrice/utility/days_years_between.h
|
libcockatrice/utility/days_years_between.h
|
||||||
libcockatrice/utility/server_rate_limiter.h
|
libcockatrice/utility/server_rate_limiter.h
|
||||||
libcockatrice/utility/translation_loader.h
|
libcockatrice/utility/translation_loader.h
|
||||||
|
libcockatrice/utility/user_data_backup.h
|
||||||
libcockatrice/utility/warning_categories.h
|
libcockatrice/utility/warning_categories.h
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
#include "cryptoutil.h"
|
#include "cryptoutil.h"
|
||||||
|
|
||||||
|
#include <QStringList>
|
||||||
|
#include <openssl/evp.h>
|
||||||
#include <openssl/rand.h>
|
#include <openssl/rand.h>
|
||||||
#include <qlogging.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
|
namespace CryptoUtil
|
||||||
{
|
{
|
||||||
QByteArray randomBytes(int count)
|
QByteArray randomBytes(int count)
|
||||||
|
|
@ -23,4 +31,99 @@ quint64 randomUInt64()
|
||||||
}
|
}
|
||||||
return value;
|
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
|
} // namespace CryptoUtil
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,21 @@
|
||||||
#define CRYPTOUTIL_H
|
#define CRYPTOUTIL_H
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <QString>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
|
||||||
namespace CryptoUtil
|
namespace CryptoUtil
|
||||||
{
|
{
|
||||||
QByteArray randomBytes(int count);
|
QByteArray randomBytes(int count);
|
||||||
quint64 randomUInt64();
|
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
|
} // namespace CryptoUtil
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
#include <QChar>
|
#include <QChar>
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
#include <libcockatrice/utility/cryptoutil.h>
|
#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)
|
QString PasswordHasher::computeHash(const QString &password, const QString &salt)
|
||||||
{
|
{
|
||||||
|
|
@ -54,3 +57,114 @@ QString PasswordHasher::generateActivationToken()
|
||||||
{
|
{
|
||||||
return QString(CryptoUtil::randomBytes(16).toBase64().left(16));
|
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
|
#ifndef PASSWORDHASHER_H
|
||||||
#define PASSWORDHASHER_H
|
#define PASSWORDHASHER_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QObject>
|
||||||
#include <QString>
|
#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
|
class PasswordHasher
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static QString computeHash(const QString &password, const QString &salt);
|
static QString computeHash(const QString &password, const QString &salt);
|
||||||
static QString generateRandomSalt(const int len = 16);
|
static QString generateRandomSalt(const int len = 16);
|
||||||
static QString generateActivationToken();
|
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
|
#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\2\name="GameType2"
|
||||||
roomlist\1\game_types\3\name="GameType3"
|
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]
|
[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).
|
; Length in seconds of the forgot-password window. Default is 3600 (1 hour).
|
||||||
forgot_password_window_seconds=3600
|
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
|
; 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
|
; 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`)
|
PRIMARY KEY (`version`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
) 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
|
-- users and user data tables
|
||||||
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
||||||
|
|
@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
||||||
`admin` tinyint(1) NOT NULL,
|
`admin` tinyint(1) NOT NULL,
|
||||||
`name` varchar(35) NOT NULL,
|
`name` varchar(35) NOT NULL,
|
||||||
`realname` varchar(255) 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,
|
`email` varchar(255) NOT NULL,
|
||||||
`country` char(2) NOT NULL,
|
`country` char(2) NOT NULL,
|
||||||
`avatar_bmp` mediumblob 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
|
FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
) 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
|
-- games
|
||||||
CREATE TABLE IF NOT EXISTS `cockatrice_games` (
|
CREATE TABLE IF NOT EXISTS `cockatrice_games` (
|
||||||
`room_name` varchar(255) NOT NULL,
|
`room_name` varchar(255) NOT NULL,
|
||||||
|
|
@ -329,9 +343,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_log` (
|
||||||
`sender_name` varchar(35) NOT NULL,
|
`sender_name` varchar(35) NOT NULL,
|
||||||
`sender_ip` varchar(45) NOT NULL,
|
`sender_ip` varchar(45) NOT NULL,
|
||||||
`log_message` text 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_id` int(7) NULL,
|
||||||
`target_name` varchar(50) NOT NULL,
|
`target_name` varchar(50) NOT NULL,
|
||||||
|
`channel` varchar(50) NULL,
|
||||||
KEY `sender_name` (`sender_name`),
|
KEY `sender_name` (`sender_name`),
|
||||||
KEY `sender_ip` (`sender_ip`),
|
KEY `sender_ip` (`sender_ip`),
|
||||||
KEY `target_id` (`target_id`),
|
KEY `target_id` (`target_id`),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_list_games.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_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_room_say.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_server_complete_list.pb.h>
|
#include <libcockatrice/protocol/pb/event_server_complete_list.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_user_joined.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)
|
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)
|
void IslInterface::roomEvent_ListGames(int roomId, const Event_ListGames &event)
|
||||||
|
|
@ -459,6 +467,9 @@ void IslInterface::processRoomEvent(const RoomEvent &event)
|
||||||
case RoomEvent::ROOM_SAY:
|
case RoomEvent::ROOM_SAY:
|
||||||
roomEvent_Say(event.room_id(), event.GetExtension(Event_RoomSay::ext));
|
roomEvent_Say(event.room_id(), event.GetExtension(Event_RoomSay::ext));
|
||||||
break;
|
break;
|
||||||
|
case RoomEvent::ROOM_CHANNEL_SAY:
|
||||||
|
roomEvent_ChannelSay(event.room_id(), event.GetExtension(Event_RoomChannelSay::ext));
|
||||||
|
break;
|
||||||
case RoomEvent::LIST_GAMES:
|
case RoomEvent::LIST_GAMES:
|
||||||
roomEvent_ListGames(event.room_id(), event.GetExtension(Event_ListGames::ext));
|
roomEvent_ListGames(event.room_id(), event.GetExtension(Event_ListGames::ext));
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class Event_UserLeft;
|
||||||
class Event_JoinRoom;
|
class Event_JoinRoom;
|
||||||
class Event_LeaveRoom;
|
class Event_LeaveRoom;
|
||||||
class Event_RoomSay;
|
class Event_RoomSay;
|
||||||
|
class Event_RoomChannelSay;
|
||||||
class Event_ListGames;
|
class Event_ListGames;
|
||||||
class Event_RemoveMessages;
|
class Event_RemoveMessages;
|
||||||
class Command_JoinGame;
|
class Command_JoinGame;
|
||||||
|
|
@ -49,7 +50,7 @@ signals:
|
||||||
void externalUserLeft(QString userName);
|
void externalUserLeft(QString userName);
|
||||||
void externalRoomUserJoined(int roomId, ServerInfo_User userInfo);
|
void externalRoomUserJoined(int roomId, ServerInfo_User userInfo);
|
||||||
void externalRoomUserLeft(int roomId, QString userName);
|
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 externalRoomGameListChanged(int roomId, ServerInfo_Game gameInfo);
|
||||||
void externalRoomRemoveMessages(int roomId, QString userName, int amount);
|
void externalRoomRemoveMessages(int roomId, QString userName, int amount);
|
||||||
void joinGameCommandReceived(const Command_JoinGame &cmd, int cmdId, int roomId, int serverId, qint64 sessionId);
|
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_UserJoined(int roomId, const Event_JoinRoom &event);
|
||||||
void roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event);
|
void roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event);
|
||||||
void roomEvent_Say(int roomId, const Event_RoomSay &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_ListGames(int roomId, const Event_ListGames &event);
|
||||||
void roomEvent_RemoveMessages(int roomId, const Event_RemoveMessages &event);
|
void roomEvent_RemoveMessages(int roomId, const Event_RemoveMessages &event);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QProcessEnvironment>
|
#include <QProcessEnvironment>
|
||||||
#include <QReadLocker>
|
#include <QReadLocker>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSet>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QSqlDatabase>
|
#include <QSqlDatabase>
|
||||||
#include <QSqlQuery>
|
#include <QSqlQuery>
|
||||||
|
|
@ -65,7 +67,9 @@
|
||||||
#include <libcockatrice/protocol/pb/event_connection_closed.pb.h>
|
#include <libcockatrice/protocol/pb/event_connection_closed.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_server_shutdown.pb.h>
|
#include <libcockatrice/protocol/pb/event_server_shutdown.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
||||||
#include <new>
|
#include <new>
|
||||||
|
#include <optional>
|
||||||
#include <qlogging.h>
|
#include <qlogging.h>
|
||||||
#include <qnamespace.h>
|
#include <qnamespace.h>
|
||||||
#include <server_room.h>
|
#include <server_room.h>
|
||||||
|
|
@ -73,6 +77,10 @@
|
||||||
|
|
||||||
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
|
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,
|
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
|
||||||
int _numberPools,
|
int _numberPools,
|
||||||
const QSqlDatabase &_sqlDatabase,
|
const QSqlDatabase &_sqlDatabase,
|
||||||
|
|
@ -249,6 +257,37 @@ Servatrice::~Servatrice()
|
||||||
prepareDestroy();
|
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()
|
bool Servatrice::initServer()
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
@ -360,10 +399,65 @@ bool Servatrice::initServer()
|
||||||
while (query2->next()) {
|
while (query2->next()) {
|
||||||
gameTypes.append(query2->value(0).toString());
|
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(),
|
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(2).toString(), query->value(3).toString().toLower(),
|
||||||
query->value(4).toString().toLower(), static_cast<bool>(query->value(5).toInt()),
|
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 {
|
} else {
|
||||||
int size = settingsCache->beginReadArray("rooms/roomlist");
|
int size = settingsCache->beginReadArray("rooms/roomlist");
|
||||||
|
|
@ -376,12 +470,61 @@ bool Servatrice::initServer()
|
||||||
gameTypes.append(settingsCache->value("name").toString());
|
gameTypes.append(settingsCache->value("name").toString());
|
||||||
}
|
}
|
||||||
settingsCache->endArray();
|
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(
|
Server_Room *newRoom = new Server_Room(
|
||||||
i, settingsCache->value("chathistorysize").toInt(), settingsCache->value("name").toString(),
|
i, settingsCache->value("chathistorysize").toInt(), settingsCache->value("name").toString(),
|
||||||
settingsCache->value("description").toString(),
|
settingsCache->value("description").toString(),
|
||||||
settingsCache->value("permissionlevel").toString().toLower(),
|
settingsCache->value("permissionlevel").toString().toLower(),
|
||||||
settingsCache->value("privilegelevel").toString().toLower(), settingsCache->value("autojoin").toBool(),
|
settingsCache->value("privilegelevel").toString().toLower(), settingsCache->value("autojoin").toBool(),
|
||||||
settingsCache->value("joinmessage").toString(), gameTypes, this);
|
settingsCache->value("joinmessage").toString(), gameTypes, this, channels);
|
||||||
addRoom(newRoom);
|
addRoom(newRoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -475,6 +618,10 @@ bool Servatrice::initServer()
|
||||||
deckShareCleanupClock->start(deckShareCleanupInterval);
|
deckShareCleanupClock->start(deckShareCleanupInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
legacyBackupSweepClock = new QTimer(this);
|
||||||
|
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
|
||||||
|
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
|
||||||
|
|
||||||
// SOCKET SERVER
|
// SOCKET SERVER
|
||||||
if (getNumberOfTCPPools() > 0) {
|
if (getNumberOfTCPPools() > 0) {
|
||||||
gameServer =
|
gameServer =
|
||||||
|
|
@ -864,6 +1011,14 @@ SessionEvent *Servatrice::getLoginSessionEvent() const
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Servatrice::sweepLegacyBackups()
|
||||||
|
{
|
||||||
|
if (!servatriceDatabaseInterface->checkSql()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
servatriceDatabaseInterface->purgeExpiredLegacyBackups();
|
||||||
|
}
|
||||||
|
|
||||||
void Servatrice::scheduleShutdown(const QString &reason, int minutes)
|
void Servatrice::scheduleShutdown(const QString &reason, int minutes)
|
||||||
{
|
{
|
||||||
shutdownStateMutex.lock();
|
shutdownStateMutex.lock();
|
||||||
|
|
@ -940,8 +1095,8 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface)
|
||||||
connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this,
|
connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this,
|
||||||
SLOT(externalRoomUserJoined(int, ServerInfo_User)));
|
SLOT(externalRoomUserJoined(int, ServerInfo_User)));
|
||||||
connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString)));
|
connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString)));
|
||||||
connect(interface, SIGNAL(externalRoomSay(int, QString, QString)), this,
|
connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this,
|
||||||
SLOT(externalRoomSay(int, QString, QString)));
|
SLOT(externalRoomSay(int, QString, QString, QString)));
|
||||||
connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this,
|
connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this,
|
||||||
SLOT(externalRoomRemoveMessages(int, QString, int)));
|
SLOT(externalRoomRemoveMessages(int, QString, int)));
|
||||||
connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this,
|
connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this,
|
||||||
|
|
@ -1056,6 +1211,42 @@ QString Servatrice::getRequiredFeatures() const
|
||||||
return settingsCache->value("server/requiredfeatures", "").toString();
|
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
|
QString Servatrice::getDBTypeString() const
|
||||||
{
|
{
|
||||||
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,15 @@ public:
|
||||||
AuthenticationSql,
|
AuthenticationSql,
|
||||||
AuthenticationPassword
|
AuthenticationPassword
|
||||||
};
|
};
|
||||||
|
enum AuthenticationStrictness
|
||||||
|
{
|
||||||
|
AuthenticationLegacy,
|
||||||
|
AuthenticationMixed,
|
||||||
|
AuthenticationStrict
|
||||||
|
};
|
||||||
private slots:
|
private slots:
|
||||||
void statusUpdate();
|
void statusUpdate();
|
||||||
|
void sweepLegacyBackups();
|
||||||
void shutdownTimeout();
|
void shutdownTimeout();
|
||||||
void cleanupExpiredDeckShares();
|
void cleanupExpiredDeckShares();
|
||||||
|
|
||||||
|
|
@ -173,6 +180,7 @@ private:
|
||||||
DatabaseType databaseType;
|
DatabaseType databaseType;
|
||||||
QTimer *pingClock, *statusUpdateClock;
|
QTimer *pingClock, *statusUpdateClock;
|
||||||
QTimer *deckShareCleanupClock;
|
QTimer *deckShareCleanupClock;
|
||||||
|
QTimer *legacyBackupSweepClock;
|
||||||
Servatrice_GameServer *gameServer;
|
Servatrice_GameServer *gameServer;
|
||||||
Servatrice_WebsocketGameServer *websocketGameServer;
|
Servatrice_WebsocketGameServer *websocketGameServer;
|
||||||
Servatrice_IslServer *islServer;
|
Servatrice_IslServer *islServer;
|
||||||
|
|
@ -251,6 +259,14 @@ public:
|
||||||
{
|
{
|
||||||
return serverRequiredFeatureList;
|
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 getServerName() const;
|
||||||
QString getLoginMessage() const override
|
QString getLoginMessage() const override
|
||||||
{
|
{
|
||||||
|
|
@ -272,6 +288,7 @@ public:
|
||||||
{
|
{
|
||||||
return authenticationMethod;
|
return authenticationMethod;
|
||||||
}
|
}
|
||||||
|
AuthenticationStrictness getAuthenticationStrictness() const;
|
||||||
bool permitUnregisteredUsers() const override
|
bool permitUnregisteredUsers() const override
|
||||||
{
|
{
|
||||||
return authenticationMethod != AuthenticationNone;
|
return authenticationMethod != AuthenticationNone;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
#include <libcockatrice/deck_list/deck_list.h>
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
#include <libcockatrice/protocol/pb/game_replay.pb.h>
|
#include <libcockatrice/protocol/pb/game_replay.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||||
|
#include <libcockatrice/utility/cryptoutil.h>
|
||||||
#include <libcockatrice/utility/passwordhasher.h>
|
#include <libcockatrice/utility/passwordhasher.h>
|
||||||
#include <qalgorithms.h>
|
#include <qalgorithms.h>
|
||||||
#include <qnamespace.h>
|
#include <qnamespace.h>
|
||||||
|
|
@ -44,6 +45,17 @@
|
||||||
|
|
||||||
inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface");
|
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)
|
Servatrice_DatabaseInterface::Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server)
|
||||||
: instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server)
|
: instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server)
|
||||||
{
|
{
|
||||||
|
|
@ -429,13 +441,104 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
|
||||||
qCWarning(DatabaseInterfaceLog) << "Login denied: user not active";
|
qCWarning(DatabaseInterfaceLog) << "Login denied: user not active";
|
||||||
return UserIsInactive;
|
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;
|
QString hashedPassword;
|
||||||
if (passwordNeedsHash) {
|
if (passwordNeedsHash) {
|
||||||
hashedPassword = PasswordHasher::computeHash(password, correctPasswordSha512.left(16));
|
hashedPassword = PasswordHasher::computeHash(password, storedCredential.left(16));
|
||||||
} else {
|
} else {
|
||||||
hashedPassword = password;
|
hashedPassword = password;
|
||||||
}
|
}
|
||||||
if (correctPasswordSha512 == hashedPassword) {
|
if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) {
|
||||||
if (forceChange) {
|
if (forceChange) {
|
||||||
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
||||||
return PasswordChangeRequired;
|
return PasswordChangeRequired;
|
||||||
|
|
@ -631,6 +734,185 @@ QString Servatrice_DatabaseInterface::getUserSalt(const QString &user)
|
||||||
return {};
|
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)
|
int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name)
|
||||||
{
|
{
|
||||||
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
||||||
|
|
@ -1286,7 +1568,8 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
||||||
const QString &logMessage,
|
const QString &logMessage,
|
||||||
LogMessage_TargetType targetType,
|
LogMessage_TargetType targetType,
|
||||||
const int targetId,
|
const int targetId,
|
||||||
const QString &targetName)
|
const QString &targetName,
|
||||||
|
const QString &channel)
|
||||||
{
|
{
|
||||||
QString targetTypeString;
|
QString targetTypeString;
|
||||||
switch (targetType) {
|
switch (targetType) {
|
||||||
|
|
@ -1296,6 +1579,12 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
||||||
}
|
}
|
||||||
targetTypeString = "room";
|
targetTypeString = "room";
|
||||||
break;
|
break;
|
||||||
|
case MessageTargetRoomChannel:
|
||||||
|
if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetTypeString = "room_channel";
|
||||||
|
break;
|
||||||
case MessageTargetGame:
|
case MessageTargetGame:
|
||||||
if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) {
|
if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) {
|
||||||
return;
|
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, "
|
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, "
|
"log_message, target_type, target_id, target_name, channel) values (now(), "
|
||||||
":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)");
|
":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_id", senderId < 1 ? QVariant() : senderId);
|
||||||
query->bindValue(":sender_name", senderName);
|
query->bindValue(":sender_name", senderName);
|
||||||
query->bindValue(":sender_ip", senderIp);
|
query->bindValue(":sender_ip", senderIp);
|
||||||
|
|
@ -1328,6 +1618,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
||||||
query->bindValue(":target_type", targetTypeString);
|
query->bindValue(":target_type", targetTypeString);
|
||||||
query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId);
|
query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId);
|
||||||
query->bindValue(":target_name", targetName);
|
query->bindValue(":target_name", targetName);
|
||||||
|
query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel);
|
||||||
execSqlQuery(query);
|
execSqlQuery(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1340,7 +1631,10 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
||||||
passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt());
|
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, "
|
QSqlQuery *passwordQuery = prepareQuery("update {prefix}_users set password_sha512=:password, "
|
||||||
|
"password_legacy_backup='', password_legacy_backup_used_at=NULL, "
|
||||||
"passwordLastChangedDate = NOW() where name = :name");
|
"passwordLastChangedDate = NOW() where name = :name");
|
||||||
passwordQuery->bindValue(":password", passwordSha512);
|
passwordQuery->bindValue(":password", passwordSha512);
|
||||||
passwordQuery->bindValue(":name", user);
|
passwordQuery->bindValue(":name", user);
|
||||||
|
|
@ -1393,13 +1687,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString correctPasswordSha512 = passwordQuery->value(0).toString();
|
const QString storedPassword = passwordQuery->value(0).toString();
|
||||||
QString oldPasswordSha512 = oldPassword;
|
// oldPasswordNeedsHash means the client sent the old password in plaintext. Verify it
|
||||||
if (oldPasswordNeedsHash) {
|
// against whatever is stored: legacy salt+hash rows or already-migrated scrypt rows
|
||||||
QString salt = correctPasswordSha512.left(16);
|
// (which must NOT be re-hashed with a salt torn out of the "$scrypt$..." string).
|
||||||
oldPasswordSha512 = PasswordHasher::computeHash(oldPassword, salt);
|
const bool oldPasswordMatches = oldPasswordNeedsHash ? PasswordHasher::verifyPassword(oldPassword, storedPassword)
|
||||||
}
|
: (oldPassword == storedPassword);
|
||||||
if (correctPasswordSha512 != oldPasswordSha512) {
|
if (!oldPasswordMatches) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1846,9 +2140,9 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
if (game || chat) {
|
if (game || chat) {
|
||||||
queryString.append(" OR `target_type` = 'room'");
|
queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'");
|
||||||
} else {
|
} else {
|
||||||
queryString.append("`target_type` = 'room'");
|
queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queryString.append(")");
|
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_type(QString(query->value(5).toString()).toStdString());
|
||||||
chatMessage.set_target_id(QString(query->value(6).toString()).toStdString());
|
chatMessage.set_target_id(QString(query->value(6).toString()).toStdString());
|
||||||
chatMessage.set_target_name(QString(query->value(7).toString()).toStdString());
|
chatMessage.set_target_name(QString(query->value(7).toString()).toStdString());
|
||||||
|
chatMessage.set_channel(QString(query->value(8).toString()).toStdString());
|
||||||
results << chatMessage;
|
results << chatMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2068,3 +2363,25 @@ void Servatrice_DatabaseInterface::addAuditRecord(const QString &user,
|
||||||
query->bindValue(":details", details);
|
query->bindValue(":details", details);
|
||||||
execSqlQuery(query);
|
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;
|
class QSqlQuery;
|
||||||
|
|
||||||
#define DATABASE_SCHEMA_VERSION 37
|
#define DATABASE_SCHEMA_VERSION 40
|
||||||
|
|
||||||
class Servatrice;
|
class Servatrice;
|
||||||
|
|
||||||
|
|
@ -96,6 +96,18 @@ public:
|
||||||
bool activeUserExists(const QString &user) override;
|
bool activeUserExists(const QString &user) override;
|
||||||
bool userExists(const QString &user) override;
|
bool userExists(const QString &user) override;
|
||||||
QString getUserSalt(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);
|
int getUserIdInDB(const QString &name);
|
||||||
QMap<QString, ServerInfo_User> getBuddyList(const QString &name) override;
|
QMap<QString, ServerInfo_User> getBuddyList(const QString &name) override;
|
||||||
QMap<QString, ServerInfo_User> getIgnoreList(const QString &name) override;
|
QMap<QString, ServerInfo_User> getIgnoreList(const QString &name) override;
|
||||||
|
|
@ -177,7 +189,8 @@ public:
|
||||||
const QString &logMessage,
|
const QString &logMessage,
|
||||||
LogMessage_TargetType targetType,
|
LogMessage_TargetType targetType,
|
||||||
const int targetId,
|
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 &password, bool passwordNeedsHash) override;
|
||||||
bool changeUserPassword(const QString &user,
|
bool changeUserPassword(const QString &user,
|
||||||
const QString &oldPassword,
|
const QString &oldPassword,
|
||||||
|
|
@ -218,6 +231,7 @@ public:
|
||||||
};
|
};
|
||||||
UptimeSnapshot getLatestUptimeSnapshot(int serverId);
|
UptimeSnapshot getLatestUptimeSnapshot(int serverId);
|
||||||
bool removeUserAvatar(const QString &userName);
|
bool removeUserAvatar(const QString &userName);
|
||||||
|
bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override;
|
||||||
bool addForgotPassword(const QString &user);
|
bool addForgotPassword(const QString &user);
|
||||||
bool removeForgotPassword(const QString &user) override;
|
bool removeForgotPassword(const QString &user) override;
|
||||||
bool doesForgotPasswordExist(const QString &user);
|
bool doesForgotPasswordExist(const QString &user);
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
|
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
|
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
|
||||||
|
#include <libcockatrice/utility/cryptoutil.h>
|
||||||
#include <libcockatrice/utility/passwordhasher.h>
|
#include <libcockatrice/utility/passwordhasher.h>
|
||||||
#include <libcockatrice/utility/report_categories.h>
|
#include <libcockatrice/utility/report_categories.h>
|
||||||
#include <libcockatrice/utility/string_limits.h>
|
#include <libcockatrice/utility/string_limits.h>
|
||||||
|
|
@ -204,7 +205,15 @@ bool AbstractServerSocketInterface::initSession()
|
||||||
identEvent.set_server_version(VERSION_STRING);
|
identEvent.set_server_version(VERSION_STRING);
|
||||||
identEvent.set_protocol_version(protocolVersion);
|
identEvent.set_protocol_version(protocolVersion);
|
||||||
if (servatrice->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
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_server_options(Event_ServerIdentification::SupportsPasswordHash);
|
||||||
|
identEvent.set_supports_challenge_response(true);
|
||||||
}
|
}
|
||||||
SessionEvent *identSe = prepareSessionEvent(identEvent);
|
SessionEvent *identSe = prepareSessionEvent(identEvent);
|
||||||
sendProtocolItem(*identSe);
|
sendProtocolItem(*identSe);
|
||||||
|
|
@ -409,6 +418,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
|
||||||
return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc);
|
return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc);
|
||||||
case SessionCommand::REPORT_DETAILS:
|
case SessionCommand::REPORT_DETAILS:
|
||||||
return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc);
|
return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc);
|
||||||
|
case SessionCommand::SUBMIT_PASSWORD_VERIFIER:
|
||||||
|
return cmdSubmitPasswordVerifier(cmd.GetExtension(Command_SubmitPasswordVerifier::ext), rc);
|
||||||
default:
|
default:
|
||||||
return Response::RespFunctionNotAllowed;
|
return Response::RespFunctionNotAllowed;
|
||||||
}
|
}
|
||||||
|
|
@ -3298,6 +3309,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
|
||||||
password = QString::fromStdString(cmd.hashed_password());
|
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 requireEmailActivation = settingsCache->value("registration/requireemailactivation", true).toBool();
|
||||||
bool regSucceeded = sqlInterface->registerUser(userName, realName, password, passwordNeedsHash, parsedEmailAddress,
|
bool regSucceeded = sqlInterface->registerUser(userName, realName, password, passwordNeedsHash, parsedEmailAddress,
|
||||||
country, !requireEmailActivation);
|
country, !requireEmailActivation);
|
||||||
|
|
@ -3342,6 +3358,30 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i
|
||||||
return servatrice->recordRegistrationAttempt(ipAddress);
|
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,
|
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
|
||||||
ResponseContainer & /*rc*/)
|
ResponseContainer & /*rc*/)
|
||||||
{
|
{
|
||||||
|
|
@ -3481,26 +3521,6 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm
|
||||||
return Response::RespOk;
|
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,
|
Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd,
|
||||||
ResponseContainer & /* rc */)
|
ResponseContainer & /* rc */)
|
||||||
{
|
{
|
||||||
|
|
@ -3527,7 +3547,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const
|
||||||
return Response::RespOk;
|
return Response::RespOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCardNameAllowed(cardName, cardProviderId)) {
|
if (!sqlInterface->isCardArtAllowed(cardName, cardProviderId)) {
|
||||||
return Response::RespFunctionNotAllowed;
|
return Response::RespFunctionNotAllowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3676,6 +3696,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C
|
||||||
newPassword = QString::fromStdString(cmd.hashed_new_password());
|
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());
|
QString userName = QString::fromStdString(userInfo->name());
|
||||||
if (!databaseInterface->changeUserPassword(userName, oldPassword, true, newPassword, newPasswordNeedsHash)) {
|
if (!databaseInterface->changeUserPassword(userName, oldPassword, true, newPassword, newPasswordNeedsHash)) {
|
||||||
return Response::RespWrongPassword;
|
return Response::RespWrongPassword;
|
||||||
|
|
@ -3822,6 +3847,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con
|
||||||
password = QString::fromStdString(cmd.hashed_new_password());
|
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 (sqlInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) {
|
||||||
if (servatrice->getEnableForgotPasswordAudit()) {
|
if (servatrice->getEnableForgotPasswordAudit()) {
|
||||||
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
|
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,
|
Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd,
|
||||||
ResponseContainer &rc)
|
ResponseContainer &rc)
|
||||||
{
|
{
|
||||||
const QString userName = nameFromStdString(cmd.user_name());
|
// Simplified exactly like cmdLogin, so the nonce bound to userName here validates
|
||||||
QString passwordSalt = sqlInterface->getUserSalt(userName);
|
// against the same normalized name during the login's challenge-response check.
|
||||||
if (passwordSalt.isEmpty()) {
|
const QString userName = nameFromStdString(cmd.user_name()).simplified();
|
||||||
|
const QString storedPasswordData = sqlInterface->getUserPasswordData(userName);
|
||||||
|
if (storedPasswordData.isEmpty()) {
|
||||||
if (server->getRegOnlyServerEnabled()) {
|
if (server->getRegOnlyServerEnabled()) {
|
||||||
return Response::RespRegistrationRequired;
|
return Response::RespRegistrationRequired;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3899,8 +3931,46 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
|
||||||
return Response::RespOk;
|
return Response::RespOk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto *re = new Response_PasswordSalt;
|
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);
|
rc.setResponseExtension(re);
|
||||||
return Response::RespOk;
|
return Response::RespOk;
|
||||||
}
|
}
|
||||||
|
|
@ -3998,6 +4068,39 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Re
|
||||||
return Response::RespOk;
|
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.
|
// ADMIN FUNCTIONS.
|
||||||
// Permission is checked by the calling function.
|
// Permission is checked by the calling function.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ class Command_ReportUserInfo;
|
||||||
class Command_RequestPasswordSalt;
|
class Command_RequestPasswordSalt;
|
||||||
class Command_ResetUserPassword;
|
class Command_ResetUserPassword;
|
||||||
class Command_SetCardArtParams;
|
class Command_SetCardArtParams;
|
||||||
|
class Command_SubmitPasswordVerifier;
|
||||||
class Command_UpdateAdminNotes;
|
class Command_UpdateAdminNotes;
|
||||||
class Command_ViewLogHistory;
|
class Command_ViewLogHistory;
|
||||||
class Command_WarnUser;
|
class Command_WarnUser;
|
||||||
|
|
@ -124,6 +125,7 @@ protected:
|
||||||
void logDebugMessage(const QString &message) override;
|
void logDebugMessage(const QString &message) override;
|
||||||
bool tooManyRegistrationAttempts(const QString &ipAddress);
|
bool tooManyRegistrationAttempts(const QString &ipAddress);
|
||||||
void processCommandContainer(const CommandContainer &cont) override;
|
void processCommandContainer(const CommandContainer &cont) override;
|
||||||
|
bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const;
|
||||||
|
|
||||||
virtual void writeToSocket(QByteArray &data) = 0;
|
virtual void writeToSocket(QByteArray &data) = 0;
|
||||||
virtual void flushSocket() = 0;
|
virtual void flushSocket() = 0;
|
||||||
|
|
@ -205,6 +207,7 @@ private:
|
||||||
Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc);
|
||||||
|
Response::ResponseCode cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode
|
Response::ResponseCode
|
||||||
processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override;
|
processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override;
|
||||||
Response::ResponseCode
|
Response::ResponseCode
|
||||||
|
|
@ -216,7 +219,6 @@ private:
|
||||||
|
|
||||||
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdAccountImage(const Command_AccountImage &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 cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &);
|
||||||
Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &);
|
Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &);
|
||||||
Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &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 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 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 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 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 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)
|
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
|
||||||
|
|
@ -273,6 +274,17 @@ cockatrice_add_test(
|
||||||
LABELS
|
LABELS
|
||||||
server
|
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(
|
cockatrice_add_test(
|
||||||
NAME
|
NAME
|
||||||
latency_tracker_test
|
latency_tracker_test
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
#include "gtest/gtest.h"
|
#include "gtest/gtest.h"
|
||||||
#include <QChar>
|
#include <QChar>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <libcockatrice/utility/cryptoutil.h>
|
||||||
#include <libcockatrice/utility/passwordhasher.h>
|
#include <libcockatrice/utility/passwordhasher.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
|
@ -39,6 +41,204 @@ TEST(PasswordHashTest, TokenHasExpectedLength)
|
||||||
const QString token = PasswordHasher::generateActivationToken();
|
const QString token = PasswordHasher::generateActivationToken();
|
||||||
ASSERT_EQ(token.size(), 16);
|
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
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char **argv)
|
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"));
|
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)
|
TEST_F(SettingsDefaultsTest, Chat_MentionForeground_Default)
|
||||||
{
|
{
|
||||||
ChatSettings s(settingsPath, nullptr);
|
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