mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 13:11:55 +00:00
* deps: bump simplexmq for ConnectTarget * chat: migration adds simplex_name to contacts, groups, connections Nullable TEXT column on all three tables, with partial indexes on contacts(user_id, simplex_name) and groups(user_id, simplex_name) for the upcoming connectPlanName lookup. connections.simplex_name is the transient carrier from APIConnect -> XInfo handler, where the value is copied to contacts.simplex_name at delayed create. No reads or writes yet - column threading lands in subsequent commits. * tests: provide namesConfig = Nothing in smpServerCfg Follow-up to the simplexmq pin bump (ee0a45e9). The new namesConfig :: Maybe NamesConfig field on ServerConfig (introduced in simplexmq's namespace branch) needs to appear in the test fixture's record literal, otherwise the test suite fails to compile under -Werror. Disabled by default (Nothing). * chat: thread simplexName through Contact/GroupInfo/Connection records Adds `simplexName :: Maybe SimplexNameInfo` to the three records and extends every SELECT path that reconstructs them to read the new column. Decoded via eitherToMaybe . strDecode . encodeUtf8 (the codebase's established pattern for Maybe Text -> typed-decode fields), extracted as decodeSimplexName helper since the chain appears in toContact / toContact' / toGroupInfo / toConnection. INSERT paths still write Nothing - the write-side wiring lands in the next commit (Task 7). * deps: bump simplexmq for SimplexNameInfo FromField/ToField * chat: cross-reference groupInfoQueryFields and getGroupAndMember_ Two helpers redundantly maintain the same g.* column list. A future g.* addition must be applied to both sites; the cross-reference comments flag this for maintainers. A proper refactor (reusing groupInfoQueryFields from Connections.hs's inline SELECT) is out of scope for this branch. * chat: persist simplexName on prepare and connect-via-plan write paths createPreparedContact/createPreparedGroup gain a Maybe SimplexNameInfo parameter that they write to contacts.simplex_name / groups.simplex_name directly. createConnection_ writes to connections.simplex_name as a transient carrier for the connect-via-plan path. The XInfo handler in Library/Subscriber.hs reads the connection's simplexName and passes it to createDirectContact so the final contact row captures the name. All current callers pass Nothing; the actual flow lights up when APIConnectPlan accepts ConnectTarget and connectPlanName threads the name through (later commits in this branch). Uses the upstream ToField SimplexNameInfo (simplexmq 0b334b66) for writes; reads continue to go via the soft-degradation helper. * chat: APIConnectPlan accepts ConnectTarget; connectPlanName looks up by name APIConnectPlan/Connect flip from Maybe AConnectionLink to Maybe ConnectTarget. connectPlan dispatches CTLink -> connectPlanLink (the prior body, renamed) and CTName -> connectPlanName (new) which looks the name up against contacts.simplex_name and groups.simplex_name via the new getContactBySimplexName / getGroupInfoBySimplexName store helpers. The hit path returns the contact's / group's stored conn link from preparedContact / preparedGroup; missing prepared state or unknown names return CEInvalidConnReq. RSLV on-chain resolution is out of scope for this branch -- known-name lookup is enough for conversation display, search, and external-link share. connLinkP_ parser is unchanged: APIConnect's preparedLink_ stays ACreatedConnLink-shaped, and the Connect / APIConnectPlan parsers already use inline strP for ConnectTarget without going through the helper. Directory.Service call sites updated to wrap their AConnectionLink in CTLink when invoking APIConnectPlan. * chat: surface simplexName in conversation view + JSON output viewConnectionPlan now shows the simplex name beneath known contacts/groups (ILPKnown, CAPKnown, CAPContactViaAddress, GLPKnown active/prepared/deleted). The TH-derived Contact / GroupInfo JSON instances automatically expose `simplexName` (omitted when Nothing per defaultJSON's omitNothingFields), which unblocks client-side display and search. No JSON test added: there is no Contact-level JSON test module in this codebase; coverage is already provided by defaultJSON's omitNothingFields = True behaviour. Server-side substring search has no existing pattern in this codebase; client renderers index simplexName themselves once it appears in the JSON shape. External-link share (preferring simplex:/name… form over the raw link when the contact has a simplexName) lands in the next commit. * chat: share/copy output prefers simplexName when present When a contact or group has a simplex_name stored, the share-link render path emits the canonical simplex:/name... URI (via strEncode) instead of the underlying connection link. Falls back to the existing link rendering when simplexName is Nothing. Final commit of the ConnectTarget plumbing chain: end-to-end users can now (a) connect via @alice.simplex / #group.simplex with the agent layer carrying the name, (b) see the simplex name on the contact/group records and in viewConnectionPlan, (c) share the contact using the namespace-canonical form rather than the raw URI. * deps: bump simplexmq for boundedNonSpace + drop unused FromField * chat: simplex_name partial indexes are UNIQUE A simplex name is a stable, per-user identity (one name → one contact or group). Without a unique constraint, a later writer that populates the column twice for the same name would silently produce two matching rows, and getContactBySimplexName/getGroupInfoBySimplexName would return whichever the planner picks first. Promote the partial indexes added in M20260603 to UNIQUE before any caller wires the writes. Predicate (WHERE simplex_name IS NOT NULL) already scopes the constraint to rows that opted in. * chat: regen Postgres schema dump for UNIQUE simplex_name indexes Follow-up tof71c579c. The SchemaDump test runs against SQLite only; the parallel PostgresSchemaDump suite gates on -fclient_postgres and a running localhost PG instance, which this environment doesn't have. Updated the Postgres schema dump by hand to mirror the migration change (two lines: CREATE INDEX → CREATE UNIQUE INDEX). * chat: CESimplexNameNotFound for name lookup misses connectPlanName now distinguishes "name not found" from "connection link is invalid". CEInvalidConnReq's message ("Connection link is invalid, possibly it was created in a previous version") was misleading when a user typed @alice.simplex against a database that simply has no contact by that name. The two "missing prepared link" cases stay on CEInvalidConnReq — the lookup found a row but the stored link is unusable, which is closer to the existing semantics. The two truly-missing cases (no contact found / no group found) move to CESimplexNameNotFound, which also surfaces the name back to the client for a precise UX. * chat: exclude soft-deleted contacts from idx_contacts_simplex_name The lookup `getContactBySimplexName` (Store/Direct.hs:781) filters `AND deleted = 0`, but the index predicate `WHERE simplex_name IS NOT NULL` covered tombstoned rows too. Forward-compat trap: once writers land a non-Nothing simplex_name, soft-deleting a contact would block re-claiming its name (UNIQUE conflict) even though the lookup reports the slot as free. Tighten the partial-index predicate to also require deleted = 0 so the constraint scope matches the live-lookup scope. Groups have no soft- delete column, so their index stays as-is. * chat: connectPlanName dispatches on nameType first Previously the function always probed contacts.simplex_name first and fell through to groups for NTPublicGroup misses. But the discriminator (`@`/`#`) is embedded in the stored bytes via strEncode, so an `#group.simplex` lookup can never match a contact row. Reorder to case on nameType up front, saving one DB query and one withFastStore transaction acquire on the group path. * chat: CESimplexNameUnprepared for found-but-no-link cases + member-removed filter connectPlanName previously threw CEInvalidConnReq when a name lookup hit a contact / group row whose preparedContact / preparedGroup was NULL. The error message ("Connection link is invalid, possibly it was created in a previous version") was wrong: the name resolved fine, the device just has no link material to reconnect via (typical for a contact created via the XInfo handler rather than the prepare path). Introduce CESimplexNameUnprepared SimplexNameInfo for this case. Also mirror the link-based path's gPlan (Commands.hs:4133) for groups whose membership state is GSMemRemoved — return CESimplexNameNotFound rather than GLPKnown for a removed-member group, since GLPKnown for removed members would be inconsistent with how /_connect plan over a short link handles the same situation. * chat: fix stale CRITICAL comment in saveConnInfo Comment claimed SEDBException is re-thrown as CRITICAL but only SEDBBusyError is (via the `critical` helper at Subscriber.hs:136 and the showCritical branch at :1695). Updated to describe the actual behaviour. * chat: fix misleading decodeSimplexName docstring The comment described "@alice.simplex" as the column's surface form, but ToField SimplexNameInfo writes the canonical strEncode output ("simplex:/name@alice.simplex"). Aligns the docstring with what the column actually holds. * chat: drop redundant T.unpack in CESimplexName* error rendering plain has a Text instance (verified by sibling simplexNameLine at View.hs:2176 which uses it directly). The T.unpack in the new error renderings was inconsistent with the same-feature helper. Cosmetic cleanup. * deps: bump simplexmq for resolveSimplexName * chat: simplexName field on Profile, GroupProfile, LocalProfile Adds a Maybe SimplexNameInfo field to the wire-level Profile and GroupProfile (and their DB sibling LocalProfile). JSON instances are TH-derived with omitNothingFields = True, so the new optional field is auto-handled and old peers / old JSON without the key decode as Nothing. Existing record-construction sites are set to simplexName = Nothing as a placeholder. Outgoing dissemination (userProfileDirect / userProfileInGroup) and incoming persistence wire-up land in follow-up commits. redactedMemberProfile passes the field through, matching how peerType is preserved. * chat: load LocalProfile.simplexName from simplex_name column Populate the embedded LocalProfile.simplexName field for the user's own profile and for peer Contact / GroupInfo from the existing simplex_name columns on contacts and groups. Previously every DB read set this field to Nothing (Task 1 placeholder), so downstream consumers that work off LocalProfile / GroupProfile (e.g., userProfileDirect / userProfileInGroup that build outgoing XInfo / XGrpInfo via fromLocalProfile) saw Nothing unconditionally. Scope is limited to the rows where the simplex_name column actually exists: contacts (per-user) and groups (per-user). Sites that only read contact_profiles / group_profiles (toContactRequest, toContactProfile, toGroupProfile, rowToLocalProfile) remain Nothing; Task 3 adds the profile-table columns and wires them up. * chat: test outgoing Profile carries simplexName from User profile userProfileDirect, userProfileInGroup' and redactedMemberProfile already pass simplexName through via fromLocalProfile (Task 1) once the embedded LocalProfile field is populated (previous commit). Lock that behavior in with focused unit tests: - userProfileDirect with Just simplexName -> wire Profile.simplexName Just - userProfileDirect with Nothing -> wire Nothing - userProfileDirect with an incognito Profile overlay -> wire Nothing (incognito identity must not leak the user's registered name) - userProfileInGroup' pass-through - redactedMemberProfile pass-through (forwarded member profiles) * chat: clarify groups.simplex_name stopgap comment Drop the asymmetric toContact comment (the mirror is now obvious post-Task-1) and rewrite the toGroupInfo stopgap comment to reflect the actual semantics: groups.simplex_name is per-user locally-known, mirrored into groupProfile as a stopgap until group_profiles.simplex_name lands. * chat: add simplex_name to contact_profiles and group_profiles Adds nullable simplex_name TEXT column and partial UNIQUE (user_id, simplex_name) index to both contact_profiles and group_profiles tables. Distinct from contacts.simplex_name / groups.simplex_name (M20260603), which carry the user's locally-known label set by the prepare-via-name path; the new columns will carry the peer's broadcast claim received via XInfo / XGrpInfo (wired up in following commits). * chat: persist peer-claimed simplexName from incoming profiles Write paths: - updateContactProfile_' / updateGroupProfile_ now set the new contact_profiles.simplex_name / group_profiles.simplex_name columns from Profile.simplexName / GroupProfile.simplexName respectively. - createContact_ INSERT writes Profile.simplexName to the new contact_profiles column (separate from the existing simplexName arg, which still writes contacts.simplex_name — the user's locally-known label). Read paths (closing Task 2's deferred sites): - toContact splits simplex_name reads: Contact.simplexName from contacts.simplex_name (existing); LocalProfile.simplexName from contact_profiles.simplex_name (new column). - toGroupInfo similarly splits: GroupInfo.simplexName from groups.simplex_name; groupProfile.simplexName from group_profiles.simplex_name. - ProfileRow / rowToLocalProfile, toContactRequest, getUserContactProfiles, toGroupProfile, getProfileById, groupMemberQuery, getGroupAndMember_, saveRcvChatItem-related quotes — all extended to read p.simplex_name and decode it into LocalProfile.simplexName / GroupProfile.simplexName. Conflict handling (Decision B): - clearConflictingContactProfileSimplexName_ / *Group* helpers do an atomic UPDATE-with-RETURNING that NULLs simplex_name on any other row in the same user that would collide on the partial UNIQUE index, returning the displaced row's display_name. - updateContactProfileWithConflict / updateGroupProfileWithConflict bundle clear+update in one transaction. - processContactProfileUpdate / xGrpInfo invoke the *WithConflict variants and emit CEvtSimplexNameConflict when a displacement happened (with the claiming and displaced display names). Adds ChatEvent CEvtSimplexNameConflict and SimplexNameConflictEntity (SNCEContact / SNCEGroup) with JSON instances and View.hs rendering. * chat: fix review findings on simplex_name persistence - updateUserProfile no longer writes contact_profiles.simplex_name on the user's own row (the column is reserved for peer claims; the user's broadcastable name lives on contacts.simplex_name via uct.simplex_name). - updateMemberContactProfile_'/Reset_' now write simplex_name; new updateMemberProfileWithConflict / updateContactMemberProfileWithConflict variants run conflict-clear and return the displaced name, with processMemberProfileUpdate emitting CEvtSimplexNameConflict. - createContact_ runs conflict-clear before INSERT to avoid UNIQUE constraint violations on first-write peer collisions, returning the displaced name; createPreparedContact / createDirectContact thread it through to APIPrepareContact and saveConnInfo XInfo for event emission. - groups conflict-clear takes ProfileId directly (avoids the NOT IN (NULL) silent-noop edge case when groups.group_profile_id is ON DELETE SET NULL). - Moves clearConflictingContactProfileSimplexName_ to Shared.hs so createContact_ can call it without inducing a circular import. * chat: resolveOnUserServers iterates user SMP servers for RSLV * chat: connectPlanName falls back to RSLV when local lookup misses * chat: RSLV-resolved NameRecord dispatched through prepared row dispatchResolvedRecord now picks the first nrContactLinks (NTContact) or nrChannelLinks (NTPublicGroup) entry from the resolved record, decodes it as AConnShortLink, fetches the short-link data, and eagerly calls createPreparedContact / createPreparedGroup with the simplex_name set. Returning CPContactAddress (CAPKnown ct) / CPGroupLink (GLPKnown g ...) mirrors the local-store-hit branch of connectPlanName: hit and miss converge on the same plan shape, so the connectWithPlan caller cannot distinguish where the prepared row came from. Threading uses the existing Maybe SimplexNameInfo parameter added in c6f26150 for the local-prepare path -- no new write path or transient carrier. Pure helper firstNameLink is extracted and exported so the link-picker contract is testable without a DB / agent. ResolveNameTests gains five cases covering the per-type selection, the first-link policy, and the empty-list to CESimplexNameNotFound collapse. * chat: regen query plans after simplex_name plumbing * chat: register ConnectTarget + CEvtSimplexNameConflict in bot docs Bot API docs generator failed with "Undefined type: ConnectTarget" sincef2394d121(prior plan) flipped APIConnectPlan/Connect from Maybe AConnectionLink to Maybe ConnectTarget without updating bots/src/API/Docs/*. Also adds SimplexNameConflictEntity (new incd0de9659) and documents the CEvtSimplexNameConflict event for peer-name displacement notifications. Regenerates the affected markdown / TypeScript / Python artefacts. * deps: bump simplexmq for NameRecord reshape; update consumers simplexmq 5ee014dd reshaped NameRecord to align with the Python resolver JSON: nrChannelLinks/nrContactLinks (lists of NameLink) became nrSimplexChannel/nrSimplexContact (Maybe Text); nrDisplayName became nrName; nrResolver was added; the NameLink wrapper type and nrIsTest/ nrExpiry/nrAdminAddress/nrAdminEmail fields were dropped. Update dispatchResolvedRecord destructure and firstNameLink signature to the new Maybe Text shape, and refresh the ResolveNameTests fixtures and assertions accordingly. * chat: resolveOnUserServers iterates only on transport errors Privacy: every miss previously broadcast the candidate name to every enabled SMP server. Now only NETWORK / TIMEOUT failures fall through to the next server; definite resolver answers (NAME / AUTH / CMD PROHIBITED / other ERR) stop iteration. * chat: document why groups simplex_name index has no soft-delete filter The contacts simplex_name index filters on (deleted = 0); the groups index has no analogous filter because the groups table has no `deleted` column. Groups are hard-deleted by deleteGroup, so the asymmetry is intentional. The remaining "removed member, row retained" edge case is flagged in the lookup comment for follow-up. * chat: document connections.simplex_name as transient carrier Audit flagged the column as "INSERTed but never UPDATEd". This is by design per the prior plan's connect-via-plan flow: the column is a transient carrier between connection-creation and contact-creation. After the Contact row is created via XInfo handling, contacts.simplex_name is the source of truth and the connections value is a historical snapshot. Documents the intent so future readers don't reflag it. * chat: extract surfaceSimplexNameConflict helper Six call sites duplicated the same forM_ ((,) <$> claim <*> displaced) shape emitting CEvtSimplexNameConflict. Extract to a single helper so future call sites don't drift on whether to emit, and so the conflict event shape change (post-Task-3 SimplexNameConflictEntity split into SNCEContact / SNCEGroup) propagates through one site. * chat: APIVerifySimplexName command + CEvtSimplexNameUnverified warning Addresses the TOFU vulnerability where peer-claimed simplex_name was accepted unverified. Adds: - contacts.simplex_name_verified_at + groups.simplex_name_verified_at (M20260606_simplex_name_verified) - APIVerifySimplexName ChatRef command: RSLV-resolves the claimed name and compares the resolved link to the peer's stored connection link; on match writes verified_at and emits CEvtSimplexNameVerified; on mismatch emits CEvtSimplexNameVerifyFailed - CEvtSimplexNameUnverified passive warning emitted on incoming XInfo / XGrpInfo when a name claim arrives without a current verification - updateContactProfileWithConflict / updateGroupProfileWithConflict clear simplex_name_verified_at whenever the peer's claim transitions (any value change including Nothing<->Just): the prior verification was bound to the prior claim. UI can surface the unverified indicator next to a contact / group's name, and prompt the user to invoke the verify command. This shifts the security model from "TOFU + last-writer-wins" to "TOFU + on-demand RSLV verification". * chat: register APIVerifySimplexName + verify events in bot docsebe90f716added the verify command + events + SimplexNameVerifyFailReason type without touching bots/src/API/Docs/. Mirrors commit0d7ea8061which addressed the same gap for ConnectTarget. Regenerates the affected markdown / TypeScript / Python artefacts. * chat: bump simplexmq pin + document cross-table simplex_name discriminator Pin bump 5ee014dd -> c9c2d19 picks up the 8 simplexmq commits since the last bump (parseBare lowercase fix, forwarded-param cleanup, ServerTests + agent end-to-end tests, TldRegistries removal, SNRC ABI decoder, NameRecord/NameOwner module extraction). Adds a brief comment on clearConflictingContactProfileSimplexName_ explaining why the audit's flagged cross-table collision (between contact_profiles.simplex_name and group_profiles.simplex_name) is structurally impossible: SimplexNameInfo's strEncode prefixes contact names with '@' and group names with '#', so the stored bytes never overlap between the two tables. Query-plan regen deferred (the test is non-deterministic in CI / dev sandbox — see prior6c990696c). * deps: bump simplexmq for HTTP resolver; adapt NameRecord consumers simplexmq 92b3d049 reshaped NameRecord text fields from Maybe Text to Text (empty string sentinel). Adapt firstNameLink to take Text directly and treat T.null as "absent". dispatchResolvedRecord destructure unchanged; passes the text values straight through. apiVerifySimplexName switches from Just/Nothing pattern to a T.null guard with the same UX. Test fixtures updated. * deps: bump simplexmq for multi-link NameRecord; adapt consumers * core: treat RSLV CMD UNKNOWN as no name-resolution support * core: fix contact-by-connection query missing simplex_name_verified_at * deps: update sha256map for simplexmq f555e9af pin * core: iterate past RSLV-unsupported name servers * core: filter RSLV servers by operator enablement * core: align resolver docs/tests with RSLV errors * deps: bump simplexmq to df1aa24c * refactor(names): agent resolution + one error type Adopt the simplexmq names rework (PR #7045): name resolution is now owned by the agent (resolveSimplexName picks a names-role server), so the chat-side iteration is removed - delete ResolveError, iterateResolvers, resolveOnUserServers, enabledSMPServersForUser and resolveErrorToChatError. One error type: resolver/agent failures flow through ChatErrorAgent; remove the CEvtSimplexName* events, SimplexNameVerifyFailReason, SimplexNameConflictEntity and CESimplexNameResolverUnavailable. APIVerifySimplexName returns CRSimplexNameVerified (verified::Bool), mirroring CRConnectionVerified. connectPlan handles the name target directly; updateProfile WithConflict aliases collapsed into the plain functions. Add the per-operator "names" SMP server role (migration 20260612_smp_role_names, official operator on by default) feeding ServerRoles.names -> UserServers.nameSrvs. Bump simplexmq pin to ce69adfd and regenerate sha256map.nix. * fix(store): match chat_schema.sql to sqlite 3.46+ indent The schema-dump test renders the partial-index WHERE via the sqlite3 CLI; sqlite >=3.46 wraps a multi-condition WHERE onto two lines ("IS NOT NULL" + indented "AND ...") where 3.45 kept it on one. The committed schema was generated with 3.45, so CI (newer sqlite) failed the comparison on idx_contacts_simplex_name. Regenerated with the newer formatter; only that one WHERE clause changes. * feat(operators): warn when no server resolves names Mirror USWNoChatRelays: validateUserServers emits USWNoNamesServers when no enabled server of an enabled operator carries the SMP names role. noNamesServersWarns is self-contained with local predicates, matching the sibling noChatRelaysWarns; noServersErrs is untouched. * test(operators): expect USWNoNamesServers warning in no-servers cases * fix(store): single-line simplex_name WHERE to match CI sqlite (<=3.45) * chore: bump simplexmq pin to 6843b14c * refactor(store): consolidate names migrations into one Unshipped feature - merge the four incremental simplex_name migrations (0603/0604/0606/0612) into a single M20260603_simplex_name. The combined UP applies the ALTERs/indexes in the same order, so the resulting schema is byte-identical (verified by SchemaDump on SQLite and pg_dump on Postgres). * update simplexmq * plan for name resolution * update types and schema * simpler resolution, name proofs * simplexmq * generate bot types, schema, unStrJSON, fix tests * ad hoc link comparison, create short link * update simplexmq * remove same link, use simplexmq instead * split verify API for contacts and public groups * remove comment * simplify warnings * test: remove unused * remove cute language * type name * remove spurious comments * refactor setting user name * refactor setting user name * remove trivial tests * refactor * remove tests using pre-short-link addresses * rename * move names * bots api * refactor more * refactor * refactor to another type * load own names * read short links when looking up by name * renames * refactor verification * mapM_ * update api types * change field for name * rename columns * api types, schema * renames * fix links * simplify * remove comments * rename fields * simplify * remove proof from channel addres * refactor * name resolution test * change tests * fix tests * fix tests * fix plan for names * test * test verification status * bot api * fix tests * update bot api types * query plan * add api for setting public group access * android, desktop, ios: connect via SimpleX name (#7068) * android, desktop, ios: connect via SimpleX name * android, desktop, ios: open known contact on name lookup; surface prepared contact Name search opens the contact (not list-filter); resolved/prepared contacts and groups are added to the chat list so they're visible and openable. Kotlin compile-verified; iOS edits pattern-matched, pending Xcode build. * feat(names): UI names role + agent NAME error Parity with the core names rework (#7045): - Add `names` to ServerRoles (Android + iOS) and a per-operator "To resolve names" toggle under the SMP section (xftp has no names role; the shared ServerRoles field stays false there). - Mirror the new agent error: NameErrorType + a NAME case on both AgentErrorType and ProtocolErrorType (the SMP ErrorType mirror), so the new SMP/agent NAME errors decode instead of crashing the decoder. - Remove ChatErrorType.SimplexNameResolverUnavailable (deleted in core) and repoint its "name resolution unavailable" alert to the agent NAME NO_SERVERS error, reusing the existing strings. Android (multiplatform) compiles clean; iOS mirrors the same changes (builds in Xcode). * feat(names): UI warning when no server resolves names Mirror core USWNoNamesServers: add the NoNamesServers variant to UserServersWarning (Kotlin sealed class + Swift enum) and its globalWarning / globalServersWarning branch, rendered by the existing ServersWarningFooter / ServersWarningView. Matches the noChatRelays warning exactly. * fix(servers): show all validation errors and warnings, not just the first globalServersError/Warning returned only the first entry, so a second warning (e.g. no names servers behind no chat relays) or a second error (e.g. no XFTP servers behind no SMP servers) was never displayed. Make them return all entries (globalServersErrors/Warnings) and render one footer row each, across the three combined-footer views. Per-protocol SMP/XFTP footers are unchanged. * docs(names): add SimpleX name UI plan * feat(names): add name model fields + SimplexName helpers * feat(names): verify + set-name API & responses * docs(names): bump core sync to5008b4e62* feat(names): show name + verification on chat info * feat(names): add Verify SimpleX names privacy toggle * feat(names): add set-name screens (user + channel) * update ui * fix kotlin * fix codable * fix ios * fix errors * api in UI * send name as string in protocol * update simplexmq, capitalize * verify that name is in profile for own and known contacts and channels as condition of name resolution * update simplexmq --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * add log * bot types * finalize renames, ui alerts * more renames * kotlin alerts * show set name * alerts * texts, icons, footers * move JSON parsing to a persistent thread with large stack * simplexmq * use uikit alerts * remove comment * move name verification to more privacy * change icon on verify names setting * verify name proof * nix shas * show verified names * better alert when saving names * revert breaking field name change * simplexmq * clean up * remove unused * remove empty line * fix error * simplify * use domain without prefix in profiles and in database, rename fields and types * rename types * fix JSON name * pass verified domain to prepare API * fix prepare api * fix ios encoding * enable name resolution via Flux servers * fix test --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
1105 lines
51 KiB
Haskell
1105 lines
51 KiB
Haskell
{-# LANGUAGE CPP #-}
|
|
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE DeriveAnyClass #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE QuasiQuotes #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
{-# LANGUAGE TemplateHaskell #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
{-# LANGUAGE TypeApplications #-}
|
|
{-# LANGUAGE TypeOperators #-}
|
|
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
|
|
|
module Simplex.Chat.Store.Profiles
|
|
( AutoAccept (..),
|
|
AddressSettings (..),
|
|
UserMsgReceiptSettings (..),
|
|
UserContactLink (..),
|
|
GroupLinkInfo (..),
|
|
createUserRecordAt,
|
|
getUsersInfo,
|
|
getUsers,
|
|
setActiveUser,
|
|
getUser,
|
|
getRelayUser,
|
|
getUserIdByName,
|
|
getUserByAConnId,
|
|
getUserByASndFileId,
|
|
getUserByARcvFileId,
|
|
getUserByContactId,
|
|
getUserByGroupId,
|
|
getUserByNoteFolderId,
|
|
getUserByFileId,
|
|
getUserFileInfo,
|
|
deleteUserRecord,
|
|
updateUserPrivacy,
|
|
updateClientService,
|
|
updateAllContactReceipts,
|
|
updateUserContactReceipts,
|
|
updateUserGroupReceipts,
|
|
updateUserAutoAcceptMemberContacts,
|
|
updateUserProfile,
|
|
setUserBadge,
|
|
setUserProfileContactLink,
|
|
getUserContactProfiles,
|
|
createUserContactLink,
|
|
getUserAddressConnection,
|
|
deleteUserAddress,
|
|
getUserAddress,
|
|
setUserSimplexDomain,
|
|
getUserContactLinkById,
|
|
getGroupLinkInfo,
|
|
getUserContactLinkByConnReq,
|
|
getUserContactLinkViaTarget,
|
|
setUserContactLinkShortLink,
|
|
getContactWithoutConnViaAddress,
|
|
getContactWithoutConnViaShortAddress,
|
|
updateUserAddressSettings,
|
|
getProtocolServers,
|
|
getChatRelays,
|
|
getChatRelayById,
|
|
insertProtocolServer,
|
|
getUpdateServerOperators,
|
|
getServerOperators,
|
|
getUserServers,
|
|
setServerOperators,
|
|
getCurrentUsageConditions,
|
|
getLatestAcceptedConditions,
|
|
setConditionsNotified,
|
|
acceptConditions,
|
|
setUserServers,
|
|
setUserServers',
|
|
createCall,
|
|
deleteCalls,
|
|
getCalls,
|
|
createCommand,
|
|
setCommandConnId,
|
|
deleteCommand,
|
|
updateCommandStatus,
|
|
getCommandDataByCorrId,
|
|
setUserUIThemes,
|
|
profileContactLink,
|
|
)
|
|
where
|
|
|
|
import Control.Monad
|
|
import Control.Monad.Except
|
|
import Control.Monad.IO.Class
|
|
import qualified Data.Aeson.TH as J
|
|
import Data.Functor (($>))
|
|
import Data.Int (Int64)
|
|
import Data.List.NonEmpty (NonEmpty)
|
|
import qualified Data.List.NonEmpty as L
|
|
import Data.Maybe (catMaybes, fromMaybe, isJust)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
|
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
|
import Simplex.Chat.Badges (LocalBadge, localBadgeToRow)
|
|
import Simplex.Chat.Call
|
|
import Simplex.Chat.Messages
|
|
import Simplex.Chat.Operators
|
|
import Simplex.Chat.Protocol
|
|
import Simplex.Chat.Store.Direct
|
|
import Simplex.Chat.Store.Shared
|
|
import Simplex.Chat.Names (claimDomain, mkDomainClaim)
|
|
import Simplex.Chat.Types
|
|
import Simplex.Chat.Types.Preferences
|
|
import Simplex.Chat.Types.Shared
|
|
import Simplex.Chat.Types.UITheme
|
|
import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..))
|
|
import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, ConnectionLink (..), CreatedConnLink (..), SimplexDomain, SimplexNameInfo (..), UserId)
|
|
import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow)
|
|
import Simplex.Messaging.Agent.Store.DB (BoolInt (..))
|
|
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
|
import Simplex.Messaging.Encoding.String
|
|
import Simplex.Messaging.Parsers (defaultJSON)
|
|
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode)
|
|
import Simplex.Messaging.Agent.Store.Entity
|
|
import Simplex.Messaging.Transport.Client (TransportHost)
|
|
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
|
|
#if defined(dbPostgres)
|
|
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
|
|
import Database.PostgreSQL.Simple.SqlQQ (sql)
|
|
#else
|
|
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
|
|
import Database.SQLite.Simple.QQ (sql)
|
|
#endif
|
|
|
|
createUserRecordAt :: DB.Connection -> AgentUserId -> Bool -> Bool -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User
|
|
createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} activeUser currentTs =
|
|
checkConstraint SEDuplicateName . liftIO $ do
|
|
when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0"
|
|
let showNtfs = True
|
|
sendRcptsContacts = True
|
|
sendRcptsSmallGroups = True
|
|
autoAcceptMemberContacts = False
|
|
order <- getNextActiveOrder db
|
|
DB.execute
|
|
db
|
|
"INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?)"
|
|
( (auId, displayName, BI activeUser, BI userChatRelay, order)
|
|
:. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI clientService, currentTs, currentTs)
|
|
)
|
|
userId <- insertedRowId db
|
|
DB.execute
|
|
db
|
|
"INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)"
|
|
(displayName, displayName, userId, currentTs, currentTs)
|
|
DB.execute
|
|
db
|
|
"INSERT INTO contact_profiles (display_name, full_name, short_descr, image, chat_peer_type, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
|
|
(displayName, fullName, shortDescr, image, peerType, userId, userPreferences, currentTs, currentTs)
|
|
profileId <- insertedRowId db
|
|
DB.execute
|
|
db
|
|
"INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)"
|
|
(profileId, displayName, userId, BI True, currentTs, currentTs, currentTs)
|
|
contactId <- insertedRowId db
|
|
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
|
|
pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing)
|
|
|
|
-- TODO [mentions]
|
|
getUsersInfo :: DB.Connection -> IO [UserInfo]
|
|
getUsersInfo db = getUsers db >>= mapM getUserInfo
|
|
where
|
|
getUserInfo :: User -> IO UserInfo
|
|
getUserInfo user@User {userId} = do
|
|
ctCount <-
|
|
maybeFirstRow fromOnly $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT COUNT(1)
|
|
FROM chat_items i
|
|
JOIN contacts ct USING (contact_id)
|
|
WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) AND ct.deleted = 0
|
|
|]
|
|
(userId, CISRcvNew)
|
|
gCount <-
|
|
maybeFirstRow fromOnly $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT COUNT(1)
|
|
FROM chat_items i
|
|
JOIN groups g USING (group_id)
|
|
WHERE i.user_id = ? AND i.item_status = ?
|
|
AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1))
|
|
|]
|
|
(userId, CISRcvNew)
|
|
pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount}
|
|
|
|
getUsers :: DB.Connection -> IO [User]
|
|
getUsers db = do
|
|
now <- getCurrentTime
|
|
map (toUser now) <$> DB.query_ db userQuery
|
|
|
|
setActiveUser :: DB.Connection -> User -> IO User
|
|
setActiveUser db user@User {userId} = do
|
|
DB.execute_ db "UPDATE users SET active_user = 0"
|
|
activeOrder <- getNextActiveOrder db
|
|
DB.execute db "UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ?" (activeOrder, userId)
|
|
pure user {activeUser = True, activeOrder}
|
|
|
|
getNextActiveOrder :: DB.Connection -> IO Int64
|
|
getNextActiveOrder db = do
|
|
order <- fromMaybe 0 . join <$> maybeFirstRow fromOnly (DB.query_ db "SELECT max(active_order) FROM users")
|
|
if order == maxBound
|
|
then 0 <$ DB.execute db "UPDATE users SET active_order = active_order - ?" (Only (maxBound :: Int64))
|
|
else pure $ order + 1
|
|
|
|
getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User
|
|
getUser db userId = do
|
|
now <- liftIO getCurrentTime
|
|
ExceptT . firstRow (toUser now) (SEUserNotFound userId) $
|
|
DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId)
|
|
|
|
getRelayUser :: DB.Connection -> ExceptT StoreError IO User
|
|
getRelayUser db = do
|
|
now <- liftIO getCurrentTime
|
|
ExceptT . firstRow (toUser now) SERelayUserNotFound $
|
|
DB.query_ db (userQuery <> " WHERE u.is_user_chat_relay = 1")
|
|
|
|
getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64
|
|
getUserIdByName db uName =
|
|
ExceptT . firstRow fromOnly (SEUserNotFoundByName uName) $
|
|
DB.query db "SELECT user_id FROM users WHERE local_display_name = ?" (Only uName)
|
|
|
|
getUserByAConnId :: DB.Connection -> AgentConnId -> IO (Maybe User)
|
|
getUserByAConnId db agentConnId = do
|
|
now <- getCurrentTime
|
|
maybeFirstRow (toUser now) $
|
|
DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId)
|
|
|
|
getUserByASndFileId :: DB.Connection -> AgentSndFileId -> IO (Maybe User)
|
|
getUserByASndFileId db aSndFileId = do
|
|
now <- getCurrentTime
|
|
maybeFirstRow (toUser now) $
|
|
DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.agent_snd_file_id = ?") (Only aSndFileId)
|
|
|
|
getUserByARcvFileId :: DB.Connection -> AgentRcvFileId -> IO (Maybe User)
|
|
getUserByARcvFileId db aRcvFileId = do
|
|
now <- getCurrentTime
|
|
maybeFirstRow (toUser now) $
|
|
DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id JOIN rcv_files r ON r.file_id = f.file_id WHERE r.agent_rcv_file_id = ?") (Only aRcvFileId)
|
|
|
|
getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User
|
|
getUserByContactId db contactId = do
|
|
now <- liftIO getCurrentTime
|
|
ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $
|
|
DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ? AND ct.deleted = 0") (Only contactId)
|
|
|
|
getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User
|
|
getUserByGroupId db groupId = do
|
|
now <- liftIO getCurrentTime
|
|
ExceptT . firstRow (toUser now) (SEUserNotFoundByGroupId groupId) $
|
|
DB.query db (userQuery <> " JOIN groups g ON g.user_id = u.user_id WHERE g.group_id = ?") (Only groupId)
|
|
|
|
getUserByNoteFolderId :: DB.Connection -> NoteFolderId -> ExceptT StoreError IO User
|
|
getUserByNoteFolderId db contactId = do
|
|
now <- liftIO getCurrentTime
|
|
ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $
|
|
DB.query db (userQuery <> " JOIN note_folders nf ON nf.user_id = u.user_id WHERE nf.note_folder_id = ?") (Only contactId)
|
|
|
|
getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User
|
|
getUserByFileId db fileId = do
|
|
now <- liftIO getCurrentTime
|
|
ExceptT . firstRow (toUser now) (SEUserNotFoundByFileId fileId) $
|
|
DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId)
|
|
|
|
getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo]
|
|
getUserFileInfo db User {userId} =
|
|
map toFileInfo
|
|
<$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ?") (Only userId)
|
|
|
|
deleteUserRecord :: DB.Connection -> User -> IO ()
|
|
deleteUserRecord db User {userId} =
|
|
DB.execute db "DELETE FROM users WHERE user_id = ?" (Only userId)
|
|
|
|
updateUserPrivacy :: DB.Connection -> User -> IO ()
|
|
updateUserPrivacy db User {userId, showNtfs, viewPwdHash} =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE users
|
|
SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ?
|
|
WHERE user_id = ?
|
|
|]
|
|
(hashSalt viewPwdHash :. (BI showNtfs, userId))
|
|
where
|
|
hashSalt = L.unzip . fmap (\UserPwdHash {hash, salt} -> (hash, salt))
|
|
|
|
updateClientService :: DB.Connection -> UserId -> Bool -> IO ()
|
|
updateClientService db userId enable =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE users
|
|
SET client_service = ?
|
|
WHERE user_id = ?
|
|
|]
|
|
(BI enable, userId)
|
|
|
|
updateAllContactReceipts :: DB.Connection -> Bool -> IO ()
|
|
updateAllContactReceipts db onOff =
|
|
DB.execute
|
|
db
|
|
"UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL"
|
|
(BI onOff, BI onOff)
|
|
|
|
updateUserContactReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO ()
|
|
updateUserContactReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do
|
|
DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ?" (BI enable, userId)
|
|
when clearOverrides $ DB.execute_ db "UPDATE contacts SET send_rcpts = NULL"
|
|
|
|
updateUserGroupReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO ()
|
|
updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do
|
|
DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (BI enable, userId)
|
|
when clearOverrides $ DB.execute_ db "UPDATE groups SET send_rcpts = NULL"
|
|
|
|
updateUserAutoAcceptMemberContacts :: DB.Connection -> User -> Bool -> IO ()
|
|
updateUserAutoAcceptMemberContacts db User {userId} autoAccept =
|
|
DB.execute db "UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ?" (BI autoAccept, userId)
|
|
|
|
updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User
|
|
updateUserProfile db user p'
|
|
| displayName == newName = liftIO $ do
|
|
currentTs <- getCurrentTime
|
|
updateUserProfileFields_' db userId profileId p' currentTs
|
|
userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs
|
|
pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
|
|
| otherwise =
|
|
checkConstraint SEDuplicateName . liftIO $ do
|
|
currentTs <- getCurrentTime
|
|
DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId)
|
|
userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs
|
|
DB.execute
|
|
db
|
|
"INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)"
|
|
(newName, newName, userId, currentTs, currentTs)
|
|
updateUserProfileFields_' db userId profileId p' currentTs
|
|
updateContactLDN_ db user userContactId localDisplayName newName currentTs
|
|
pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
|
|
where
|
|
updateUserMemberProfileUpdatedAt_ currentTs
|
|
| userMemberProfileChanged = do
|
|
DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (currentTs, userId)
|
|
pure $ Just currentTs
|
|
| otherwise = pure userMemberProfileUpdatedAt
|
|
userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image
|
|
User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user
|
|
Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p'
|
|
fullPreferences = fullPreferences' preferences
|
|
|
|
-- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge)
|
|
updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO ()
|
|
updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE contact_profiles
|
|
SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?
|
|
WHERE user_id = ? AND contact_profile_id = ?
|
|
|]
|
|
((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId))
|
|
|
|
-- store the user's own badge credential; touches only the badge columns.
|
|
-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message.
|
|
setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User
|
|
setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do
|
|
ts <- getCurrentTime
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE contact_profiles
|
|
SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ?
|
|
WHERE user_id = ? AND contact_profile_id = ?
|
|
|]
|
|
(localBadgeToRow localBadge :. (ts, userId, profileId))
|
|
DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId)
|
|
pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts}
|
|
|
|
setUserSimplexDomain :: DB.Connection -> User -> Maybe SimplexDomain -> IO User
|
|
setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}} domain_ = do
|
|
ts <- getCurrentTime
|
|
DB.execute
|
|
db
|
|
"UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ?"
|
|
(domain_, ts, userId, profileId)
|
|
pure (user :: User) {profile = p {contactDomain = mkDomainClaim <$> domain_}}
|
|
|
|
setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User
|
|
setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do
|
|
ts <- getCurrentTime
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE contact_profiles
|
|
SET contact_link = ?, updated_at = ?
|
|
WHERE user_id = ? AND contact_profile_id = ?
|
|
|]
|
|
(contactLink, ts, userId, profileId)
|
|
pure (user :: User) {profile = p {contactLink}}
|
|
where
|
|
contactLink = profileContactLink <$> ucl_
|
|
|
|
-- only used in tests
|
|
getUserContactProfiles :: DB.Connection -> User -> IO [Profile]
|
|
getUserContactProfiles db User {userId} =
|
|
map toContactProfile
|
|
<$> DB.query
|
|
db
|
|
[sql|
|
|
SELECT display_name, full_name, short_descr, image, contact_link, chat_peer_type, contact_domain, preferences
|
|
FROM contact_profiles
|
|
WHERE user_id = ?
|
|
|]
|
|
(Only userId)
|
|
where
|
|
toContactProfile :: (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe SimplexDomain, Maybe Preferences) -> Profile
|
|
toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, domain_, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, contactDomain = mkDomainClaim <$> domain_, peerType, preferences, badge = Nothing}
|
|
|
|
createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> C.PrivateKeyEd25519 -> ExceptT StoreError IO ()
|
|
createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode linkPrivSigKey =
|
|
checkConstraint SEDuplicateContactLink . liftIO $ do
|
|
currentTs <- getCurrentTime
|
|
let slDataSet = BI (isJust shortLink)
|
|
DB.execute
|
|
db
|
|
"INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, link_priv_sig_key, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
|
|
(userId, cReq, shortLink, slDataSet, slDataSet, linkPrivSigKey, currentTs, currentTs)
|
|
userContactLinkId <- insertedRowId db
|
|
void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff
|
|
|
|
getUserAddressConnection :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO Connection
|
|
getUserAddressConnection db cxt User {userId} = do
|
|
ExceptT . firstRow (toConnection cxt) SEUserContactLinkNotFound $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
|
|
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
|
|
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
|
|
c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version
|
|
FROM connections c
|
|
JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id
|
|
WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL
|
|
|]
|
|
(userId, userId)
|
|
|
|
deleteUserAddress :: DB.Connection -> User -> IO ()
|
|
deleteUserAddress db user@User {userId} = do
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
DELETE FROM connections WHERE connection_id IN (
|
|
SELECT connection_id
|
|
FROM connections c
|
|
JOIN user_contact_links uc USING (user_contact_link_id)
|
|
WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL
|
|
)
|
|
|]
|
|
(Only userId)
|
|
void $ setUserProfileContactLink db user Nothing
|
|
DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL" (Only userId)
|
|
|
|
data UserMsgReceiptSettings = UserMsgReceiptSettings
|
|
{ enable :: Bool,
|
|
clearOverrides :: Bool
|
|
}
|
|
deriving (Show)
|
|
|
|
data UserContactLink = UserContactLink
|
|
{ userContactLinkId :: Int64,
|
|
connLinkContact :: CreatedLinkContact,
|
|
shortLinkDataSet :: Bool,
|
|
shortLinkLargeDataSet :: BoolDef,
|
|
addressSettings :: AddressSettings
|
|
}
|
|
deriving (Show)
|
|
|
|
profileContactLink :: UserContactLink -> ConnLinkContact
|
|
profileContactLink UserContactLink {connLinkContact = CCLink cReq sLink} = maybe (CLFull cReq) CLShort sLink
|
|
|
|
data GroupLinkInfo = GroupLinkInfo
|
|
{ groupId :: GroupId,
|
|
memberRole :: GroupMemberRole
|
|
}
|
|
deriving (Show)
|
|
|
|
data AddressSettings = AddressSettings
|
|
{ businessAddress :: Bool, -- possibly, it can be wrapped together with acceptIncognito, or AutoAccept made sum type
|
|
autoAccept :: Maybe AutoAccept, -- accept automatically
|
|
autoReply :: Maybe MsgContent -- included in short link information, sent on acceptance in case wasn't shown during connection
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data AutoAccept = AutoAccept
|
|
{ acceptIncognito :: IncognitoEnabled -- "incognito" is allowed onle for old addresses without short link data
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
$(J.deriveJSON defaultJSON ''AutoAccept)
|
|
|
|
$(J.deriveJSON defaultJSON ''AddressSettings)
|
|
|
|
$(J.deriveJSON defaultJSON ''UserContactLink)
|
|
|
|
toUserContactLink :: (Int64, ConnReqContact, Maybe ShortLinkContact, BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink
|
|
toUserContactLink (userContactLinkId, connReq, shortLink, BI shortLinkDataSet, BI slLargeDataSet, BI businessAddress, BI autoAccept', BI acceptIncognito, autoReply) =
|
|
UserContactLink userContactLinkId (CCLink connReq shortLink) shortLinkDataSet (BoolDef slLargeDataSet) $
|
|
let autoAccept = if autoAccept' then Just AutoAccept {acceptIncognito} else Nothing
|
|
in AddressSettings {businessAddress, autoAccept, autoReply}
|
|
|
|
getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink
|
|
getUserAddress db User {userId} =
|
|
ExceptT . firstRow toUserContactLink SEUserContactLinkNotFound $
|
|
DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL") (Only userId)
|
|
|
|
getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupLinkInfo)
|
|
getUserContactLinkById db userId userContactLinkId =
|
|
ExceptT . firstRow (\(ucl :. gli) -> (toUserContactLink ucl, toGroupLinkInfo gli)) SEUserContactLinkNotFound $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role
|
|
FROM user_contact_links
|
|
WHERE user_id = ? AND user_contact_link_id = ?
|
|
|]
|
|
(userId, userContactLinkId)
|
|
|
|
toGroupLinkInfo :: (Maybe GroupId, Maybe GroupMemberRole) -> Maybe GroupLinkInfo
|
|
toGroupLinkInfo (groupId_, mRole_) =
|
|
(\groupId -> GroupLinkInfo {groupId, memberRole = fromMaybe GRMember mRole_})
|
|
<$> groupId_
|
|
|
|
getGroupLinkInfo :: DB.Connection -> UserId -> GroupId -> IO (Maybe GroupLinkInfo)
|
|
getGroupLinkInfo db userId groupId =
|
|
fmap join $ maybeFirstRow toGroupLinkInfo $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT group_id, group_link_member_role
|
|
FROM user_contact_links
|
|
WHERE user_id = ? AND group_id = ?
|
|
|]
|
|
(userId, groupId)
|
|
|
|
getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink)
|
|
getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) =
|
|
maybeFirstRow toUserContactLink $
|
|
DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND conn_req_contact IN (?,?)") (userId, cReqSchema1, cReqSchema2)
|
|
|
|
getUserContactLinkViaTarget :: DB.Connection -> User -> ContactNameOrLink -> IO (Maybe UserContactLink)
|
|
getUserContactLinkViaTarget db User {userId, profile = LocalProfile {contactDomain}} = \case
|
|
CTLink shortLink ->
|
|
maybeFirstRow toUserContactLink $
|
|
DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND short_link_contact = ?") (userId, shortLink)
|
|
CTName ni
|
|
| (claimDomain <$> contactDomain) == Just (nameDomain ni) ->
|
|
maybeFirstRow toUserContactLink $
|
|
DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND group_id IS NULL AND short_link_contact IS NOT NULL") (Only userId)
|
|
| otherwise -> pure Nothing
|
|
|
|
userContactLinkQuery :: Query
|
|
userContactLinkQuery =
|
|
[sql|
|
|
SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content
|
|
FROM user_contact_links
|
|
|]
|
|
|
|
setUserContactLinkShortLink :: DB.Connection -> Int64 -> ShortLinkContact -> IO ()
|
|
setUserContactLinkShortLink db userContactLinkId shortLink =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE user_contact_links
|
|
SET short_link_contact = ?,
|
|
short_link_data_set = ?,
|
|
short_link_large_data_set = ?,
|
|
auto_accept_incognito = ?
|
|
WHERE user_contact_link_id = ?
|
|
|]
|
|
(shortLink, BI True, BI True, BI False, userContactLinkId)
|
|
|
|
getContactWithoutConnViaAddress :: DB.Connection -> StoreCxt -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact)
|
|
getContactWithoutConnViaAddress db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do
|
|
ctId_ <-
|
|
maybeFirstRow fromOnly $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT ct.contact_id
|
|
FROM contacts ct
|
|
JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id
|
|
LEFT JOIN connections c ON c.contact_id = ct.contact_id
|
|
WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL
|
|
|]
|
|
(userId, cReqSchema1, cReqSchema2)
|
|
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db cxt user) ctId_
|
|
|
|
getContactWithoutConnViaShortAddress :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> IO (Maybe Contact)
|
|
getContactWithoutConnViaShortAddress db cxt user@User {userId} shortLink = do
|
|
ctId_ <-
|
|
maybeFirstRow fromOnly $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT ct.contact_id
|
|
FROM contacts ct
|
|
JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id
|
|
LEFT JOIN connections c ON c.contact_id = ct.contact_id
|
|
WHERE cp.user_id = ? AND cp.contact_link = ? AND c.connection_id IS NULL
|
|
|]
|
|
(userId, shortLink)
|
|
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db cxt user) ctId_
|
|
|
|
updateUserAddressSettings :: DB.Connection -> Int64 -> AddressSettings -> IO ()
|
|
updateUserAddressSettings db userContactLinkId AddressSettings {businessAddress, autoAccept, autoReply} =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE user_contact_links
|
|
SET auto_accept = ?, auto_accept_incognito = ?, business_address = ?, auto_reply_msg_content = ?
|
|
WHERE user_contact_link_id = ?
|
|
|]
|
|
(autoAcceptValues :. (BI businessAddress, autoReply, userContactLinkId))
|
|
where
|
|
autoAcceptValues = case autoAccept of
|
|
Just AutoAccept {acceptIncognito} -> (BI True, BI acceptIncognito)
|
|
Nothing -> (BI False, BI False)
|
|
|
|
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p]
|
|
getProtocolServers db p User {userId} =
|
|
map toUserServer
|
|
<$> DB.query
|
|
db
|
|
[sql|
|
|
SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled
|
|
FROM protocol_servers
|
|
WHERE user_id = ? AND protocol = ?
|
|
|]
|
|
(userId, decodeLatin1 $ strEncode p)
|
|
where
|
|
toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) -> UserServer p
|
|
toUserServer (serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) =
|
|
let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
|
|
in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, deleted = False}
|
|
|
|
insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p)
|
|
insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO protocol_servers
|
|
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
|]
|
|
(serverColumns p server :. (BI preset, BI <$> tested, BI enabled, userId, ts, ts))
|
|
sId <- insertedRowId db
|
|
pure (srv :: NewUserServer p) {serverId = DBEntityId sId}
|
|
|
|
updateProtocolServer :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> UTCTime -> UserServer p -> IO ()
|
|
updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled} =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE protocol_servers
|
|
SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?,
|
|
preset = ?, tested = ?, enabled = ?, updated_at = ?
|
|
WHERE smp_server_id = ?
|
|
|]
|
|
(serverColumns p server :. (BI preset, BI <$> tested, BI enabled, ts, serverId))
|
|
|
|
serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text)
|
|
serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) =
|
|
let protocol = decodeLatin1 $ strEncode p
|
|
auth = safeDecodeUtf8 . unBasicAuth <$> auth_
|
|
in (protocol, host, port, keyHash, auth)
|
|
|
|
getChatRelays :: DB.Connection -> User -> IO [UserChatRelay]
|
|
getChatRelays db User {userId} =
|
|
map toChatRelay
|
|
<$> DB.query
|
|
db
|
|
[sql|
|
|
SELECT chat_relay_id, address, display_name, full_name, short_descr, image, domains, preset, tested, enabled
|
|
FROM chat_relays
|
|
WHERE user_id = ? AND deleted = 0
|
|
|]
|
|
(Only userId)
|
|
|
|
toChatRelay :: (DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay
|
|
toChatRelay (chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset, tested, BI enabled) =
|
|
UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False}
|
|
|
|
getChatRelayById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserChatRelay
|
|
getChatRelayById db User {userId} relayId =
|
|
ExceptT . firstRow toChatRelay (SEUserChatRelayNotFound relayId) $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT chat_relay_id, address, display_name, full_name, short_descr, image, domains, preset, tested, enabled
|
|
FROM chat_relays
|
|
WHERE user_id = ? AND chat_relay_id = ? AND deleted = 0
|
|
|]
|
|
(userId, relayId)
|
|
|
|
insertChatRelay :: DB.Connection -> User -> UTCTime -> NewUserChatRelay -> IO UserChatRelay
|
|
insertChatRelay db User {userId} ts relay@UserChatRelay {address, relayProfile = RelayProfile {displayName, fullName, shortDescr, image}, domains, preset, tested, enabled} = do
|
|
crId <-
|
|
fromOnly . head
|
|
<$> DB.query
|
|
db
|
|
[sql|
|
|
INSERT INTO chat_relays
|
|
(address, display_name, full_name, short_descr, image, domains, preset, tested, enabled, user_id, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
RETURNING chat_relay_id
|
|
|]
|
|
((address, displayName, fullName, shortDescr, image, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, userId) :. (ts, ts))
|
|
pure (relay :: NewUserChatRelay) {chatRelayId = DBEntityId crId}
|
|
|
|
updateChatRelay :: DB.Connection -> UTCTime -> UserChatRelay -> IO ()
|
|
updateChatRelay db ts UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {displayName, fullName, shortDescr, image}, domains, preset, tested, enabled} =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE chat_relays
|
|
SET address = ?, display_name = ?, full_name = ?, short_descr = ?, image = ?, domains = ?,
|
|
preset = ?, tested = ?, enabled = ?, updated_at = ?
|
|
WHERE chat_relay_id = ?
|
|
|]
|
|
((address, displayName, fullName, shortDescr, image, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, ts) :. Only chatRelayId)
|
|
|
|
getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions
|
|
getServerOperators db = do
|
|
currentConditions <- getCurrentUsageConditions db
|
|
liftIO $ do
|
|
now <- getCurrentTime
|
|
latestAcceptedConds_ <- getLatestAcceptedConditions db
|
|
let getConds op = (\ca -> op {conditionsAcceptance = ca}) <$> getOperatorConditions_ db op currentConditions latestAcceptedConds_ now
|
|
ops <- mapM getConds =<< getServerOperators_ db
|
|
let conditionsAction = usageConditionsAction ops currentConditions now
|
|
pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction}
|
|
|
|
getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay])
|
|
getUserServers db user =
|
|
(,,,)
|
|
<$> (map Just . serverOperators <$> getServerOperators db)
|
|
<*> liftIO (getProtocolServers db SPSMP user)
|
|
<*> liftIO (getProtocolServers db SPXFTP user)
|
|
<*> liftIO (getChatRelays db user)
|
|
|
|
setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO ()
|
|
setServerOperators db ops = do
|
|
currentTs <- getCurrentTime
|
|
mapM_ (updateServerOperator db currentTs) ops
|
|
|
|
updateServerOperator :: DB.Connection -> UTCTime -> ServerOperator -> IO ()
|
|
updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, xftpRoles} =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE server_operators
|
|
SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, smp_role_names = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ?
|
|
WHERE server_operator_id = ?
|
|
|]
|
|
(BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), currentTs, operatorId)
|
|
|
|
getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)]
|
|
getUpdateServerOperators db presetOps newUser = do
|
|
conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery
|
|
now <- getCurrentTime
|
|
let (currentConds, condsToAdd) = usageConditionsToAdd newUser now conds
|
|
mapM_ insertConditions condsToAdd
|
|
latestAcceptedConds_ <- getLatestAcceptedConditions db
|
|
ops <- updatedServerOperators presetOps <$> getServerOperators_ db
|
|
forM ops $ traverse $ mapM $ \(ASO _ op) ->
|
|
-- traverse for tuple, mapM for Maybe
|
|
case operatorId op of
|
|
DBNewEntity -> insertOperator op
|
|
DBEntityId _ -> do
|
|
updateOperator op
|
|
getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case
|
|
CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now
|
|
ca -> pure op {conditionsAcceptance = ca}
|
|
where
|
|
insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO usage_conditions
|
|
(usage_conditions_id, conditions_commit, notified_at, created_at)
|
|
VALUES (?,?,?,?)
|
|
|]
|
|
(conditionsId, conditionsCommit, notifiedAt, createdAt)
|
|
updateOperator :: ServerOperator -> IO ()
|
|
updateOperator ServerOperator {operatorId, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE server_operators
|
|
SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, smp_role_names = ?, xftp_role_storage = ?, xftp_role_proxy = ?
|
|
WHERE server_operator_id = ?
|
|
|]
|
|
(tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), operatorId)
|
|
insertOperator :: NewServerOperator -> IO ServerOperator
|
|
insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO server_operators
|
|
(server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, smp_role_names, xftp_role_storage, xftp_role_proxy)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
|]
|
|
(operatorTag, tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles))
|
|
opId <- insertedRowId db
|
|
pure op {operatorId = DBEntityId opId}
|
|
autoAcceptConditions op UsageConditions {conditionsCommit} now =
|
|
acceptConditions_ db op conditionsCommit now True
|
|
$> op {conditionsAcceptance = CAAccepted (Just now) True}
|
|
|
|
serverOperatorQuery :: Query
|
|
serverOperatorQuery =
|
|
[sql|
|
|
SELECT server_operator_id, server_operator_tag, trade_name, legal_name,
|
|
server_domains, enabled, smp_role_storage, smp_role_proxy, smp_role_names, xftp_role_storage, xftp_role_proxy
|
|
FROM server_operators
|
|
|]
|
|
|
|
getServerOperators_ :: DB.Connection -> IO [ServerOperator]
|
|
getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery
|
|
|
|
toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, BoolInt) :. (BoolInt, BoolInt, BoolInt) :. (BoolInt, BoolInt) -> ServerOperator
|
|
toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, BI enabled) :. smpRoles' :. xftpRoles') =
|
|
ServerOperator
|
|
{ operatorId,
|
|
operatorTag,
|
|
tradeName,
|
|
legalName,
|
|
serverDomains = T.splitOn "," domains,
|
|
conditionsAcceptance = CARequired Nothing,
|
|
enabled,
|
|
smpRoles = serverRolesSMP smpRoles',
|
|
xftpRoles = serverRolesXFTP xftpRoles'
|
|
}
|
|
where
|
|
serverRolesSMP (BI storage, BI proxy, BI names) = ServerRoles {storage, proxy, names}
|
|
serverRolesXFTP (BI storage, BI proxy) = ServerRoles {storage, proxy, names = False}
|
|
|
|
getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance
|
|
getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do
|
|
case latestAcceptedConds_ of
|
|
Nothing -> pure $ CARequired Nothing -- no conditions accepted by any operator
|
|
Just UsageConditions {conditionsCommit = latestAcceptedCommit} -> do
|
|
operatorAcceptedConds_ <-
|
|
maybeFirstRow id $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT conditions_commit, accepted_at, auto_accepted
|
|
FROM operator_usage_conditions
|
|
WHERE server_operator_id = ?
|
|
ORDER BY operator_usage_conditions_id DESC
|
|
LIMIT 1
|
|
|]
|
|
(Only operatorId)
|
|
pure $ case operatorAcceptedConds_ of
|
|
Just (operatorCommit, acceptedAt_, BI autoAccept)
|
|
| operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled?
|
|
| currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt)
|
|
| otherwise -> CAAccepted acceptedAt_ autoAccept
|
|
_ -> CARequired Nothing -- no conditions were accepted for this operator
|
|
|
|
getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions
|
|
getCurrentUsageConditions db =
|
|
ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $
|
|
DB.query_ db (usageCondsQuery <> " DESC LIMIT 1")
|
|
|
|
usageCondsQuery :: Query
|
|
usageCondsQuery =
|
|
[sql|
|
|
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
|
|
FROM usage_conditions
|
|
ORDER BY usage_conditions_id
|
|
|]
|
|
|
|
toUsageConditions :: (Int64, Text, Maybe UTCTime, UTCTime) -> UsageConditions
|
|
toUsageConditions (conditionsId, conditionsCommit, notifiedAt, createdAt) =
|
|
UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt}
|
|
|
|
getLatestAcceptedConditions :: DB.Connection -> IO (Maybe UsageConditions)
|
|
getLatestAcceptedConditions db =
|
|
maybeFirstRow toUsageConditions $
|
|
DB.query_
|
|
db
|
|
[sql|
|
|
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
|
|
FROM usage_conditions
|
|
WHERE conditions_commit = (
|
|
SELECT conditions_commit
|
|
FROM operator_usage_conditions
|
|
ORDER BY accepted_at DESC
|
|
LIMIT 1
|
|
)
|
|
|]
|
|
|
|
setConditionsNotified :: DB.Connection -> Int64 -> UTCTime -> IO ()
|
|
setConditionsNotified db condId notifiedAt =
|
|
DB.execute db "UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ?" (notifiedAt, condId)
|
|
|
|
acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> ExceptT StoreError IO ()
|
|
acceptConditions db condId opIds acceptedAt = do
|
|
UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId
|
|
operators <- mapM getServerOperator_ opIds
|
|
liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit acceptedAt False
|
|
where
|
|
getServerOperator_ opId =
|
|
ExceptT $
|
|
firstRow toServerOperator (SEOperatorNotFound opId) $
|
|
DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId)
|
|
|
|
acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> UTCTime -> Bool -> IO ()
|
|
acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt autoAccepted = do
|
|
acceptedAt_ :: Maybe (Maybe UTCTime) <- maybeFirstRow fromOnly $ DB.query db "SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ?" (operatorId, conditionsCommit)
|
|
case acceptedAt_ of
|
|
Just Nothing ->
|
|
DB.execute
|
|
db
|
|
(q <> "ON CONFLICT (server_operator_id, conditions_commit) DO UPDATE SET accepted_at = ?, auto_accepted = ?")
|
|
(operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted, acceptedAt, BI autoAccepted)
|
|
Just (Just _) ->
|
|
DB.execute
|
|
db
|
|
(q <> "ON CONFLICT (server_operator_id, conditions_commit) DO NOTHING")
|
|
(operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted)
|
|
Nothing ->
|
|
DB.execute
|
|
db
|
|
q
|
|
(operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted)
|
|
where
|
|
q =
|
|
[sql|
|
|
INSERT INTO operator_usage_conditions
|
|
(server_operator_id, server_operator_tag, conditions_commit, accepted_at, auto_accepted)
|
|
VALUES (?,?,?,?,?)
|
|
|]
|
|
|
|
getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions
|
|
getUsageConditionsById_ db conditionsId =
|
|
ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
|
|
FROM usage_conditions
|
|
WHERE usage_conditions_id = ?
|
|
|]
|
|
(Only conditionsId)
|
|
|
|
setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> ExceptT StoreError IO UserOperatorServers
|
|
setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts
|
|
|
|
setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers
|
|
setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers, chatRelays} = do
|
|
mapM_ (updateServerOperator db ts) operator
|
|
smpSrvs' <- catMaybes <$> mapM (upsertOrDeleteSrv SPSMP) smpServers
|
|
xftpSrvs' <- catMaybes <$> mapM (upsertOrDeleteSrv SPXFTP) xftpServers
|
|
cRelays' <- catMaybes <$> mapM upsertOrDeleteCRelay chatRelays
|
|
pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs', chatRelays = cRelays'}
|
|
where
|
|
upsertOrDeleteSrv :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p))
|
|
upsertOrDeleteSrv p (AUS _ s@UserServer {serverId, deleted}) = case serverId of
|
|
DBNewEntity
|
|
| deleted -> pure Nothing
|
|
| otherwise -> Just <$> insertProtocolServer db p user ts s
|
|
DBEntityId srvId
|
|
| deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, BI False)
|
|
| otherwise -> Just s <$ updateProtocolServer db p ts s
|
|
upsertOrDeleteCRelay :: AUserChatRelay -> IO (Maybe UserChatRelay)
|
|
upsertOrDeleteCRelay (AUCR _ relay@UserChatRelay {chatRelayId, address, deleted}) = case chatRelayId of
|
|
DBNewEntity
|
|
| deleted -> pure Nothing
|
|
| otherwise -> do
|
|
-- When a relay referenced in group_relays is deleted, it's soft-deleted (deleted=1).
|
|
-- On re-add with the same address, un-delete the existing row to preserve group_relays FK.
|
|
-- Only address is matched — it's the relay's identity. Name and other settings are updated.
|
|
-- Re-adding with same name but different address is a different relay and will fail on UNIQUE constraint.
|
|
existing <- maybeFirstRow fromOnly $ DB.query db
|
|
"SELECT chat_relay_id FROM chat_relays WHERE user_id = ? AND address = ? AND deleted = 1 LIMIT 1"
|
|
(userId, address)
|
|
case existing of
|
|
Just existingId -> do
|
|
undeleteRelay existingId relay
|
|
pure $ Just (relay :: NewUserChatRelay) {chatRelayId = DBEntityId existingId}
|
|
Nothing -> Just <$> insertChatRelay db user ts relay
|
|
DBEntityId relayId
|
|
| deleted -> do
|
|
-- If relay is referenced in group_relays, mark it as deleted instead of deleting
|
|
referenced <- fromOnly . head <$> DB.query db "SELECT EXISTS (SELECT 1 FROM group_relays WHERE chat_relay_id = ?)" (Only relayId)
|
|
if referenced
|
|
then DB.execute db "UPDATE chat_relays SET deleted = 1, updated_at = ? WHERE chat_relay_id = ?" (ts, relayId)
|
|
else DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, relayId, BI False)
|
|
pure Nothing
|
|
| otherwise -> Just relay <$ updateChatRelay db ts relay
|
|
-- Un-delete soft-deleted relay, updating name and settings but keeping the address unchanged.
|
|
undeleteRelay :: Int64 -> NewUserChatRelay -> IO ()
|
|
undeleteRelay existingId UserChatRelay {relayProfile = RelayProfile {displayName, fullName, shortDescr, image}, domains, preset, tested, enabled} =
|
|
DB.execute db
|
|
[sql|
|
|
UPDATE chat_relays
|
|
SET display_name = ?, full_name = ?, short_descr = ?, image = ?, domains = ?,
|
|
preset = ?, tested = ?, enabled = ?, deleted = 0, updated_at = ?
|
|
WHERE chat_relay_id = ?
|
|
|]
|
|
(displayName, fullName, shortDescr, image, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, ts, existingId)
|
|
|
|
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
|
|
createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do
|
|
currentTs <- getCurrentTime
|
|
deleteCalls db user contactId
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO calls
|
|
(contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?)
|
|
|]
|
|
(contactId, callId, callUUID, chatItemId, callState, callTs, userId, currentTs, currentTs)
|
|
|
|
deleteCalls :: DB.Connection -> User -> ContactId -> IO ()
|
|
deleteCalls db User {userId} contactId = do
|
|
DB.execute db "DELETE FROM calls WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
|
|
|
getCalls :: DB.Connection -> IO [Call]
|
|
getCalls db =
|
|
map toCall
|
|
<$> DB.query_
|
|
db
|
|
[sql|
|
|
SELECT
|
|
contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts
|
|
FROM calls
|
|
ORDER BY call_ts ASC
|
|
|]
|
|
where
|
|
toCall :: (ContactId, CallId, Text, ChatItemId, CallState, UTCTime) -> Call
|
|
toCall (contactId, callId, callUUID, chatItemId, callState, callTs) = Call {contactId, callId, callUUID, chatItemId, callState, callTs}
|
|
|
|
createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId
|
|
createCommand db User {userId} connId commandFunction = do
|
|
currentTs <- getCurrentTime
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO commands (connection_id, command_function, command_status, user_id, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?)
|
|
|]
|
|
(connId, commandFunction, CSCreated, userId, currentTs, currentTs)
|
|
insertedRowId db
|
|
|
|
deleteCommand :: DB.Connection -> User -> CommandId -> IO ()
|
|
deleteCommand db User {userId} cmdId =
|
|
DB.execute db "DELETE FROM commands WHERE user_id = ? AND command_id = ?" (userId, cmdId)
|
|
|
|
updateCommandStatus :: DB.Connection -> User -> CommandId -> CommandStatus -> IO ()
|
|
updateCommandStatus db User {userId} cmdId status = do
|
|
updatedAt <- getCurrentTime
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
UPDATE commands
|
|
SET command_status = ?, updated_at = ?
|
|
WHERE user_id = ? AND command_id = ?
|
|
|]
|
|
(status, updatedAt, userId, cmdId)
|
|
|
|
getCommandDataByCorrId :: DB.Connection -> User -> ACorrId -> IO (Maybe CommandData)
|
|
getCommandDataByCorrId db User {userId} corrId =
|
|
maybeFirstRow toCommandData $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT command_id, connection_id, command_function, command_status
|
|
FROM commands
|
|
WHERE user_id = ? AND command_id = ?
|
|
|]
|
|
(userId, commandId corrId)
|
|
where
|
|
toCommandData :: (CommandId, Maybe Int64, CommandFunction, CommandStatus) -> CommandData
|
|
toCommandData (cmdId, cmdConnId, cmdFunction, cmdStatus) = CommandData {cmdId, cmdConnId, cmdFunction, cmdStatus}
|
|
|
|
setUserUIThemes :: DB.Connection -> User -> Maybe UIThemeEntityOverrides -> IO ()
|
|
setUserUIThemes db User {userId} uiThemes = do
|
|
updatedAt <- getCurrentTime
|
|
DB.execute db "UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ?" (uiThemes, updatedAt, userId)
|