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