Compare commits

..

13 commits

Author SHA1 Message Date
Lukas Brübach
84ad1185b6
[Client] Settings: match CI clang-format-18 line breaking 2026-10-16 22:57:40 +02:00
Lukas Brübach
232b302c73
[Client] Settings: extract SettingsView from DlgSettings
Move the search bar, tab bar, stacked pages and search results out of
DlgSettings into a reusable SettingsView widget. The dialog becomes a
thin frame (title, OK button, close validation) delegating to the view.
No visual or behavior change; tr() contexts preserved as DlgSettings.
2026-10-16 22:57:40 +02:00
BruebachL
a139084ad6
[Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords (#7193)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords

Challenge-response authentication: the client derives a scrypt verifier
(RFC 7914, EVP_PBE_scrypt, N=32768, r=8, p=1) and authenticates with
HMAC-SHA256(key, nonce), so neither the password nor its hash is
transmitted. The stored format becomes
"$scrypt$<n>$<r>$<p>$<salt>$<verifier>" and Response_PasswordSalt
now carries the cost parameters. Strict servers only accept scrypt
verifiers; legacy accounts are migrated after a successful login.

Fix #344 for challenge-response servers: a saved profile stores the
derived verifier under the password key instead of the plaintext password.
The connect dialog loads it without revealing it, autoconnect passes it
through, the change-password dialog no longer prefills the old password
field with it, and the client only persists the verifier when
"Save password" is checked.

Took 3 minutes

Took 1 minute

Took 10 seconds

Took 7 minutes

* [Security] Harden credential format acceptance and challenge nonce validation

* [Security] Refuse KDF downgrade; trim profile name and warn on silent credential drop

* [Security] Add SupportsAll ServerOptions enumerator to fix enum validation assert

The server advertised both SupportsPasswordHash (1) and
SupportsChallengeResponseAuth (2) via a bitwise-OR that produced the
value 3, which no enumerator declared. Protobuf's proto2 enum
validation rejects the value in _internal_set_server_options, crashing
servatrice on the debug assertion. Add a SupportsAll = 3 enumerator with
an identical wire value so the bitmask reads unchanged while the value
is now a documented, valid member of the enum.

* [Security] Normalize username when issuing challenge nonce

cmdRequestPasswordSalt bound the challenge nonce to the raw, un-simplified
username while cmdLogin verifies it against the simplified name. A user
who typed leading or trailing whitespace in the username box would be
silently rejected during challenge-response login even with the correct
password. Simplify at the nonce-issuing site so both commands agree on
the normalized name.

Also drive-by formatting: collapse the now single-expression
serverOptions initializer onto one line.

* [Security] Keep an encrypted legacy password backup for old-client logins

Migrating an account to scrypt stranded pre-challenge clients, which can
only present the legacy salt+hash: after migration the server served them
a scrypt salt they cannot use, so the only recovery was a password reset.

When security/legacy_backup_key is configured (64 hex chars,
openssl rand -hex 32), submitPasswordVerifier now seals the legacy
credential with AES-256-GCM under that key before overwriting the row,
storing it in password_legacy_backup with the time it was last used. Old
clients keep working: cmdRequestPasswordSalt serves the backup's 16-char
salt in password_salt so they can derive the legacy hash they were
always able to compute, while new clients derive the verifier from the
new scrypt_salt field, which always carries the verifier salt. The
non-challenge login path accepts the plaintext or legacy hash against
the decrypted backup and refreshes its window; a migrated row with no
usable backup fails closed.

Backups age out so a database dump taken after the window closes never
carries a crackable legacy hash: getUsableLegacyBackup purges on first
use past security/legacy_backup_ttl_days (default 90, clamped 1..3650),
and a six-hourly sweep drops backups idle accounts never touch.
changeUserPassword clears the backup since a new credential invalidates
it. Without a configured key nothing is sealed or purged, so a
temporarily missing key cannot destroy existing backups.

The AAD is the simplified user name on both sides, sealing the blob to
its row so it cannot be moved between accounts. Sealing failures
degrade to the previous lockout behaviour rather than blocking
migration. The columns extend the unmerged 0036->0037 migration, so the
schema version stays 37.

* [Security] Address review: proto compat, plaintext verify, AAD case, strict scope

Fix the two blockers and follow-ups from the 2026-10-16 review:

- Advertise challenge-response in a new supports_challenge_response
  field instead of the ServerOptions enum and drop SupportsAll:
  proto2 enums are closed, so already-released clients parsed
  server_options = 3 as NoOptions and fell back to plaintext
  passwords. server_options stays exactly SupportsPasswordHash
  on the wire; old clients ignore the new field.
- Verify a plaintext password directly against a migrated scrypt
  row with verifyPassword() so email changes (cmdAccountEdit)
  and old clients sending password keep working without a
  legacy backup key. Pre-hashed values are still checked
  against the backup, and its TTL window is refreshed only
  when the backup is what actually matched.
- Use the stored row name as the AES-GCM AAD on both seal
  and open so a login whose case differs from the stored
  name (utf8mb4_unicode_ci) still opens its backup.
- Require challenge-response in strict mode only for
  registered SQL accounts (userExists gate), leaving
  guest/unregistered logins and none/password auth
  methods unaffected.
- Document in servatrice.ini.example that a captured
  legacy hash can take over a not-yet-migrated account
  and that strict only constrains registered SQL
  accounts. Schema version is 38.

* [Security] Raise legacy backup TTL default to 180 days

Gives operators and slow-to-upgrade users a longer downgrade
window for pre-challenge clients before unused encrypted
legacy password backups age out.

* [Security] Fix servatrice.cpp formatting per clang-format-18

* [Security] Add missing forward declaration for Command_SubmitPasswordVerifier

The cmdSubmitPasswordVerifier declaration references the proto-generated
type, which otherwise does not name a type in this header and breaks
every C++ build.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:52:26 +02:00
BruebachL
155f7b0143
[Servatrice] Relay room channel messages over ISL (#7480)
* [Servatrice] Relay room channel messages over ISL

* [Servatrice] Skip audit log for unknown ISL channel per review

- externalRoomSay drops messages for channels not in the local
  catalog, matching Server_Room::say, instead of logging what was
  never delivered

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:46:40 +02:00
BruebachL
ce51e7147d
[Servatrice] Log room channel messages in audit log (#7479)
* [Servatrice] Log room channel messages and bump schema to 38

* Fixup: show room_channel audit rows and keep migration nullable

The log history query returns room_channel rows, but the log tab
dropped them. Render them in the room table with an extra Channel
column. Also drop the NOT NULL on target_type from the 37->38
migration so migrated schemas match the nullable servatrice.sql
definition (and strict-mode ALTERs cannot fail on legacy NULL rows).
Update the channel tests to the flag-based privilege model: the admin
fixture now carries IsModerator alongside IsAdmin, as the database
layer assigns.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:46:39 +02:00
BruebachL
c99113ab0e
[Client] Tabbed room channels with unread states and collapsible chat (#7478)
* [Client] Add room channel selector with per-channel chat views

* [Client] Tabbed room channels with unread states, collapse, read-only, banners

Reworks the room chat channel display: channels move out of the combobox
into a tab bar below the message input showing every channel available
to the user.

- Each tab shows its unread message count and is tinted when it holds
  unreads or a mention; both colors are user-configurable chat settings
  (channelUnreadColor, channelMentionColor) with a settings-page section.
- Clicking the active tab collapses the chat: message view and input
  hide and the splitter section minimizes to the tab strip; expanding
  restores the previous splitter sizes. Viewing a channel clears its
  unread state.
- Channels the user may read but not write (per write_access_level)
  show with the message input disabled and a read-only placeholder;
  sends are guarded locally, the server still enforces.
- Channel banner_message strings render at the top of their channel
  view on join, like the room join message on Main.
- Replayed channel history no longer inflates unread counts; moderation
  redaction now applies across all channel views.

* Fixup: keep chat tab bar visible when chat is collapsed

QSplitter children are collapsible by default, so setSizes({1, 0})
hid the whole chat group box including the channel tab bar.
Mark the chat widget non-collapsible so collapsing clamps to its
minimum size (title + tab bar) instead.

* [Room] Explicitly initialize MAIN_CHANNEL per review

* [Room] Address channel tab review follow-ups

- gate @all mentions on moderator/admin sender to match ChatView,
  whole-word matching for own name and highlight words
- hide single-channel tab bar so old servers do not show a lone Main
  tab that collapses chat on misclick
- capture target ChatView in sendMessage so errors land in the
  channel the message was sent from
- share ignore/unregistered filtering between RoomSay and
  RoomChannelSay via isRoomMessageIgnored

* [Client] Fix tab_room.cpp formatting per clang-format-18

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:46:39 +02:00
BruebachL
05dcde69db
[Server] Gate room channels on privilege levels (#7477)
* [Server] Gate room channels on privilege levels

* [Server] Filter channel catalog per user, split read/write gating, add banners

- Server_Room::getInfo() now takes the requesting user and only lists
  channels that user may read. Previously every room member received the
  full catalog (e.g. the moderators channel) while message delivery was
  already gated, so restricted channels were visible but silent.
- Split channel access into mayReadChannel/mayWriteChannel: history
  replay, catalog listing, and event fan-out use the read level
  (access_level); Command_RoomChannelSay uses the write level
  (write_access_level, falling back to the read level when unset).
- Plumb write_access_level and banner_message through both room sources
  (SQL rooms_channels table and config-file channels via writeaccess
  and banner keys).
- Schema 38 -> 39: add write_access_level (NULL means inherit the read
  level) and banner_message columns; also widen access_level to the
  full six-value privilege enum to match the 37 -> 38 migration.

* Fixup: evaluate channel access per user flag instead of ladder

User flags are independent bits: IsDeveloper does not imply
IsModerator/IsAdmin, so developer-only users must not reach
moderator/admin channels. Replace the userPrivilegeLevel ladder with
hasChannelPrivilege: moderator channels need IsModerator (admins hold
it alongside IsAdmin), admin channels need IsAdmin, developer channels
need IsDeveloper or IsAdmin, judge channels admit IsJudge/IsModerator.
Covers catalog filtering, history replay, write gating and fan-out,
and corrects the ini example and proto docs.

* [Server] Make channel privilege helpers static per review

* [Server] Fail closed on unknown channel access levels per review

- roomChannelAccessFromKey returns nullopt for unknown keywords
  instead of silently falling back to Public
- skip channels with unknown read/write levels with qWarning, empty
  read defaults to Public, empty write means no override
- mayWriteChannel now also requires read access so write-below-read
  configs cannot post into invisible channels

* [Server] Remove stray conflict marker in channel replay comment

* [Server] Fix servatrice.cpp formatting per clang-format-18

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:46:38 +02:00
BruebachL
17d2e8d86e
[Server] Add room chat channels with per-channel fan-out and history (#7476)
* [Server] Add room chat channels with per-channel fan-out and history

* Fixup: redact all channel histories on message removal

removeSaidMessages only redacted the main history, so banned/warned
content stayed visible to late joiners on every other channel.
Drop the unused channelId parameter and redact up to amount messages
per channel across the whole channelChatHistory map.

* [Server] Validate room channel ids and fix catalog schema per review

- servatrice.sql: expand rooms_channels access_level enum to full set
  to match 0037->0038 migration
- servatrice.cpp: skip empty, reserved (__main__) and duplicate channel
  ids when loading from ini and DB with qWarning
- server_protocolhandler.cpp: clarify catalog replay comment (filtering
  happens in moderator stack)
- server_room: drop duplicate channelIndex, linear search preserves
  catalog order

* [Server] Fix server_room.cpp formatting per clang-format-18

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:46:38 +02:00
BruebachL
be38e8d429
[Protocol] Add room chat channel extensions and catalog payload (#7475)
* [Protocol] Add room chat channel extensions and catalog payload

* [Protocol] Add per-channel write level and banner to room channel catalog

Adds two optional fields to ServerInfo_RoomChannel:

- write_access_level: minimum privilege level required to send to the
  channel. When unset, writing requires the same level as reading
  (access_level), preserving the previous single-level behavior. This
  allows read-mostly channels such as a developer news feed: readable
  by everyone, writable only by developers.
- banner_message: static notice shown at the top of the channel,
  analogous to the room join message on Main. Empty means none.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:46:37 +02:00
BruebachL
6c660d6b21
[Installer][Client] Back up user data before old uninstallers can delete it (#7503)
* [Installer][Client] Back up user data before old uninstallers can delete it

* [Installer][Client] Fix stale backup resurrection, batch session backups, extend coverage

NSIS: clear stale %TEMP% backup and only arm restore when something was copied, directory-existence checks, back up loose .dec decks, drop downloaded pics but keep pics\CUSTOM.

Helper: single session dir per run (ms timestamps), same-basename disambiguation, partial copies still report their location.

* [Installer][Client] Fix review findings: throttle backups, NSIS old dir, portable messaging

* [Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap

* [Installer][Client] Fix review findings: sounds, session coverage, NSIS wording

Client: back up data/sounds in portable mode and mention it in the
warning; reuse a recent session only when complete and covering the
current sources (source manifest), estimate size only for fresh copies,
normalize dedup via isInsideDir.

Helper: source manifest per session with sessionCoversSources(), clear
out-params on all failure paths.

NSIS: document defensive portable branch, merge backup over surviving
data/ to repair partial deletes, copy (not move) wording in relocate,
note .txt exclusion in deck notice.

* [Installer][Client] Fix review findings: stale backup, empty reuse, relocate, portable warning

NSIS: wipe TEMP backup once per run to avoid resurrecting deleted files,
check data dir existence in relocate so dir-only trees are moved.

Client: never reuse a recent session for an empty source set, only fall
back to a covering session on copy failure, warn fresh portable installs
instead of staying silent.

* [Installer][Client] Fix review findings: usable session reuse, loose decks, NSIS pics, symlinks

* [Installer][Client] Fix portable backup exclusions in offender merge

* [Client] Split at-risk data warning into focused helpers

Ricky's nit on the 200+ line warnAboutDataPathsInProgramDir: extract
collection (portable/installed), backup, message building and dialog
into named helpers. No behavior change, all tr() strings untouched.

* [Client] Read path settings inside isPortableBackupExcluded

* [Client] Make stateless at-risk helpers static

* [Client] Extract at-risk data warning out of MainWindow

* [Installer][Client] Fix review findings: subdir backup, portable warning, backup API

NSIS:
- Back up unknown top-level install subdirs (e.g. <installdir>\decks),
  not just loose deck files, so the 3.1.1 -> new upgrade no longer loses
  data folders the old RMDir /r deletes before the client ever runs.
  Payload dirs (plugins/translations/sounds/themes) stay in place to avoid
  restoring stale DLLs; restore + relocate handle the new backup set and
  the one-time notice covers both decks and folders.
- Restart the %TEMP% backup when the 32/64-bit passes point at different
  dirs instead of merging (which restored one dir's files into the other);
  document that only the last pass relocates and the %TEMP% copy is not
  a durable second copy.

Client:
- Drop the portable at-risk warning: portable installs never shipped an
  uninstaller and portable updates preserve data/, so every portable user
  got a spurious dialog plus daily host-machine copies.
- AtRiskDataWarning is now a plain class with Q_DECLARE_TR_FUNCTIONS
  instead of QObject+Q_OBJECT; logging category renamed to
  at_risk_data_warning; MainWindow wrapper inlined into startupConfigCheck.
- Lower the synchronous startup backup cap 500 -> 200 MB.

Utility/tests:
- Remove test-only backupAtRiskPath()/findRecentBackupSession() and the
  no-marker/no-manifest legacy paths; isBackupSessionComplete() now
  requires the complete marker; tests updated to the plural API.

* [Client] Restore collectInstalledAtRiskPaths per review

a23e02b5 removed the definition along with the portable helpers,
breaking the build (undefined reference at link time, and
isInsideAnyDir unused under -Werror on Arch/Debian 13). Restores
the 202c0d65 version (pathsInsideDir plus loose top-level deck
files) as suggested.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 22:05:42 +02:00
BruebachL
711386574d
[Client] Tell the user when their playmat art is blocked (#7533)
* [Client] Tell the user when their playmat art is blocked

A server DENY rule silently drops banned playmat art: SetPlaymat fails
with no handler and DeckSelect strips the mat while answering RespOk.

Surface both cases with the same critical message box the banner card
art flow uses. DeckSelect strips are detected by comparing the
requested mat, passed via PendingCommand extraData, against the
server-echoed deck. Notices dedupe per card and reset on success.

* [Client] Gate playmat blocked notice on RespFunctionNotAllowed per review

Only SetPlaymat with RespFunctionNotAllowed signals a card art DENY
rule. Older servers answer RespInvalidCommand and strip the mat from
DeckSelect, so drop the DeckSelect comparison to avoid false
blacklisted popups on pre-playmat servers.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@users.noreply.github.com>
2026-10-16 19:50:57 +02:00
BruebachL
b11b096828
[Server] Apply card art rules to playmats (#7532)
* [Server] Apply card art rules to playmats

Card art DENY rules were only enforced for user card art
(SetCardArtParams) while playmats using the same art bypassed them.

Move the rule lookup to Servatrice_DatabaseInterface::isCardArtAllowed
with a default-allow base on Server_DatabaseInterface, delegate the
session check to it, reject DENY-listed Command_SetPlaymat with
RespFunctionNotAllowed, and strip DENY-listed deck playmats on
DeckSelect so banned art is never broadcast.

* [Server] Address review nits on playmat card art rules

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 19:50:43 +02:00
BruebachL
bbe438b09a
[Completer] Highlight popup rows without rewriting the line edit (#7502)
* [Completer] Highlight popup rows without rewriting the line edit

Selecting a popup row with ClearAndSelect emits selectionChanged, which
QCompleter turns into highlighted() and QLineEdit turns into setText().
Since be2c16eca5 (#7466) that rewrites the typed text on every completion
reset / arrow-key press (e.g. typing "A" becomes "Axe" and cannot be
edited), regressing the 514014dc00 (#7447) fix that used NoUpdate.

Add selectWithoutRewrite(), which blocks the selection-model signals while
applying ClearAndSelect so the highlight stays visible without touching
the line edit, then syncs the preview manually. Route both the
above-popup auto-select and Up/Down navigation through it, and handle
Up/Down for below-popups too instead of falling through to Qt's
rewriting default. Enter/click acceptance via activated() is unchanged.

* [Completer] Repaint popup highlight after silent selection change

Blocking the selection model's signals also suppresses the view's own
selectionChanged/currentChanged repaints, so force a viewport update
after restoring signals. Use EnsureVisible to match Qt's normal scroll
behaviour.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 19:01:38 +02:00
74 changed files with 4940 additions and 215 deletions

View file

@ -15,8 +15,17 @@ Var ReinstallMode
Var AllowForceClose
Var GuardDir
Var OldUninstallString
Var OldInstallDir
Var DataBackupDir
Var DataBackupDone
Var DataBackupForDir
Var LooseDecksBackupDone
Var InstallSubdirsBackupDone
Var LooseDeckNoticeShown
Var StaleFindHandle
Var StaleFindName
Var DataBackupFindHandle
Var DataBackupFindName
Var IsUpgrade
Var AbortReason
Var WaitPid
@ -125,6 +134,17 @@ FunctionEnd
Function .onInit
StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log"
; No backup has been taken yet; RestoreUserDataAfterUninstall must be a no-op
; until BackupUserDataBeforeUninstall sets this after actually copying something.
StrCpy $DataBackupDir ""
StrCpy $DataBackupDone ""
StrCpy $DataBackupForDir ""
StrCpy $LooseDecksBackupDone ""
StrCpy $InstallSubdirsBackupDone ""
StrCpy $LooseDeckNoticeShown ""
; No old install location resolved yet; Backup/Restore fall back to $INSTDIR
; when this is empty (the /R path where $INSTDIR already is the old dir).
StrCpy $OldInstallDir ""
; Start each run from a clean file so the log that gets sent back is the last
; run, not every past run glued together.
FileOpen $LogHandle "$InstallLogPath" w
@ -314,10 +334,15 @@ SetShellVarContext all
SetRegView 32
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'"
; On the /R path the updater passes /D=<appDir>, so $INSTDIR already is the old
; install dir. Publish it for the backup/restore pair below.
StrCpy $OldInstallDir $INSTDIR
!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$OldInstallDir'"
StrCmp $R0 "" done32
DetailPrint "Removing previous version (32-bit)..."
ExecWait '$R0 _?=$INSTDIR'
Call BackupUserDataBeforeUninstall
ExecWait '$R0 _?=$OldInstallDir'
Call RestoreUserDataAfterUninstall
done32:
@ -326,16 +351,312 @@ ${If} ${RunningX64}
SetRegView 64
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
!insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'"
StrCpy $OldInstallDir $INSTDIR
!insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'"
StrCmp $R0 "" done64
DetailPrint "Removing previous version (64-bit)..."
ExecWait '$R0 _?=$INSTDIR'
Call BackupUserDataBeforeUninstall
ExecWait '$R0 _?=$OldInstallDir'
Call RestoreUserDataAfterUninstall
done64:
${EndIf}
FunctionEnd
; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall
;
; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR".
; For a portable install that deletes $INSTDIR\data (decks, settings, pictures)
; along with the payload, and for a normal install it deletes anything the user
; pointed into $INSTDIR. The fixed uninstaller preserves foreign files, but the
; destructive one is already on disk on affected machines, so stash a copy
; outside $INSTDIR before invoking it and put it back afterwards. Best effort
; only: a failed backup must never abort the install.
;
; The backup is left in %TEMP% afterwards on a best-effort basis only: the next
; installer run that takes a new backup wipes it first (see below), so it is not
; a durable second copy. Anything this backup does not cover - e.g. loose files
; with other extensions - relies on the client's startup safety backup
; (warnAboutDataPathsInProgramDir), which only exists after the first launch of
; the new client.
;
; The 32-bit and 64-bit uninstall blocks below share $TEMP\CockatriceDataBackup.
; When both are registered with different InstallLocations, backing up the second
; directory into the first one's backup would restore the first dir's files into
; the second one. BackupUserDataBeforeUninstall therefore starts over when
; $OldInstallDir changes: the previous pass already restored its own backup, so
; only its leftover %TEMP% copy is lost. RelocateRestoredDataToInstDir only sees
; the last pass, so with two different old dirs plus a new target dir the first
; dir's restored data stays in place instead of moving along - no loss, but the
; user has to copy it manually.
;
; The portable-data branch below is defensive: both callers (AutoUninstallIfNeeded
; on /R, componentsPagePre interactively) only run for non-portable installs, and
; portable installs never write the registry uninstall keys that trigger them.
; It fires only when $OldInstallDir nevertheless contains portable.dat - e.g. a
; stale registry InstallLocation pointing at a portable dir, or portable.dat added
; to a normal install dir. The loose-decks and install-subdirs branches are the
; live ones for normal installs.
Function BackupUserDataBeforeUninstall
; $OldInstallDir is the directory being uninstalled: on the /R path it equals
; $INSTDIR (updater passes /D=<appDir>), on the interactive path componentsPagePre
; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is
; still the default there (directory page comes later).
${If} $OldInstallDir == ""
StrCpy $OldInstallDir $INSTDIR
${EndIf}
; %TEMP% survives across installer runs, so a previous run's safety copy would
; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files
; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B
; from run 2) or restoring stale content after a failed copy. Wipe once per
; installer run - $DataBackupDir is empty until the first backup of this run,
; so the second call in sequence (32-bit then 64-bit uninstall) still merges.
${If} $DataBackupDir == ""
RMDir /r "$TEMP\CockatriceDataBackup"
${EndIf}
; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second
; block points at a different directory, merging would restore the first dir's
; files into the second one, so start over instead. The previous pass already
; restored its own backup; only its leftover %TEMP% copy is discarded.
; StrCmp is case-sensitive while Windows paths are not, so a casing-only
; difference triggers a harmless redundant re-backup of the same content.
${If} $DataBackupDir != ""
${AndIf} $DataBackupForDir != ""
${AndIf} $OldInstallDir != $DataBackupForDir
!insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup"
RMDir /r "$TEMP\CockatriceDataBackup"
StrCpy $DataBackupDir ""
StrCpy $DataBackupDone ""
StrCpy $LooseDecksBackupDone ""
StrCpy $InstallSubdirsBackupDone ""
${EndIf}
${If} ${FileExists} "$OldInstallDir\portable.dat"
${AndIf} ${FileExists} "$OldInstallDir\data"
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
StrCpy $DataBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)"
DetailPrint "Backing up portable user data before removing previous version..."
CreateDirectory "$DataBackupDir\data"
; Copy every top-level entry except pics (downloaded pictures are large and
; re-downloadable): copying everything first and deleting pics afterwards
; would duplicate gigabytes into %TEMP% just to throw them away.
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*"
${If} $DataBackupFindHandle != ""
backup_data_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} $DataBackupFindName != "pics"
${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*"
CreateDirectory "$DataBackupDir\data\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\"
${Else}
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\"
${EndIf}
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 backup_data_loop
FindClose $DataBackupFindHandle
${EndIf}
; Keep user-made custom pics (data\pics\CUSTOM).
${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM"
CreateDirectory "$DataBackupDir\data\pics\CUSTOM"
CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\"
${EndIf}
DetailPrint "Portable user data backup complete."
${EndIf}
; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a
; blanket *.txt copy would also grab payload-adjacent text files, while the
; deck-specific extensions are unambiguous user content.
${If} ${FileExists} "$OldInstallDir\*.cod"
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
${If} $DataBackupDir == ""
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
${EndIf}
StrCpy $LooseDecksBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying loose deck files"
DetailPrint "Backing up deck files stored in the install directory..."
CreateDirectory "$DataBackupDir\loose_decks"
CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\"
${EndIf}
; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the
; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the
; client's startup backup has never run yet, so stash every top-level directory
; that is not part of the payload. Payload dirs stay in place: they are rewritten
; by the section below, and restoring them could put stale DLLs next to the new
; executables. "data" is skipped once the portable branch above already took it;
; without portable.dat it is user content like any other unknown dir.
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*"
${If} $DataBackupFindHandle != ""
backup_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} $DataBackupFindName != "plugins"
${AndIf} $DataBackupFindName != "Plugins"
${AndIf} $DataBackupFindName != "translations"
${AndIf} $DataBackupFindName != "sounds"
${AndIf} $DataBackupFindName != "themes"
${If} $DataBackupFindName == "data"
${AndIf} $DataBackupDone == "1"
Goto backup_subdirs_next
${EndIf}
${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
${If} $DataBackupDir == ""
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
${EndIf}
StrCpy $InstallSubdirsBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName"
DetailPrint "Backing up $DataBackupFindName stored in the install directory..."
CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\"
${EndIf}
${EndIf}
backup_subdirs_next:
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 backup_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
; Remember which directory the current backup belongs to, so a later pass for a
; different directory starts over instead of merging (see above).
${If} $DataBackupDir != ""
StrCpy $DataBackupForDir $OldInstallDir
${EndIf}
FunctionEnd
Function RestoreUserDataAfterUninstall
StrCmp $DataBackupDir "" done
${If} $OldInstallDir == ""
StrCpy $OldInstallDir $INSTDIR
${EndIf}
${If} $DataBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\data"
${If} ${FileExists} "$OldInstallDir\data"
; Existence-only: a partially-deleted data dir (locked files during the old
; uninstall) looks the same as a survived one, so the backup is always kept.
; Merge the backup back over it to repair partial deletes; CopyFiles only
; adds/overwrites, so a fully-survived dir is unaffected.
!insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it"
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data."
${Else}
!insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data"
CreateDirectory "$OldInstallDir\data"
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
DetailPrint "Restored portable user data from pre-uninstall backup."
${EndIf}
${EndIf}
${If} $LooseDecksBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\loose_decks"
!insertmacro CockLog "post-uninstall restore: restoring loose deck files"
CreateDirectory "$OldInstallDir"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\"
DetailPrint "Restored deck files stored in the install directory."
${EndIf}
${If} $InstallSubdirsBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
!insertmacro CockLog "post-uninstall restore: restoring install subdirectories"
CreateDirectory "$OldInstallDir"
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
${If} $DataBackupFindHandle != ""
restore_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*"
!insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName"
CreateDirectory "$OldInstallDir\$DataBackupFindName"
CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\"
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 restore_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
DetailPrint "Restored folders stored in the install directory."
${EndIf}
; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the
; notice a single time per installer run, covering both loose decks and
; restored subdirectories.
${IfNot} ${Silent}
${AndIf} $LooseDeckNoticeShown != "1"
${If} $LooseDecksBackupDone == "1"
${OrIf} $InstallSubdirsBackupDone == "1"
StrCpy $LooseDeckNoticeShown "1"
MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again."
${EndIf}
${EndIf}
done:
FunctionEnd
; RelocateRestoredDataToInstDir
;
; componentsPagePre backs up and restores around the old uninstaller before the
; directory page, so $OldInstallDir (registry InstallLocation) is authoritative
; there while $INSTDIR is still the default. When the user then picks a different
; target directory, the restored user data would otherwise stay orphaned in the
; old directory while the new install starts empty. Copy it across when they differ
; (a copy, not a move: the source is user data and is deliberately left in place).
; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup
; comment above); with two different old dirs the first one's data stays restored
; in place instead of moving along.
; StrCmp is case-sensitive while Windows paths are not; a casing-only difference
; triggers a harmless redundant copy onto itself.
Function RelocateRestoredDataToInstDir
StrCmp $DataBackupDir "" done
StrCmp $OldInstallDir "" done
StrCmp $OldInstallDir $INSTDIR done
${If} $DataBackupDone == "1"
${AndIf} ${FileExists} "$OldInstallDir\data"
!insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data"
CreateDirectory "$INSTDIR\data"
CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\"
DetailPrint "Copied restored user data to the new install folder."
${EndIf}
${If} $LooseDecksBackupDone == "1"
${If} ${FileExists} "$OldInstallDir\*.cod"
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
!insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR"
CreateDirectory "$INSTDIR"
CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\"
DetailPrint "Copied restored deck files to the new install folder."
${EndIf}
${EndIf}
${If} $InstallSubdirsBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
${If} $DataBackupFindHandle != ""
relocate_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
!insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName"
CreateDirectory "$INSTDIR\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\"
DetailPrint "Copied restored folder $DataBackupFindName to the new install folder."
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 relocate_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
${EndIf}
done:
FunctionEnd
; --- Running instance handling ---
; Cockatrice, Oracle and Servatrice must not be running while files are
; replaced or deleted. A still-running process holds locks on its .exe and
@ -1037,11 +1358,17 @@ ${If} $PortableMode = 0
; instance of the application is still running out of the *old* install
; directory before letting it delete anything. The uninstall string is
; stashed away first, the check works on $R0.
; $INSTDIR is still the default here (directory page comes later), so the
; backup/restore dir comes from the registry InstallLocation via $GuardDir,
; not from $INSTDIR - otherwise a custom-location install is unprotected.
StrCpy $OldUninstallString $R0
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'"
Call EnsureOldInstallNotRunning
StrCpy $OldInstallDir $GuardDir
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
ClearErrors
ExecWait "$OldUninstallString _?=$INSTDIR"
Call BackupUserDataBeforeUninstall
ExecWait "$OldUninstallString _?=$OldInstallDir"
Call RestoreUserDataAfterUninstall
done32:
@ -1061,10 +1388,13 @@ ${If} $PortableMode = 0
uninst64:
StrCpy $OldUninstallString $R0
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'"
Call EnsureOldInstallNotRunning
StrCpy $OldInstallDir $GuardDir
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
ClearErrors
ExecWait "$OldUninstallString _?=$INSTDIR"
Call BackupUserDataBeforeUninstall
ExecWait "$OldUninstallString _?=$OldInstallDir"
Call RestoreUserDataAfterUninstall
done64:
${EndIf}
@ -1188,6 +1518,11 @@ ${EndIf}
!insertmacro CockLog "section: writing payload to $INSTDIR"
@CPACK_NSIS_FULL_INSTALL@
!insertmacro CockLog "section: payload written"
; The pre-uninstall backup/restore above runs before the directory page, so a
; user picking a different target folder would otherwise leave the restored data
; orphaned in the old directory. Move it across when they differ (no-op on the
; /R path where they are equal).
Call RelocateRestoredDataToInstDir
!insertmacro CockLogListing "final" "Qt6*.dll"
${If} $PortableMode = 0

View file

@ -142,6 +142,8 @@ set(cockatrice_SOURCES
src/game_graphics/zones/view_zone.cpp
src/game_graphics/zones/view_zone_widget.cpp
src/game_graphics/board/abstract_graphics_item.cpp
src/interface/at_risk_data_warning.cpp
src/interface/at_risk_data_warning.h
src/interface/card_database_update/card_database_update_status_bar.cpp
src/interface/card_database_update/card_database_update_status_bar.h
src/interface/card_database_update/card_update_progress.cpp

View file

@ -95,6 +95,9 @@ void ConnectionController::wireClientSignals()
connect(remoteClient, &RemoteClient::sigPromptForForgotPasswordChallenge, this,
&ConnectionController::onPromptForgotPasswordChallenge);
connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this,
&ConnectionController::onPasswordVerifierReady);
}
void ConnectionController::connectToServer()
@ -103,16 +106,32 @@ void ConnectionController::connectToServer()
connect(dlgConnect, &DlgConnect::sigStartForgotPasswordRequest, this, &ConnectionController::forgotPasswordRequest);
if (dlgConnect->exec()) {
pendingSaveName = dlgConnect->getSaveName();
pendingSavePassword = dlgConnect->getSavePassword();
remoteClient->setStoredVerifier(dlgConnect->getStoredVerifier());
remoteClient->connectToServer(dlgConnect->getHost(), static_cast<unsigned int>(dlgConnect->getPort()),
dlgConnect->getPlayerName(), dlgConnect->getPassword());
}
}
void ConnectionController::onPasswordVerifierReady(const QString &verifier)
{
if (pendingSavePassword) {
SettingsCache::instance().servers().setServerPassword(pendingSaveName, verifier);
}
}
void ConnectionController::connectToServerDirect(const QString &host,
unsigned int port,
const QString &playerName,
const QString &password)
const QString &password,
const QString &storedVerifier,
const QString &saveName,
bool savePassword)
{
pendingSaveName = saveName;
pendingSavePassword = savePassword;
remoteClient->setStoredVerifier(storedVerifier);
remoteClient->connectToServer(host, port, playerName, password);
}

View file

@ -40,8 +40,13 @@ public:
void registerToServer();
void forgotPasswordRequest();
void connectToServer();
void
connectToServerDirect(const QString &host, unsigned int port, const QString &playerName, const QString &password);
void connectToServerDirect(const QString &host,
unsigned int port,
const QString &playerName,
const QString &password,
const QString &storedVerifier = QString(),
const QString &saveName = QString(),
bool savePassword = false);
void disconnectFromServer();
void refreshWindowTitle()
@ -86,6 +91,9 @@ private slots:
void onPromptForgotPasswordReset();
void onPromptForgotPasswordChallenge();
// Persists the derived scrypt verifier after a successful challenge-response login
void onPasswordVerifierReady(const QString &verifier);
private:
void wireClientSignals();
void updateWindowTitle();
@ -102,6 +110,10 @@ private:
// Kept as a member so the forgot-password signal can be wired to it
DlgConnect *dlgConnect{nullptr};
// Captured from the connect dialog when a connection is initiated
QString pendingSaveName;
bool pendingSavePassword{false};
};
#endif // COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H

View file

@ -354,6 +354,19 @@ void DeckViewContainer::resolveAndSendPlaymat()
pp->set_vertical_offset(resolved.params.verticalOffset);
pp->set_zoom(resolved.params.zoom);
PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd);
const QString sentName = resolved.card.name;
connect(playmatPend, &PendingCommand::finished, this, [this, sentName](const Response &r) {
// Only RespFunctionNotAllowed signals a card art DENY rule. Older servers that predate
// playmats answer RespInvalidCommand and strip the mat from DeckSelect, which must stay silent.
if (r.response_code() == Response::RespFunctionNotAllowed) {
if (!sentName.isEmpty()) {
notifyPlaymatBlacklisted(
sentName, tr("The selected playmat card is blacklisted on this server, so it was not applied."));
}
} else if (r.response_code() == Response::RespOk && !sentName.isEmpty()) {
lastBlacklistedPlaymatNotice.clear();
}
});
parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId);
}
@ -412,6 +425,15 @@ void DeckViewContainer::deckSelectFinished(const Response &r)
switchToDeckLoadedView();
}
void DeckViewContainer::notifyPlaymatBlacklisted(const QString &cardName, const QString &message)
{
if (cardName.isEmpty() || cardName == lastBlacklistedPlaymatNotice) {
return;
}
lastBlacklistedPlaymatNotice = cardName;
QMessageBox::critical(this, tr("Error"), message);
}
void DeckViewContainer::readyStart()
{
sendReadyStartCommand(!readyStartButton->getState());

View file

@ -60,12 +60,14 @@ private:
VisualDeckStorageWidget *visualDeckStorageWidget;
TabGame *parentGame;
int playerId;
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
DeckList currentDeck; ///< Cached deck for live settings re-resolution.
PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
QString lastBlacklistedPlaymatNotice; ///< Card name already reported as blacklisted, to avoid repeat popups.
void tryCreateVisualDeckStorageWidget();
void sendReadyStartCommand(bool ready);
void notifyPlaymatBlacklisted(const QString &cardName, const QString &message);
private slots:
void switchToDeckSelectView();
void switchToDeckLoadedView();

View file

@ -0,0 +1,215 @@
#include "at_risk_data_warning.h"
#include "../client/settings/cache_settings.h"
#include <QCheckBox>
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QMessageBox>
#include <QWidget>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/user_data_backup.h>
Q_LOGGING_CATEGORY(AtRiskDataWarningLog, "at_risk_data_warning");
static bool isDeckFileSuffix(const QString &suffix)
{
return suffix == QStringLiteral("cod") || suffix == QStringLiteral("dec") || suffix == QStringLiteral("dek") ||
suffix == QStringLiteral("mwdeck");
}
static bool isInsideAnyDir(const QString &path, const QStringList &bases)
{
for (const QString &base : bases) {
if (PathsSettings::isInsideDir(path, base)) {
return true;
}
}
return false;
}
static bool hasLooseDeckFile(const QStringList &offenders)
{
for (const QString &offender : offenders) {
if (isDeckFileSuffix(QFileInfo(offender).suffix().toLower())) {
return true;
}
}
return false;
}
static QString resolveBackupRoot(const QString &appDir)
{
QString backupRoot = UserDataBackup::defaultBackupRoot();
if (PathsSettings::isInsideDir(backupRoot, appDir)) {
backupRoot = QDir::temp().absoluteFilePath(QStringLiteral("CockatriceBackups"));
}
return backupRoot;
}
QStringList AtRiskDataWarning::collectInstalledAtRiskPaths(const QString &appDir)
{
QStringList offenders = SettingsCache::instance().paths().pathsInsideDir(appDir);
// Loose decks sitting directly in the program folder are wiped by the old uninstaller
// just like configured paths. Mirror the installer backup set (DeckLoader extensions
// minus *.txt) so a manual run of the old uninstall.exe is covered too.
const QDir appDirHandle(appDir);
const QStringList topLevelFiles = appDirHandle.entryList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot);
for (const QString &fileName : topLevelFiles) {
if (!isDeckFileSuffix(QFileInfo(fileName).suffix().toLower())) {
continue;
}
const QString looseDeck = QDir::cleanPath(appDirHandle.absoluteFilePath(fileName));
if (!isInsideAnyDir(looseDeck, offenders)) {
offenders.append(looseDeck);
}
}
return offenders;
}
AtRiskDataWarning::AtRiskBackupOutcome AtRiskDataWarning::backUpAtRiskPaths(const QStringList &sources,
const QString &appDir)
{
AtRiskBackupOutcome outcome;
const QString backupRoot = resolveBackupRoot(appDir);
// Throttle: dismissing the dialog without "Don't show again" must not create
// a full duplicate on every launch. A recent session is reused only when it is
// complete and its source manifest covers the current source set; otherwise a
// fresh copy is taken so the dialog never claims an uncovered path is backed up.
// The size estimate runs only when a fresh copy is actually going to happen,
// keeping a synchronous tree walk off the reuse fast path at startup.
// Search all recent sessions for a complete, covering one: a partial failure must not
// pin the newest slot and force a full re-copy on every later launch.
const QString reusableSession = UserDataBackup::findRecentUsableBackupSession(backupRoot, sources, 24);
if (!reusableSession.isEmpty()) {
outcome.sessionDir = reusableSession;
outcome.locations = UserDataBackup::listSessionBackupLocations(reusableSession);
outcome.reused = true;
qCInfo(AtRiskDataWarningLog) << "Reusing recent at-risk user data backup at" << outcome.sessionDir;
} else if (!sources.isEmpty()) {
// The copy runs synchronously on the UI thread at startup, so cap it: a
// pictures folder pointed into the program dir can be gigabytes, and freezing
// the client to duplicate it is worse than asking for a manual copy.
outcome.bytes = UserDataBackup::estimateBackupSize(sources);
if (outcome.bytes >= UserDataBackup::MAX_AUTO_BACKUP_BYTES) {
outcome.skippedDueToSize = true;
qCWarning(AtRiskDataWarningLog)
<< "At-risk user data backup skipped: estimated" << outcome.bytes << "bytes exceeds automatic cap";
} else {
// One timestamped session directory for all sources, so a second boundary
// never splits a single startup check across two backup folders and
// same-named sources cannot overwrite each other.
outcome.complete =
UserDataBackup::backupAtRiskPaths(sources, backupRoot, &outcome.locations, &outcome.sessionDir);
UserDataBackup::pruneOldBackupSessions(backupRoot, 5);
if (outcome.locations.isEmpty()) {
// Fresh copy failed entirely: fall back to the newest covering session so the
// dialog still points at something, flagged incomplete below.
const QString fallbackSession =
UserDataBackup::findRecentCoveringBackupSession(backupRoot, sources, 24);
if (!fallbackSession.isEmpty()) {
outcome.sessionDir = fallbackSession;
outcome.locations = UserDataBackup::listSessionBackupLocations(fallbackSession);
outcome.complete = false;
}
}
}
}
for (const QString &backupLocation : outcome.locations) {
qCInfo(AtRiskDataWarningLog) << "Backed up at-risk user data to" << backupLocation;
}
if (!outcome.complete) {
qCWarning(AtRiskDataWarningLog) << "At-risk user data backup incomplete, session dir:" << outcome.sessionDir;
}
return outcome;
}
QString AtRiskDataWarning::buildInstalledAtRiskMessage(const QStringList &offenders,
const QStringList &sources,
const AtRiskBackupOutcome &outcome)
{
const QString backupVerb = outcome.reused ? tr("is already available") : tr("has been created");
const QString sizeInfo = tr("%1 MB (automatic backups are capped at %2 MB)")
.arg(QString::number(outcome.bytes / 1048576.0, 'f', 1),
QString::number(UserDataBackup::MAX_AUTO_BACKUP_BYTES / 1048576));
QString message = tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
"Cockatrice keeps its own data outside the program directory by default. Files "
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
"Change them in the Settings dialog - most are on the General page - to keep "
"them safe.")
.arg(offenders.join(QLatin1Char('\n')));
if (!outcome.locations.isEmpty()) {
message += tr("\n\nA safety backup %1 at:\n%2").arg(backupVerb, outcome.locations.join(QLatin1Char('\n')));
if (!outcome.complete) {
message += tr("\n\nThe backup may be incomplete; please back up these folders manually as well.");
}
if (hasLooseDeckFile(offenders)) {
message += tr("\n\nDeck files ending in .txt are not backed up - move those manually.");
}
} else if (outcome.skippedDueToSize) {
message += tr("\n\nThe automatic safety backup was skipped because of its size: %1. "
"Please back up these folders manually.")
.arg(sizeInfo);
} else if (sources.isEmpty()) {
message += tr("\n\nNone of these paths exist on disk yet, so there was nothing to back up. "
"Files created there later are still at risk until the paths are moved.");
} else {
message += tr("\n\nThe automatic safety backup failed; please back up these folders manually.");
}
return message;
}
void AtRiskDataWarning::showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message)
{
QMessageBox box(QMessageBox::Warning, title, message, QMessageBox::Ok, parent);
box.setTextFormat(Qt::PlainText);
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
box.setCheckBox(dontShowAgain);
box.exec();
if (dontShowAgain->isChecked()) {
SettingsCache::instance().paths().setAppDirWarningAcknowledged(true);
}
}
void AtRiskDataWarning::warnAboutDataPathsInProgramDir(QWidget *parent)
{
auto &paths = SettingsCache::instance().paths();
if (paths.getAppDirWarningAcknowledged()) {
return;
}
// Portable installs keep their data inside the program directory by design, but they
// never shipped an uninstaller, and portable updates use the installer's payload
// purge, which preserves data/ - so there is nothing destructive to warn about.
if (SettingsCache::instance().getIsPortableBuild()) {
return;
}
const QString appDir = QCoreApplication::applicationDirPath();
// Sources to back up outside the install directory before warning. The uninstallers
// shipped in 3.1.0/3.1.1 delete the whole install tree, so a safety copy is kept
// even though the fixed uninstaller preserves foreign files - the destructive one
// is already on disk on affected machines.
const QStringList offenders = collectInstalledAtRiskPaths(appDir);
if (offenders.isEmpty()) {
return;
}
// Only existing paths can be copied; a configured-but-never-created path has
// nothing to lose yet and must not turn the warning into a bogus "failed".
QStringList sourcesToBackUp;
for (const QString &offender : offenders) {
if (QFileInfo::exists(offender)) {
sourcesToBackUp.append(offender);
}
}
const AtRiskBackupOutcome outcome = backUpAtRiskPaths(sourcesToBackUp, appDir);
const QString message = buildInstalledAtRiskMessage(offenders, sourcesToBackUp, outcome);
showAtRiskDataWarning(parent, tr("Data paths inside the program directory"), message);
}

View file

@ -0,0 +1,50 @@
#ifndef AT_RISK_DATA_WARNING_H
#define AT_RISK_DATA_WARNING_H
#include <QCoreApplication>
#include <QString>
#include <QStringList>
#include <QtGlobal>
class QWidget;
/**
* @brief Warns when user data lives inside the program directory and backs it up first.
*
* The uninstallers shipped in Cockatrice 3.1.0/3.1.1 delete the whole install
* tree, so data paths configured inside the program directory get a safety
* backup outside the install directory before the warning is shown.
* Portable installs keep their data inside the program directory by design but
* never shipped an uninstaller, so they are skipped.
*/
class AtRiskDataWarning
{
Q_DECLARE_TR_FUNCTIONS(AtRiskDataWarning)
public:
struct AtRiskBackupOutcome
{
QStringList locations; ///< backup targets created for this check
QString sessionDir; ///< session directory holding the backup
bool complete = true; ///< false when only a partial/fallback backup exists
bool reused = false; ///< true when a recent session was reused instead of copying
bool skippedDueToSize = false; ///< true when the automatic copy was skipped for size
qint64 bytes = 0; ///< estimated bytes, only set when a fresh copy was attempted
};
/**
* @brief Backs up at-risk data paths and warns about them.
* @param parent Parent widget for the warning dialog
*/
static void warnAboutDataPathsInProgramDir(QWidget *parent);
private:
static QStringList collectInstalledAtRiskPaths(const QString &appDir);
static AtRiskBackupOutcome backUpAtRiskPaths(const QStringList &sources, const QString &appDir);
static QString buildInstalledAtRiskMessage(const QStringList &offenders,
const QStringList &sources,
const AtRiskBackupOutcome &outcome);
static void showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message);
};
#endif // AT_RISK_DATA_WARNING_H

View file

@ -289,9 +289,15 @@ void DlgConnect::updateDisplayInfo(const QString &saveName)
playernameEdit->setText(_data.at(3));
playernameEdit->setFocus();
savePasswordCheckBox->setChecked(savePasswordStatus);
storedVerifier.clear();
if (savePasswordStatus) {
passwordEdit->setText(_data.at(4));
const QString stored = _data.at(4);
if (stored.startsWith("$")) {
storedVerifier = stored;
} else {
passwordEdit->setText(stored);
}
}
if (!_data.at(6).isEmpty()) {
@ -317,6 +323,7 @@ void DlgConnect::newHostSelected(bool state)
portEdit->setDisabled(false);
playernameEdit->clear();
passwordEdit->clear();
storedVerifier.clear();
saveEdit->clear();
saveEdit->setPlaceholderText(tr("Unique Server Name"));
saveEdit->setDisabled(false);
@ -342,6 +349,11 @@ void DlgConnect::actOk()
{
ServersSettings &servers = SettingsCache::instance().servers();
// Never write a newly typed plaintext password to disk when a verifier is already stored:
// the typed value is used for this connection and a fresh verifier is persisted after a
// successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior.
const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier;
if (newHostButton->isChecked()) {
if (saveEdit->text().isEmpty()) {
QMessageBox::critical(this, tr("Connection Warning"), tr("You need to name your new connection profile."));
@ -349,10 +361,10 @@ void DlgConnect::actOk()
}
servers.addNewServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
playernameEdit->text().trimmed(), passwordEdit->text(), savePasswordCheckBox->isChecked());
playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked());
} else {
servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
playernameEdit->text().trimmed(), passwordEdit->text(),
playernameEdit->text().trimmed(), passwordToSave,
savePasswordCheckBox->isChecked());
}

View file

@ -7,6 +7,7 @@
#ifndef DLG_CONNECT_H
#define DLG_CONNECT_H
#include <QCheckBox>
#include <QDialog>
#include <QLineEdit>
#include <QMap>
@ -51,6 +52,22 @@ public:
{
return passwordEdit->text();
}
//! \brief Stored "$scrypt$..." verifier for challenge-response servers (never the plaintext password).
[[nodiscard]] QString getStoredVerifier() const
{
return storedVerifier;
}
[[nodiscard]] QString getSaveName() const
{
// Trim so the profile lookup key always matches the name DlgConnect::actOk
// stored (it saves under saveEdit->text().trimmed()); otherwise a trailing
// space here misses the index and silently drops the saved verifier.
return saveEdit->text().trimmed();
}
[[nodiscard]] bool getSavePassword() const
{
return savePasswordCheckBox->isChecked();
}
public slots:
void downloadThePublicServers();
@ -81,6 +98,7 @@ private:
QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer;
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
HandlePublicServers *hps;
QString storedVerifier;
const QString placeHolderText = tr("Downloading...");
};
#endif

View file

@ -23,7 +23,10 @@ DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent)
auto &servers = SettingsCache::instance().servers();
if (servers.getSavePassword()) {
oldPasswordEdit->setText(servers.getPassword());
const QString stored = servers.getPassword();
if (!stored.startsWith("$")) {
oldPasswordEdit->setText(stored);
}
}
oldPasswordLabel->setBuddy(oldPasswordEdit);

View file

@ -301,7 +301,9 @@ void UserInfoBox::changePassword(const QString &oldPassword, const QString &newP
{
Command_AccountPassword cmd;
cmd.set_old_password(oldPassword.toStdString());
if (client->getServerSupportsPasswordHash()) {
if (client->getServerSupportsChallengeResponse()) {
cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString());
} else if (client->getServerSupportsPasswordHash()) {
auto passwordSalt = PasswordHasher::generateRandomSalt();
QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt);
cmd.set_hashed_new_password(hashedPassword.toStdString());

View file

@ -118,6 +118,26 @@ MessagesSettingsPage::MessagesSettingsPage()
highlightGroupBox = new QGroupBox;
highlightGroupBox->setLayout(highlightNotice);
channelUnreadColor = new QLineEdit();
channelUnreadColor->setText(SettingsCache::instance().chat().getChannelUnreadColor());
updateChannelUnreadPreview();
connect(channelUnreadColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelUnreadColor);
channelMentionColor = new QLineEdit();
channelMentionColor->setText(SettingsCache::instance().chat().getChannelMentionColor());
updateChannelMentionPreview();
connect(channelMentionColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateChannelMentionColor);
auto *channelGrid = new QGridLayout;
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
channelGrid->addWidget(channelUnreadColor, 0, 1);
channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2);
channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
channelGrid->addWidget(channelMentionColor, 1, 1);
channelGrid->addWidget(&hexChannelMentionLabel, 1, 2);
channelGroupBox = new QGroupBox;
channelGroupBox->setLayout(channelGrid);
messageList = new QListWidget;
int count = SettingsCache::instance().messages().getCount();
@ -159,6 +179,7 @@ MessagesSettingsPage::MessagesSettingsPage()
mainLayout->addWidget(messageGroupBox);
mainLayout->addWidget(chatGroupBox);
mainLayout->addWidget(highlightGroupBox);
mainLayout->addWidget(channelGroupBox);
setLayout(mainLayout);
@ -195,6 +216,34 @@ void MessagesSettingsPage::updateHighlightColor(const QString &value)
}
}
void MessagesSettingsPage::updateChannelUnreadColor(const QString &value)
{
#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0))
QColor colorToSet = QColor::fromString("#" + value);
#else
QColor colorToSet;
colorToSet.setNamedColor("#" + value);
#endif
if (colorToSet.isValid()) {
SettingsCache::instance().chat().setChannelUnreadColor(value);
updateChannelUnreadPreview();
}
}
void MessagesSettingsPage::updateChannelMentionColor(const QString &value)
{
#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0))
QColor colorToSet = QColor::fromString("#" + value);
#else
QColor colorToSet;
colorToSet.setNamedColor("#" + value);
#endif
if (colorToSet.isValid()) {
SettingsCache::instance().chat().setChannelMentionColor(value);
updateChannelMentionPreview();
}
}
void MessagesSettingsPage::updateTextColor(QT_STATE_CHANGED_T value)
{
SettingsCache::instance().chat().setChatMentionForeground(value);
@ -221,6 +270,18 @@ void MessagesSettingsPage::updateHighlightPreview()
";color: " + (SettingsCache::instance().chat().getChatHighlightForeground() ? "white" : "black") + ";}");
}
void MessagesSettingsPage::updateChannelUnreadPreview()
{
channelUnreadColor->setStyleSheet("QLineEdit{background:#" +
SettingsCache::instance().chat().getChannelUnreadColor() + ";}");
}
void MessagesSettingsPage::updateChannelMentionPreview()
{
channelMentionColor->setStyleSheet("QLineEdit{background:#" +
SettingsCache::instance().chat().getChannelMentionColor() + ";}");
}
void MessagesSettingsPage::storeSettings()
{
SettingsCache::instance().messages().setCount(messageList->count());
@ -267,6 +328,9 @@ void MessagesSettingsPage::retranslateUi()
{
chatGroupBox->setTitle(tr("Chat settings"));
highlightGroupBox->setTitle(tr("Custom alert words"));
channelGroupBox->setTitle(tr("Room channel tabs"));
channelUnreadColorLabel.setText(tr("Unread messages color"));
channelMentionColorLabel.setText(tr("Mentioned messages color"));
chatMentionCheckBox.setText(tr("Enable chat mentions"));
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
messageGroupBox->setTitle(tr("In-game message macros"));
@ -284,6 +348,8 @@ void MessagesSettingsPage::retranslateUi()
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
hexLabel.setText(tr("(Color is hexadecimal)"));
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)"));
hexChannelMentionLabel.setText(tr("(Color is hexadecimal)"));
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
aAdd->setText(tr("Add New Message"));

View file

@ -27,6 +27,8 @@ private slots:
void actRemove();
void updateColor(const QString &value);
void updateHighlightColor(const QString &value);
void updateChannelUnreadColor(const QString &value);
void updateChannelMentionColor(const QString &value);
void updateTextColor(QT_STATE_CHANGED_T value);
void updateTextHighlightColor(QT_STATE_CHANGED_T value);
@ -49,18 +51,27 @@ private:
QCheckBox useGameTimeCheckBox;
QGroupBox *chatGroupBox;
QGroupBox *highlightGroupBox;
QGroupBox *channelGroupBox;
QGroupBox *messageGroupBox;
QLineEdit *mentionColor;
QLineEdit *highlightColor;
QLineEdit *channelUnreadColor;
QLineEdit *channelMentionColor;
QLineEdit *customAlertString;
QLabel hexLabel;
QLabel hexHighlightLabel;
QLabel channelUnreadColorLabel;
QLabel channelMentionColorLabel;
QLabel hexChannelUnreadLabel;
QLabel hexChannelMentionLabel;
QLabel customAlertStringLabel;
QLabel explainMessagesLabel;
void storeSettings();
void updateMentionPreview();
void updateHighlightPreview();
void updateChannelUnreadPreview();
void updateChannelMentionPreview();
};
#endif // COCKATRICE_MESSAGES_SETTINGS_PAGE_H

View file

@ -35,10 +35,10 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
{
roomTable = new QTableWidget();
roomTable->setColumnCount(6);
roomTable->setColumnCount(7);
roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
roomTable->setHorizontalHeaderLabels(
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";"));
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName;Channel")).split(";"));
gameTable = new QTableWidget();
gameTable->setColumnCount(6);
@ -317,7 +317,8 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
for (int i = 0; i < response.log_message_size(); ++i) {
ServerInfo_ChatMessage message = response.log_message(i);
if (QString::fromStdString(message.target_type()) == "room") {
const QString targetType = QString::fromStdString(message.target_type());
if (targetType == "room" || targetType == "room_channel") {
roomTable->insertRow(roomCounter);
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
@ -325,10 +326,11 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel())));
++roomCounter;
}
if (QString::fromStdString(message.target_type()) == "game") {
if (targetType == "game") {
gameTable->insertRow(gameCounter);
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
@ -339,7 +341,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
++gameCounter;
}
if (QString::fromStdString(message.target_type()) == "chat") {
if (targetType == "chat") {
chatTable->insertRow(chatCounter);
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));

View file

@ -29,6 +29,7 @@
#include <QAction>
#include <QApplication>
#include <QCloseEvent>
#include <QColor>
#include <QCompleter>
#include <QDateTime>
#include <QFlag>
@ -38,9 +39,11 @@
#include <QMenu>
#include <QRegularExpression>
#include <QSplitter>
#include <QStackedWidget>
#include <QStringListModel>
#include <QStringLiteral>
#include <QSystemTrayIcon>
#include <QTabBar>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
@ -52,6 +55,7 @@
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
#include <libcockatrice/protocol/pb/event_list_games.pb.h>
#include <libcockatrice/protocol/pb/event_remove_messages.pb.h>
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
#include <libcockatrice/protocol/pb/room_commands.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
@ -61,12 +65,45 @@
#include <qnamespace.h>
#include <string>
namespace
{
// Empty id refers to the implicit Main channel.
const QString MAIN_CHANNEL = QString();
/**
* @brief Privilege rank mirroring Server_Room::userPrivilegeLevel.
*
* Higher = more privileged. Used to evaluate channel write access locally so
* the input can reflect read-only channels; the server re-checks on send.
*/
int userPrivilegeRank(uint32_t userLevel)
{
if (userLevel & ServerInfo_User::IsDeveloper) {
return static_cast<int>(ServerInfo_RoomChannel::Developer);
}
if (userLevel & ServerInfo_User::IsAdmin) {
return static_cast<int>(ServerInfo_RoomChannel::Admin);
}
if (userLevel & ServerInfo_User::IsModerator) {
return static_cast<int>(ServerInfo_RoomChannel::Moderator);
}
if (userLevel & ServerInfo_User::IsJudge) {
return static_cast<int>(ServerInfo_RoomChannel::Judge);
}
if (userLevel & ServerInfo_User::IsRegistered) {
return static_cast<int>(ServerInfo_RoomChannel::Registered);
}
return static_cast<int>(ServerInfo_RoomChannel::Public);
}
} // namespace
TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
AbstractClient *_client,
ServerInfo_User *_ownUser,
const ServerInfo_Room &info)
: Tab(_tabSupervisor), client(_client), roomId(info.room_id()), roomName(QString::fromStdString(info.name())),
ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager())
ownUser(_ownUser), userListProxy(_tabSupervisor->getUserListManager()), activeChannelId(MAIN_CHANNEL),
chatCollapsed(false)
{
const int gameTypeListSize = info.gametype_list_size();
for (int i = 0; i < gameTypeListSize; ++i) {
@ -86,14 +123,61 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); };
userList->setGameInviteLinkProvider(gameInviteLinkProvider);
chatView = new ChatView(tabSupervisor, nullptr, true, this);
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog);
connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated);
connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup);
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup);
connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag);
chatViewStack = new QStackedWidget;
channelTabBar = new QTabBar;
channelTabBar->setShape(QTabBar::RoundedSouth);
channelTabBar->setExpanding(true);
channelTabBar->setUsesScrollButtons(true);
channelViews.insert(MAIN_CHANNEL, makeChatView());
channelIds << MAIN_CHANNEL;
channelDisplayNames.insert(MAIN_CHANNEL, tr("Main"));
channelUnreadCounts.insert(MAIN_CHANNEL, 0);
channelMentioned.insert(MAIN_CHANNEL, false);
channelWritable.insert(MAIN_CHANNEL, true);
channelTabBar->addTab(channelDisplayNames.value(MAIN_CHANNEL));
chatViewStack->addWidget(channelViews.value(MAIN_CHANNEL));
const uint32_t ownLevel = ownUser != nullptr ? ownUser->user_level() : 0;
const int ownRank = userPrivilegeRank(ownLevel);
const int channelCount = info.channel_list_size();
for (int i = 0; i < channelCount; ++i) {
const ServerInfo_RoomChannel &channel = info.channel_list(i);
const QString channelId = QString::fromStdString(channel.id());
if (channelId.isEmpty()) {
continue;
}
QString displayName = QString::fromStdString(channel.display_name());
if (displayName.isEmpty()) {
displayName = channelId;
}
const int writeLevel = channel.has_write_access_level() ? static_cast<int>(channel.write_access_level())
: static_cast<int>(channel.access_level());
ChatView *channelView = makeChatView();
const QString banner = QString::fromStdString(channel.banner_message());
if (!banner.isEmpty()) {
channelView->appendMessage(banner);
}
channelViews.insert(channelId, channelView);
channelIds << channelId;
channelDisplayNames.insert(channelId, displayName);
channelUnreadCounts.insert(channelId, 0);
channelMentioned.insert(channelId, false);
channelWritable.insert(channelId, ownRank >= writeLevel);
channelTabBar->addTab(displayName);
chatViewStack->addWidget(channelViews.value(channelId));
}
chatView = channelViews.value(MAIN_CHANNEL);
chatViewStack->setCurrentWidget(chatView);
channelTabBar->setCurrentIndex(0);
// Old servers never send a channel catalog, so the tab bar would only show
// a single "Main" tab where a misclick hides the chat. Only show it when
// there is more than one channel to switch between.
channelTabBar->setVisible(channelIds.size() > 1);
connect(channelTabBar, &QTabBar::currentChanged, this, &TabRoom::onChannelChanged);
connect(channelTabBar, &QTabBar::tabBarClicked, this, &TabRoom::onChannelTabClicked);
connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this,
&TabRoom::actCompleterChanged);
sayLabel = new QLabel;
@ -101,6 +185,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
sayLabel->setBuddy(sayEdit);
connect(sayEdit, &LineEditCompleter::returnPressed, this, &TabRoom::sendMessage);
updateSayEnabled();
auto *chatSettingsMenu = new QMenu(this);
@ -122,19 +207,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
sayHbox->addWidget(sayEdit);
sayHbox->addWidget(chatSettingsButton);
sayWidget = new QWidget;
sayWidget->setLayout(sayHbox);
auto *chatVbox = new QVBoxLayout;
chatVbox->addWidget(chatView);
chatVbox->addLayout(sayHbox);
chatVbox->addWidget(chatViewStack);
chatVbox->addWidget(sayWidget);
chatVbox->addWidget(channelTabBar);
chatGroupBox = new QGroupBox;
chatGroupBox->setLayout(chatVbox);
auto *splitter = new QSplitter(Qt::Vertical);
splitter->addWidget(gameSelector);
splitter->addWidget(chatGroupBox);
chatSplitter = new QSplitter(Qt::Vertical);
chatSplitter->addWidget(gameSelector);
chatSplitter->addWidget(chatGroupBox);
// Splitter children are collapsible by default, so a zero size would hide
// the whole chat group box including the tab bar. Keep it clamped to its
// minimum size (title + tab bar) when collapsed.
chatSplitter->setCollapsible(1, false);
auto *hbox = new QHBoxLayout;
hbox->addWidget(splitter, 3);
hbox->addWidget(chatSplitter, 3);
hbox->addWidget(userListPanel, 1);
aLeaveRoom = new QAction(this);
@ -189,7 +282,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
void TabRoom::retranslateUi()
{
gameSelector->retranslateUi();
chatView->retranslateUi();
for (auto it = channelViews.constBegin(); it != channelViews.constEnd(); ++it) {
it.value()->retranslateUi();
}
userListPanel->retranslateUi();
sayLabel->setText(tr("&Say:"));
chatGroupBox->setTitle(tr("Chat"));
@ -197,6 +292,11 @@ void TabRoom::retranslateUi()
aLeaveRoom->setText(tr("&Leave room"));
aClearChat->setText(tr("&Clear chat"));
aOpenChatSettings->setText(tr("Chat Settings..."));
channelDisplayNames.insert(MAIN_CHANNEL, tr("Main"));
for (const QString &channelId : channelIds) {
updateChannelTab(channelId);
}
updateSayEnabled();
}
void TabRoom::focusTab()
@ -233,6 +333,11 @@ void TabRoom::tabActivated()
if (!sayEdit->hasFocus()) {
sayEdit->setFocus();
}
if (!chatCollapsed) {
channelUnreadCounts[activeChannelId] = 0;
channelMentioned[activeChannelId] = false;
updateChannelTab(activeChannelId);
}
}
QString TabRoom::sanitizeHtml(QString dirty) const
@ -240,29 +345,232 @@ QString TabRoom::sanitizeHtml(QString dirty) const
return dirty.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
void TabRoom::updateSayEnabled()
{
const bool writable = channelWritable.value(activeChannelId, true);
sayLabel->setEnabled(writable);
sayEdit->setEnabled(writable);
sayEdit->setPlaceholderText(writable ? QString() : tr("This channel is read-only."));
}
void TabRoom::sendMessage()
{
if (!channelWritable.value(activeChannelId, true)) {
return;
}
if (sayEdit->text().isEmpty()) {
return;
} else if (sayEdit->hasVisibleCompleterPopup()) {
sayEdit->hideCompleterPopups();
return;
} else {
Command_RoomSay cmd;
cmd.set_message(sayEdit->text().toStdString());
ChatView *targetView = channelViews.value(activeChannelId, chatView);
auto onFinished = [this, targetView](const Response &response) {
if (response.response_code() == Response::RespChatFlood) {
targetView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds."));
} else if (response.response_code() == Response::RespFunctionNotAllowed ||
response.response_code() == Response::RespInvalidCommand) {
targetView->appendMessage(tr("Your message could not be sent: the channel is not available or you "
"are not allowed to talk there."));
}
};
PendingCommand *pend = prepareRoomCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabRoom::sayFinished);
sendRoomCommand(pend);
if (activeChannelId.isEmpty()) {
Command_RoomSay cmd;
cmd.set_message(sayEdit->text().toStdString());
PendingCommand *pend = prepareRoomCommand(cmd);
connect(pend, &PendingCommand::finished, this, onFinished);
sendRoomCommand(pend);
} else {
Command_RoomChannelSay cmd;
cmd.set_channel_id(activeChannelId.toStdString());
cmd.set_message(sayEdit->text().toStdString());
PendingCommand *pend = prepareRoomCommand(cmd);
connect(pend, &PendingCommand::finished, this, onFinished);
sendRoomCommand(pend);
}
sayEdit->clear();
}
}
void TabRoom::sayFinished(const Response &response)
void TabRoom::onChannelChanged(int index)
{
if (response.response_code() == Response::RespChatFlood) {
chatView->appendMessage(tr("You are flooding the chat. Please wait a couple of seconds."));
if (index < 0 || index >= channelIds.size()) {
return;
}
activeChannelId = channelIds.at(index);
ChatView *view = channelViews.value(activeChannelId);
if (view) {
chatView = view;
chatViewStack->setCurrentWidget(view);
}
// Viewing a channel clears its unread state
channelUnreadCounts[activeChannelId] = 0;
channelMentioned[activeChannelId] = false;
updateChannelTab(activeChannelId);
updateSayEnabled();
if (chatCollapsed) {
setChatCollapsed(false);
}
}
void TabRoom::onChannelTabClicked(int index)
{
if (index < 0 || index >= channelIds.size()) {
return;
}
// Clicking the active tab toggles collapse; clicking another tab switches to it
if (channelTabBar->currentIndex() == index && channelIds.at(index) == activeChannelId) {
setChatCollapsed(!chatCollapsed);
}
}
void TabRoom::setChatCollapsed(bool collapsed)
{
chatCollapsed = collapsed;
chatViewStack->setVisible(!collapsed);
sayWidget->setVisible(!collapsed);
if (collapsed) {
// Minimize the chat section of the splitter down to just the tab bar
savedSplitterSizes = chatSplitter->sizes();
chatSplitter->setSizes({1, 0});
} else {
// Expanding onto the active channel marks it read
channelUnreadCounts[activeChannelId] = 0;
channelMentioned[activeChannelId] = false;
updateChannelTab(activeChannelId);
chatViewStack->setCurrentWidget(chatView);
if (!savedSplitterSizes.isEmpty()) {
chatSplitter->setSizes(savedSplitterSizes);
}
}
}
QColor TabRoom::parseColorHex(const QString &hex, const QColor &fallback)
{
#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0))
QColor color = QColor::fromString("#" + hex);
#else
QColor color;
color.setNamedColor("#" + hex);
#endif
return color.isValid() ? color : fallback;
}
void TabRoom::updateChannelTab(const QString &channelId)
{
int index = channelIds.indexOf(channelId);
if (index < 0) {
return;
}
QString baseName = channelDisplayNames.value(channelId, channelId.isEmpty() ? tr("Main") : channelId);
int unread = channelUnreadCounts.value(channelId, 0);
bool mentioned = channelMentioned.value(channelId, false);
QString label = baseName;
if (unread > 0) {
label += tr(" (%1)").arg(unread);
}
channelTabBar->setTabText(index, label);
if (mentioned) {
channelTabBar->setTabTextColor(
index, parseColorHex(SettingsCache::instance().chat().getChannelMentionColor(), QColor(194, 31, 47)));
} else if (unread > 0) {
channelTabBar->setTabTextColor(
index, parseColorHex(SettingsCache::instance().chat().getChannelUnreadColor(), QColor(26, 123, 0)));
} else {
channelTabBar->setTabTextColor(index, QColor());
}
}
bool TabRoom::messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const
{
if (ownUser == nullptr) {
return false;
}
QString ownName = QString::fromStdString(ownUser->name());
if (ownName.isEmpty()) {
return false;
}
// Match ChatView::checkMention / checkWord: whole-word, case-insensitive.
// "@zach" must not match "@zachary", and highlight words match full words.
auto containsWholeWord = [](const QString &haystack, const QString &needle) {
if (needle.isEmpty()) {
return false;
}
QRegularExpression re(QStringLiteral("\\b%1\\b").arg(QRegularExpression::escape(needle)),
QRegularExpression::CaseInsensitiveOption);
return re.match(haystack).hasMatch();
};
if (containsWholeWord(message, "@" + ownName)) {
return true;
}
// ChatView only highlights @all for moderator/admin senders
// (ChatView::isModeratorSendingGlobal). Gating here keeps the tab
// mention color from drifting ahead of the message view.
const uint32_t senderLevel = senderInfo.user_level();
const bool senderIsPrivileged =
(senderLevel & ServerInfo_User::IsModerator) || (senderLevel & ServerInfo_User::IsAdmin);
if (senderIsPrivileged && containsWholeWord(message, "@all")) {
return true;
}
const QStringList words = SettingsCache::instance().chat().getHighlightWords().split(' ', Qt::SkipEmptyParts);
for (const QString &word : words) {
if (containsWholeWord(message, word)) {
return true;
}
}
return false;
}
void TabRoom::markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo)
{
bool roomVisible = (tabSupervisor->currentWidget() == this);
bool viewing = (channelId == activeChannelId) && !chatCollapsed && roomVisible;
if (viewing) {
return;
}
channelUnreadCounts[channelId] = channelUnreadCounts.value(channelId, 0) + 1;
if (messageContainsMention(message, senderInfo)) {
channelMentioned[channelId] = true;
}
updateChannelTab(channelId);
}
bool TabRoom::isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const
{
if (userListProxy->isUserIgnored(senderName)) {
return true;
}
UserListTWI *twi = userList->getUsers().value(senderName);
if (twi) {
userInfoOut = twi->getUserInfo();
if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() &&
!UserLevelFlags(userInfoOut.user_level()).testFlag(ServerInfo_User::IsRegistered)) {
return true;
}
} else {
userInfoOut = ServerInfo_User{};
}
return false;
}
ChatView *TabRoom::makeChatView()
{
ChatView *view = new ChatView(tabSupervisor, nullptr, true, this);
connect(view, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
connect(view, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
connect(view, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog);
connect(view, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated);
connect(view, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup);
connect(view, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup);
connect(view, &ChatView::addMentionTag, this, &TabRoom::addMentionTag);
return view;
}
void TabRoom::actClearChat()
@ -298,6 +606,9 @@ void TabRoom::processRoomEvent(const RoomEvent &event)
case RoomEvent::ROOM_SAY:
processRoomSayEvent(event.GetExtension(Event_RoomSay::ext));
break;
case RoomEvent::ROOM_CHANNEL_SAY:
processRoomChannelSayEvent(event.GetExtension(Event_RoomChannelSay::ext));
break;
case RoomEvent::REMOVE_MESSAGES:
processRemoveMessagesEvent(event.GetExtension(Event_RemoveMessages::ext));
break;
@ -335,18 +646,9 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event)
QString senderName = QString::fromStdString(event.name());
QString message = QString::fromStdString(event.message());
if (userListProxy->isUserIgnored(senderName)) {
return;
}
UserListTWI *twi = userList->getUsers().value(senderName);
ServerInfo_User userInfo = {};
if (twi) {
userInfo = twi->getUserInfo();
if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() &&
!UserLevelFlags(userInfo.user_level()).testFlag(ServerInfo_User::IsRegistered)) {
return;
}
if (isRoomMessageIgnored(senderName, userInfo)) {
return;
}
if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) {
@ -360,7 +662,49 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event)
"] " + message;
}
chatView->appendMessage(message, event.message_type(), userInfo, true);
ChatView *mainView = channelViews.value(MAIN_CHANNEL);
if (mainView == nullptr) {
return;
}
mainView->appendMessage(message, event.message_type(), userInfo, true);
if (event.message_type() != Event_RoomSay::ChatHistory) {
markChannelMessage(MAIN_CHANNEL, message, userInfo);
}
emit userEvent(false);
}
void TabRoom::processRoomChannelSayEvent(const Event_RoomChannelSay &event)
{
const QString channelId = QString::fromStdString(event.channel_id());
ChatView *view = channelViews.value(channelId);
if (view == nullptr) {
return;
}
QString senderName = QString::fromStdString(event.name());
QString message = QString::fromStdString(event.message());
ServerInfo_User userInfo = {};
if (isRoomMessageIgnored(senderName, userInfo)) {
return;
}
if (senderName.isEmpty()) {
if (!SettingsCache::instance().chat().getRoomHistory()) {
return;
}
message =
"[" +
QString(QDateTime::fromMSecsSinceEpoch(event.time_of()).toLocalTime().toString("d MMM yyyy HH:mm:ss")) +
"] " + message;
}
view->appendMessage(message, {}, userInfo, true);
if (!senderName.isEmpty()) {
// Empty senders mark replayed history, which must not count as unread
markChannelMessage(channelId, message, userInfo);
}
emit userEvent(false);
}
@ -368,7 +712,9 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event)
{
QString userName = QString::fromStdString(event.name());
int amount = event.amount();
chatView->redactMessages(userName, amount);
for (ChatView *view : channelViews) {
view->redactMessages(userName, amount);
}
}
void TabRoom::refreshShortcuts()

View file

@ -10,6 +10,7 @@
#include "tab.h"
#include <QColor>
#include <QList>
#include <QMap>
#include <QString>
@ -18,8 +19,12 @@
class UserListProxy;
class QAction;
class QTabBar;
class QWidget;
class QGroupBox;
class QSplitter;
class QMenu;
class QStackedWidget;
class QStringListModel;
class TabSupervisor;
@ -42,6 +47,7 @@ class Event_ListGames;
class Event_JoinRoom;
class Event_LeaveRoom;
class Event_RoomSay;
class Event_RoomChannelSay;
class Event_RemoveMessages;
class GameSelector;
class Response;
@ -64,10 +70,23 @@ private:
UserListWidget *userList;
const UserListProxy *userListProxy;
ChatView *chatView;
QStackedWidget *chatViewStack;
QTabBar *channelTabBar;
QWidget *sayWidget;
QMap<QString, ChatView *> channelViews;
QMap<QString, QString> channelDisplayNames;
QMap<QString, int> channelUnreadCounts;
QMap<QString, bool> channelMentioned;
QMap<QString, bool> channelWritable;
QStringList channelIds;
QString activeChannelId;
bool chatCollapsed;
QLabel *sayLabel;
LineEditCompleter *sayEdit;
QStringListModel *mentionModel;
QGroupBox *chatGroupBox;
QSplitter *chatSplitter;
QList<int> savedSplitterSizes;
QMenu *roomMenu;
QAction *aLeaveRoom;
@ -85,7 +104,9 @@ signals:
void gameListUpdated();
private slots:
void sendMessage();
void sayFinished(const Response &response);
void onChannelChanged(int index);
void onChannelTabClicked(int index);
void setChatCollapsed(bool collapsed);
void actClearChat();
void actOpenChatSettings();
void addMentionTag(QString mentionTag);
@ -98,9 +119,18 @@ private slots:
void processJoinRoomEvent(const Event_JoinRoom &event);
void processLeaveRoomEvent(const Event_LeaveRoom &event);
void processRoomSayEvent(const Event_RoomSay &event);
void processRoomChannelSayEvent(const Event_RoomChannelSay &event);
void processRemoveMessagesEvent(const Event_RemoveMessages &event);
void refreshShortcuts();
ChatView *makeChatView();
void updateChannelTab(const QString &channelId);
void updateSayEnabled();
void markChannelMessage(const QString &channelId, const QString &message, const ServerInfo_User &senderInfo);
[[nodiscard]] bool messageContainsMention(const QString &message, const ServerInfo_User &senderInfo) const;
[[nodiscard]] bool isRoomMessageIgnored(const QString &senderName, ServerInfo_User &userInfoOut) const;
static QColor parseColorHex(const QString &hex, const QColor &fallback);
protected slots:
void closeEvent(QCloseEvent *event) override;

View file

@ -133,29 +133,33 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event)
return false;
}
if (!above) {
return false;
}
auto *popup = completer->popup();
const int rowCount = completer->completionModel()->rowCount();
if (rowCount == 0) {
return true;
}
// Handle navigation ourselves so arrow keys move the highlight without
// rewriting the line edit. QCompleter's default handling selects with a
// selection change that QLineEdit turns into setText(), which fights the
// user while typing (e.g. typing "A" becomes "Axe" and cannot be edited).
// With the popup above the text edit the rows are shown in reverse order and
// the closest match is in the bottom-most row. The up arrow then advances
// through the list (towards its end at the top of the popup).
const int currentRow = popup->currentIndex().row();
const int step = (key == Qt::Key_Up) ? -1 : 1;
const int newRow = qBound(0, currentRow + step, rowCount - 1);
if (newRow != currentRow) {
const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn());
popup->selectionModel()->setCurrentIndex(newIndex,
QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
const QModelIndex current = popup->currentIndex();
int newRow;
if (!current.isValid()) {
newRow = (key == Qt::Key_Down) ? 0 : rowCount - 1;
} else {
const int step = (key == Qt::Key_Up) ? -1 : 1;
newRow = qBound(0, current.row() + step, rowCount - 1);
if (newRow == current.row()) {
return true;
}
}
selectWithoutRewrite(completer->completionModel()->index(newRow, completer->completionColumn()));
return true;
}
@ -218,8 +222,37 @@ void CardCompleterStyler::ensureClosestSelected()
const int closestRow = rowCount - 1;
if (currentRow != closestRow) {
popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()),
QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
selectWithoutRewrite(completionModel->index(closestRow, completer->completionColumn()));
}
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::selectWithoutRewrite(const QModelIndex &index)
{
auto *popup = completer->popup();
auto *selectionModel = popup->selectionModel();
// Changing the popup selection emits selectionChanged, which QCompleter
// turns into highlighted() and QLineEdit turns into setText(). That
// rewrites what the user typed (e.g. "A" becomes "Axe") and fights further
// typing. Block the signals so the highlight and preview move without
// touching the line edit; Enter/click still activates via activated().
const bool wasBlocked = selectionModel->blockSignals(true);
if (!index.isValid()) {
selectionModel->clear();
} else {
selectionModel->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
}
selectionModel->blockSignals(wasBlocked);
popup->viewport()->update();
// currentRowChanged was suppressed above, so sync the preview manually.
updatePreview(index);
if (index.isValid()) {
popup->scrollTo(index, QAbstractItemView::EnsureVisible);
} else {
popup->scrollToTop();
}
}

View file

@ -55,6 +55,7 @@ private:
void applyPopupWidth();
void updateOrientation();
void ensureClosestSelected();
void selectWithoutRewrite(const QModelIndex &index);
bool handlePopupKeyPress(QKeyEvent *event);
bool isPopupAboveWidget() const;

View file

@ -37,6 +37,7 @@
#include "../interface/widgets/tabs/tab_supervisor.h"
#include "../main.h"
#include "abstract_client.h"
#include "at_risk_data_warning.h"
#include "card_database_update/card_database_update_status_bar.h"
#include "card_database_update/card_update_progress.h"
#include "intents/contexts/context_connect_to_server.h"
@ -58,13 +59,12 @@
#include <QAction>
#include <QApplication>
#include <QButtonGroup>
#include <QCheckBox>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDesktopServices>
#include <QDialog>
#include <QDir>
#include <QEvent>
#include <QFile>
#include <QFileDialog>
@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent)
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
}
void MainWindow::warnAboutDataPathsInProgramDir()
{
auto &paths = SettingsCache::instance().paths();
if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) {
return;
}
const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath());
if (offenders.isEmpty()) {
return;
}
QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"),
tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
"Cockatrice keeps its own data outside the program directory by default. Files "
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
"Change them in the Settings dialog - most are on the General page - to keep "
"them safe.")
.arg(offenders.join(QLatin1Char('\n'))),
QMessageBox::Ok, this);
box.setTextFormat(Qt::PlainText);
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
box.setCheckBox(dontShowAgain);
box.exec();
if (dontShowAgain->isChecked()) {
paths.setAppDirWarningAcknowledged(true);
}
}
void MainWindow::startupConfigCheck()
{
warnAboutDataPathsInProgramDir();
AtRiskDataWarning::warnAboutDataPathsInProgramDir(this);
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
@ -1029,7 +1000,8 @@ void MainWindow::attemptStartupAutoConnect()
qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect...";
DlgConnect dlg(this);
connectionController->connectToServerDirect(dlg.getHost(), static_cast<unsigned int>(dlg.getPort()),
dlg.getPlayerName(), dlg.getPassword());
dlg.getPlayerName(), dlg.getPassword(), dlg.getStoredVerifier(),
dlg.getSaveName(), dlg.getSavePassword());
}
}

View file

@ -149,9 +149,6 @@ private:
void runFirstRunWizard(bool firstRun = false);
/** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */
void warnAboutDataPathsInProgramDir();
inline QString getCardUpdaterBinaryName()
{
return "oracle";

View file

@ -12,6 +12,8 @@ public:
[[nodiscard]] virtual bool getChatMentionCompleter() const = 0;
[[nodiscard]] virtual QString getChatMentionColor() const = 0;
[[nodiscard]] virtual QString getChatHighlightColor() const = 0;
[[nodiscard]] virtual QString getChannelUnreadColor() const = 0;
[[nodiscard]] virtual QString getChannelMentionColor() const = 0;
[[nodiscard]] virtual bool getChatMentionForeground() const = 0;
[[nodiscard]] virtual bool getChatHighlightForeground() const = 0;
[[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0;

View file

@ -41,7 +41,8 @@
#include <libcockatrice/protocol/pending_command.h>
AbstractClient::AbstractClient(QObject *parent)
: QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false)
: QObject(parent), nextCmdId(0), status(StatusDisconnected), serverSupportsPasswordHash(false),
serverSupportsChallengeResponse(false)
{
qRegisterMetaType<QVariant>("QVariant");
qRegisterMetaType<CommandContainer>("CommandContainer");

View file

@ -133,6 +133,7 @@ protected:
QMap<int, PendingCommand *> pendingCommands;
QString userName, password, email, country, realName, token;
bool serverSupportsPasswordHash;
bool serverSupportsChallengeResponse;
void setStatus(ClientStatus _status);
int getNewCmdId()
{
@ -166,6 +167,10 @@ public:
{
return serverSupportsPasswordHash;
}
bool getServerSupportsChallengeResponse() const
{
return serverSupportsChallengeResponse;
}
const QString &getUserName() const
{
return userName;

View file

@ -42,7 +42,8 @@ static const unsigned int protocolVersion = 14;
RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider)
: AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0),
messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword()
messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword(),
passwordNeedsMigration(false)
{
clearNewClientFeatures();
@ -129,6 +130,11 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
return;
}
serverSupportsPasswordHash = event.server_options() & Event_ServerIdentification::SupportsPasswordHash;
// Challenge-response is advertised in its own field so server_options stays
// exactly SupportsPasswordHash on the wire for already-released clients
// (proto2 enums are closed: an unknown value parses as NoOptions and those
// clients would fall back to plaintext passwords).
serverSupportsChallengeResponse = event.has_supports_challenge_response() && event.supports_challenge_response();
if (getStatus() == StatusRequestingForgotPassword) {
Command_ForgotPasswordRequest cmdForgotPasswordRequest;
@ -145,7 +151,13 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
cmdForgotPasswordReset.set_user_name(userName.toStdString());
cmdForgotPasswordReset.set_clientid(getSrvClientID(lastHostname).toStdString());
cmdForgotPasswordReset.set_token(token.toStdString());
if (!password.isEmpty() && serverSupportsPasswordHash) {
if (!password.isEmpty() && serverSupportsChallengeResponse) {
hashedPassword = PasswordHasher::generatePasswordVerifier(password);
// Only this branch yields a challenge-response verifier worth persisting; the
// legacy-hash and plaintext branches below must not be saved under the password key.
derivedVerifier = hashedPassword;
cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString());
} else if (!password.isEmpty() && serverSupportsPasswordHash) {
auto passwordSalt = PasswordHasher::generateRandomSalt();
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString());
@ -173,7 +185,10 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
if (getStatus() == StatusRegistering) {
Command_Register cmdRegister;
cmdRegister.set_user_name(userName.toStdString());
if (!password.isEmpty() && serverSupportsPasswordHash) {
if (!password.isEmpty() && serverSupportsChallengeResponse) {
hashedPassword = PasswordHasher::generatePasswordVerifier(password);
cmdRegister.set_hashed_password(hashedPassword.toStdString());
} else if (!password.isEmpty() && serverSupportsPasswordHash) {
auto passwordSalt = PasswordHasher::generateRandomSalt();
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
cmdRegister.set_hashed_password(hashedPassword.toStdString());
@ -238,7 +253,9 @@ Command_Login RemoteClient::generateCommandLogin()
void RemoteClient::doLogin()
{
if (!password.isEmpty() && serverSupportsPasswordHash) {
if ((!password.isEmpty() || !storedVerifier.isEmpty()) && serverSupportsChallengeResponse) {
doRequestPasswordSalt(); // ask salt + nonce to build the challenge response
} else if (!password.isEmpty() && serverSupportsPasswordHash) {
//! \todo Store and log in using stored hashed password.
if (hashedPassword.isEmpty()) {
doRequestPasswordSalt(); // ask salt to create hashedPassword, then log in
@ -272,6 +289,30 @@ void RemoteClient::doHashedLogin()
sendCommand(pend);
}
void RemoteClient::doSubmitPasswordVerifier()
{
pendingVerifier = PasswordHasher::generatePasswordVerifier(password);
Command_SubmitPasswordVerifier cmdSubmitVerifier;
cmdSubmitVerifier.set_password_verifier(pendingVerifier.toStdString());
PendingCommand *pend = prepareSessionCommand(cmdSubmitVerifier);
connect(pend, &PendingCommand::finished, this, &RemoteClient::submitPasswordVerifierResponse);
sendCommand(pend);
}
void RemoteClient::submitPasswordVerifierResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
qCDebug(RemoteClientLog) << "Password verifier migrated successfully";
if (!pendingVerifier.isEmpty()) {
emit sigPasswordVerifierReady(pendingVerifier);
pendingVerifier.clear();
}
} else {
qCWarning(RemoteClientLog) << "Failed to migrate password verifier:" << response.response_code();
}
}
void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/)
{
doDisconnectFromServer();
@ -284,7 +325,70 @@ void RemoteClient::passwordSaltResponse(const Response &response)
auto passwordSalt = QString::fromStdString(resp.password_salt());
if (passwordSalt.isEmpty()) { // the server does not recognize the user but allows them to enter unregistered
password.clear(); // the password will not be used
storedVerifier.clear();
doLogin();
} else if (serverSupportsChallengeResponse && resp.has_nonce()) {
const QByteArray nonce = QByteArray::fromStdString(resp.nonce());
// needs_migration is a server-controlled flag over an unauthenticated
// transport (plain TCP by default). If we already hold a scrypt
// verifier for this account, a server telling us to fall back to the
// 1000-round legacy hash is a downgrade of the KDF — refuse it rather
// than hand an attacker a weakly-keyed HMAC they can attack offline.
if (resp.needs_migration() && PasswordHasher::parsePasswordVerifier(storedVerifier).isValid) {
emit loginError(Response::RespClientUpdateRequired,
QStringLiteral("Server asked to downgrade authentication for a migrated account."), 0,
{});
return;
}
QByteArray key;
if (resp.needs_migration()) {
// The account still uses the legacy format; the legacy full hash
// is only derivable from the plaintext password.
if (password.isEmpty()) {
emit loginError(Response::RespClientUpdateRequired,
QStringLiteral("This account must be logged in with its password once."), 0, {});
return;
}
key = PasswordHasher::computeHash(password, passwordSalt).toUtf8();
} else if (!password.isEmpty()) {
// A hostile server must not be able to make us run or allocate for
// unreasonable scrypt parameters.
const int n = resp.has_n() ? resp.n() : SCRYPT_N;
const int r = resp.has_r() ? resp.r() : SCRYPT_R;
const int p = resp.has_p() ? resp.p() : SCRYPT_P;
if (!PasswordHasher::costParamsAreSane(n, r, p)) {
emit loginError(Response::RespClientUpdateRequired,
QStringLiteral("The server requested unreasonable scrypt cost parameters."), 0, {});
return;
}
// password_salt may carry a legacy backup salt for pre-challenge clients;
// the verifier itself is always derived from scrypt_salt when present.
const QString scryptSalt =
resp.has_scrypt_salt() ? QString::fromStdString(resp.scrypt_salt()) : passwordSalt;
key = PasswordHasher::deriveKey(password, QByteArray::fromBase64(scryptSalt.toUtf8()), n, r, p);
if (key.isEmpty()) {
emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Unable to derive verifier."), 0,
{});
return;
}
derivedVerifier =
QString("$scrypt$%1$%2$%3$%4$%5").arg(n).arg(r).arg(p).arg(scryptSalt).arg(QString(key.toBase64()));
} else if (!storedVerifier.isEmpty()) {
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedVerifier);
if (!verifier.isValid) {
emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Stored verifier is invalid."),
0, {});
return;
}
key = verifier.verifier;
} else {
emit loginError(Response::RespLoginNeeded, {}, 0, {});
return;
}
passwordNeedsMigration = resp.needs_migration();
const QByteArray responseBytes = PasswordHasher::computeResponse(key, nonce);
hashedPassword = "$challenge$" + QString(nonce.toBase64()) + "$" + QString(responseBytes.toBase64());
doHashedLogin();
} else {
hashedPassword = PasswordHasher::computeHash(password, passwordSalt);
doHashedLogin();
@ -309,6 +413,14 @@ void RemoteClient::loginResponse(const Response &response)
setStatus(StatusLoggedIn);
emit userInfoChanged(resp.user_info());
if (passwordNeedsMigration) {
// The account still used the legacy password format; upgrade it to scrypt.
doSubmitPasswordVerifier();
} else if (!derivedVerifier.isEmpty()) {
emit sigPasswordVerifierReady(derivedVerifier);
derivedVerifier.clear();
}
QList<ServerInfo_User> buddyList;
for (int i = resp.buddy_list_size() - 1; i >= 0; --i) {
buddyList.append(resp.buddy_list(i));
@ -565,6 +677,8 @@ void RemoteClient::doDisconnectFromServer()
websocket->close();
}
socket->close();
derivedVerifier.clear();
pendingVerifier.clear();
}
void RemoteClient::ping()
@ -736,6 +850,12 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
emit sigForgotPasswordSuccess();
// Persist only a real scrypt verifier; a legacy hash must not be stored under
// the password key, where it would break future challenge-response logins.
if (!derivedVerifier.isEmpty()) {
emit sigPasswordVerifierReady(derivedVerifier);
derivedVerifier.clear();
}
} else {
emit sigForgotPasswordError();
}

View file

@ -66,6 +66,11 @@ signals:
unsigned int port,
const QString &_userName,
const QString &_email);
//! \brief Emitted once a scrypt verifier for the given account is known and
//! can be persisted instead of the plaintext password. The receiving side
//! should only store it for the connection it is currently negotiating, so
//! the hostname and user name are intentionally not part of the signal.
void sigPasswordVerifierReady(const QString &verifier);
private slots:
void slotConnected();
void readData();
@ -92,6 +97,8 @@ private slots:
void doLogin();
void doHashedLogin();
Command_Login generateCommandLogin();
void doSubmitPasswordVerifier();
void submitPasswordVerifierResponse(const Response &response);
void doDisconnectFromServer();
void doActivateToServer(const QString &_token);
void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName);
@ -123,6 +130,14 @@ private:
QString lastHostname;
unsigned int lastPort;
QString hashedPassword;
bool passwordNeedsMigration;
//! \brief A previously stored "$scrypt$..." verifier used to authenticate
//! without the plaintext password.
QString storedVerifier;
//! \brief Verifier derived during the current login, persisted after success.
QString derivedVerifier;
//! \brief Verifier sent for migration, persisted once the server accepts it.
QString pendingVerifier;
QString getSrvClientID(const QString &_hostname);
bool newMissingFeatureFound(const QString &_serversMissingFeatures);
@ -161,6 +176,12 @@ public:
}
void
connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password);
//! \brief Provide a stored "$scrypt$..." verifier so the client can
//! authenticate without the plaintext password.
void setStoredVerifier(const QString &verifier)
{
storedVerifier = verifier;
}
void registerToServer(const QString &hostname,
unsigned int port,
const QString &_userName,

View file

@ -257,6 +257,19 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r
return Response::RespInternalError;
}
// Card art rules apply to playmats as well: never broadcast a DENY-listed
// printing as a playmat. Stripping keeps the deck selectable while hiding
// the banned art from all clients.
const auto deckPlaymat = newDeck->getPlaymat();
if (!deckPlaymat.card.name.isEmpty()) {
auto *db = game->getRoom()->getServer()->getDatabaseInterface();
// Check the truncated values actually broadcast below; the rule columns are varchar(255).
if (db && !db->isCardArtAllowed(deckPlaymat.card.name.left(MAX_NAME_LENGTH),
deckPlaymat.card.providerId.left(MAX_NAME_LENGTH))) {
newDeck->setPlaymat({});
}
}
delete deck;
deck = newDeck;
sideboardLocked = true;
@ -631,6 +644,14 @@ Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &r
if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) {
return Response::RespInvalidData;
}
// Card art rules apply to playmats as well: reject DENY-listed art, like
// cmdSetCardArtParams does. Empty clears the playmat and is always allowed.
if (!rawName.isEmpty()) {
auto *db = game->getRoom()->getServer()->getDatabaseInterface();
if (db && !db->isCardArtAllowed(rawName, rawProviderId)) {
return Response::RespFunctionNotAllowed;
}
}
PlaymatInfo playmat;
playmat.card.name = rawName;
playmat.card.providerId = rawProviderId;

View file

@ -441,7 +441,7 @@ void Server::externalRoomUserLeft(int roomId, const QString &userName)
room->removeExternalUser(userName);
}
void Server::externalRoomSay(int roomId, const QString &userName, const QString &message)
void Server::externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId)
{
// This function is always called from the main thread via signal/slot.
QReadLocker locker(&roomsLock);
@ -451,10 +451,22 @@ void Server::externalRoomSay(int roomId, const QString &userName, const QString
qDebug() << "externalRoomSay: room id=" << roomId << "not found";
return;
}
room->say(userName, message, false);
if (!channelId.isEmpty() && !Server_Room::isMainChannel(channelId) && room->findChannel(channelId) == nullptr) {
// The receiver does not know this channel; say() would drop it, so do
// not log what was never delivered.
qDebug() << "externalRoomSay: unknown channel" << channelId << "for room id=" << roomId << "dropping";
return;
}
room->say(userName, message, channelId, false);
getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom,
room->getId(), room->getName());
if (channelId.isEmpty()) {
getDatabaseInterface()->logMessage(0, userName, "ISL", message, Server_DatabaseInterface::MessageTargetIslRoom,
room->getId(), room->getName());
} else {
getDatabaseInterface()->logMessage(0, userName, "ISL", message,
Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(),
room->getName(), channelId);
}
}
void Server::externalRoomRemoveMessages(int roomId, const QString &userName, int amount)

View file

@ -87,6 +87,11 @@ public:
{
return QMap<QString, bool>();
}
/** @brief True when only challenge-response logins are accepted (strict mode). */
virtual bool requiresChallengeResponseAuth() const
{
return false;
}
void addClient(Server_ProtocolHandler *player);
void removeClient(Server_ProtocolHandler *player);
QList<QString> getOnlineModeratorList() const;
@ -265,7 +270,7 @@ protected slots:
void externalUserLeft(const QString &userName);
void externalRoomUserJoined(int roomId, const ServerInfo_User &userInfo);
void externalRoomUserLeft(int roomId, const QString &userName);
void externalRoomSay(int roomId, const QString &userName, const QString &message);
void externalRoomSay(int roomId, const QString &userName, const QString &message, const QString &channelId);
void externalRoomRemoveMessages(int roomId, const QString &userName, int amount);
void externalRoomGameListChanged(int roomId, const ServerInfo_Game &gameInfo);
void

View file

@ -53,6 +53,14 @@ public:
{
return {};
}
virtual QString getUserPasswordData(const QString & /* user */)
{
return {};
}
virtual bool submitPasswordVerifier(const QString & /* user */, const QString & /* passwordVerifier */)
{
return false;
}
virtual QMap<QString, ServerInfo_User> getBuddyList(const QString & /* name */)
{
return QMap<QString, ServerInfo_User>();
@ -151,7 +159,8 @@ public:
MessageTargetRoom,
MessageTargetGame,
MessageTargetChat,
MessageTargetIslRoom
MessageTargetIslRoom,
MessageTargetRoomChannel
};
virtual void logMessage(const int /* senderId */,
const QString & /* senderName */,
@ -159,7 +168,8 @@ public:
const QString & /* logMessage */,
LogMessage_TargetType /* targetType */,
const int /* targetId */,
const QString & /* targetName */)
const QString & /* targetName */,
const QString & /* channel */ = QString())
{
}
virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */,
@ -188,6 +198,16 @@ public:
virtual void setForcePasswordChange(const QString & /* user */, bool /* force */)
{
}
/**
* @brief Whether the given card art may be shown (card art rules).
*
* Default allows everything; the real server overrides this with a lookup
* in {prefix}_card_art_name_rules. Empty names are always allowed (clear path).
*/
virtual bool isCardArtAllowed(const QString & /* cardName */, const QString & /* providerId */)
{
return true;
}
};
#endif

View file

@ -1,5 +1,8 @@
#include "server_protocolhandler.h"
// Challenge-response nonces are valid for at most one minute from issuance.
static constexpr qint64 kAuthNonceLifetimeSeconds = 60;
#include "game/game_config.h"
#include "game/server_abstract_participant.h"
#include "game/server_game.h"
@ -13,6 +16,7 @@
#include "libcockatrice/protocol/pb/server_message.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_game.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_room_channel.pb.h"
#include "libcockatrice/protocol/pb/session_commands.pb.h"
#include "libcockatrice/protocol/pb/session_event.pb.h"
#include "server_database_interface.h"
@ -40,6 +44,7 @@
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/event_list_rooms.pb.h>
#include <libcockatrice/protocol/pb/event_notify_user.pb.h>
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
#include <libcockatrice/protocol/pb/event_user_message.pb.h>
@ -70,6 +75,28 @@ Server_ProtocolHandler::~Server_ProtocolHandler()
{
}
void Server_ProtocolHandler::setAuthNonce(const QByteArray &nonce, const QString &userName)
{
authNonce = nonce;
authNonceUser = userName;
authNonceCreated = QDateTime::currentDateTimeUtc();
}
bool Server_ProtocolHandler::isAuthNonceValid(const QByteArray &nonce, const QString &userName) const
{
// secsTo is signed, so a wall-clock step backwards (NTP correction, VM resume)
// must not make the elapsed time negative and re-validate an old nonce.
const qint64 elapsed = authNonceCreated.secsTo(QDateTime::currentDateTimeUtc());
return !authNonce.isEmpty() && authNonce == nonce && authNonceUser == userName && authNonceCreated.isValid() &&
elapsed >= 0 && elapsed < kAuthNonceLifetimeSeconds;
}
void Server_ProtocolHandler::clearAuthNonce()
{
authNonce.clear();
authNonceUser.clear();
}
// This function must only be called from the thread this object lives in.
// Except when the server is shutting down.
// The thread must not hold any server locks when calling this (e.g. clientsLock, roomsLock).
@ -252,6 +279,9 @@ Response::ResponseCode Server_ProtocolHandler::processRoomCommandContainer(const
case RoomCommand::ROOM_SAY:
resp = cmdRoomSay(sc.GetExtension(Command_RoomSay::ext), room, rc);
break;
case RoomCommand::ROOM_CHANNEL_SAY:
resp = cmdRoomChannelSay(sc.GetExtension(Command_RoomChannelSay::ext), room, rc);
break;
case RoomCommand::CREATE_GAME:
resp = cmdCreateGame(sc.GetExtension(Command_CreateGame::ext), room, rc);
break;
@ -563,6 +593,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
return Response::RespContextError;
}
// In strict mode only challenge-response logins are accepted, but only for
// registered SQL accounts: guests (no DB row) and non-SQL authentication
// methods have no stored verifier to challenge against. userExists is false
// for both (it requires AuthenticationSql), so a single check covers them.
const bool isRegisteredAccount = databaseInterface != nullptr && databaseInterface->userExists(userName);
if (server->requiresChallengeResponseAuth() && isRegisteredAccount &&
(!cmd.has_hashed_password() || cmd.hashed_password().rfind("$challenge$", 0) != 0)) {
auto *re = new Response_Login;
re->set_denied_reason_str("Client upgrade required");
re->add_missing_features("challenge_response_auth");
rc.setResponseExtension(re);
return Response::RespClientUpdateRequired;
}
// check client feature set against server feature set
FeatureSet features;
QMap<QString, bool> receivedClientFeatures;
@ -764,7 +808,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetGamesOfUser(const Command_G
while (roomIterator.hasNext()) {
Server_Room *room = roomIterator.next().value();
room->gamesLock.lockForRead();
room->getInfo(*re->add_room_list(), false, true);
room->getInfo(*re->add_room_list(), false, true, true, userInfo);
QListIterator<ServerInfo_Game> gameIterator(room->getGamesOfUser(nameFromStdString(cmd.user_name())));
while (gameIterator.hasNext()) {
re->add_game_list()->CopyFrom(gameIterator.next());
@ -822,7 +866,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdListRooms(const Command_ListRo
Event_ListRooms event;
QMapIterator<int, Server_Room *> roomIterator(server->getRooms());
while (roomIterator.hasNext()) {
roomIterator.next().value()->getInfo(*event.add_room_list(), false);
roomIterator.next().value()->getInfo(*event.add_room_list(), false, false, true, userInfo);
}
rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event));
@ -856,10 +900,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo
rooms.insert(room->getId(), room);
QReadLocker chatHistoryLocker(&room->historyLock);
QList<ServerInfo_ChatMessage> chatHistory = room->getChatHistory();
ServerInfo_ChatMessage chatMessage;
for (int i = 0; i < chatHistory.size(); ++i) {
chatMessage = chatHistory.at(i);
const QList<ServerInfo_ChatMessage> mainChatHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY);
for (const ServerInfo_ChatMessage &chatMessage : mainChatHistory) {
Event_RoomSay roomChatHistory;
roomChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message());
roomChatHistory.set_message_type(Event_RoomSay::ChatHistory);
@ -868,13 +910,31 @@ Response::ResponseCode Server_ProtocolHandler::cmdJoinRoom(const Command_JoinRoo
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(roomChatHistory));
}
// Replay the history of every catalog channel the joining user may see.
// Public channels are replayed to everyone; restricted channels only to
// members whose privilege level meets the channel's access level.
for (const ServerInfo_RoomChannel &channel : room->getChannels()) {
if (!room->mayReadChannel(*userInfo, channel)) {
continue;
}
const QList<ServerInfo_ChatMessage> channelHistory = room->getChatHistory(QString::fromStdString(channel.id()));
for (const ServerInfo_ChatMessage &chatMessage : channelHistory) {
Event_RoomChannelSay channelChatHistory;
channelChatHistory.set_channel_id(channel.id());
channelChatHistory.set_message(chatMessage.sender_name() + ": " + chatMessage.message());
channelChatHistory.set_time_of(
QDateTime::fromString(QString::fromStdString(chatMessage.time())).toMSecsSinceEpoch());
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(channelChatHistory));
}
}
Event_RoomSay joinMessageEvent;
joinMessageEvent.set_message(room->getJoinMessage().toStdString());
joinMessageEvent.set_message_type(Event_RoomSay::Welcome);
rc.enqueuePostResponseItem(ServerMessage::ROOM_EVENT, room->prepareRoomEvent(joinMessageEvent));
auto *re = new Response_JoinRoom;
room->getInfo(*re->mutable_room_info(), true);
room->getInfo(*re->mutable_room_info(), true, false, true, userInfo);
rc.setResponseExtension(re);
return Response::RespOk;
@ -959,6 +1019,36 @@ Server_ProtocolHandler::cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room
return Response::RespOk;
}
Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_RoomChannelSay &cmd,
Server_Room *room,
ResponseContainer & /*rc*/)
{
const QString channelId = QString::fromStdString(cmd.channel_id());
const ServerInfo_RoomChannel *channel = room->findChannel(channelId);
if (channel == nullptr) {
// The client only ever offers catalog ids; a crafted or stale id is rejected.
return Response::RespInvalidCommand;
}
if (!room->mayWriteChannel(*userInfo, *channel)) {
return Response::RespFunctionNotAllowed;
}
if (!addSaidMessageSize(static_cast<int>(cmd.message().size()))) {
return Response::RespChatFlood;
}
QString msg = QString::fromStdString(cmd.message());
msg.replace(QChar('\n'), QChar(' '));
room->say(QString::fromStdString(userInfo->name()), msg, channelId);
databaseInterface->logMessage(
userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()), msg,
Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(), room->getName(), channelId);
return Response::RespOk;
}
Response::ResponseCode
Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc)
{

View file

@ -4,6 +4,8 @@
#include "server.h"
#include "server_abstractuserinterface.h"
#include <QByteArray>
#include <QDateTime>
#include <QList>
#include <QMap>
#include <QObject>
@ -30,6 +32,7 @@ class Command_ListRooms;
class Command_JoinRoom;
class Command_LeaveRoom;
class Command_RoomSay;
class Command_RoomChannelSay;
class Command_CreateGame;
class Command_JoinGame;
@ -46,6 +49,9 @@ protected:
bool acceptsUserListChanges;
bool acceptsRoomListChanges;
bool idleClientWarningSent;
QByteArray authNonce;
QString authNonceUser;
QDateTime authNonceCreated;
virtual void logDebugMessage(const QString & /* message */)
{
}
@ -66,6 +72,8 @@ private:
Response::ResponseCode cmdListUsers(const Command_ListUsers &cmd, ResponseContainer &rc);
Response::ResponseCode cmdLeaveRoom(const Command_LeaveRoom &cmd, Server_Room *room, ResponseContainer &rc);
Response::ResponseCode cmdRoomSay(const Command_RoomSay &cmd, Server_Room *room, ResponseContainer &rc);
Response::ResponseCode
cmdRoomChannelSay(const Command_RoomChannelSay &cmd, Server_Room *room, ResponseContainer &rc);
Response::ResponseCode cmdCreateGame(const Command_CreateGame &cmd, Server_Room *room, ResponseContainer &rc);
Response::ResponseCode cmdJoinGame(const Command_JoinGame &cmd, Server_Room *room, ResponseContainer &rc);
@ -121,6 +129,13 @@ public:
return databaseInterface;
}
/** @brief Store a fresh challenge nonce bound to @p userName for the next challenge-response login attempt. */
void setAuthNonce(const QByteArray &nonce, const QString &userName);
/** @brief True if nonce matches the pending one, was issued for @p userName, and is less than 60 seconds old. */
bool isAuthNonceValid(const QByteArray &nonce, const QString &userName) const;
/** @brief Invalidate the pending nonce (single-use). */
void clearAuthNonce();
int getLastCommandTime() const
{
return timeRunning - lastDataReceived;

View file

@ -26,16 +26,20 @@
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
#include <libcockatrice/protocol/pb/event_list_games.pb.h>
#include <libcockatrice/protocol/pb/event_remove_messages.pb.h>
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
#include <libcockatrice/protocol/pb/room_commands.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
#include <libcockatrice/utility/string_limits.h>
#include <qlogging.h>
#include <qnamespace.h>
#include <string>
#include <utility>
const QString Server_Room::MAIN_CHANNEL_KEY = QStringLiteral("__main__");
Server_Room::Server_Room(int _id,
int _chatHistorySize,
const QString &_name,
@ -45,10 +49,11 @@ Server_Room::Server_Room(int _id,
bool _autoJoin,
const QString &_joinMessage,
const QStringList &_gameTypes,
Server *parent)
Server *parent,
const QList<ServerInfo_RoomChannel> &_channels)
: QObject(parent), id(_id), chatHistorySize(_chatHistorySize), name(_name), description(_description),
permissionLevel(_permissionLevel), privilegeLevel(_privilegeLevel), autoJoin(_autoJoin),
joinMessage(_joinMessage), gameTypes(_gameTypes), gamesLock(QReadWriteLock::Recursive)
joinMessage(_joinMessage), gameTypes(_gameTypes), channels(_channels), gamesLock(QReadWriteLock::Recursive)
{
connect(
this, &Server_Room::gameListChanged, this, [this](auto gameInfo) { broadcastGameListUpdate(gameInfo); },
@ -102,8 +107,11 @@ Server *Server_Room::getServer() const
return static_cast<Server *>(parent());
}
const ServerInfo_Room &
Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes, bool includeExternalData) const
const ServerInfo_Room &Server_Room::getInfo(ServerInfo_Room &result,
bool complete,
bool showGameTypes,
bool includeExternalData,
const ServerInfo_User *forUser) const
{
result.set_room_id(id);
result.set_name(name.toStdString());
@ -152,9 +160,73 @@ Server_Room::getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes,
}
}
for (const ServerInfo_RoomChannel &channel : channels) {
if (forUser != nullptr && !mayReadChannel(*forUser, channel)) {
continue;
}
result.add_channel_list()->CopyFrom(channel);
}
return result;
}
bool Server_Room::isMainChannel(const QString &channelId)
{
return channelId.isEmpty() || channelId == MAIN_CHANNEL_KEY;
}
const ServerInfo_RoomChannel *Server_Room::findChannel(const QString &channelId) const
{
for (const ServerInfo_RoomChannel &channel : channels) {
if (QString::fromStdString(channel.id()) == channelId) {
return &channel;
}
}
return nullptr;
}
bool Server_Room::hasChannelPrivilege(const ServerInfo_User &userInfo,
ServerInfo_RoomChannel::AccessLevel requiredLevel)
{
// User flags are independent bits, not a ladder: holding IsDeveloper does
// not imply IsModerator or IsAdmin. The database grants admins the admin
// and the moderator bit together, so admins still reach moderator (and
// judge) channels; moderators likewise reach judge channels.
const uint32_t userLevel = userInfo.user_level();
switch (requiredLevel) {
case ServerInfo_RoomChannel::Public:
return true;
case ServerInfo_RoomChannel::Registered:
return userLevel & ServerInfo_User::IsRegistered;
case ServerInfo_RoomChannel::Judge:
return userLevel & (ServerInfo_User::IsJudge | ServerInfo_User::IsModerator);
case ServerInfo_RoomChannel::Moderator:
return userLevel & ServerInfo_User::IsModerator;
case ServerInfo_RoomChannel::Admin:
return userLevel & ServerInfo_User::IsAdmin;
case ServerInfo_RoomChannel::Developer:
return userLevel & (ServerInfo_User::IsDeveloper | ServerInfo_User::IsAdmin);
default:
return false;
}
}
bool Server_Room::mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel)
{
return hasChannelPrivilege(userInfo, channel.access_level());
}
bool Server_Room::mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel)
{
const auto writeLevel = channel.has_write_access_level() ? channel.write_access_level() : channel.access_level();
return mayReadChannel(userInfo, channel) && hasChannelPrivilege(userInfo, writeLevel);
}
QList<ServerInfo_ChatMessage> Server_Room::getChatHistory(const QString &channelId) const
{
return channelChatHistory.value(isMainChannel(channelId) ? MAIN_CHANNEL_KEY : channelId);
}
RoomEvent *Server_Room::prepareRoomEvent(const ::google::protobuf::Message &roomEvent)
{
auto *event = new RoomEvent;
@ -310,12 +382,35 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam
return result;
}
void Server_Room::say(const QString &userName, const QString &userMessage, bool sendToIsl)
void Server_Room::say(const QString &userName, const QString &userMessage, const QString &channelId, bool sendToIsl)
{
Event_RoomSay event;
event.set_name(userName.toStdString());
event.set_message(userMessage.toStdString());
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
const bool isMain = isMainChannel(channelId);
ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public;
if (!isMain) {
const ServerInfo_RoomChannel *channel = findChannel(channelId);
if (channel == nullptr) {
// The channel was not whitelisted against the catalog; drop the message.
return;
}
accessLevel = channel->access_level();
// V1 does not relay restricted channels across ISL (plan N5).
if (accessLevel != ServerInfo_RoomChannel::Public) {
sendToIsl = false;
}
}
if (isMain) {
Event_RoomSay event;
event.set_name(userName.toStdString());
event.set_message(userMessage.toStdString());
sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel);
} else {
Event_RoomChannelSay event;
event.set_channel_id(channelId.toStdString());
event.set_name(userName.toStdString());
event.set_message(userMessage.toStdString());
sendRoomEvent(prepareRoomEvent(event), sendToIsl, accessLevel);
}
if (chatHistorySize != 0) {
ServerInfo_ChatMessage chatMessage;
@ -324,13 +419,18 @@ void Server_Room::say(const QString &userName, const QString &userMessage, bool
chatMessage.set_time(dateTimeString.toStdString());
chatMessage.set_sender_name(userName.toStdString());
chatMessage.set_message(userMessage.simplified().toStdString());
historyLock.lockForWrite();
if (chatHistory.size() >= chatHistorySize) {
chatHistory.removeAt(0);
if (!isMain) {
chatMessage.set_channel(channelId.toStdString());
}
chatHistory.push_back(std::move(chatMessage));
const QString storageKey = isMain ? MAIN_CHANNEL_KEY : channelId;
historyLock.lockForWrite();
QList<ServerInfo_ChatMessage> &history = channelChatHistory[storageKey];
if (history.size() >= chatHistorySize) {
history.removeAt(0);
}
history.push_back(std::move(chatMessage));
historyLock.unlock();
}
}
@ -344,26 +444,34 @@ void Server_Room::removeSaidMessages(const QString &userName, int amount, bool s
sendRoomEvent(prepareRoomEvent(event), sendToIsl);
if (chatHistorySize != 0) {
int removed = 0;
historyLock.lockForWrite();
// redact [amount] of the most recent messages from this user from history
for (auto message = chatHistory.rbegin(); message != chatHistory.rend() && removed != amount; ++message) {
if (message->sender_name() == stdStringUserName) {
message->clear_message();
++removed;
// redact [amount] of the most recent messages from this user from every channel history,
// so removed content does not resurface for late joiners on any stream
for (auto historyIt = channelChatHistory.begin(); historyIt != channelChatHistory.end(); ++historyIt) {
QList<ServerInfo_ChatMessage> &history = historyIt.value();
int removed = 0;
for (auto message = history.rbegin(); message != history.rend() && removed != amount; ++message) {
if (message->sender_name() == stdStringUserName) {
message->clear_message();
++removed;
}
}
}
historyLock.unlock();
}
}
void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl)
void Server_Room::sendRoomEvent(RoomEvent *event, bool sendToIsl, ServerInfo_RoomChannel::AccessLevel accessLevel)
{
usersLock.lockForRead();
{
QMapIterator<QString, Server_ProtocolHandler *> userIterator(users);
while (userIterator.hasNext()) {
userIterator.next().value()->sendProtocolItem(*event);
Server_ProtocolHandler *user = userIterator.next().value();
if (!hasChannelPrivilege(*user->getUserInfo(), accessLevel)) {
continue;
}
user->sendProtocolItem(*event);
}
}
usersLock.unlock();

View file

@ -9,6 +9,7 @@
#include <QStringList>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
#include <qtmetamacros.h>
class Server_ProtocolHandler;
@ -47,15 +48,18 @@ private:
bool autoJoin;
QString joinMessage;
QStringList gameTypes;
QList<ServerInfo_RoomChannel> channels;
QMap<int, Server_Game *> games;
QMap<int, ServerInfo_Game> externalGames;
QMap<QString, Server_ProtocolHandler *> users;
QMap<QString, ServerInfo_User_Container> externalUsers;
QList<ServerInfo_ChatMessage> chatHistory;
QMap<QString, QList<ServerInfo_ChatMessage>> channelChatHistory;
private slots:
void broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool sendToIsl = true);
public:
static const QString MAIN_CHANNEL_KEY;
mutable QReadWriteLock usersLock;
mutable QReadWriteLock gamesLock;
mutable QReadWriteLock historyLock;
@ -68,7 +72,8 @@ public:
bool _autoJoin,
const QString &_joinMessage,
const QStringList &_gameTypes,
Server *parent);
Server *parent,
const QList<ServerInfo_RoomChannel> &_channels = {});
~Server_Room() override;
int getId() const
{
@ -103,6 +108,35 @@ public:
{
return gameTypes;
}
const QList<ServerInfo_RoomChannel> &getChannels() const
{
return channels;
}
/**
* @brief Looks up a catalog channel by its stable id.
* @return The matching channel, or nullptr when the id is not in the catalog.
*/
const ServerInfo_RoomChannel *findChannel(const QString &channelId) const;
/** @brief True when @p channelId refers to the implicit Main channel. */
static bool isMainChannel(const QString &channelId);
/**
* @brief True when the flags in @p userInfo satisfy @p requiredLevel.
*
* User flags are independent bits, not a ladder: IsDeveloper does not
* imply IsModerator or IsAdmin, so developer-only users cannot read or
* post in moderator/admin channels. Admins hold the moderator bit
* alongside the admin bit and moderators are admitted to judge channels.
*/
static bool hasChannelPrivilege(const ServerInfo_User &userInfo, ServerInfo_RoomChannel::AccessLevel requiredLevel);
/** @brief True when @p userInfo may read @p channel. */
static bool mayReadChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel);
/**
* @brief True when @p userInfo may write to @p channel.
*
* Writing requires the channel's write level, or — when no explicit write
* level is configured — the same level as reading.
*/
static bool mayWriteChannel(const ServerInfo_User &userInfo, const ServerInfo_RoomChannel &channel);
const QMap<int, Server_Game *> &getGames() const
{
return games;
@ -112,14 +146,14 @@ public:
return externalGames;
}
Server *getServer() const;
const ServerInfo_Room &
getInfo(ServerInfo_Room &result, bool complete, bool showGameTypes = false, bool includeExternalData = true) const;
const ServerInfo_Room &getInfo(ServerInfo_Room &result,
bool complete,
bool showGameTypes = false,
bool includeExternalData = true,
const ServerInfo_User *forUser = nullptr) const;
int getGamesCreatedByUser(const QString &name) const;
QList<ServerInfo_Game> getGamesOfUser(const QString &name) const;
QList<ServerInfo_ChatMessage> &getChatHistory()
{
return chatHistory;
}
QList<ServerInfo_ChatMessage> getChatHistory(const QString &channelId) const;
void addClient(Server_ProtocolHandler *client);
void removeClient(Server_ProtocolHandler *client);
@ -136,13 +170,15 @@ public:
ResponseContainer &rc,
Server_AbstractUserInterface *userInterface);
void say(const QString &userName, const QString &s, bool sendToIsl = true);
void say(const QString &userName, const QString &s, const QString &channelId = QString(), bool sendToIsl = true);
void removeSaidMessages(const QString &userName, int amount, bool sendToIsl = true);
void addGame(Server_Game *game);
void removeGame(Server_Game *game);
void sendRoomEvent(RoomEvent *event, bool sendToIsl = true);
void sendRoomEvent(RoomEvent *event,
bool sendToIsl = true,
ServerInfo_RoomChannel::AccessLevel accessLevel = ServerInfo_RoomChannel::Public);
RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent);
};

View file

@ -26,7 +26,8 @@ void FeatureSet::initalizeFeatureList(QMap<QString, bool> &_featureList)
_featureList.insert("idle_client", false);
_featureList.insert("forgot_password", false);
_featureList.insert("websocket", false);
// featureList.insert("hashed_password_login", false);
_featureList.insert("hashed_password_login", false);
_featureList.insert("challenge_response_auth", false);
// These are temp to force users onto a newer client
_featureList.insert("2.7.0_min_version", false);
_featureList.insert("2.8.0_min_version", false);

View file

@ -117,6 +117,7 @@ set(PROTO_FILES
event_reveal_cards.proto
event_reverse_turn.proto
event_roll_die.proto
event_room_channel_say.proto
event_room_say.proto
event_server_complete_list.proto
event_server_identification.proto
@ -201,6 +202,7 @@ set(PROTO_FILES
serverinfo_replay_match.proto
serverinfo_report.proto
serverinfo_room.proto
serverinfo_room_channel.proto
serverinfo_user.proto
serverinfo_user_alt.proto
serverinfo_user_session.proto

View file

@ -0,0 +1,12 @@
syntax = "proto2";
import "room_event.proto";
message Event_RoomChannelSay {
extend RoomEvent {
optional Event_RoomChannelSay ext = 1005; // next free RoomEvent extension
}
optional string channel_id = 1;
optional string name = 2;
optional string message = 3;
optional uint64 time_of = 4;
}

View file

@ -13,4 +13,10 @@ message Event_ServerIdentification {
optional string server_version = 2;
optional uint32 protocol_version = 3;
optional ServerOptions server_options = 4 [default = NoOptions];
// Advertises challenge-response authentication separately from server_options.
// server_options must stay exactly SupportsPasswordHash on the wire: it is a
// proto2 enum (closed), so already-released clients compiled without any new
// enumerator parse an unknown value as NoOptions and fall back to sending
// the plaintext password. New clients check this field instead.
optional bool supports_challenge_response = 5;
}

View file

@ -5,5 +5,23 @@ message Response_PasswordSalt {
extend Response {
optional Response_PasswordSalt ext = 1017;
}
// Salt for the legacy password hash. Pre-challenge clients derive and send
// that hash from it. For migrated accounts holding a fresh legacy backup
// this carries the backup's salt so those clients can still log in; clients
// that speak challenge-response must use scrypt_salt instead when present.
optional string password_salt = 1;
// scrypt cost parameters for the verifier. Absent/zero for legacy accounts.
optional int32 n = 2;
optional int32 r = 3;
optional int32 p = 4;
// Server-generated challenge. When present the client must authenticate
// with a challenge-response instead of transmitting the password hash.
optional bytes nonce = 5;
// True when the account still uses the legacy password format and should
// be migrated to the scrypt format after a successful login.
optional bool needs_migration = 6;
// Salt of the stored scrypt verifier. Present for migrated accounts;
// challenge-response clients derive the verifier from this rather than
// from password_salt, which may carry a legacy backup salt.
optional string scrypt_salt = 7;
}

View file

@ -5,6 +5,7 @@ message RoomCommand {
ROOM_SAY = 1001;
CREATE_GAME = 1002;
JOIN_GAME = 1003;
ROOM_CHANNEL_SAY = 1004;
}
extensions 100 to max;
}
@ -22,6 +23,14 @@ message Command_RoomSay {
optional string message = 1;
}
message Command_RoomChannelSay {
extend RoomCommand {
optional Command_RoomChannelSay ext = 1004;
}
optional string channel_id = 1;
optional string message = 2;
}
// Create a new game in the room
message Command_CreateGame {
extend RoomCommand {

View file

@ -6,6 +6,7 @@ message RoomEvent {
ROOM_SAY = 1002;
LIST_GAMES = 1003;
REMOVE_MESSAGES = 1004;
ROOM_CHANNEL_SAY = 1005;
}
optional sint32 room_id = 1;
extensions 100 to max;

View file

@ -13,4 +13,5 @@ message ServerInfo_ChatMessage {
optional string target_type = 6; // target type (room,game,chat)
optional string target_id = 7; // id of target
optional string target_name = 8; // name of target
optional string channel = 9; // channel id, for room_channel targets
}

View file

@ -2,6 +2,7 @@ syntax = "proto2";
import "serverinfo_game.proto";
import "serverinfo_user.proto";
import "serverinfo_gametype.proto";
import "serverinfo_room_channel.proto";
message ServerInfo_Room {
optional sint32 room_id = 1;
@ -15,4 +16,5 @@ message ServerInfo_Room {
repeated ServerInfo_GameType gametype_list = 9;
optional string permissionlevel = 10;
optional string privilegelevel = 11;
repeated ServerInfo_RoomChannel channel_list = 12;
}

View file

@ -0,0 +1,25 @@
syntax = "proto2";
message ServerInfo_RoomChannel {
enum AccessLevel {
// Required user flag set, evaluated per level (not as a ladder):
// Registered needs IsRegistered; Judge needs IsJudge or IsModerator;
// Moderator needs IsModerator (admins hold it alongside IsAdmin);
// Admin needs IsAdmin; Developer needs IsDeveloper or IsAdmin.
Public = 0;
Registered = 1;
Judge = 2;
Moderator = 3;
Admin = 4;
Developer = 5;
}
optional string id = 1; // stable ASCII id, e.g. "deck-discussion"
optional string display_name = 2; // server-provided, localizable only server-side
optional AccessLevel access_level = 3 [default = Public]; // minimum privilege level required to read the channel
// Minimum privilege level required to write to the channel. When unset, writing requires the same level as
// reading. This allows read-mostly channels such as a developer news feed: readable by everyone, writable
// only by developers.
optional AccessLevel write_access_level = 4;
// Static banner shown at the top of the channel, analogous to the room join message on Main. Empty = none.
optional string banner_message = 5;
}

View file

@ -36,6 +36,7 @@ message SessionCommand {
DECK_DOWNLOAD_PUBLIC = 1031;
DECK_SHARE_LIST_MINE = 1032;
DECK_SHARE_REMOVE = 1033;
SUBMIT_PASSWORD_VERIFIER = 1034;
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102;
@ -231,3 +232,14 @@ message Command_SetCardArtParams {
optional double vertical_offset = 5;
optional double zoom = 6;
}
// Client uploads the new password verifier to migrate a legacy account
// after a successful challenge-response login. Idempotent; only applies
// to accounts still using the legacy password format.
message Command_SubmitPasswordVerifier {
extend SessionCommand {
optional Command_SubmitPasswordVerifier ext = 1034;
}
// Full verifier string to store, e.g. "$scrypt$32768$8$1$<salt>$<verifier>"
required string password_verifier = 1;
}

View file

@ -31,6 +31,16 @@ QString ChatSettings::getChatHighlightColor() const
return getValue("highlightColor", QString(), QString(), "A6120D").toString();
}
QString ChatSettings::getChannelUnreadColor() const
{
return getValue("channelUnreadColor", QString(), QString(), "1A7B00").toString();
}
QString ChatSettings::getChannelMentionColor() const
{
return getValue("channelMentionColor", QString(), QString(), "A6120D").toString();
}
bool ChatSettings::getChatMentionForeground() const
{
return getValue("mentionForeground", QString(), QString(), true).toBool();
@ -107,6 +117,16 @@ void ChatSettings::setChatHighlightColor(const QString &_chatHighlightColor)
setValue(_chatHighlightColor, "highlightColor");
}
void ChatSettings::setChannelUnreadColor(const QString &_channelUnreadColor)
{
setValue(_channelUnreadColor, "channelUnreadColor");
}
void ChatSettings::setChannelMentionColor(const QString &_channelMentionColor)
{
setValue(_channelMentionColor, "channelMentionColor");
}
void ChatSettings::setChatMentionForeground(bool _chatMentionForeground)
{
setValue(_chatMentionForeground, "mentionForeground");

View file

@ -19,6 +19,8 @@ public:
[[nodiscard]] bool getChatMentionCompleter() const override;
[[nodiscard]] QString getChatMentionColor() const override;
[[nodiscard]] QString getChatHighlightColor() const override;
[[nodiscard]] QString getChannelUnreadColor() const override;
[[nodiscard]] QString getChannelMentionColor() const override;
[[nodiscard]] bool getChatMentionForeground() const override;
[[nodiscard]] bool getChatHighlightForeground() const override;
[[nodiscard]] bool getIgnoreUnregisteredUsers() const override;
@ -35,6 +37,8 @@ public:
void setChatMentionCompleter(bool _chatMentionCompleter);
void setChatMentionColor(const QString &_chatMentionColor);
void setChatHighlightColor(const QString &_chatHighlightColor);
void setChannelUnreadColor(const QString &_channelUnreadColor);
void setChannelMentionColor(const QString &_channelMentionColor);
void setChatMentionForeground(bool _chatMentionForeground);
void setChatHighlightForeground(bool _chatHighlightForeground);
void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers);

View file

@ -155,6 +155,19 @@ void ServersSettings::setFPPlayerName(QString playerName)
setValue(playerName, "fpPlayerName");
}
void ServersSettings::setServerPassword(const QString &saveName, const QString &password)
{
const int index = getPrevioushostindex(saveName);
if (index >= 0) {
setValue(password, QString("password%1").arg(index), "server", "server_details");
} else {
// A credential write that silently no-ops is hard to diagnose from a
// bug report; surface a mismatched profile name instead.
qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName
<< "- password not saved";
}
}
QString ServersSettings::getFPPlayerName(QString defaultName) const
{
QVariant name = getValue("fpPlayerName");

View file

@ -51,6 +51,8 @@ public:
void setFPHostName(QString hostname);
void setFPPort(QString port);
void setFPPlayerName(QString playerName);
//! \brief Store a password (or a "$scrypt$..." verifier) for the given saved server.
void setServerPassword(const QString &saveName, const QString &password);
void addNewServer(const QString &saveName,
const QString &serv,
const QString &port,

View file

@ -13,6 +13,7 @@ set(UTILITY_SOURCES
libcockatrice/utility/report_categories.cpp
libcockatrice/utility/server_rate_limiter.cpp
libcockatrice/utility/translation_loader.cpp
libcockatrice/utility/user_data_backup.cpp
libcockatrice/utility/warning_categories.cpp
)
@ -35,6 +36,7 @@ set(UTILITY_HEADERS
libcockatrice/utility/days_years_between.h
libcockatrice/utility/server_rate_limiter.h
libcockatrice/utility/translation_loader.h
libcockatrice/utility/user_data_backup.h
libcockatrice/utility/warning_categories.h
)

View file

@ -1,8 +1,16 @@
#include "cryptoutil.h"
#include <QStringList>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <qlogging.h>
// Sealed secret layout: "enc1:<base64 nonce>:<base64 ciphertext+tag>".
static constexpr int SECRET_KEY_LENGTH = 32;
static constexpr int SECRET_NONCE_LENGTH = 12;
static constexpr int SECRET_TAG_LENGTH = 16;
static const char *SECRET_FORMAT_PREFIX = "enc1";
namespace CryptoUtil
{
QByteArray randomBytes(int count)
@ -23,4 +31,99 @@ quint64 randomUInt64()
}
return value;
}
QString encryptSecret(const QByteArray &plaintext, const QByteArray &key, const QByteArray &aad)
{
if (key.size() != SECRET_KEY_LENGTH) {
return {};
}
const QByteArray nonce = randomBytes(SECRET_NONCE_LENGTH);
QByteArray ciphertext(plaintext.size() + SECRET_TAG_LENGTH, Qt::Uninitialized);
QByteArray tag(SECRET_TAG_LENGTH, Qt::Uninitialized);
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if (ctx == nullptr) {
return {};
}
int written = 0;
int finalWritten = 0;
bool ok = EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 &&
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, nonce.size(), nullptr) == 1 &&
EVP_EncryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast<const unsigned char *>(key.constData()),
reinterpret_cast<const unsigned char *>(nonce.constData())) == 1;
if (ok && !aad.isEmpty()) {
ok = EVP_EncryptUpdate(ctx, nullptr, &written, reinterpret_cast<const unsigned char *>(aad.constData()),
aad.size()) == 1;
}
if (ok) {
ok = EVP_EncryptUpdate(ctx, reinterpret_cast<unsigned char *>(ciphertext.data()), &written,
reinterpret_cast<const unsigned char *>(plaintext.constData()), plaintext.size()) == 1;
}
if (ok) {
ok = EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(ciphertext.data() + written), &finalWritten) ==
1 &&
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, SECRET_TAG_LENGTH, tag.data()) == 1;
}
EVP_CIPHER_CTX_free(ctx);
if (!ok) {
return {};
}
ciphertext.truncate(written + finalWritten);
ciphertext.append(tag);
return QString::fromLatin1(SECRET_FORMAT_PREFIX) + QLatin1Char(':') + nonce.toBase64() + QLatin1Char(':') +
ciphertext.toBase64();
}
QByteArray decryptSecret(const QString &sealed, const QByteArray &key, const QByteArray &aad)
{
if (key.size() != SECRET_KEY_LENGTH) {
return {};
}
const QStringList parts = sealed.split(QLatin1Char(':'));
if (parts.size() != 3 || parts.at(0) != QLatin1String(SECRET_FORMAT_PREFIX)) {
return {};
}
const QByteArray nonce = QByteArray::fromBase64(parts.at(1).toUtf8());
const QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8());
if (nonce.size() != SECRET_NONCE_LENGTH || ciphertext.size() < SECRET_TAG_LENGTH) {
return {};
}
const int plaintextLength = ciphertext.size() - SECRET_TAG_LENGTH;
QByteArray plaintext(qMax(plaintextLength, 1), Qt::Uninitialized);
QByteArray tag = ciphertext.right(SECRET_TAG_LENGTH);
const QByteArray body = ciphertext.left(plaintextLength);
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if (ctx == nullptr) {
return {};
}
int written = 0;
int finalWritten = 0;
bool ok = EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 &&
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, nonce.size(), nullptr) == 1 &&
EVP_DecryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast<const unsigned char *>(key.constData()),
reinterpret_cast<const unsigned char *>(nonce.constData())) == 1;
if (ok && !aad.isEmpty()) {
ok = EVP_DecryptUpdate(ctx, nullptr, &written, reinterpret_cast<const unsigned char *>(aad.constData()),
aad.size()) == 1;
}
if (ok) {
// The tag must be set before the final call, which authenticates it; a
// mismatch makes EVP_DecryptFinal_ex fail and the secret stays opaque.
ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, tag.size(), tag.data()) == 1 &&
EVP_DecryptUpdate(ctx, reinterpret_cast<unsigned char *>(plaintext.data()), &written,
reinterpret_cast<const unsigned char *>(body.constData()), body.size()) == 1;
}
if (ok) {
ok =
EVP_DecryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(plaintext.data() + written), &finalWritten) == 1;
}
EVP_CIPHER_CTX_free(ctx);
if (!ok) {
return {};
}
plaintext.truncate(written + finalWritten);
return plaintext;
}
} // namespace CryptoUtil

View file

@ -2,12 +2,21 @@
#define CRYPTOUTIL_H
#include <QByteArray>
#include <QString>
#include <QtGlobal>
namespace CryptoUtil
{
QByteArray randomBytes(int count);
quint64 randomUInt64();
/** @brief Seal a secret with AES-256-GCM as "enc1:<b64 nonce>:<b64 ciphertext+tag>".
* Returns an empty string unless @p key is a valid 32-byte key and the cipher
* completes. @p aad is authenticated but not stored (bind the ciphertext to its
* context, e.g. the user name it belongs to). */
QString encryptSecret(const QByteArray &plaintext, const QByteArray &key, const QByteArray &aad = {});
/** @brief Open a string produced by encryptSecret(). Returns an empty array on any
* failure: unknown format, malformed base64, wrong key, wrong aad, or tampering. */
QByteArray decryptSecret(const QString &sealed, const QByteArray &key, const QByteArray &aad = {});
} // namespace CryptoUtil
#endif

View file

@ -4,6 +4,9 @@
#include <QChar>
#include <QCryptographicHash>
#include <libcockatrice/utility/cryptoutil.h>
#include <openssl/crypto.h>
#include <openssl/evp.h>
#include <openssl/hmac.h>
QString PasswordHasher::computeHash(const QString &password, const QString &salt)
{
@ -54,3 +57,114 @@ QString PasswordHasher::generateActivationToken()
{
return QString(CryptoUtil::randomBytes(16).toBase64().left(16));
}
QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p)
{
QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0');
const QByteArray passwordUtf8 = password.toUtf8();
// EVP_PBE_scrypt aborts unless maxmem covers the required working memory,
// which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array).
const auto maxmem = static_cast<quint64>(128) * n * r + static_cast<quint64>(128) * r * p + 4096;
if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(),
reinterpret_cast<const unsigned char *>(salt.constData()), salt.size(), n, r, p, maxmem,
reinterpret_cast<unsigned char *>(key.data()), key.size()) != 1) {
return QByteArray();
}
return key;
}
bool PasswordHasher::costParamsAreSane(int n, int r, int p)
{
// Bounds adopted during review: n in [1024, 2**20] and a power of two, r in [1, 32],
// p in [1, 16]. Anything else is rejected before we allocate or derive for it.
return n >= 1024 && n <= (1 << 20) && (n & (n - 1)) == 0 && r >= 1 && r <= 32 && p >= 1 && p <= 16;
}
QString PasswordHasher::generatePasswordVerifier(const QString &password)
{
const QByteArray salt = CryptoUtil::randomBytes(SCRYPT_SALT_LENGTH);
const QByteArray verifier = deriveKey(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P);
return QString("$scrypt$%1$%2$%3$%4$%5")
.arg(SCRYPT_N)
.arg(SCRYPT_R)
.arg(SCRYPT_P)
.arg(QString(salt.toBase64()))
.arg(QString(verifier.toBase64()));
}
PasswordVerifier PasswordHasher::parsePasswordVerifier(const QString &stored)
{
PasswordVerifier result;
const QStringList parts = stored.split("$");
if (parts.size() != 7 || parts.at(1) != "scrypt") {
return result;
}
bool ok = false;
const int n = parts.at(2).toInt(&ok);
if (!ok) {
return result;
}
const int r = parts.at(3).toInt(&ok);
if (!ok) {
return result;
}
const int p = parts.at(4).toInt(&ok);
if (!ok || !costParamsAreSane(n, r, p)) {
return result;
}
const QByteArray salt = QByteArray::fromBase64(parts.at(5).toUtf8());
const QByteArray verifier = QByteArray::fromBase64(parts.at(6).toUtf8());
if (salt.isEmpty() || verifier.size() != SCRYPT_VERIFIER_LENGTH) {
return result;
}
result.format = PasswordFormat::Scrypt;
result.n = n;
result.r = r;
result.p = p;
result.salt = salt;
result.verifier = verifier;
result.isValid = true;
return result;
}
bool PasswordHasher::isLegacyFormat(const QString &stored)
{
return !stored.startsWith("$");
}
bool PasswordHasher::verifyPassword(const QString &password, const QString &storedPasswordData)
{
if (isLegacyFormat(storedPasswordData)) {
return storedPasswordData == computeHash(password, storedPasswordData.left(16));
}
const PasswordVerifier verifier = parsePasswordVerifier(storedPasswordData);
if (!verifier.isValid) {
return false;
}
const QByteArray derived = deriveKey(password, verifier.salt, verifier.n, verifier.r, verifier.p);
return !derived.isEmpty() && constantTimeEquals(derived, verifier.verifier);
}
QByteArray PasswordHasher::computeResponse(const QByteArray &key, const QByteArray &nonce)
{
QByteArray response(EVP_MAX_MD_SIZE, '\0');
unsigned int responseLength = 0;
if (HMAC(EVP_sha256(), key.constData(), key.size(), reinterpret_cast<const unsigned char *>(nonce.constData()),
nonce.size(), reinterpret_cast<unsigned char *>(response.data()), &responseLength) == nullptr) {
qFatal("PasswordHasher::computeResponse: HMAC failed");
}
response.resize(responseLength);
return response;
}
bool PasswordHasher::constantTimeEquals(const QByteArray &a, const QByteArray &b)
{
if (a.size() != b.size()) {
return false;
}
return CRYPTO_memcmp(a.constData(), b.constData(), a.size()) == 0;
}

View file

@ -1,14 +1,58 @@
#ifndef PASSWORDHASHER_H
#define PASSWORDHASHER_H
#include <QByteArray>
#include <QObject>
#include <QString>
// scrypt cost parameters used for newly created password verifiers. These match
// the RFC 7914 recommended parameters for interactive use.
constexpr int SCRYPT_N = 32768;
constexpr int SCRYPT_R = 8;
constexpr int SCRYPT_P = 1;
constexpr int SCRYPT_SALT_LENGTH = 16;
constexpr int SCRYPT_VERIFIER_LENGTH = 64;
enum class PasswordFormat
{
None = 0,
Scrypt
};
struct PasswordVerifier
{
PasswordFormat format = PasswordFormat::None;
int n = 0;
int r = 0;
int p = 0;
QByteArray salt;
QByteArray verifier;
bool isValid = false;
};
class PasswordHasher
{
public:
static QString computeHash(const QString &password, const QString &salt);
static QString generateRandomSalt(const int len = 16);
static QString generateActivationToken();
/** @brief Derive the scrypt verifier for the given password, salt and cost parameters. Empty on failure. */
static QByteArray deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p);
/** @brief True if the scrypt cost parameters are acceptable for server and client use. */
static bool costParamsAreSane(int n, int r, int p);
/** @brief Build a "$scrypt$<n>$<r>$<p>$<salt>$<verifier>" string with a fresh random salt. */
static QString generatePasswordVerifier(const QString &password);
/** @brief Parse a stored "$scrypt$..." string into its components. */
static PasswordVerifier parsePasswordVerifier(const QString &stored);
/** @brief True if the stored value is not in the scrypt format (legacy salt+hash). */
static bool isLegacyFormat(const QString &stored);
/** @brief True if the password matches the stored credential, whether legacy salt+hash or scrypt. */
static bool verifyPassword(const QString &password, const QString &storedPasswordData);
/** @brief HMAC-SHA256 of nonce keyed with the password verifier, used for challenge-response logins. */
static QByteArray computeResponse(const QByteArray &key, const QByteArray &nonce);
/** @brief Constant-time byte comparison. */
static bool constantTimeEquals(const QByteArray &a, const QByteArray &b);
};
#endif

View file

@ -0,0 +1,405 @@
#include "user_data_backup.h"
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
namespace UserDataBackup
{
namespace
{
const QString COMPLETE_MARKER = QStringLiteral("backup.complete");
const QString INCOMPLETE_MARKER = QStringLiteral("backup.incomplete");
const QString SOURCES_MANIFEST = QStringLiteral("backup.sources");
bool isMarkerFile(const QString &fileName)
{
return fileName == COMPLETE_MARKER || fileName == INCOMPLETE_MARKER || fileName == SOURCES_MANIFEST;
}
bool pathsEqual(const QString &a, const QString &b)
{
#if defined(Q_OS_WIN) || defined(Q_OS_MACOS)
return QDir::cleanPath(a).compare(QDir::cleanPath(b), Qt::CaseInsensitive) == 0;
#else
return QDir::cleanPath(a) == QDir::cleanPath(b);
#endif
}
void writeCompletionMarker(const QString &sessionDir, bool complete)
{
// Best effort: a missing marker only means isBackupSessionComplete()
// reports the session as incomplete.
QFile marker(QDir(sessionDir).absoluteFilePath(complete ? COMPLETE_MARKER : INCOMPLETE_MARKER));
if (marker.open(QIODevice::WriteOnly | QIODevice::Text)) {
marker.write(complete ? "complete\n" : "incomplete\n");
}
}
QString uniqueTargetPath(const QString &sessionDir, const QString &fileName)
{
QDir dir(sessionDir);
QString candidate = dir.absoluteFilePath(fileName);
if (!QFileInfo::exists(candidate)) {
return candidate;
}
const QFileInfo info(fileName);
const QString base = info.completeBaseName();
const QString suffix = info.suffix();
for (int i = 2;; ++i) {
QString numbered = suffix.isEmpty() ? QStringLiteral("%1 (%2)").arg(base).arg(i)
: QStringLiteral("%1 (%2).%3").arg(base).arg(i).arg(suffix);
candidate = dir.absoluteFilePath(numbered);
if (!QFileInfo::exists(candidate)) {
return candidate;
}
}
}
} // namespace
bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir)
{
QDir source(sourceDir);
if (!source.exists()) {
return false;
}
if (!QDir().mkpath(destDir)) {
return false;
}
bool ok = true;
QDirIterator it(sourceDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden,
QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
QString entryRel = source.relativeFilePath(it.filePath());
QString target = QDir(destDir).absoluteFilePath(entryRel);
if (it.fileInfo().isSymLink()) {
// Copy the link itself best-effort without dereferencing it. QDirIterator does not
// follow symlinks here, so a symlink-to-directory must not take the isDir() branch
// below (which would create an empty dir and silently drop the contents).
QDir().mkpath(QFileInfo(target).absolutePath());
if (QFile::exists(target) && !QFile::remove(target)) {
ok = false;
continue;
}
if (!QFile::copy(it.filePath(), target)) {
ok = false;
}
} else if (it.fileInfo().isDir()) {
if (!QDir().mkpath(target)) {
ok = false;
}
} else {
// Ensure the parent exists even if the iterator yields a file
// before its directory entry.
QDir().mkpath(QFileInfo(target).absolutePath());
if (QFile::exists(target) && !QFile::remove(target)) {
ok = false;
continue;
}
if (!QFile::copy(it.filePath(), target)) {
ok = false;
}
}
}
return ok;
}
bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut)
{
if (!QDir().mkpath(backupRoot)) {
return false;
}
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-hhmmss-zzz"));
const QDir root(backupRoot);
QString sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-") + timestamp);
for (int i = 1; QFileInfo::exists(sessionDir); ++i) {
sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-%1-%2").arg(timestamp).arg(i));
}
if (!QDir().mkpath(sessionDir)) {
return false;
}
if (sessionDirOut) {
*sessionDirOut = sessionDir;
}
return true;
}
bool backupAtRiskPaths(const QStringList &sourcePaths,
const QString &backupRoot,
QStringList *backupLocationsOut,
QString *sessionDirOut)
{
QStringList existing;
for (const QString &sourcePath : sourcePaths) {
if (QFileInfo::exists(sourcePath)) {
existing.append(sourcePath);
}
}
if (existing.isEmpty()) {
if (backupLocationsOut) {
backupLocationsOut->clear();
}
if (sessionDirOut) {
sessionDirOut->clear();
}
return false;
}
QString sessionDir;
if (!createBackupSessionDir(backupRoot, &sessionDir)) {
if (backupLocationsOut) {
backupLocationsOut->clear();
}
if (sessionDirOut) {
sessionDirOut->clear();
}
return false;
}
if (sessionDirOut) {
*sessionDirOut = sessionDir;
}
bool allOk = true;
QStringList locations;
for (const QString &sourcePath : existing) {
const QFileInfo source(sourcePath);
const QString backupLocation = uniqueTargetPath(sessionDir, source.fileName());
bool ok;
if (source.isDir()) {
ok = copyDirectoryRecursively(sourcePath, backupLocation);
} else {
ok = QFile::copy(sourcePath, backupLocation);
}
// Report the location whenever a target was created, even for partial
// copies, so callers can point the user at what was salvaged.
if (QFileInfo::exists(backupLocation)) {
locations.append(backupLocation);
} else {
// Total failure with no target created (e.g. QFile::copy failed).
ok = false;
}
allOk = allOk && ok;
}
if (locations.isEmpty()) {
// Nothing salvaged: remove the session again so the empty directory
// neither blocks a retry via the recent-session reuse nor evicts a
// good session in pruneOldBackupSessions().
QDir(sessionDir).removeRecursively();
if (backupLocationsOut) {
backupLocationsOut->clear();
}
if (sessionDirOut) {
sessionDirOut->clear();
}
return false;
}
writeCompletionMarker(sessionDir, allOk);
// Record which sources this session covers so callers can reuse it only
// when it actually covers the current source set (see sessionCoversSources).
QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST));
if (manifest.open(QIODevice::WriteOnly | QIODevice::Text)) {
for (const QString &sourcePath : existing) {
manifest.write(QDir::cleanPath(sourcePath).toUtf8() + "\n");
}
}
if (backupLocationsOut) {
*backupLocationsOut = locations;
}
return allOk;
}
qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes)
{
qint64 total = 0;
for (const QString &sourcePath : sourcePaths) {
const QFileInfo source(sourcePath);
if (!source.exists()) {
continue;
}
if (source.isDir()) {
QDirIterator it(sourcePath, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
total += it.fileInfo().size();
if (total >= capBytes) {
return total;
}
}
} else {
total += source.size();
if (total >= capBytes) {
return total;
}
}
}
return total;
}
QString defaultBackupRoot()
{
QString documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
if (!documents.isEmpty()) {
return QDir(documents).absoluteFilePath("CockatriceBackups");
}
QString appData = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
if (!appData.isEmpty()) {
return QDir::cleanPath(QDir(appData).absoluteFilePath("../CockatriceBackups"));
}
return QDir::temp().absoluteFilePath("CockatriceBackups");
}
QStringList listBackupSessions(const QString &backupRoot)
{
QDir root(backupRoot);
if (!root.exists()) {
return {};
}
const QStringList names = root.entryList(QStringList() << QStringLiteral("CockatriceDataBackup-*"),
QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name | QDir::Reversed);
QStringList sessions;
sessions.reserve(names.size());
for (const QString &name : names) {
sessions.append(root.absoluteFilePath(name));
}
return sessions;
}
QString findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours)
{
const QStringList sessions = listBackupSessions(backupRoot);
const QDateTime now = QDateTime::currentDateTime();
for (const QString &session : sessions) {
const QFileInfo info(session);
if (!info.exists()) {
continue;
}
if (info.lastModified().secsTo(now) >= static_cast<qint64>(maxAgeHours) * 3600) {
continue;
}
if (listSessionBackupLocations(session).isEmpty()) {
continue;
}
if (!sessionCoversSources(session, sourcePaths)) {
continue;
}
return session;
}
return {};
}
QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours)
{
if (sourcePaths.isEmpty()) {
return {};
}
const QStringList sessions = listBackupSessions(backupRoot);
const QDateTime now = QDateTime::currentDateTime();
for (const QString &session : sessions) {
const QFileInfo info(session);
if (!info.exists()) {
continue;
}
if (info.lastModified().secsTo(now) >= static_cast<qint64>(maxAgeHours) * 3600) {
continue;
}
if (listSessionBackupLocations(session).isEmpty()) {
continue;
}
if (!isBackupSessionComplete(session)) {
continue;
}
if (!sessionCoversSources(session, sourcePaths)) {
continue;
}
return session;
}
return {};
}
bool isBackupSessionComplete(const QString &sessionDir)
{
const QDir session(sessionDir);
if (!session.exists()) {
return false;
}
return QFileInfo::exists(session.absoluteFilePath(COMPLETE_MARKER));
}
void pruneOldBackupSessions(const QString &backupRoot, int keepCount)
{
const QStringList sessions = listBackupSessions(backupRoot);
QStringList kept;
kept.reserve(sessions.size());
for (const QString &session : sessions) {
// Empty sessions are junk from failed attempts: drop them outright so
// they can never evict a good session from the kept set.
if (listSessionBackupLocations(session).isEmpty()) {
QDir(session).removeRecursively();
continue;
}
if (kept.size() < keepCount) {
kept.append(session);
continue;
}
QDir(session).removeRecursively();
}
}
QStringList listSessionBackupLocations(const QString &sessionDir)
{
const QDir session(sessionDir);
if (!session.exists()) {
return {};
}
const QStringList entries = session.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden);
QStringList locations;
locations.reserve(entries.size());
for (const QString &entry : entries) {
if (isMarkerFile(entry)) {
continue;
}
locations.append(session.absoluteFilePath(entry));
}
return locations;
}
bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths)
{
QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST));
if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) {
// No readable manifest (e.g. the write failed): coverage cannot be
// verified, so report not-covered and let the caller take a fresh copy.
return false;
}
QStringList recorded;
while (!manifest.atEnd()) {
const QString line = QString::fromUtf8(manifest.readLine()).trimmed();
if (!line.isEmpty()) {
recorded.append(line);
}
}
for (const QString &source : sourcePaths) {
bool found = false;
for (const QString &entry : recorded) {
if (pathsEqual(source, entry)) {
found = true;
break;
}
}
if (!found) {
return false;
}
}
return true;
}
} // namespace UserDataBackup

View file

@ -0,0 +1,132 @@
/**
* @file user_data_backup.h
* @ingroup Utility
* @brief Best-effort safety copies of user data that lives inside the install directory.
*
* Background: Windows uninstallers shipped in 3.1.0/3.1.1 ran a blanket
* "RMDir /r $INSTDIR". For a portable install that deletes $INSTDIR\data
* (decks, settings, pictures) along with the payload, and for a normal install
* it deletes anything the user pointed into $INSTDIR. The fixed uninstaller
* preserves foreign files, but the destructive one is already on disk on
* affected machines, so the client keeps a safety copy outside the install
* directory before warning the user. Detection of at-risk paths lives in
* PathsSettings::pathsInsideDir; this helper only copies.
*/
#ifndef USER_DATA_BACKUP_H
#define USER_DATA_BACKUP_H
#include <QString>
#include <QStringList>
namespace UserDataBackup
{
/** @brief Above this total, the client skips the automatic startup backup and asks for a manual copy.
* Kept small on purpose: the copy runs synchronously on the UI thread at startup. */
constexpr qint64 MAX_AUTO_BACKUP_BYTES = 200LL * 1024 * 1024;
/**
* @brief Recursively copies @p sourceDir into @p destDir, creating it as needed.
* @returns true when every file was copied (or there was nothing to copy).
* Symlinks (file or directory) are copied best-effort via QFile::copy without
* dereferencing them; a dangling link fails that entry only.
*/
bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir);
/**
* @brief Creates a fresh timestamped session directory under @p backupRoot.
* @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups.
* @param sessionDirOut receives the created directory on success.
* @returns true on success. Millisecond timestamps plus a numeric suffix keep rapid
* successive calls (e.g. one per installer phase) from sharing a directory.
*/
bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut);
/**
* @brief Copies several at-risk paths into a single timestamped session directory.
* @param sourcePaths files or directories holding user data; missing entries are skipped.
* @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups.
* @param backupLocationsOut receives one entry per source that produced a backup target
* (including partial copies).
* @param sessionDirOut optionally receives the shared session directory; set to empty
* when nothing was backed up (all sources missing, or every copy failed outright).
* @returns true when every existing source was copied completely.
* A false return with a non-empty @p backupLocationsOut means a partial backup exists.
* When nothing at all could be salvaged the session directory is removed again, so a
* failed attempt never leaves an empty session behind that would block retries.
* Completeness is recorded with a marker file; see isBackupSessionComplete().
*/
bool backupAtRiskPaths(const QStringList &sourcePaths,
const QString &backupRoot,
QStringList *backupLocationsOut,
QString *sessionDirOut = nullptr);
/**
* @brief Estimates the total bytes that backing up @p sourcePaths would copy.
* @param capBytes stop counting once the total exceeds this; the returned value is then
* at least @p capBytes (used to skip the scan early on huge trees).
* Missing entries contribute 0. Best effort only.
*/
qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes = MAX_AUTO_BACKUP_BYTES);
/**
* @brief Default backup root guaranteed to live outside the install directory.
*/
QString defaultBackupRoot();
/**
* @brief Lists existing backup session directories under @p backupRoot, newest first.
*/
QStringList listBackupSessions(const QString &backupRoot);
/**
* @brief Returns the newest session that is non-empty, complete and covering @p sourcePaths, or empty.
*
* An incomplete newest session (partial failure) or one covering a different
* source set does not block reuse of an older good session.
*/
QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24);
/**
* @brief Returns the newest non-empty session covering @p sourcePaths, regardless of completeness.
*
* Used as a fallback pointer when a fresh copy fails entirely, so the dialog can still
* point at salvaged content. Completeness is left to the caller to report.
*/
QString
findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24);
/**
* @brief Reports whether @p sessionDir holds a complete backup.
* @returns true when the session carries the complete marker, false otherwise
* (partial backups and empty/missing sessions).
*/
bool isBackupSessionComplete(const QString &sessionDir);
/**
* @brief Deletes oldest sessions keeping only the newest @p keepCount ones.
* Empty sessions are junk from failed attempts and are always removed, so they
* can never evict a good session from the kept set.
*/
void pruneOldBackupSessions(const QString &backupRoot, int keepCount = 5);
/**
* @brief Lists the backup targets inside an existing session directory.
* @returns absolute paths of the entries, for reuse messages without a new copy.
* Completeness marker and source manifest files are internal bookkeeping and are never listed.
*/
QStringList listSessionBackupLocations(const QString &sessionDir);
/**
* @brief Reports whether @p sessionDir was recorded as covering all of @p sourcePaths.
* @returns true when the session's source manifest lists every entry of @p sourcePaths
* (normalized via QDir::cleanPath, case-insensitive on Windows/macOS). Sessions
* without a readable manifest return false so a changed source set always triggers
* a fresh copy instead of reusing a backup that may not cover it.
*/
bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths);
} // namespace UserDataBackup
#endif // USER_DATA_BACKUP_H

View file

@ -0,0 +1,29 @@
-- Servatrice db migration from version 37 to version 38
-- Room chat channels.
--
-- A room now offers a fixed catalog of named message streams in addition to the
-- implicit Main channel. Public channels are broadcast to every room member;
-- restricted channels are only delivered to (and sendable by) members whose
-- privilege level meets the channel's access level.
-- 1. Catalog table, mirroring the config-driven channels of no-DB servers.
CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` (
`id_room` int(7) unsigned NOT NULL,
`id_server` tinyint(3) NOT NULL DEFAULT 1,
`channel_id` varchar(50) NOT NULL,
`display_name` varchar(50) NOT NULL,
`access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public',
PRIMARY KEY (`id_room`, `id_server`, `channel_id`),
FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- 2. Audit log: room_channel target type plus a channel column so moderators
-- can tell which stream a logged message belongs to.
ALTER TABLE `cockatrice_log`
MODIFY `target_type` ENUM('room', 'game', 'chat', 'room_channel');
ALTER TABLE `cockatrice_log`
ADD COLUMN `channel` varchar(50) NULL AFTER `target_name`;
UPDATE cockatrice_schema_version SET version=38 WHERE version=37;

View file

@ -0,0 +1,19 @@
-- Servatrice db migration from version 38 to version 39
-- Per-channel read/write split and banner messages.
--
-- write_access_level gates sending: NULL (the default) means writing requires
-- the same level as reading (access_level), preserving the previous
-- single-level behavior. Set it higher than access_level for read-mostly
-- channels (e.g. a developer news feed readable by everyone but writable only
-- by developers).
--
-- banner_message is a static notice shown at the top of the channel,
-- analogous to the room join message on Main. Empty = none.
ALTER TABLE `cockatrice_rooms_channels`
MODIFY `access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public',
ADD COLUMN `write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL,
ADD COLUMN `banner_message` varchar(255) NOT NULL DEFAULT '';
UPDATE cockatrice_schema_version SET version=39 WHERE version=38;

View file

@ -0,0 +1,17 @@
-- Servatrice db migration from version 39 to version 40
-- The column must hold "$scrypt$<n>$<r>$<p>$<salt>$<verifier>" (up to ~255 chars) and arbitrary
-- legacy base64 hashes, so it grows beyond the old 120-char size. varchar(255) is used because
-- widening a CHAR requires a table rebuild, which ALGORITHM=INSTANT cannot perform — dropping the
-- clause lets the server pick a suitable algorithm (and any row-format change is avoided anyway).
ALTER TABLE `cockatrice_users` MODIFY `password_sha512` varchar(255) NOT NULL;
-- Encrypted downgrade backup: lets pre-challenge clients keep logging in after the
-- account migrates to scrypt, without storing the fast-crackable legacy hash in the
-- clear. Sealed with security/legacy_backup_key and purged after
-- security/legacy_backup_ttl_days without use (see password_legacy_backup_used_at).
ALTER TABLE `cockatrice_users`
ADD COLUMN `password_legacy_backup` varchar(255) NOT NULL DEFAULT '',
ADD COLUMN `password_legacy_backup_used_at` datetime DEFAULT NULL;
UPDATE cockatrice_schema_version SET version=40 WHERE version=39;

View file

@ -332,6 +332,40 @@ roomlist\1\game_types\1\name="GameType1"
roomlist\1\game_types\2\name="GameType2"
roomlist\1\game_types\3\name="GameType3"
; Optional chat channels for room number 1, in addition to the implicit Main
; channel every room has. Each channel needs a stable ASCII id, a display name
; (server data, like the room name) and an access level. Supported levels, from
; least to most restrictive, are: public, registered, judge, moderator, admin
; and developer. Access is evaluated against the member's user flags, not as a
; ladder: a moderator channel admits moderators (admins hold the moderator
; flag alongside the admin flag, so they are admitted too), an admin channel
; admits only admins, and a developer channel admits only developers and
; admins. In particular, developers cannot read moderator or admin channels.
; A judge channel additionally admits moderators.
; Public channels are visible to every room member.
;
; Write access defaults to the read level, so by default a channel is readable
; and writable by the same members. Set writeaccess higher for read-mostly
; channels (e.g. a developer news feed readable by everyone but writable only
; by developers). Members who can read but not write see the channel with a
; disabled message input.
;
; banner is an optional static notice shown at the top of the channel, similar
; to the room joinmessage on Main. Empty or unset = none.
roomlist\1\channels\size=3
roomlist\1\channels\1\id=looking-for-players
roomlist\1\channels\1\displayname="Looking for players"
roomlist\1\channels\1\access=public
roomlist\1\channels\1\banner="Find opponents here. Be friendly!"
roomlist\1\channels\2\id=moderators
roomlist\1\channels\2\displayname="Moderators"
roomlist\1\channels\2\access=moderator
roomlist\1\channels\3\id=developer-news
roomlist\1\channels\3\displayname="Developer news"
roomlist\1\channels\3\access=public
roomlist\1\channels\3\writeaccess=developer
roomlist\1\channels\3\banner="Official announcements from the developers."
[game]
@ -386,6 +420,48 @@ max_forgot_password_requests_per_ip=3
; Length in seconds of the forgot-password window. Default is 3600 (1 hour).
forgot_password_window_seconds=3600
; How strictly new authentication features are enforced. Possible values:
; * legacy: accounts that still use the legacy 1000-round SHA-512 hash keep logging in with it
; (they are served the legacy salt, never a challenge-response nonce) and are never
; auto-migrated to scrypt. Already-migrated scrypt rows keep logging in via
; challenge-response. New credentials may use either format;
; * mixed: accept both legacy hashes and challenge-response authentication (default);
; * strict: only accept challenge-response authentication from clients that support it,
; and reject plain password submissions. Legacy accounts are migrated to scrypt
; automatically on their next successful login. Strict only constrains
; registered SQL accounts: guest/unregistered logins and servers with
; authentication/method set to none or password are unaffected, since
; they have no stored verifier to challenge against.
;
; Note: anyone holding only the legacy salt+hash of a not-yet-migrated account
; (e.g. from a wire capture of an old client, or an old database dump) can log in
; with it and then set a brand new password of their choosing. Before
; challenge-response a captured hash yielded a session, but changing the password
; still required the plaintext old password. There is no way around this without
; a plaintext proof at migration time, so not-yet-migrated rows should be treated
; accordingly.
;
; Challenge-response verifiers are scrypt (RFC 7914, N=32768, r=8, p=1) stored as
; "$scrypt$<n>$<r>$<p>$<salt>$<verifier>". The stored verifier is password-equivalent:
; plaintext passwords never reach the client configuration and never go over the wire, but
; a database dump yields credentials that can answer a login challenge directly.
authentication_strictness=mixed
; Optional key that seals the legacy password backup (64 hex characters).
; Migrating an account to scrypt normally strands old clients, which can only present
; the legacy hash. With a key configured, the server keeps that hash encrypted next to
; the scrypt verifier, so old clients can still log in during the window below without
; the database holding a fast-crackable hash in the clear.
; Generate with: openssl rand -hex 32
; If unset, migrated accounts have no downgrade window: old clients must use a password
; reset to get back in. Rotating the key invalidates existing backups (they become
; unusable immediately; expired ones are still purged).
legacy_backup_key=
; Days a legacy password backup may go unused before it is purged from the database.
; Logging in with an old client refreshes the window; backups that are never touched
; age out. Default 180.
legacy_backup_ttl_days=180
; You may want to allow an unlimited number of users from a trusted source. This setting can contain a
; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the

View file

@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
INSERT INTO cockatrice_schema_version VALUES(37);
INSERT INTO cockatrice_schema_version VALUES(40);
-- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` (
`admin` tinyint(1) NOT NULL,
`name` varchar(35) NOT NULL,
`realname` varchar(255) NOT NULL,
`password_sha512` char(120) NOT NULL,
`password_sha512` varchar(255) NOT NULL,
`password_legacy_backup` varchar(255) NOT NULL DEFAULT '',
`password_legacy_backup_used_at` datetime DEFAULT NULL,
`email` varchar(255) NOT NULL,
`country` char(2) NOT NULL,
`avatar_bmp` mediumblob NOT NULL,
@ -158,6 +160,18 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_gametypes` (
FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` (
`id_room` int(7) unsigned NOT NULL,
`id_server` tinyint(3) NOT NULL DEFAULT 1,
`channel_id` varchar(50) NOT NULL,
`display_name` varchar(50) NOT NULL,
`access_level` ENUM('public','registered','judge','moderator','admin','developer') NOT NULL DEFAULT 'public',
`write_access_level` ENUM('public','registered','judge','moderator','admin','developer') NULL DEFAULT NULL,
`banner_message` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id_room`, `id_server`, `channel_id`),
FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- games
CREATE TABLE IF NOT EXISTS `cockatrice_games` (
`room_name` varchar(255) NOT NULL,
@ -329,9 +343,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_log` (
`sender_name` varchar(35) NOT NULL,
`sender_ip` varchar(45) NOT NULL,
`log_message` text NOT NULL,
`target_type` ENUM('room', 'game', 'chat'),
`target_type` ENUM('room', 'game', 'chat', 'room_channel'),
`target_id` int(7) NULL,
`target_name` varchar(50) NOT NULL,
`channel` varchar(50) NULL,
KEY `sender_name` (`sender_name`),
KEY `sender_ip` (`sender_ip`),
KEY `target_id` (`target_id`),

View file

@ -36,6 +36,7 @@
#include <libcockatrice/protocol/pb/event_leave_room.pb.h>
#include <libcockatrice/protocol/pb/event_list_games.pb.h>
#include <libcockatrice/protocol/pb/event_remove_messages.pb.h>
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
#include <libcockatrice/protocol/pb/event_server_complete_list.pb.h>
#include <libcockatrice/protocol/pb/event_user_joined.pb.h>
@ -383,7 +384,14 @@ void IslInterface::roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event)
void IslInterface::roomEvent_Say(int roomId, const Event_RoomSay &event)
{
emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()));
emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()),
QString());
}
void IslInterface::roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event)
{
emit externalRoomSay(roomId, QString::fromStdString(event.name()), QString::fromStdString(event.message()),
QString::fromStdString(event.channel_id()));
}
void IslInterface::roomEvent_ListGames(int roomId, const Event_ListGames &event)
@ -459,6 +467,9 @@ void IslInterface::processRoomEvent(const RoomEvent &event)
case RoomEvent::ROOM_SAY:
roomEvent_Say(event.room_id(), event.GetExtension(Event_RoomSay::ext));
break;
case RoomEvent::ROOM_CHANNEL_SAY:
roomEvent_ChannelSay(event.room_id(), event.GetExtension(Event_RoomChannelSay::ext));
break;
case RoomEvent::LIST_GAMES:
roomEvent_ListGames(event.room_id(), event.GetExtension(Event_ListGames::ext));
break;

View file

@ -26,6 +26,7 @@ class Event_UserLeft;
class Event_JoinRoom;
class Event_LeaveRoom;
class Event_RoomSay;
class Event_RoomChannelSay;
class Event_ListGames;
class Event_RemoveMessages;
class Command_JoinGame;
@ -49,7 +50,7 @@ signals:
void externalUserLeft(QString userName);
void externalRoomUserJoined(int roomId, ServerInfo_User userInfo);
void externalRoomUserLeft(int roomId, QString userName);
void externalRoomSay(int roomId, QString userName, QString message);
void externalRoomSay(int roomId, QString userName, QString message, QString channelId);
void externalRoomGameListChanged(int roomId, ServerInfo_Game gameInfo);
void externalRoomRemoveMessages(int roomId, QString userName, int amount);
void joinGameCommandReceived(const Command_JoinGame &cmd, int cmdId, int roomId, int serverId, qint64 sessionId);
@ -79,6 +80,7 @@ private:
void roomEvent_UserJoined(int roomId, const Event_JoinRoom &event);
void roomEvent_UserLeft(int roomId, const Event_LeaveRoom &event);
void roomEvent_Say(int roomId, const Event_RoomSay &event);
void roomEvent_ChannelSay(int roomId, const Event_RoomChannelSay &event);
void roomEvent_ListGames(int roomId, const Event_ListGames &event);
void roomEvent_RemoveMessages(int roomId, const Event_RemoveMessages &event);

View file

@ -45,6 +45,8 @@
#include <QObject>
#include <QProcessEnvironment>
#include <QReadLocker>
#include <QRegularExpression>
#include <QSet>
#include <QSharedPointer>
#include <QSqlDatabase>
#include <QSqlQuery>
@ -65,7 +67,9 @@
#include <libcockatrice/protocol/pb/event_connection_closed.pb.h>
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
#include <libcockatrice/protocol/pb/event_server_shutdown.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
#include <new>
#include <optional>
#include <qlogging.h>
#include <qnamespace.h>
#include <server_room.h>
@ -73,6 +77,10 @@
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
// Expired legacy password backups are also purged on first use, but idle accounts
// need this sweep so credentials that are never touched still age out of the database.
static constexpr qint64 kLegacyBackupSweepIntervalMs = 6 * 60 * 60 * 1000;
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
int _numberPools,
const QSqlDatabase &_sqlDatabase,
@ -249,6 +257,37 @@ Servatrice::~Servatrice()
prepareDestroy();
}
/**
* @brief Maps a configured channel access level (database column or ini value)
* onto the ServerInfo_RoomChannel privilege ladder.
*
* Returns nullopt for unknown keywords so callers can fail closed instead of
* silently downgrading a restricted channel to Public.
*/
static std::optional<ServerInfo_RoomChannel::AccessLevel> roomChannelAccessFromKey(const QString &key)
{
const QString level = key.trimmed().toLower();
if (level == "developer") {
return ServerInfo_RoomChannel::Developer;
}
if (level == "admin") {
return ServerInfo_RoomChannel::Admin;
}
if (level == "moderator") {
return ServerInfo_RoomChannel::Moderator;
}
if (level == "judge") {
return ServerInfo_RoomChannel::Judge;
}
if (level == "registered") {
return ServerInfo_RoomChannel::Registered;
}
if (level == "public") {
return ServerInfo_RoomChannel::Public;
}
return std::nullopt;
}
bool Servatrice::initServer()
{
@ -360,10 +399,65 @@ bool Servatrice::initServer()
while (query2->next()) {
gameTypes.append(query2->value(0).toString());
}
QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery(
"select channel_id, display_name, access_level, write_access_level, banner_message from "
"{prefix}_rooms_channels where id_room = :id_room "
"AND id_server = :id_server order by channel_id asc");
query3->bindValue(":id_server", serverId);
query3->bindValue(":id_room", query->value(0).toInt());
servatriceDatabaseInterface->execSqlQuery(query3);
QList<ServerInfo_RoomChannel> channels;
QSet<QString> seenChannelIds;
while (query3->next()) {
const QString channelId = query3->value(0).toString();
if (channelId.isEmpty()) {
qWarning() << "Skipping room channel with empty id";
continue;
}
if (Server_Room::isMainChannel(channelId)) {
qWarning() << "Skipping room channel with reserved id" << channelId;
continue;
}
if (seenChannelIds.contains(channelId)) {
qWarning() << "Skipping duplicate room channel id" << channelId;
continue;
}
seenChannelIds.insert(channelId);
const QString accessKey = query3->value(2).toString();
auto accessLevel =
accessKey.trimmed().isEmpty()
? std::optional<ServerInfo_RoomChannel::AccessLevel>(ServerInfo_RoomChannel::Public)
: roomChannelAccessFromKey(accessKey);
if (!accessLevel.has_value()) {
qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id"
<< channelId;
continue;
}
ServerInfo_RoomChannel channel;
channel.set_id(channelId.toStdString());
channel.set_display_name(query3->value(1).toString().toStdString());
channel.set_access_level(accessLevel.value());
if (!query3->isNull(3)) {
const QString writeKey = query3->value(3).toString();
if (!writeKey.trimmed().isEmpty()) {
auto writeLevel = roomChannelAccessFromKey(writeKey);
if (!writeLevel.has_value()) {
qWarning() << "Skipping room channel with unknown write access level" << writeKey
<< "for id" << channelId;
continue;
}
channel.set_write_access_level(writeLevel.value());
}
}
channel.set_banner_message(query3->value(4).toString().toStdString());
channels.append(channel);
}
addRoom(new Server_Room(query->value(0).toInt(), query->value(7).toInt(), query->value(1).toString(),
query->value(2).toString(), query->value(3).toString().toLower(),
query->value(4).toString().toLower(), static_cast<bool>(query->value(5).toInt()),
query->value(6).toString(), gameTypes, this));
query->value(6).toString(), gameTypes, this, channels));
}
} else {
int size = settingsCache->beginReadArray("rooms/roomlist");
@ -376,12 +470,61 @@ bool Servatrice::initServer()
gameTypes.append(settingsCache->value("name").toString());
}
settingsCache->endArray();
QList<ServerInfo_RoomChannel> channels;
QSet<QString> seenChannelIds;
int size3 = settingsCache->beginReadArray("channels");
for (int k = 0; k < size3; ++k) {
settingsCache->setArrayIndex(k);
const QString channelId = settingsCache->value("id").toString();
if (channelId.isEmpty()) {
qWarning() << "Skipping room channel with empty id";
continue;
}
if (Server_Room::isMainChannel(channelId)) {
qWarning() << "Skipping room channel with reserved id" << channelId;
continue;
}
if (seenChannelIds.contains(channelId)) {
qWarning() << "Skipping duplicate room channel id" << channelId;
continue;
}
seenChannelIds.insert(channelId);
const QString accessKey = settingsCache->value("access").toString();
auto accessLevel =
accessKey.trimmed().isEmpty()
? std::optional<ServerInfo_RoomChannel::AccessLevel>(ServerInfo_RoomChannel::Public)
: roomChannelAccessFromKey(accessKey);
if (!accessLevel.has_value()) {
qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id"
<< channelId;
continue;
}
ServerInfo_RoomChannel channel;
channel.set_id(channelId.toStdString());
channel.set_display_name(settingsCache->value("displayname").toString().toStdString());
channel.set_access_level(accessLevel.value());
if (settingsCache->contains("writeaccess")) {
const QString writeKey = settingsCache->value("writeaccess").toString();
if (!writeKey.trimmed().isEmpty()) {
auto writeLevel = roomChannelAccessFromKey(writeKey);
if (!writeLevel.has_value()) {
qWarning() << "Skipping room channel with unknown write access level" << writeKey
<< "for id" << channelId;
continue;
}
channel.set_write_access_level(writeLevel.value());
}
}
channel.set_banner_message(settingsCache->value("banner").toString().toStdString());
channels.append(channel);
}
settingsCache->endArray();
Server_Room *newRoom = new Server_Room(
i, settingsCache->value("chathistorysize").toInt(), settingsCache->value("name").toString(),
settingsCache->value("description").toString(),
settingsCache->value("permissionlevel").toString().toLower(),
settingsCache->value("privilegelevel").toString().toLower(), settingsCache->value("autojoin").toBool(),
settingsCache->value("joinmessage").toString(), gameTypes, this);
settingsCache->value("joinmessage").toString(), gameTypes, this, channels);
addRoom(newRoom);
}
@ -475,6 +618,10 @@ bool Servatrice::initServer()
deckShareCleanupClock->start(deckShareCleanupInterval);
}
legacyBackupSweepClock = new QTimer(this);
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
// SOCKET SERVER
if (getNumberOfTCPPools() > 0) {
gameServer =
@ -864,6 +1011,14 @@ SessionEvent *Servatrice::getLoginSessionEvent() const
return nullptr;
}
void Servatrice::sweepLegacyBackups()
{
if (!servatriceDatabaseInterface->checkSql()) {
return;
}
servatriceDatabaseInterface->purgeExpiredLegacyBackups();
}
void Servatrice::scheduleShutdown(const QString &reason, int minutes)
{
shutdownStateMutex.lock();
@ -940,8 +1095,8 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface)
connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this,
SLOT(externalRoomUserJoined(int, ServerInfo_User)));
connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString)));
connect(interface, SIGNAL(externalRoomSay(int, QString, QString)), this,
SLOT(externalRoomSay(int, QString, QString)));
connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this,
SLOT(externalRoomSay(int, QString, QString, QString)));
connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this,
SLOT(externalRoomRemoveMessages(int, QString, int)));
connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this,
@ -1056,6 +1211,42 @@ QString Servatrice::getRequiredFeatures() const
return settingsCache->value("server/requiredfeatures", "").toString();
}
Servatrice::AuthenticationStrictness Servatrice::getAuthenticationStrictness() const
{
const QString strictness = settingsCache->value("security/authentication_strictness", "mixed").toString();
if (strictness == "strict") {
return AuthenticationStrict;
}
if (strictness == "legacy") {
return AuthenticationLegacy;
}
return AuthenticationMixed;
}
QByteArray Servatrice::getLegacyBackupKey() const
{
const QString keyHex = settingsCache->value("security/legacy_backup_key").toString().trimmed();
if (keyHex.isEmpty()) {
return {};
}
static const QRegularExpression validHexKey(QStringLiteral("^[0-9a-fA-F]{64}$"));
if (!validHexKey.match(keyHex).hasMatch()) {
qDebug() << "security/legacy_backup_key is not 64 hex characters;" << "legacy password backups are disabled";
return {};
}
return QByteArray::fromHex(keyHex.toUtf8());
}
int Servatrice::getLegacyBackupTtlDays() const
{
bool ok = false;
const int days = settingsCache->value("security/legacy_backup_ttl_days", 180).toInt(&ok);
if (!ok || days < 1 || days > 3650) {
return 180;
}
return days;
}
QString Servatrice::getDBTypeString() const
{
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {

View file

@ -155,8 +155,15 @@ public:
AuthenticationSql,
AuthenticationPassword
};
enum AuthenticationStrictness
{
AuthenticationLegacy,
AuthenticationMixed,
AuthenticationStrict
};
private slots:
void statusUpdate();
void sweepLegacyBackups();
void shutdownTimeout();
void cleanupExpiredDeckShares();
@ -173,6 +180,7 @@ private:
DatabaseType databaseType;
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
QTimer *legacyBackupSweepClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
@ -251,6 +259,14 @@ public:
{
return serverRequiredFeatureList;
}
bool requiresChallengeResponseAuth() const override
{
return getAuthenticationStrictness() == AuthenticationStrict;
}
/** @brief Key sealing legacy password backups, or empty when they are disabled. */
QByteArray getLegacyBackupKey() const;
/** @brief Days a legacy password backup may go unused before it is purged. */
int getLegacyBackupTtlDays() const;
QString getServerName() const;
QString getLoginMessage() const override
{
@ -272,6 +288,7 @@ public:
{
return authenticationMethod;
}
AuthenticationStrictness getAuthenticationStrictness() const;
bool permitUnregisteredUsers() const override
{
return authenticationMethod != AuthenticationNone;

View file

@ -37,6 +37,7 @@
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/game_replay.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/utility/cryptoutil.h>
#include <libcockatrice/utility/passwordhasher.h>
#include <qalgorithms.h>
#include <qnamespace.h>
@ -44,6 +45,17 @@
inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface");
// Oldest password_legacy_backup_used_at that is still inside the TTL. The value is
// compared against (and stored as) a UTC "yyyy-MM-dd HH:mm:ss" string, which sorts
// lexicographically, so no timezone-aware parsing is needed on either side.
static QString legacyBackupExpiryCutoff(int ttlDays)
{
return QDateTime::currentDateTimeUtc().addDays(-ttlDays).toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
}
// Width of the password_legacy_backup column; a sealed backup must fit inside it.
static constexpr int MAX_LEGACY_BACKUP_LENGTH = 255;
Servatrice_DatabaseInterface::Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server)
: instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server)
{
@ -429,13 +441,104 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
qCWarning(DatabaseInterfaceLog) << "Login denied: user not active";
return UserIsInactive;
}
// Fail closed on an absent stored credential: an empty key would
// otherwise authenticate anyone who can compute HMAC("", nonce).
if (correctPasswordSha512.isEmpty()) {
qCWarning(DatabaseInterfaceLog) << "Login denied: empty stored credential";
return NotLoggedIn;
}
if (password.startsWith("$challenge$")) {
// Challenge-response login: verify HMAC(stored_key, nonce) without
// ever transmitting the stored credential or password hash.
const QStringList parts = password.split("$");
if (parts.size() != 4) {
return NotLoggedIn;
}
const QByteArray nonce = QByteArray::fromBase64(parts.at(2).toUtf8());
const QByteArray response = QByteArray::fromBase64(parts.at(3).toUtf8());
if (nonce.isEmpty() || response.isEmpty() || !handler->isAuthNonceValid(nonce, user)) {
return NotLoggedIn;
}
QByteArray key;
if (PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
key = correctPasswordSha512.toUtf8();
} else {
// Design note: the stored scrypt verifier IS the challenge-response key, so a
// database dump yields credentials that can answer a login challenge directly.
// We deliberately accept this trade: it removes plaintext passwords from the
// client config and from the wire, but does not protect against DB compromise.
// A password-equivalent proof scheme (e.g. SRP-6a/OPAQUE) would be the proper
// escalation and is out of scope here.
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(correctPasswordSha512);
if (!verifier.isValid) {
return NotLoggedIn;
}
key = verifier.verifier;
}
const QByteArray expected = PasswordHasher::computeResponse(key, nonce);
handler->clearAuthNonce();
if (PasswordHasher::constantTimeEquals(expected, response)) {
qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right";
return PasswordRight;
}
qCDebug(DatabaseInterfaceLog) << "Login denied: challenge-response password wrong";
return NotLoggedIn;
}
// A migrated (scrypt) row distinguishes how the credential arrived:
// a plaintext password (passwordNeedsHash) is verified directly against
// the scrypt verifier, so email changes (cmdAccountEdit) and old clients
// that still send `password` keep working without a legacy backup key.
// A pre-hashed legacy value is accepted against the encrypted legacy
// backup while that backup is fresh. Without one there is no downgrade
// path. The backup window is refreshed only when the backup is what
// actually matched, so a plaintext proof never keeps the
// fast-crackable hash alive.
if (!PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
if (passwordNeedsHash) {
// Cost is bounded by costParamsAreSane inside parsePasswordVerifier.
if (PasswordHasher::verifyPassword(password, correctPasswordSha512)) {
if (forceChange) {
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
return PasswordChangeRequired;
}
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
return PasswordRight;
}
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
return NotLoggedIn;
}
const QString backupCredential = getUsableLegacyBackup(user);
if (backupCredential.isEmpty()) {
qCDebug(DatabaseInterfaceLog)
<< "Login denied: migrated account without a usable legacy backup";
return NotLoggedIn;
}
if (PasswordHasher::constantTimeEquals(backupCredential.toUtf8(), password.toUtf8())) {
touchLegacyBackup(user);
if (forceChange) {
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
return PasswordChangeRequired;
}
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
return PasswordRight;
}
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
return NotLoggedIn;
}
QString storedCredential = correctPasswordSha512;
QString hashedPassword;
if (passwordNeedsHash) {
hashedPassword = PasswordHasher::computeHash(password, correctPasswordSha512.left(16));
hashedPassword = PasswordHasher::computeHash(password, storedCredential.left(16));
} else {
hashedPassword = password;
}
if (correctPasswordSha512 == hashedPassword) {
if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) {
if (forceChange) {
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
return PasswordChangeRequired;
@ -631,6 +734,185 @@ QString Servatrice_DatabaseInterface::getUserSalt(const QString &user)
return {};
}
QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user)
{
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
return {};
}
checkSql();
QSqlQuery *query = prepareQuery("SELECT password_sha512 FROM {prefix}_users WHERE name = :name");
query->bindValue(":name", user);
if (!execSqlQuery(query)) {
return {};
}
if (!query->next()) {
return {};
}
return query->value(0).toString();
}
bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier)
{
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
return false;
}
checkSql();
// Read the credential that is about to be replaced: while it is still in the
// legacy format it becomes the encrypted downgrade backup, sealed before the
// row is overwritten with the scrypt verifier.
QSqlQuery *currentQuery = prepareQuery("SELECT name, password_sha512 FROM {prefix}_users WHERE name = :name");
currentQuery->bindValue(":name", user);
if (!execSqlQuery(currentQuery) || !currentQuery->next()) {
return false;
}
// Use the stored row name (not the caller-supplied one) as the AAD: name is
// utf8mb4_unicode_ci, so `alice` logs in as `Alice` and the SELECT still finds
// the row while a client-typed name would fail the GCM tag check on open.
const QString storedName = currentQuery->value(0).toString();
const QString currentCredential = currentQuery->value(1).toString();
QString sealedBackup;
QDateTime backupUsedAt;
if (PasswordHasher::isLegacyFormat(currentCredential)) {
const QByteArray key = server->getLegacyBackupKey();
if (!key.isEmpty()) {
const QByteArray aad = storedName.simplified().toUtf8();
sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad);
if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) {
qCWarning(DatabaseInterfaceLog)
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
sealedBackup.clear();
} else {
backupUsedAt = QDateTime::currentDateTimeUtc();
}
}
}
// Only migrate accounts that still use the credential this backup was taken from;
// the guard makes a re-migration (or a raced second submission) a no-op.
QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512 = :verifier, "
"password_legacy_backup = :backup, password_legacy_backup_used_at = :used_at "
"where name = :user and password_sha512 = :expected and password_sha512 not like "
"'$%'");
query->bindValue(":verifier", passwordVerifier);
query->bindValue(":backup", sealedBackup);
query->bindValue(":used_at", backupUsedAt.isValid()
? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")))
: QVariant());
query->bindValue(":user", user);
query->bindValue(":expected", currentCredential);
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Failed to submit password verifier for user" << user << query->lastError();
return false;
}
// The guard makes a re-migration a no-op; only report success when a row was actually updated.
return query->numRowsAffected() > 0;
}
QString Servatrice_DatabaseInterface::getUsableLegacyBackup(const QString &user)
{
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
return {};
}
// Without a configured key the backup cannot be sealed or opened; do not touch
// (and never purge on this path) so a temporarily missing key cannot destroy it.
const QByteArray key = server->getLegacyBackupKey();
if (key.isEmpty()) {
return {};
}
checkSql();
QSqlQuery *query = prepareQuery(
"SELECT name, password_legacy_backup, password_legacy_backup_used_at FROM {prefix}_users WHERE name = :name");
query->bindValue(":name", user);
if (!execSqlQuery(query) || !query->next()) {
return {};
}
// Open with the stored row name as the AAD: sealing used the canonical DB name
// while the caller passes the client-typed name, and the two differ whenever
// the login case does not match the stored case (utf8mb4_unicode_ci).
const QString storedName = query->value(0).toString();
const QString sealed = query->value(1).toString();
const QString usedAt = query->value(2).toString();
if (sealed.isEmpty()) {
return {};
}
// Age out: a backup unused for the whole TTL is purged on sight, so a database
// dump taken after the window closes never carries a crackable legacy hash.
if (usedAt.isEmpty() || usedAt < legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())) {
qCDebug(DatabaseInterfaceLog) << "Purging expired legacy backup for user" << user;
clearLegacyBackup(user);
return {};
}
const QByteArray legacyCredential = CryptoUtil::decryptSecret(sealed, key, storedName.simplified().toUtf8());
if (legacyCredential.isEmpty()) {
qCWarning(DatabaseInterfaceLog) << "Legacy backup for user" << user << "failed to decrypt"
<< "- check security/legacy_backup_key";
return {};
}
return QString::fromUtf8(legacyCredential);
}
void Servatrice_DatabaseInterface::touchLegacyBackup(const QString &user)
{
if (!checkSql()) {
return;
}
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup_used_at = UTC_TIMESTAMP() "
"WHERE name = :name AND password_legacy_backup <> ''");
query->bindValue(":name", user);
execSqlQuery(query);
}
void Servatrice_DatabaseInterface::clearLegacyBackup(const QString &user)
{
if (!checkSql()) {
return;
}
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
"password_legacy_backup_used_at = NULL WHERE name = :name");
query->bindValue(":name", user);
execSqlQuery(query);
}
void Servatrice_DatabaseInterface::purgeExpiredLegacyBackups()
{
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
return;
}
if (!checkSql()) {
return;
}
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
"password_legacy_backup_used_at = NULL "
"WHERE password_legacy_backup <> '' AND "
"(password_legacy_backup_used_at IS NULL OR password_legacy_backup_used_at < "
":cutoff)");
query->bindValue(":cutoff", legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays()));
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Legacy backup sweep failed:" << query->lastError();
return;
}
if (query->numRowsAffected() > 0) {
qCDebug(DatabaseInterfaceLog) << "Purged" << query->numRowsAffected() << "expired legacy password backups";
}
}
int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name)
{
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
@ -1286,7 +1568,8 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
const QString &logMessage,
LogMessage_TargetType targetType,
const int targetId,
const QString &targetName)
const QString &targetName,
const QString &channel)
{
QString targetTypeString;
switch (targetType) {
@ -1296,6 +1579,12 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
}
targetTypeString = "room";
break;
case MessageTargetRoomChannel:
if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) {
return;
}
targetTypeString = "room_channel";
break;
case MessageTargetGame:
if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) {
return;
@ -1319,8 +1608,9 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
}
QSqlQuery *query = prepareQuery("insert into {prefix}_log (log_time, sender_id, sender_name, sender_ip, "
"log_message, target_type, target_id, target_name) values (now(), :sender_id, "
":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)");
"log_message, target_type, target_id, target_name, channel) values (now(), "
":sender_id, :sender_name, :sender_ip, :log_message, :target_type, :target_id, "
":target_name, :channel)");
query->bindValue(":sender_id", senderId < 1 ? QVariant() : senderId);
query->bindValue(":sender_name", senderName);
query->bindValue(":sender_ip", senderIp);
@ -1328,6 +1618,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
query->bindValue(":target_type", targetTypeString);
query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId);
query->bindValue(":target_name", targetName);
query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel);
execSqlQuery(query);
}
@ -1340,7 +1631,10 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt());
}
// The backup authenticates the credential it was taken from; a new credential
// invalidates it (the old password is dead, and the new one has no legacy form).
QSqlQuery *passwordQuery = prepareQuery("update {prefix}_users set password_sha512=:password, "
"password_legacy_backup='', password_legacy_backup_used_at=NULL, "
"passwordLastChangedDate = NOW() where name = :name");
passwordQuery->bindValue(":password", passwordSha512);
passwordQuery->bindValue(":name", user);
@ -1393,13 +1687,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
return false;
}
const QString correctPasswordSha512 = passwordQuery->value(0).toString();
QString oldPasswordSha512 = oldPassword;
if (oldPasswordNeedsHash) {
QString salt = correctPasswordSha512.left(16);
oldPasswordSha512 = PasswordHasher::computeHash(oldPassword, salt);
}
if (correctPasswordSha512 != oldPasswordSha512) {
const QString storedPassword = passwordQuery->value(0).toString();
// oldPasswordNeedsHash means the client sent the old password in plaintext. Verify it
// against whatever is stored: legacy salt+hash rows or already-migrated scrypt rows
// (which must NOT be re-hashed with a salt torn out of the "$scrypt$..." string).
const bool oldPasswordMatches = oldPasswordNeedsHash ? PasswordHasher::verifyPassword(oldPassword, storedPassword)
: (oldPassword == storedPassword);
if (!oldPasswordMatches) {
return false;
}
@ -1846,9 +2140,9 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
if (room) {
if (game || chat) {
queryString.append(" OR `target_type` = 'room'");
queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'");
} else {
queryString.append("`target_type` = 'room'");
queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'");
}
}
queryString.append(")");
@ -1899,6 +2193,7 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
chatMessage.set_target_type(QString(query->value(5).toString()).toStdString());
chatMessage.set_target_id(QString(query->value(6).toString()).toStdString());
chatMessage.set_target_name(QString(query->value(7).toString()).toStdString());
chatMessage.set_channel(QString(query->value(8).toString()).toStdString());
results << chatMessage;
}
@ -2068,3 +2363,25 @@ void Servatrice_DatabaseInterface::addAuditRecord(const QString &user,
query->bindValue(":details", details);
execSqlQuery(query);
}
bool Servatrice_DatabaseInterface::isCardArtAllowed(const QString &cardName, const QString &cardProviderId)
{
if (cardName.isEmpty() || !checkSql()) {
return true;
}
QSqlQuery *q = prepareQuery(
"SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider");
q->bindValue(":name", cardName);
q->bindValue(":provider", cardProviderId);
if (!execSqlQuery(q)) {
qWarning() << "Card art rule lookup failed; failing open for" << cardName;
return true;
}
if (!q->next()) {
return true; // default allow
}
return q->value(0).toString() != "DENY";
}

View file

@ -21,7 +21,7 @@
class QSqlQuery;
#define DATABASE_SCHEMA_VERSION 37
#define DATABASE_SCHEMA_VERSION 40
class Servatrice;
@ -96,6 +96,18 @@ public:
bool activeUserExists(const QString &user) override;
bool userExists(const QString &user) override;
QString getUserSalt(const QString &user) override;
QString getUserPasswordData(const QString &user) override;
bool submitPasswordVerifier(const QString &user, const QString &passwordVerifier) override;
/** @brief Decrypt the account's legacy password backup when it exists, is decryptable
* with the configured key and has not aged out; purges it when it has. Returns the
* legacy credential in the stored "salt + hash" format, or an empty string. */
QString getUsableLegacyBackup(const QString &user);
/** @brief Refresh the age window of the account's legacy password backup. */
void touchLegacyBackup(const QString &user);
/** @brief Drop the account's legacy password backup. */
void clearLegacyBackup(const QString &user);
/** @brief Drop every legacy password backup that has not been used within the TTL. */
void purgeExpiredLegacyBackups();
int getUserIdInDB(const QString &name);
QMap<QString, ServerInfo_User> getBuddyList(const QString &name) override;
QMap<QString, ServerInfo_User> getIgnoreList(const QString &name) override;
@ -177,7 +189,8 @@ public:
const QString &logMessage,
LogMessage_TargetType targetType,
const int targetId,
const QString &targetName) override;
const QString &targetName,
const QString &channel = QString()) override;
bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override;
bool changeUserPassword(const QString &user,
const QString &oldPassword,
@ -218,6 +231,7 @@ public:
};
UptimeSnapshot getLatestUptimeSnapshot(int serverId);
bool removeUserAvatar(const QString &userName);
bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override;
bool addForgotPassword(const QString &user);
bool removeForgotPassword(const QString &user) override;
bool doesForgotPasswordExist(const QString &user);

View file

@ -161,6 +161,7 @@
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
#include <libcockatrice/utility/cryptoutil.h>
#include <libcockatrice/utility/passwordhasher.h>
#include <libcockatrice/utility/report_categories.h>
#include <libcockatrice/utility/string_limits.h>
@ -204,7 +205,15 @@ bool AbstractServerSocketInterface::initSession()
identEvent.set_server_version(VERSION_STRING);
identEvent.set_protocol_version(protocolVersion);
if (servatrice->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
// Challenge-response is advertised in every strictness mode: legacy accounts keep
// logging in with the legacy hash, but already-migrated scrypt rows are always
// served challenge-response (authentication_strictness only governs NEW credentials).
// server_options stays exactly SupportsPasswordHash on the wire: already-released
// clients parse an unknown proto2 enum value as NoOptions and would fall back to
// plaintext passwords. Challenge support is advertised in the separate
// supports_challenge_response field, which old clients simply ignore.
identEvent.set_server_options(Event_ServerIdentification::SupportsPasswordHash);
identEvent.set_supports_challenge_response(true);
}
SessionEvent *identSe = prepareSessionEvent(identEvent);
sendProtocolItem(*identSe);
@ -409,6 +418,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc);
case SessionCommand::REPORT_DETAILS:
return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc);
case SessionCommand::SUBMIT_PASSWORD_VERIFIER:
return cmdSubmitPasswordVerifier(cmd.GetExtension(Command_SubmitPasswordVerifier::ext), rc);
default:
return Response::RespFunctionNotAllowed;
}
@ -3298,6 +3309,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
password = QString::fromStdString(cmd.hashed_password());
}
// Reject credential formats the configured authentication strictness does not accept.
if (!acceptsCredentialFormat(passwordNeedsHash, password)) {
return Response::RespClientUpdateRequired;
}
bool requireEmailActivation = settingsCache->value("registration/requireemailactivation", true).toBool();
bool regSucceeded = sqlInterface->registerUser(userName, realName, password, passwordNeedsHash, parsedEmailAddress,
country, !requireEmailActivation);
@ -3342,6 +3358,30 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i
return servatrice->recordRegistrationAttempt(ipAddress);
}
bool AbstractServerSocketInterface::acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const
{
// An empty credential must never reach the database: it would be accepted
// as a legacy format and stored as '' (fail-open on login, see the empty
// stored-credential guard in Servatrice_DatabaseInterface).
if (password.isEmpty()) {
return false;
}
// "scryptFormat" means the client sent a derived verifier rather than a
// password to hash ourselves. parsePasswordVerifier enforces the sane-cost
// clamp, so nothing starting with '$' reaches the database unparsed.
const bool scryptFormat = !passwordNeedsHash && PasswordHasher::parsePasswordVerifier(password).isValid;
// The strictness mode governs how existing legacy accounts are served, not which new-credential
// formats are tolerated: a legacy-mode server must still accept scrypt verifiers, because clients
// derive them whenever challenge-response is advertised (and it must be, so already-migrated
// scrypt rows keep logging in). strict is the only mode that rejects legacy formats.
if (servatrice->getAuthenticationStrictness() == Servatrice::AuthenticationStrict) {
return scryptFormat;
}
// legacy and mixed accept a valid scrypt verifier or a genuine legacy salt+hash.
return scryptFormat || PasswordHasher::isLegacyFormat(password);
}
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
ResponseContainer & /*rc*/)
{
@ -3481,26 +3521,6 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm
return Response::RespOk;
}
bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName, const QString &cardProviderId)
{
QSqlQuery *q = sqlInterface->prepareQuery(
"SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider");
q->bindValue(":name", cardName);
q->bindValue(":provider", cardProviderId);
if (!sqlInterface->execSqlQuery(q)) {
qWarning() << "Card art rule lookup failed; failing open for" << cardName;
return true;
}
if (!q->next()) {
return true; // default allow
}
return q->value(0).toString() != "DENY";
}
Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd,
ResponseContainer & /* rc */)
{
@ -3527,7 +3547,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const
return Response::RespOk;
}
if (!isCardNameAllowed(cardName, cardProviderId)) {
if (!sqlInterface->isCardArtAllowed(cardName, cardProviderId)) {
return Response::RespFunctionNotAllowed;
}
@ -3676,6 +3696,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C
newPassword = QString::fromStdString(cmd.hashed_new_password());
}
// Reject new credential formats the configured authentication strictness does not accept.
if (!acceptsCredentialFormat(newPasswordNeedsHash, newPassword)) {
return Response::RespClientUpdateRequired;
}
QString userName = QString::fromStdString(userInfo->name());
if (!databaseInterface->changeUserPassword(userName, oldPassword, true, newPassword, newPasswordNeedsHash)) {
return Response::RespWrongPassword;
@ -3822,6 +3847,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con
password = QString::fromStdString(cmd.hashed_new_password());
}
// Reject new credential formats the configured authentication strictness does not accept.
if (!acceptsCredentialFormat(passwordNeedsHash, password)) {
return Response::RespClientUpdateRequired;
}
if (sqlInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) {
if (servatrice->getEnableForgotPasswordAudit()) {
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
@ -3889,9 +3919,11 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa
Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd,
ResponseContainer &rc)
{
const QString userName = nameFromStdString(cmd.user_name());
QString passwordSalt = sqlInterface->getUserSalt(userName);
if (passwordSalt.isEmpty()) {
// Simplified exactly like cmdLogin, so the nonce bound to userName here validates
// against the same normalized name during the login's challenge-response check.
const QString userName = nameFromStdString(cmd.user_name()).simplified();
const QString storedPasswordData = sqlInterface->getUserPasswordData(userName);
if (storedPasswordData.isEmpty()) {
if (server->getRegOnlyServerEnabled()) {
return Response::RespRegistrationRequired;
} else {
@ -3899,8 +3931,46 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
return Response::RespOk;
}
}
auto *re = new Response_PasswordSalt;
re->set_password_salt(passwordSalt.toStdString());
if (PasswordHasher::isLegacyFormat(storedPasswordData)) {
re->set_password_salt(storedPasswordData.left(16).toStdString());
re->set_needs_migration(true);
// Legacy rows get a challenge-response nonce only outside legacy mode (there the client
// logs in with the legacy hash and the account is migrated). In legacy mode the row is
// served the legacy salt, since legacy mode only governs what NEW credentials are accepted.
if (servatrice->getAuthenticationStrictness() != Servatrice::AuthenticationLegacy) {
const QByteArray nonce = CryptoUtil::randomBytes(32);
setAuthNonce(nonce, userName);
re->set_nonce(nonce.constData(), nonce.size());
}
} else {
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedPasswordData);
if (!verifier.isValid) {
delete re;
return Response::RespContextError;
}
const QString scryptSalt = QString(verifier.salt.toBase64());
re->set_password_salt(scryptSalt.toStdString());
re->set_scrypt_salt(scryptSalt.toStdString());
re->set_n(verifier.n);
re->set_r(verifier.r);
re->set_p(verifier.p);
re->set_needs_migration(false);
// Pre-challenge clients derive the legacy hash from password_salt. While a fresh
// encrypted legacy backup exists, serve its salt there so they can still log in;
// challenge-response clients must use scrypt_salt, which always carries the
// verifier salt.
const QString legacyBackup = sqlInterface->getUsableLegacyBackup(userName);
if (!legacyBackup.isEmpty()) {
re->set_password_salt(legacyBackup.left(16).toStdString());
}
// scrypt rows are served challenge-response in every mode so migrated accounts never lock out.
const QByteArray nonce = CryptoUtil::randomBytes(32);
setAuthNonce(nonce, userName);
re->set_nonce(nonce.constData(), nonce.size());
}
rc.setResponseExtension(re);
return Response::RespOk;
}
@ -3998,6 +4068,39 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Re
return Response::RespOk;
}
Response::ResponseCode
AbstractServerSocketInterface::cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd,
ResponseContainer & /*rc*/)
{
if (authState != PasswordRight) {
return Response::RespLoginNeeded;
}
// Limit to the size of the database column (password_sha512 varchar(255)).
constexpr int MAX_PASSWORD_VERIFIER_LENGTH = 255;
const QString passwordVerifier = QString::fromStdString(cmd.password_verifier());
if (passwordVerifier.isEmpty() || passwordVerifier.length() > MAX_PASSWORD_VERIFIER_LENGTH ||
PasswordHasher::isLegacyFormat(passwordVerifier)) {
return Response::RespContextError;
}
// Reject unparseable or hostile cost parameters before they reach the database.
const PasswordVerifier parsedVerifier = PasswordHasher::parsePasswordVerifier(passwordVerifier);
if (!parsedVerifier.isValid) {
qCWarning(AbstractServerSocketInterfaceLog)
<< "Rejecting password verifier submission with invalid or insane cost parameters";
return Response::RespContextError;
}
if (!sqlInterface->submitPasswordVerifier(QString::fromStdString(userInfo->name()), passwordVerifier)) {
return Response::RespContextError;
}
qCDebug(AbstractServerSocketInterfaceLog)
<< "Password verifier migrated for user" << QString::fromStdString(userInfo->name());
return Response::RespOk;
}
// ADMIN FUNCTIONS.
// Permission is checked by the calling function.

View file

@ -103,6 +103,7 @@ class Command_ReportUserInfo;
class Command_RequestPasswordSalt;
class Command_ResetUserPassword;
class Command_SetCardArtParams;
class Command_SubmitPasswordVerifier;
class Command_UpdateAdminNotes;
class Command_ViewLogHistory;
class Command_WarnUser;
@ -124,6 +125,7 @@ protected:
void logDebugMessage(const QString &message) override;
bool tooManyRegistrationAttempts(const QString &ipAddress);
void processCommandContainer(const CommandContainer &cont) override;
bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const;
virtual void writeToSocket(QByteArray &data) = 0;
virtual void flushSocket() = 0;
@ -205,6 +207,7 @@ private:
Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc);
Response::ResponseCode cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, ResponseContainer &rc);
Response::ResponseCode
processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override;
Response::ResponseCode
@ -216,7 +219,6 @@ private:
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc);
bool isCardNameAllowed(const QString &cardName, const QString &cardProviderId);
Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &);
Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &);
Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &);

View file

@ -174,6 +174,7 @@ endfunction()
cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
@ -273,6 +274,17 @@ cockatrice_add_test(
LABELS
server
)
cockatrice_add_test(
NAME
server_room_channels_test
SOURCES
server_room_channels_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)
cockatrice_add_test(
NAME
latency_tracker_test

View file

@ -1,7 +1,9 @@
#include "gtest/gtest.h"
#include <QChar>
#include <QString>
#include <QStringList>
#include <cstring>
#include <libcockatrice/utility/cryptoutil.h>
#include <libcockatrice/utility/passwordhasher.h>
#include <string>
@ -39,6 +41,204 @@ TEST(PasswordHashTest, TokenHasExpectedLength)
const QString token = PasswordHasher::generateActivationToken();
ASSERT_EQ(token.size(), 16);
}
TEST(PasswordHashTest, DeriveKeyMatchesKnownVector)
{
// RFC 7914 scrypt test vector, P="password", S="NaCl", N=1024, r=8, p=16
const QByteArray expected = QByteArray::fromHex("fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b"
"3731622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cb"
"dfa2cc0640");
const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16);
ASSERT_EQ(derived.toHex(), expected.toHex());
}
TEST(PasswordHashTest, PasswordVerifierRoundTrip)
{
const QString stored = PasswordHasher::generatePasswordVerifier("hunter2");
ASSERT_FALSE(stored.isEmpty());
ASSERT_TRUE(stored.startsWith("$scrypt$"));
const PasswordVerifier parsed = PasswordHasher::parsePasswordVerifier(stored);
ASSERT_TRUE(parsed.isValid);
ASSERT_EQ(parsed.format, PasswordFormat::Scrypt);
ASSERT_EQ(parsed.n, SCRYPT_N);
ASSERT_EQ(parsed.r, SCRYPT_R);
ASSERT_EQ(parsed.p, SCRYPT_P);
ASSERT_EQ(parsed.salt.size(), SCRYPT_SALT_LENGTH);
ASSERT_EQ(parsed.verifier.size(), SCRYPT_VERIFIER_LENGTH);
}
TEST(PasswordHashTest, PasswordVerifierInvalidInput)
{
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("garbage").isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$not-an-int$8$1$AAAA$BBBB").isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$scrypt$1024$8$1$AAAA$too-short").isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier("$pbkdf2-sha512$1000$AAAA$BBBB").isValid);
}
TEST(PasswordHashTest, LegacyFormatDetection)
{
ASSERT_TRUE(PasswordHasher::isLegacyFormat("salt+hash"));
ASSERT_FALSE(PasswordHasher::isLegacyFormat(PasswordHasher::generatePasswordVerifier("password")));
}
TEST(PasswordHashTest, DeriveKeyDependsOnCostParameters)
{
const QByteArray keyA = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 16);
const QByteArray keyB = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 2048, 8, 16);
const QByteArray keyC = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, 8, 1);
ASSERT_NE(keyA, keyB);
ASSERT_NE(keyA, keyC);
}
TEST(PasswordHashTest, ComputeResponseIsDeterministic)
{
const QByteArray nonce = QByteArray("a nonce value");
const QByteArray key = QByteArray("the verifier bytes");
const QByteArray r1 = PasswordHasher::computeResponse(key, nonce);
const QByteArray r2 = PasswordHasher::computeResponse(key, nonce);
const QByteArray r3 = PasswordHasher::computeResponse(QByteArray("a different key"), nonce);
ASSERT_EQ(r1, r2);
ASSERT_NE(r1, r3);
}
TEST(PasswordHashTest, ConstantTimeEquals)
{
ASSERT_TRUE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("same")));
ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("same"), QByteArray("diff")));
ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("short"), QByteArray("longer")));
}
TEST(PasswordHashTest, CostParamsAreSane)
{
// Accept the recommended interactive parameters and the RFC 7914 test vector's.
ASSERT_TRUE(PasswordHasher::costParamsAreSane(SCRYPT_N, SCRYPT_R, SCRYPT_P));
ASSERT_TRUE(PasswordHasher::costParamsAreSane(1024, 8, 16));
// n must be in [1024, 2**20] and a power of two.
ASSERT_FALSE(PasswordHasher::costParamsAreSane(512, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1 << 21, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1025, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(0, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(-1024, 8, 1));
// r in [1, 32], p in [1, 16].
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 0, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 33, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 0));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 17));
}
TEST(PasswordHashTest, ParsePasswordVerifierRejectsHostileCostParams)
{
// 16 bytes of salt and 64 bytes of verifier, base64 encoded.
const QString saltB64 = QLatin1String("c2FsdHNhbHRzYWx0c2FsdA==");
const QString verifierB64 = QString(QByteArray(SCRYPT_VERIFIER_LENGTH, '\x42').toBase64());
ASSERT_TRUE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
// n not a power of two, below 1024, or above 2**20.
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1025$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$512$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1073741824$8$1$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
// r and p out of range.
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$33$1$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$17$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
}
TEST(PasswordHashTest, VerifyPasswordLegacyRow)
{
const QString salt = PasswordHasher::generateRandomSalt();
const QString legacyStored = PasswordHasher::computeHash("correct horse", salt);
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", legacyStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", legacyStored));
}
TEST(PasswordHashTest, VerifyPasswordScryptRow)
{
const QString scryptStored = PasswordHasher::generatePasswordVerifier("correct horse");
// Regression for the changeUserPassword bug that re-hashed the old password with
// a 16-char salt torn out of the "$scrypt$..." string, which could never match.
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", scryptStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", scryptStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("correct horse", "garbage"));
}
TEST(CryptoUtilTest, SecretRoundTrip)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QByteArray plaintext = PasswordHasher::computeHash("correct horse", "saltsaltsaltsalt").toUtf8();
const QByteArray aad = "alice";
const QString sealed = CryptoUtil::encryptSecret(plaintext, key, aad);
ASSERT_FALSE(sealed.isEmpty());
ASSERT_TRUE(sealed.startsWith("enc1:"));
ASSERT_EQ(CryptoUtil::decryptSecret(sealed, key, aad), plaintext);
}
TEST(CryptoUtilTest, SealIsNonDeterministic)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QByteArray plaintext = "same secret";
// Fresh random nonce per seal: identical inputs must not produce identical blobs.
ASSERT_NE(CryptoUtil::encryptSecret(plaintext, key), CryptoUtil::encryptSecret(plaintext, key));
}
TEST(CryptoUtilTest, RejectsWrongKey)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QByteArray otherKey = CryptoUtil::randomBytes(32);
const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice");
ASSERT_FALSE(sealed.isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, otherKey, "alice").isEmpty());
}
TEST(CryptoUtilTest, RejectsWrongAad)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QString sealed = CryptoUtil::encryptSecret("secret", key, "alice");
ASSERT_FALSE(sealed.isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key, "bob").isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret(sealed, key).isEmpty());
}
TEST(CryptoUtilTest, RejectsTampering)
{
const QByteArray key = CryptoUtil::randomBytes(32);
const QString sealed = CryptoUtil::encryptSecret("secret", key);
ASSERT_FALSE(sealed.isEmpty());
// Flip a character inside the ciphertext segment (keep it valid base64).
QStringList parts = sealed.split(':');
ASSERT_EQ(parts.size(), 3);
QByteArray ciphertext = QByteArray::fromBase64(parts.at(2).toUtf8());
ciphertext[0] = static_cast<char>(ciphertext.at(0) ^ 0x01);
parts[2] = QString(ciphertext.toBase64());
ASSERT_TRUE(CryptoUtil::decryptSecret(parts.join(':'), key).isEmpty());
}
TEST(CryptoUtilTest, RejectsMalformedInput)
{
const QByteArray key = CryptoUtil::randomBytes(32);
ASSERT_TRUE(CryptoUtil::decryptSecret("garbage", key).isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:notbase64!!:also-not!", key).isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAA", key).isEmpty());
ASSERT_TRUE(CryptoUtil::decryptSecret("", key).isEmpty());
// A short or oversized key must never reach the cipher.
ASSERT_TRUE(
CryptoUtil::decryptSecret("enc1:AAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", CryptoUtil::randomBytes(16))
.isEmpty());
ASSERT_TRUE(CryptoUtil::encryptSecret("secret", CryptoUtil::randomBytes(16)).isEmpty());
}
} // namespace
int main(int argc, char **argv)

View file

@ -0,0 +1,550 @@
/** @file server_room_channels_test.cpp
* @brief Tests for room chat channels: fan-out, privilege gating, whitelist, history replay and audit logging.
* @ingroup Tests
*/
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/server_message.pb.h"
#include "server.h"
#include "server_database_interface.h"
#include "server_protocolhandler.h"
#include "server_room.h"
#include <QList>
#include <QObject>
#include <QString>
#include <gtest/gtest.h>
#include <libcockatrice/protocol/get_pb_extension.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
#include <libcockatrice/protocol/pb/isl_message.pb.h>
#include <libcockatrice/protocol/pb/room_commands.pb.h>
#include <libcockatrice/protocol/pb/room_event.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <string>
RNG_Abstract *rng = nullptr; // referenced by the server_remote library
namespace
{
class MockDatabaseInterface : public Server_DatabaseInterface
{
public:
struct LogEntry
{
QString senderName;
QString message;
LogMessage_TargetType targetType;
int targetId;
QString targetName;
QString channel;
};
QList<LogEntry> logEntries;
void logMessage(const int /* senderId */,
const QString &senderName,
const QString & /* senderIp */,
const QString &message,
LogMessage_TargetType targetType,
const int targetId,
const QString &targetName,
const QString &channel = QString()) override
{
logEntries.append({senderName, message, targetType, targetId, targetName, channel});
}
AuthenticationResult checkUserPassword(Server_ProtocolHandler *,
const QString &,
const QString &,
const QString &,
QString &,
int &,
bool) override
{
return NotLoggedIn;
}
int getNextReplayId() override
{
return 1;
}
int getNextGameId() override
{
return 1;
}
int getActiveUserCount(QString) override
{
return 0;
}
ServerInfo_User getUserData(const QString &, bool) override
{
return ServerInfo_User();
}
};
class FakeServer : public Server
{
public:
explicit FakeServer(Server_DatabaseInterface *db) : Server()
{
setDatabaseInterface(db);
}
void insertRoom(Server_Room *newRoom)
{
addRoom(newRoom);
}
};
class TestRoomHandler : public Server_ProtocolHandler
{
public:
TestRoomHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db)
{
}
QString getAddress() const override
{
return {};
}
QString getConnectionType() const override
{
return {};
}
void authenticate(const ServerInfo_User &userInfo_)
{
setUserInfo(userInfo_);
authState = PasswordRight;
}
void attachToRoom(Server_Room *targetRoom)
{
rooms.insert(targetRoom->getId(), targetRoom);
}
QList<RoomEvent> receivedRoomEvents;
Response::ResponseCode lastResponseCode = Response::RespNothing;
void clearReceived()
{
receivedRoomEvents.clear();
lastResponseCode = Response::RespNothing;
}
protected:
void transmitProtocolItem(const ServerMessage &item) override
{
if (item.message_type() == ServerMessage::ROOM_EVENT) {
receivedRoomEvents.append(item.room_event());
} else if (item.message_type() == ServerMessage::RESPONSE) {
lastResponseCode = item.response().response_code();
}
}
};
class RoomChannelTest : public ::testing::Test
{
protected:
MockDatabaseInterface db;
FakeServer server{&db};
QList<ServerInfo_RoomChannel> catalog;
Server_Room *room = nullptr;
TestRoomHandler mod{&server, &db};
TestRoomHandler adminUser{&server, &db};
TestRoomHandler user{&server, &db};
ServerInfo_User moderator;
ServerInfo_User admin;
ServerInfo_User plainUser;
void SetUp() override
{
ServerInfo_RoomChannel lobby;
lobby.set_id("lobby");
lobby.set_display_name("Lobby");
lobby.set_access_level(ServerInfo_RoomChannel::Public);
catalog.append(lobby);
ServerInfo_RoomChannel mods;
mods.set_id("mods");
mods.set_display_name("Moderators");
mods.set_access_level(ServerInfo_RoomChannel::Moderator);
catalog.append(mods);
ServerInfo_RoomChannel ops;
ops.set_id("ops");
ops.set_display_name("Admins");
ops.set_access_level(ServerInfo_RoomChannel::Admin);
catalog.append(ops);
room = new Server_Room(1, 1000, "test room", "", "", "", false, "Welcome", QStringList(), &server, catalog);
moderator.set_name("mod");
moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered |
ServerInfo_User::IsModerator);
// Mirrors the database layer: the admin bit grants IsAdmin alongside
// IsModerator, so admins reach moderator channels via the moderator flag.
admin.set_name("admin");
admin.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsAdmin |
ServerInfo_User::IsModerator);
plainUser.set_name("user");
plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered);
}
void TearDown() override
{
delete room;
}
// Make @p handler a member of the room directly, without history replay or JOIN_ROOM bookkeeping.
void joinMember(TestRoomHandler &handler, const ServerInfo_User &userInfo_)
{
handler.authenticate(userInfo_);
handler.attachToRoom(room);
room->addClient(&handler);
}
void sendMainSay(TestRoomHandler &handler, const QString &message)
{
CommandContainer cont;
cont.set_cmd_id(1);
cont.set_room_id(room->getId());
Command_RoomSay cmd;
cmd.set_message(message.toStdString());
cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->CopyFrom(cmd);
handler.processCommandContainer(cont);
}
void sendChannelSay(TestRoomHandler &handler, const QString &channelId, const QString &message)
{
CommandContainer cont;
cont.set_cmd_id(1);
cont.set_room_id(room->getId());
Command_RoomChannelSay cmd;
cmd.set_channel_id(channelId.toStdString());
cmd.set_message(message.toStdString());
cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd);
handler.processCommandContainer(cont);
}
void joinThroughCommand(TestRoomHandler &handler)
{
CommandContainer cont;
cont.set_cmd_id(1);
Command_JoinRoom cmd;
cmd.set_room_id(room->getId());
cont.add_session_command()->MutableExtension(Command_JoinRoom::ext)->CopyFrom(cmd);
handler.processCommandContainer(cont);
}
static QList<RoomEvent> channelSayEvents(const TestRoomHandler &handler)
{
QList<RoomEvent> result;
for (const RoomEvent &event : handler.receivedRoomEvents) {
if (static_cast<RoomEvent::RoomEventType>(getPbExtension(event)) == RoomEvent::ROOM_CHANNEL_SAY) {
result.append(event);
}
}
return result;
}
static QList<RoomEvent> roomSayEvents(const TestRoomHandler &handler)
{
QList<RoomEvent> result;
for (const RoomEvent &event : handler.receivedRoomEvents) {
if (static_cast<RoomEvent::RoomEventType>(getPbExtension(event)) == RoomEvent::ROOM_SAY) {
result.append(event);
}
}
return result;
}
static QString channelIdOf(const RoomEvent &event)
{
return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).channel_id());
}
static QString messageOf(const RoomEvent &event)
{
return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).message());
}
static QString mainMessageOf(const RoomEvent &event)
{
return QString::fromStdString(event.GetExtension(Event_RoomSay::ext).message());
}
};
TEST_F(RoomChannelTest, MainSayStillBroadcastsToAllRoomMembers)
{
joinMember(mod, moderator);
joinMember(user, plainUser);
mod.clearReceived();
user.clearReceived();
sendMainSay(user, "hello everyone");
EXPECT_EQ(user.lastResponseCode, Response::RespOk);
EXPECT_EQ(roomSayEvents(mod).size(), 1);
EXPECT_EQ(mainMessageOf(roomSayEvents(mod).first()), "hello everyone");
EXPECT_EQ(roomSayEvents(user).size(), 1);
EXPECT_EQ(mainMessageOf(roomSayEvents(user).first()), "hello everyone");
EXPECT_TRUE(channelSayEvents(mod).isEmpty());
EXPECT_TRUE(channelSayEvents(user).isEmpty());
}
TEST_F(RoomChannelTest, PublicChannelSaysBroadcastToEveryone)
{
joinMember(mod, moderator);
joinMember(user, plainUser);
mod.clearReceived();
user.clearReceived();
sendChannelSay(user, "lobby", "is there a game tonight?");
EXPECT_EQ(user.lastResponseCode, Response::RespOk);
EXPECT_EQ(channelSayEvents(mod).size(), 1);
EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "lobby");
EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "is there a game tonight?");
EXPECT_EQ(channelSayEvents(user).size(), 1);
}
TEST_F(RoomChannelTest, ModeratorChannelReachesModeratorAndAbove)
{
joinMember(mod, moderator);
joinMember(adminUser, admin);
joinMember(user, plainUser);
mod.clearReceived();
adminUser.clearReceived();
user.clearReceived();
sendChannelSay(mod, "mods", "quiet channel");
EXPECT_EQ(mod.lastResponseCode, Response::RespOk);
EXPECT_EQ(channelSayEvents(mod).size(), 1);
EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "mods");
// An admin holds the moderator flag alongside the admin flag and so also receives the channel message.
EXPECT_EQ(channelSayEvents(adminUser).size(), 1);
EXPECT_TRUE(channelSayEvents(user).isEmpty());
EXPECT_TRUE(roomSayEvents(user).isEmpty());
}
TEST_F(RoomChannelTest, AdminReachesModeratorChannel)
{
joinMember(mod, moderator);
joinMember(adminUser, admin);
mod.clearReceived();
adminUser.clearReceived();
sendChannelSay(adminUser, "mods", "admin checking in");
EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk);
EXPECT_EQ(channelSayEvents(mod).size(), 1);
EXPECT_EQ(channelSayEvents(adminUser).size(), 1);
EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "admin checking in");
}
TEST_F(RoomChannelTest, UserBelowRequiredLevelCannotSendToChannel)
{
joinMember(mod, moderator);
joinMember(user, plainUser);
mod.clearReceived();
user.clearReceived();
sendChannelSay(user, "mods", "let me in");
EXPECT_EQ(user.lastResponseCode, Response::RespFunctionNotAllowed);
EXPECT_TRUE(channelSayEvents(mod).isEmpty());
EXPECT_TRUE(channelSayEvents(user).isEmpty());
bool loggedAsChannel = false;
for (const MockDatabaseInterface::LogEntry &entry : db.logEntries) {
loggedAsChannel |= entry.targetType == Server_DatabaseInterface::MessageTargetRoomChannel;
}
EXPECT_FALSE(loggedAsChannel);
}
TEST_F(RoomChannelTest, AdminChannelRejectsModerator)
{
joinMember(mod, moderator);
joinMember(adminUser, admin);
mod.clearReceived();
adminUser.clearReceived();
sendChannelSay(mod, "ops", "not allowed");
EXPECT_EQ(mod.lastResponseCode, Response::RespFunctionNotAllowed);
EXPECT_TRUE(channelSayEvents(mod).isEmpty());
EXPECT_TRUE(channelSayEvents(adminUser).isEmpty());
}
TEST_F(RoomChannelTest, AdminChannelReachesAdminsOnly)
{
joinMember(mod, moderator);
joinMember(adminUser, admin);
joinMember(user, plainUser);
mod.clearReceived();
adminUser.clearReceived();
user.clearReceived();
sendChannelSay(adminUser, "ops", "staff only");
EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk);
EXPECT_EQ(channelSayEvents(adminUser).size(), 1);
EXPECT_TRUE(channelSayEvents(mod).isEmpty());
EXPECT_TRUE(channelSayEvents(user).isEmpty());
}
TEST_F(RoomChannelTest, UnknownChannelIdIsRejected)
{
joinMember(user, plainUser);
user.clearReceived();
sendChannelSay(user, "not-a-channel", "spam");
EXPECT_EQ(user.lastResponseCode, Response::RespInvalidCommand);
EXPECT_TRUE(channelSayEvents(user).isEmpty());
EXPECT_TRUE(db.logEntries.isEmpty());
}
TEST_F(RoomChannelTest, MissingAccessLevelDefaultsToPublic)
{
ServerInfo_RoomChannel defaultAccess;
defaultAccess.set_id("public-by-default");
defaultAccess.set_display_name("Default Access");
catalog.append(defaultAccess);
auto *secondRoom = new Server_Room(2, 1000, "second", "", "", "", false, "", QStringList(), &server, catalog);
TestRoomHandler joiner{&server, &db};
joiner.authenticate(plainUser);
joiner.attachToRoom(secondRoom);
secondRoom->addClient(&joiner);
CommandContainer cont;
cont.set_cmd_id(1);
cont.set_room_id(2);
Command_RoomChannelSay cmd;
cmd.set_channel_id("public-by-default");
cmd.set_message("hi");
cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd);
joiner.processCommandContainer(cont);
EXPECT_EQ(joiner.lastResponseCode, Response::RespOk);
EXPECT_EQ(channelSayEvents(joiner).size(), 1);
delete secondRoom;
}
TEST_F(RoomChannelTest, HistoryStoredPerChannel)
{
room->say("user", "main message", QString(), false);
room->say("user", "lobby message", "lobby", false);
room->say("user", "mods message", "mods", false);
const QList<ServerInfo_ChatMessage> mainHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY);
EXPECT_EQ(mainHistory.size(), 1);
EXPECT_EQ(QString::fromStdString(mainHistory.first().message()), "main message");
EXPECT_TRUE(QString::fromStdString(mainHistory.first().channel()).isEmpty());
const QList<ServerInfo_ChatMessage> lobbyHistory = room->getChatHistory("lobby");
EXPECT_EQ(lobbyHistory.size(), 1);
EXPECT_EQ(QString::fromStdString(lobbyHistory.first().message()), "lobby message");
EXPECT_EQ(QString::fromStdString(lobbyHistory.first().channel()), "lobby");
const QList<ServerInfo_ChatMessage> modsHistory = room->getChatHistory("mods");
EXPECT_EQ(modsHistory.size(), 1);
EXPECT_EQ(QString::fromStdString(modsHistory.first().message()), "mods message");
EXPECT_EQ(QString::fromStdString(modsHistory.first().channel()), "mods");
// A channel id maps to its own history bucket, never to the main one.
EXPECT_EQ(room->getChatHistory("").size(), mainHistory.size());
}
TEST_F(RoomChannelTest, JoinReplaysOnlyVisibleChannelHistory)
{
server.insertRoom(room);
joinMember(mod, moderator);
joinMember(user, plainUser);
room->say("user", "main message", QString(), false);
room->say("user", "lobby message", "lobby", false);
room->say("mod", "mods message", "mods", false);
room->say("admin", "ops message", "ops", false);
TestRoomHandler lateUser{&server, &db};
lateUser.authenticate(plainUser);
joinThroughCommand(lateUser);
EXPECT_EQ(lateUser.lastResponseCode, Response::RespOk);
const QList<RoomEvent> lateChannelEvents = channelSayEvents(lateUser);
// Replay: one main history event, one public channel history event, the welcome message -- but no restricted
// history.
EXPECT_EQ(roomSayEvents(lateUser).size(), 2);
EXPECT_EQ(lateChannelEvents.size(), 1);
EXPECT_EQ(channelIdOf(lateChannelEvents.first()), "lobby");
TestRoomHandler lateMod{&server, &db};
lateMod.authenticate(moderator);
joinThroughCommand(lateMod);
EXPECT_EQ(lateMod.lastResponseCode, Response::RespOk);
const QList<RoomEvent> lateModChannelEvents = channelSayEvents(lateMod);
EXPECT_EQ(lateModChannelEvents.size(), 2);
EXPECT_EQ(channelIdOf(lateModChannelEvents.at(0)), "lobby");
EXPECT_EQ(channelIdOf(lateModChannelEvents.at(1)), "mods");
TestRoomHandler lateAdmin{&server, &db};
lateAdmin.authenticate(admin);
joinThroughCommand(lateAdmin);
EXPECT_EQ(lateAdmin.lastResponseCode, Response::RespOk);
const QList<RoomEvent> lateAdminChannelEvents = channelSayEvents(lateAdmin);
// An admin sees the moderator channel and the admin channel on top of the public one.
EXPECT_EQ(lateAdminChannelEvents.size(), 3);
EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(0)), "lobby");
EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(1)), "mods");
EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(2)), "ops");
}
TEST_F(RoomChannelTest, AuditLoggingTagsChannelMessages)
{
joinMember(user, plainUser);
sendMainSay(user, "main log line");
sendChannelSay(user, "lobby", "channel log line");
ASSERT_EQ(db.logEntries.size(), 2);
EXPECT_EQ(db.logEntries.at(0).targetType, Server_DatabaseInterface::MessageTargetRoom);
EXPECT_TRUE(db.logEntries.at(0).channel.isEmpty());
EXPECT_EQ(db.logEntries.at(1).targetType, Server_DatabaseInterface::MessageTargetRoomChannel);
EXPECT_EQ(db.logEntries.at(1).channel, "lobby");
EXPECT_EQ(db.logEntries.at(1).senderName, "user");
EXPECT_EQ(db.logEntries.at(1).message, "channel log line");
}
TEST_F(RoomChannelTest, PublicChannelRelaysOverIslButRestrictedChannelsDoNot)
{
QList<IslMessage> relMessages;
QObject::connect(&server, &Server::sigSendIslMessage,
[&relMessages](const IslMessage &message, int) { relMessages.append(message); });
room->say("mod", "public msg", "lobby");
room->say("mod", "mods msg", "mods");
room->say("admin", "ops msg", "ops");
ASSERT_EQ(relMessages.size(), 1);
const RoomEvent &relayed = relMessages.first().room_event();
EXPECT_EQ(static_cast<RoomEvent::RoomEventType>(getPbExtension(relayed)), RoomEvent::ROOM_CHANNEL_SAY);
EXPECT_EQ(channelIdOf(relayed), "lobby");
EXPECT_EQ(messageOf(relayed), "public msg");
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -285,6 +285,32 @@ TEST_F(SettingsDefaultsTest, Chat_HighlightColor_Default)
ASSERT_EQ(s.getChatHighlightColor(), QString("A6120D"));
}
TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_Default)
{
ChatSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getChannelUnreadColor(), QString("1A7B00"));
}
TEST_F(SettingsDefaultsTest, Chat_ChannelUnreadColor_SetAndGet)
{
ChatSettings s(settingsPath, nullptr);
s.setChannelUnreadColor(QString("0000FF"));
ASSERT_EQ(s.getChannelUnreadColor(), QString("0000FF"));
}
TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_Default)
{
ChatSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getChannelMentionColor(), QString("A6120D"));
}
TEST_F(SettingsDefaultsTest, Chat_ChannelMentionColor_SetAndGet)
{
ChatSettings s(settingsPath, nullptr);
s.setChannelMentionColor(QString("FF0000"));
ASSERT_EQ(s.getChannelMentionColor(), QString("FF0000"));
}
TEST_F(SettingsDefaultsTest, Chat_MentionForeground_Default)
{
ChatSettings s(settingsPath, nullptr);

View file

@ -0,0 +1,581 @@
#include "gtest/gtest.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <libcockatrice/utility/user_data_backup.h>
namespace
{
TEST(UserDataBackup, CopyDirectoryKeepsNestedStructure)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
QDir source(sourceDir.path());
ASSERT_TRUE(source.mkpath("decks/sub"));
QFile deck(source.absoluteFilePath("decks/sub/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
EXPECT_TRUE(QFile::exists(dest + "/decks/sub/mydeck.cod"));
}
TEST(UserDataBackup, CopyDirectoryKeepsHiddenFiles)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
QFile hidden(QDir(sourceDir.path()).absoluteFilePath(".hidden.ini"));
ASSERT_TRUE(hidden.open(QIODevice::WriteOnly | QIODevice::Text));
hidden.write("[general]\n");
hidden.close();
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
EXPECT_TRUE(QFile::exists(dest + "/.hidden.ini"));
}
TEST(UserDataBackup, CopyDirectoryOverwritesExistingTarget)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
const QString sourceFile = QDir(sourceDir.path()).absoluteFilePath("settings.ini");
QFile first(sourceFile);
ASSERT_TRUE(first.open(QIODevice::WriteOnly | QIODevice::Text));
first.write("version=1\n");
first.close();
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
ASSERT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
QFile second(sourceFile);
ASSERT_TRUE(second.open(QIODevice::WriteOnly | QIODevice::Text));
second.write("version=2\n");
second.close();
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
QFile result(dest + "/settings.ini");
ASSERT_TRUE(result.open(QIODevice::ReadOnly | QIODevice::Text));
EXPECT_EQ(result.readAll(), QByteArray("version=2\n"));
}
TEST(UserDataBackup, CopyEmptyDirectorySucceeds)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
ASSERT_TRUE(QDir(sourceDir.path()).mkpath("empty"));
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
EXPECT_TRUE(QDir(dest + "/empty").exists());
}
TEST(UserDataBackup, CopyMissingDirectoryFails)
{
QTemporaryDir destParent;
ASSERT_TRUE(destParent.isValid());
EXPECT_FALSE(
UserDataBackup::copyDirectoryRecursively("/nonexistent-cockatrice-path-xyz", destParent.path() + "/copy"));
}
TEST(UserDataBackup, BackupDirectoryCreatesTimestampedCopy)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
ASSERT_EQ(locations.size(), 1);
EXPECT_TRUE(QFile::exists(locations.first() + "/mydeck.cod"));
EXPECT_TRUE(locations.first().startsWith(QDir(backupRoot.path()).absolutePath()));
}
TEST(UserDataBackup, BackupSingleFile)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QString cardDb = QDir(sourceDir.path()).absoluteFilePath("cards.xml");
QFile db(cardDb);
ASSERT_TRUE(db.open(QIODevice::WriteOnly | QIODevice::Text));
db.write("<cockatrice_card_database/>\n");
db.close();
QStringList locations;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({cardDb}, backupRoot.path(), &locations, nullptr));
ASSERT_EQ(locations.size(), 1);
EXPECT_TRUE(QFile::exists(locations.first()));
EXPECT_TRUE(locations.first().endsWith("cards.xml"));
}
TEST(UserDataBackup, BackupMissingSourceFails)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QStringList locations;
QString sessionDir;
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod")},
backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(locations.isEmpty());
EXPECT_TRUE(sessionDir.isEmpty());
}
TEST(UserDataBackup, BackupPathsShareSingleSessionDir)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QDir source(sourceDir.path());
ASSERT_TRUE(source.mkpath("decks"));
ASSERT_TRUE(source.mkpath("settings"));
QFile deck(source.absoluteFilePath("decks/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QFile settings(source.absoluteFilePath("settings/settings.ini"));
ASSERT_TRUE(settings.open(QIODevice::WriteOnly | QIODevice::Text));
settings.write("[general]\n");
settings.close();
QStringList locations;
QString sessionDir;
EXPECT_TRUE(
UserDataBackup::backupAtRiskPaths({source.absoluteFilePath("decks"), source.absoluteFilePath("settings")},
backupRoot.path(), &locations, &sessionDir));
ASSERT_EQ(locations.size(), 2);
// Both backups live in the same timestamped session, even across a second boundary.
for (const QString &location : locations) {
EXPECT_TRUE(location.startsWith(QDir(sessionDir).absolutePath()));
}
EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")));
EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("settings/settings.ini")));
}
TEST(UserDataBackup, BackupPathsWithSameNameDoNotCollide)
{
QTemporaryDir firstDir;
QTemporaryDir secondDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(firstDir.isValid());
ASSERT_TRUE(secondDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
for (const QString &dir : {firstDir.path(), secondDir.path()}) {
QFile deck(QDir(dir).absoluteFilePath("cards.xml"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_card_database/>\n");
deck.close();
}
QStringList locations;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths(
{QDir(firstDir.path()).absoluteFilePath("cards.xml"), QDir(secondDir.path()).absoluteFilePath("cards.xml")},
backupRoot.path(), &locations, nullptr));
ASSERT_EQ(locations.size(), 2);
EXPECT_NE(locations.at(0), locations.at(1));
EXPECT_TRUE(QFile::exists(locations.at(0)));
EXPECT_TRUE(QFile::exists(locations.at(1)));
}
TEST(UserDataBackup, RapidBackupsGetUniqueSessionDirs)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QString firstSession;
QString secondSession;
QStringList locations;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession));
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession));
EXPECT_NE(firstSession, secondSession);
}
TEST(UserDataBackup, DefaultBackupRootIsUsable)
{
QString root = UserDataBackup::defaultBackupRoot();
EXPECT_FALSE(root.isEmpty());
EXPECT_TRUE(QDir(root).isAbsolute());
}
TEST(UserDataBackup, DefaultBackupRootHasNoDotDotSegments)
{
QString root = UserDataBackup::defaultBackupRoot();
EXPECT_FALSE(root.contains(QStringLiteral("..")));
EXPECT_EQ(root, QDir::cleanPath(root));
}
TEST(UserDataBackup, ListSessionsFindsRecentAndPrunesOld)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString firstSession;
QString secondSession;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession));
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession));
const QStringList sessions = UserDataBackup::listBackupSessions(backupRoot.path());
ASSERT_EQ(sessions.size(), 2);
// Newest first
EXPECT_EQ(sessions.first(), secondSession);
const QString recent = UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24);
EXPECT_EQ(recent, secondSession);
const QStringList reusedLocations = UserDataBackup::listSessionBackupLocations(recent);
EXPECT_FALSE(reusedLocations.isEmpty());
for (const QString &location : reusedLocations) {
EXPECT_TRUE(location.startsWith(QDir(recent).absolutePath()));
EXPECT_TRUE(QFile::exists(location));
}
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 1);
const QStringList pruned = UserDataBackup::listBackupSessions(backupRoot.path());
ASSERT_EQ(pruned.size(), 1);
EXPECT_EQ(pruned.first(), secondSession);
}
TEST(UserDataBackup, FindRecentReturnsEmptyWhenNone)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(backupRoot.path() + "/missing").isEmpty());
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(backupRoot.path() + "/missing"));
// Pruning an empty root must not fail
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5);
}
TEST(UserDataBackup, AllMissingSourcesCreateNoSession)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QStringList locations;
QString sessionDir;
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({backupRoot.path() + "/nope1", backupRoot.path() + "/nope2"},
backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(locations.isEmpty());
EXPECT_TRUE(sessionDir.isEmpty());
// No empty session left behind to block a retry or evict good sessions
EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty());
}
TEST(UserDataBackup, EmptySessionsAreSkippedAndPruned)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QString emptySession;
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &emptySession));
// An empty session from a failed attempt must never block a retry
EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(emptySession).isEmpty());
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(emptySession));
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5);
EXPECT_FALSE(QDir(emptySession).exists());
}
TEST(UserDataBackup, SuccessfulBackupIsMarkedComplete)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(UserDataBackup::isBackupSessionComplete(sessionDir));
// Marker files are bookkeeping and must not leak into the user-facing locations
const QStringList listed = UserDataBackup::listSessionBackupLocations(sessionDir);
EXPECT_FALSE(listed.isEmpty());
for (const QString &location : listed) {
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.complete")));
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.incomplete")));
}
}
TEST(UserDataBackup, IncompleteMarkerIsNotUsableButRemainsCovering)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
ASSERT_TRUE(QFile::remove(QDir(sessionDir).absoluteFilePath("backup.complete")));
QFile marker(QDir(sessionDir).absoluteFilePath("backup.incomplete"));
ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text));
marker.write("incomplete\n");
marker.close();
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir));
// Incomplete sessions are skipped by the usable search but still found by the
// covering search, so a failed copy falls back to the salvaged content.
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24).isEmpty());
EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 24), sessionDir);
}
TEST(UserDataBackup, SessionWithoutMarkerCountsAsIncomplete)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QString sessionDir;
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir));
ASSERT_TRUE(QDir(sessionDir).mkpath("decks"));
QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir));
// No source manifest either, so neither search reuses it.
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
}
TEST(UserDataBackup, FindRecentIgnoresExpiredSessions)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty());
}
TEST(UserDataBackup, EstimateBackupSize)
{
QTemporaryDir sourceDir;
ASSERT_TRUE(sourceDir.isValid());
const QString missing = QDir(sourceDir.path()).absoluteFilePath("missing");
EXPECT_EQ(UserDataBackup::estimateBackupSize({missing}), 0);
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("123456");
deck.close();
EXPECT_EQ(UserDataBackup::estimateBackupSize({sourceDir.path(), missing}), 6);
// Early-out: once over the cap the exact total no longer matters
EXPECT_GE(UserDataBackup::estimateBackupSize({sourceDir.path()}, 1), 1);
}
TEST(UserDataBackup, SessionCoversSourcesRequiresManifestMatch)
{
QTemporaryDir sourceA;
QTemporaryDir sourceB;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceA.isValid());
ASSERT_TRUE(sourceB.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod"));
ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text));
fa.write("a");
fa.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path()}));
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path(), sourceB.path()}));
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceB.path()}));
}
TEST(UserDataBackup, SessionWithoutManifestIsNotCovering)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QString sessionDir;
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir));
ASSERT_TRUE(QDir(sessionDir).mkpath("decks"));
QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {QDir(sessionDir).absoluteFilePath("decks")}));
}
TEST(UserDataBackup, ManifestIsHiddenFromSessionLocations)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("x");
deck.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
for (const QString &location : UserDataBackup::listSessionBackupLocations(sessionDir)) {
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.sources")));
}
}
TEST(UserDataBackup, FindRecentUsableSkipsIncompleteNewest)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString oldSession;
QString newSession;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &oldSession));
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &newSession));
ASSERT_NE(oldSession, newSession);
// Newest session becomes incomplete: usable search must fall back to the older complete one.
ASSERT_TRUE(QFile::remove(QDir(newSession).absoluteFilePath("backup.complete")));
QFile marker(QDir(newSession).absoluteFilePath("backup.incomplete"));
ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text));
marker.write("incomplete\n");
marker.close();
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}), oldSession);
// Covering search still finds the newest session even though it is incomplete.
EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}), newSession);
}
TEST(UserDataBackup, FindRecentUsableRequiresCoveringSet)
{
QTemporaryDir sourceA;
QTemporaryDir sourceB;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceA.isValid());
ASSERT_TRUE(sourceB.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod"));
ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text));
fa.write("a");
fa.close();
QFile fb(QDir(sourceB.path()).absoluteFilePath("b.cod"));
ASSERT_TRUE(fb.open(QIODevice::WriteOnly | QIODevice::Text));
fb.write("b");
fb.close();
QStringList locations;
QString oldSession;
QString newSession;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &oldSession));
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceB.path()}, backupRoot.path(), &locations, &newSession));
// Newest covers B only, so a query for A must reuse the older session.
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path()}), oldSession);
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceB.path()}), newSession);
EXPECT_TRUE(
UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path(), sourceB.path()}).isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {}).isEmpty());
}
TEST(UserDataBackup, CopyDirectoryDoesNotSilentlyEmptyDirSymlink)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
QDir source(sourceDir.path());
ASSERT_TRUE(source.mkpath("real"));
QFile inner(source.absoluteFilePath("real/card.cod"));
ASSERT_TRUE(inner.open(QIODevice::WriteOnly | QIODevice::Text));
inner.write("x");
inner.close();
if (!QFile::link(source.absoluteFilePath("real"), source.absoluteFilePath("link"))) {
GTEST_SKIP() << "Symlinks not permitted in this environment";
}
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
const bool ok = UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest);
const QFileInfo linkCopy(QDir(dest).absoluteFilePath("link"));
// Old behavior created an empty plain dir and reported success, silently dropping contents.
// New behavior copies the link best-effort; if that fails it must report failure.
if (ok) {
EXPECT_TRUE(linkCopy.exists() || linkCopy.isSymLink());
if (linkCopy.isDir() && !linkCopy.isSymLink()) {
EXPECT_FALSE(QDir(linkCopy.absoluteFilePath()).isEmpty());
}
} else {
EXPECT_TRUE(linkCopy.isSymLink() || !linkCopy.exists() || !QDir(linkCopy.absoluteFilePath()).isEmpty());
}
}
TEST(UserDataBackup, PluralBackupClearsOutParamsOnTotalFailure)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QStringList locations = {QStringLiteral("stale")};
QString sessionDir = QStringLiteral("stale");
const QString missing = QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod");
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({missing}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(locations.isEmpty());
EXPECT_TRUE(sessionDir.isEmpty());
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}