From 10a814694cc0da03bb065f14b3e4c3a7ff3e36f3 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:51:52 +0400 Subject: [PATCH] core, ui: support SimpleX names (#7045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 to f71c579c. 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" since f2394d121 (prior plan) flipped APIConnectPlan/Connect from Maybe AConnectionLink to Maybe ConnectTarget without updating bots/src/API/Docs/*. Also adds SimplexNameConflictEntity (new in cd0de9659) 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 docs ebe90f716 added the verify command + events + SimplexNameVerifyFailReason type without touching bots/src/API/Docs/. Mirrors commit 0d7ea8061 which 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 prior 6c990696c). * 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 to 5008b4e62 * 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 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 Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/AppAPITypes.swift | 40 +- apps/ios/Shared/Model/SimpleXAPI.swift | 88 ++- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 78 +++ .../Views/Chat/ChatItem/MsgContentView.swift | 4 +- .../Chat/Group/ChannelWebAccessView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 50 ++ .../Shared/Views/ChatList/ChatListView.swift | 13 +- .../Views/NewChat/NewChatMenuButton.swift | 13 +- .../Shared/Views/NewChat/NewChatView.swift | 53 +- .../NetworkAndServers/NetworkAndServers.swift | 33 +- .../NetworkAndServers/OperatorView.swift | 18 +- .../ProtocolServersView.swift | 14 +- .../Views/UserSettings/PrivacySettings.swift | 4 + .../Views/UserSettings/SettingsView.swift | 2 + .../Views/UserSettings/UserAddressView.swift | 76 +++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +- apps/ios/SimpleXChat/APITypes.swift | 86 ++- apps/ios/SimpleXChat/ChatTypes.swift | 82 ++- .../chat/simplex/common/model/ChatModel.kt | 48 +- .../chat/simplex/common/model/SimpleXAPI.kt | 193 ++++++- .../simplex/common/views/chat/ChatInfoView.kt | 14 + .../common/views/chat/SimplexNameView.kt | 94 ++++ .../views/chat/group/ChannelWebPageView.kt | 2 +- .../views/chat/group/GroupChatInfoView.kt | 44 ++ .../common/views/chat/item/TextItemView.kt | 11 +- .../common/views/chatlist/ChatListView.kt | 15 +- .../common/views/newchat/ConnectPlan.kt | 32 +- .../common/views/newchat/NewChatSheet.kt | 15 +- .../common/views/newchat/NewChatView.kt | 22 +- .../views/usersettings/PrivacySettings.kt | 5 + .../views/usersettings/SetSimplexNameView.kt | 79 +++ .../views/usersettings/UserAddressView.kt | 33 ++ .../networkAndServers/NetworkAndServers.kt | 50 +- .../networkAndServers/OperatorView.kt | 46 +- .../networkAndServers/ProtocolServersView.kt | 10 +- .../commonMain/resources/MR/base/strings.xml | 23 + .../src/Directory/Service.hs | 8 +- bots/api/COMMANDS.md | 20 +- bots/api/TYPES.md | 97 +++- bots/src/API/Docs/Commands.hs | 10 +- bots/src/API/Docs/Responses.hs | 2 + bots/src/API/Docs/Types.hs | 17 +- bots/src/API/TypeInfo.hs | 3 + cabal.project | 2 +- .../types/typescript/src/commands.ts | 12 +- .../types/typescript/src/types.ts | 95 +++- .../src/simplex_chat/types/_commands.py | 12 +- .../src/simplex_chat/types/_types.py | 70 ++- plans/2026-06-25-name-resolution.md | 506 ++++++++++++++++++ plans/2026-06-27-namespace-ui-display-set.md | 243 +++++++++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 7 + src/Simplex/Chat/Badges.hs | 23 +- src/Simplex/Chat/Controller.hs | 27 +- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 319 ++++++++--- src/Simplex/Chat/Library/Internal.hs | 49 +- src/Simplex/Chat/Library/Subscriber.hs | 37 +- src/Simplex/Chat/Names.hs | 61 +++ src/Simplex/Chat/Operators.hs | 18 +- src/Simplex/Chat/Operators/Presets.hs | 4 +- src/Simplex/Chat/ProfileGenerator.hs | 2 +- src/Simplex/Chat/Store/Connections.hs | 15 +- src/Simplex/Chat/Store/ContactRequest.hs | 4 +- src/Simplex/Chat/Store/Direct.hs | 86 ++- src/Simplex/Chat/Store/Groups.hs | 125 +++-- src/Simplex/Chat/Store/Messages.hs | 10 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260603_simplex_name.hs | 38 ++ .../Store/Postgres/Migrations/chat_schema.sql | 19 +- src/Simplex/Chat/Store/Profiles.hs | 74 ++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260603_simplex_name.hs | 37 ++ .../SQLite/Migrations/chat_query_plans.txt | 211 +++++--- .../Store/SQLite/Migrations/chat_schema.sql | 14 +- src/Simplex/Chat/Store/Shared.hs | 79 +-- src/Simplex/Chat/Types.hs | 96 +++- src/Simplex/Chat/View.hs | 96 +++- tests/Bots/BroadcastTests.hs | 2 +- tests/Bots/DirectoryTests.hs | 2 +- tests/ChatClient.hs | 21 +- tests/ChatTests/Direct.hs | 6 +- tests/ChatTests/Names.hs | 143 +++++ tests/ChatTests/Profiles.hs | 91 +--- tests/ChatTests/Utils.hs | 2 +- tests/MarkdownTests.hs | 4 +- tests/NameResolver.hs | 83 +++ tests/OperatorTests.hs | 6 +- tests/PostgresSchemaDump.hs | 4 +- tests/ProtocolTests.hs | 5 +- tests/SchemaDump.hs | 4 +- tests/Test.hs | 6 + 92 files changed, 3532 insertions(+), 815 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt create mode 100644 plans/2026-06-25-name-resolution.md create mode 100644 plans/2026-06-27-namespace-ui-display-set.md create mode 100644 src/Simplex/Chat/Names.hs create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260603_simplex_name.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260603_simplex_name.hs create mode 100644 tests/ChatTests/Names.hs create mode 100644 tests/NameResolver.hs diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index a5a56174b1..9e27c6fa16 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -84,6 +84,7 @@ enum ChatCommand: ChatCmdProtocol { case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) + case apiSetPublicGroupAccess(groupId: Int64, access: PublicGroupAccess) case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole) case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) case apiDeleteGroupLink(groupId: Int64) @@ -131,8 +132,8 @@ enum ChatCommand: ChatCmdProtocol { case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiChangeConnectionUser(connId: Int64, userId: Int64) case apiConnectPlan(userId: Int64, connLink: String, linkOwnerSig: LinkOwnerSig?) - case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) - case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) + case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain?) + case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain?) case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) @@ -155,6 +156,9 @@ enum ChatCommand: ChatCmdProtocol { case apiAddMyAddressShortLink(userId: Int64) case apiSetProfileAddress(userId: Int64, on: Bool) case apiSetAddressSettings(userId: Int64, addressSettings: AddressSettings) + case apiSetUserDomain(userId: Int64, simplexDomain: String?) + case apiVerifyContactDomain(contactId: Int64) + case apiVerifyGroupDomain(groupId: Int64) case apiAcceptContact(incognito: Bool, contactReqId: Int64) case apiRejectContact(contactReqId: Int64) // WebRTC calls @@ -346,8 +350,8 @@ enum ChatCommand: ChatCmdProtocol { case let .apiConnectPlan(userId, connLink, linkOwnerSig): let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" } return "/_connect plan \(userId) \(connLink)\(sigStr)" - case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))" - case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(encodeJSON(groupShortLinkData))" + case let .apiPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain): return "/_prepare contact \(userId) \(connLink.cmdString)\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(contactShortLinkData))" + case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain): return "/_prepare group \(userId) \(connLink.cmdString) direct=\(onOff(directLink))\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(groupShortLinkData))" case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)" case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)" case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))" @@ -370,6 +374,10 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAddMyAddressShortLink(userId): return "/_short_link_address \(userId)" case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" case let .apiSetAddressSettings(userId, addressSettings): return "/_address_settings \(userId) \(encodeJSON(addressSettings))" + case let .apiSetUserDomain(userId, simplexDomain): return "/_set domain \(userId)" + (simplexDomain.map { " " + $0 } ?? "") + case let .apiSetPublicGroupAccess(groupId, access): return "/_public group access #\(groupId) \(encodeJSON(access))" + case let .apiVerifyContactDomain(contactId): return "/_verify domain @\(contactId)" + case let .apiVerifyGroupDomain(groupId): return "/_verify domain #\(groupId)" case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" @@ -481,6 +489,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiLeaveGroup: return "apiLeaveGroup" case .apiListMembers: return "apiListMembers" case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" + case .apiSetPublicGroupAccess: return "apiSetPublicGroupAccess" case .apiCreateGroupLink: return "apiCreateGroupLink" case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" case .apiDeleteGroupLink: return "apiDeleteGroupLink" @@ -551,6 +560,9 @@ enum ChatCommand: ChatCmdProtocol { case .apiAddMyAddressShortLink: return "apiAddMyAddressShortLink" case .apiSetProfileAddress: return "apiSetProfileAddress" case .apiSetAddressSettings: return "apiSetAddressSettings" + case .apiSetUserDomain: return "apiSetUserDomain" + case .apiVerifyContactDomain: return "apiVerifyContactDomain" + case .apiVerifyGroupDomain: return "apiVerifyGroupDomain" case .apiAcceptContact: return "apiAcceptContact" case .apiRejectContact: return "apiRejectContact" case .apiSendCallInvitation: return "apiSendCallInvitation" @@ -960,6 +972,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) case groupUpdated(user: UserRef, toGroup: GroupInfo) + case contactDomainVerified(user: UserRef, contact: Contact, verificationFailure: String?) + case groupDomainVerified(user: UserRef, groupInfo: GroupInfo, verificationFailure: String?) case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink) case groupLink(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink) case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) @@ -1015,6 +1029,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .membersRoleUser: "membersRoleUser" case .membersBlockedForAllUser: "membersBlockedForAllUser" case .groupUpdated: "groupUpdated" + case .contactDomainVerified: "contactDomainVerified" + case .groupDomainVerified: "groupDomainVerified" case .groupLinkCreated: "groupLinkCreated" case .groupLink: "groupLink" case .groupLinkDeleted: "groupLinkDeleted" @@ -1066,6 +1082,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .contactDomainVerified(u, contact, verificationFailure): return withUser(u, "contact: \(contact)\nverificationFailure: \(verificationFailure ?? "ok")") + case let .groupDomainVerified(u, groupInfo, verificationFailure): return withUser(u, "groupInfo: \(groupInfo)\nverificationFailure: \(verificationFailure ?? "ok")") case let .groupLinkCreated(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)") case let .groupLink(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)") case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) @@ -1383,7 +1401,7 @@ enum InvitationLinkPlan: Decodable, Hashable { } enum ContactAddressPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, verifiedDomain: SimplexDomain?) case ownLink case connectingConfirmReconnect case connectingProhibit(contact: Contact) @@ -1398,7 +1416,7 @@ public struct GroupShortLinkInfo: Decodable, Hashable { } enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, verifiedDomain: SimplexDomain?) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) @@ -1766,14 +1784,15 @@ struct ServerOperator: Identifiable, Equatable, Codable { serverDomains: ["simplex.im"], conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: true, - smpRoles: ServerRoles(storage: true, proxy: true), - xftpRoles: ServerRoles(storage: true, proxy: true) + smpRoles: ServerRoles(storage: true, proxy: true, names: true), + xftpRoles: ServerRoles(storage: true, proxy: true, names: false) ) } struct ServerRoles: Equatable, Codable { var storage: Bool var proxy: Bool + var names: Bool } struct UserOperatorServers: Identifiable, Equatable, Codable { @@ -1800,8 +1819,8 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { serverDomains: [], conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: false, - smpRoles: ServerRoles(storage: true, proxy: true), - xftpRoles: ServerRoles(storage: true, proxy: true) + smpRoles: ServerRoles(storage: true, proxy: true, names: true), + xftpRoles: ServerRoles(storage: true, proxy: true, names: false) ) } set { `operator` = newValue } @@ -1824,6 +1843,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { public enum UserServersWarning: Decodable { case noChatRelays(user: UserRef?) + case noNamesServers(user: UserRef?) } enum UserServersError: Decodable { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ea2de31569..3b610663dc 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1083,6 +1083,24 @@ private func apiConnectResponseAlert(_ r: APIResult) -> Alert { title: "Unsupported connection link", message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." ) + case let .error(.simplexDomainNotReady(domain, err)): + switch err { + case .noValidLink: + mkAlert( + title: "No valid link", + message: "The SimpleX name \(domain.fullDomainName) is registered, but it has no valid link." + ) + case .unknownDomain: + mkAlert( + title: "Unconfirmed name", + message: "The SimpleX name \(domain.fullDomainName) is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner." + ) + } + case .errorAgent(.NO_NAME_SERVERS): + mkAlert( + title: "SimpleX name error", + message: "None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link." + ) case .errorAgent(.SMP(_, .AUTH)): mkAlert( title: "Connection error (AUTH)", @@ -1113,6 +1131,24 @@ private func apiConnectResponseAlert(_ r: APIResult) -> Alert { } else { connectionErrorAlert(r) } + case let .errorAgent(.SMP(serverAddress, .NAME(nameErr))): + switch nameErr { + case .NOT_FOUND: + mkAlert( + title: "Name not found", + message: "This SimpleX name is not registered. Please check the name." + ) + case .NO_RESOLVER: + mkAlert( + title: "SimpleX name error", + message: "Server \(serverAddress) does not support name resolution. Configure servers, or use a connection link." + ) + case let .RESOLVER(resolverErr): + mkAlert( + title: "SimpleX name error", + message: "Resolver error: \(resolverErr)" + ) + } default: connectionErrorAlert(r) } } @@ -1156,16 +1192,16 @@ private func connectionErrorAlert(_ r: APIResult) -> Alert { } } -func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) async throws -> ChatData { +func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain? = nil) async throws -> ChatData { let userId = try currentUserId("apiPrepareContact") - let r: ChatResponse1 = try await chatSendCmd(.apiPrepareContact(userId: userId, connLink: connLink, contactShortLinkData: contactShortLinkData)) + let r: ChatResponse1 = try await chatSendCmd(.apiPrepareContact(userId: userId, connLink: connLink, contactShortLinkData: contactShortLinkData, verifiedDomain: verifiedDomain)) if case let .newPreparedChat(_, chat) = r { return chat } throw r.unexpected } -func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) async throws -> ChatData { +func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain? = nil) async throws -> ChatData { let userId = try currentUserId("apiPrepareGroup") - let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData)) + let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData, verifiedDomain: verifiedDomain)) if case let .newPreparedChat(_, chat) = r { return chat } throw r.unexpected } @@ -1329,6 +1365,43 @@ func apiSetProfileAddress(on: Bool) async throws -> User? { } } +// name is the encoded SimplexName (e.g. "@alice.simplex"); nil clears it +// owner-specific SNENoValidLink wording; everything else reuses the general apiConnectResponseAlert +func showSetSimplexNameError(_ r: APIResult, isChannel: Bool) { + if case let .error(.simplexDomainNotReady(domain, .noValidLink)) = r.unexpected { + let format = isChannel + ? NSLocalizedString("The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page.", comment: "alert message") + : NSLocalizedString("The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page.", comment: "alert message") + showAlert(NSLocalizedString("Error saving name", comment: "alert title"), message: String.localizedStringWithFormat(format, domain.fullDomainName)) + } else { + AlertManager.shared.showAlert(apiConnectResponseAlert(r)) + } +} + +func apiSetUserDomain(_ simplexDomain: String?) async throws -> User { + let userId = try currentUserId("apiSetUserDomain") + let r: APIResult = await chatApiSendCmd(.apiSetUserDomain(userId: userId, simplexDomain: simplexDomain)) + switch r { + case let .result(.userProfileUpdated(user, _, _, _)): return user + case let .result(.userProfileNoChange(user)): return user + default: + showSetSimplexNameError(r, isChannel: false) + throw r.unexpected + } +} + +func apiVerifyContactDomain(_ contactId: Int64) async throws -> (Contact, String?) { + let r: ChatResponse2 = try await chatSendCmd(.apiVerifyContactDomain(contactId: contactId)) + if case let .contactDomainVerified(_, contact, verificationFailure) = r { return (contact, verificationFailure) } + throw r.unexpected +} + +func apiVerifyGroupDomain(_ groupId: Int64) async throws -> (GroupInfo, String?) { + let r: ChatResponse2 = try await chatSendCmd(.apiVerifyGroupDomain(groupId: groupId)) + if case let .groupDomainVerified(_, groupInfo, verificationFailure) = r { return (groupInfo, verificationFailure) } + throw r.unexpected +} + func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? { let r: ChatResponse1 = try await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact } @@ -1995,6 +2068,13 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws throw r.unexpected } +func apiSetPublicGroupAccess(_ groupId: Int64, access: PublicGroupAccess) async throws -> GroupInfo { + let r: APIResult = await chatApiSendCmd(.apiSetPublicGroupAccess(groupId: groupId, access: access)) + if case let .result(.groupUpdated(_, toGroup)) = r { return toGroup } + showSetSimplexNameError(r, isChannel: true) + throw r.unexpected +} + func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index fdd1dc8a6a..9988b30d4e 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -392,6 +392,26 @@ struct ChatInfoView: View { .lineLimit(3) .padding(.bottom, 2) } + if let domain = contact.profile.contactDomain, + contact.profile.contactDomainVerified != nil || domain.proof != nil { + SimplexNameView( + simplexName: "@\(domain.shortName)", + verified: contact.profile.contactDomainVerified, + verify: { + do { + let (ct, reason) = try await apiVerifyContactDomain(contact.contactId) + await MainActor.run { + chatModel.updateContact(ct) + contact = ct + } + return (ct.profile.contactDomainVerified, reason) + } catch { + logger.error("apiVerifyContactDomain: \(responseError(error))") + return nil + } + } + ) + } if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" { let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background) msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true) @@ -1361,6 +1381,64 @@ private func deleteNotReadyContact( )) } +struct SimplexNameView: View { + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) var autoVerify = false + let simplexName: String + let verified: Bool? + let verify: () async -> (Bool?, String?)? + @State private var inFlight = false + @State private var showSpinner = false + + var body: some View { + HStack(spacing: 6) { + Text(simplexName) + .font(verified == true ? .subheadline : .system(.subheadline, design: .monospaced)) + .foregroundColor(verified == true ? theme.colors.primary : theme.colors.secondary) + indicator() + } + .padding(.bottom, 2) + .onAppear { if autoVerify && verified == nil { runVerify(manual: false) } } + } + + @ViewBuilder private func indicator() -> some View { + if showSpinner { + ProgressView() + } else if verified == true { + Image(systemName: "checkmark") + } else if verified == false { + Image(systemName: "xmark") + .foregroundColor(.red) + .onTapGesture { runVerify(manual: true) } + } else { + Button { runVerify(manual: true) } label: { + Text("Verify name").font(.subheadline).foregroundColor(theme.colors.primary) + } + } + } + + private func runVerify(manual: Bool) { + if inFlight { return } + inFlight = true + // delay the spinner so a fast result on appear doesn't flash it + Task { + try? await Task.sleep(nanoseconds: 300_000000) + await MainActor.run { if inFlight { showSpinner = true } } + } + Task { + let res = await verify() + await MainActor.run { + inFlight = false + showSpinner = false + // show the reason on a manual run, or on an inconclusive auto run (state stayed nil) + if let (newV, reason) = res, let reason, manual || newV == nil { + showAlert(NSLocalizedString("SimpleX name not verified", comment: "alert title"), message: reason) + } + } + } + } +} + struct ChatInfoView_Previews: PreviewProvider { static var previews: some View { ChatInfoView( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 11c3c4c3f4..5cb93ad279 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -214,8 +214,8 @@ private func handleTextTaps( var simplex: Bool = false s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in if index >= range.location && index < range.location + range.length { - if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo { - showUnsupportedNameAlert(nameInfo) + if attrs[nameAttrKey] is SimplexNameInfo { + planAndConnect(s.attributedSubstring(from: range).string, theme: AppTheme.shared, dismiss: false) } else if let url = attrs[linkAttrKey] as? String { linkURL = url browser = attrs[webLinkAttrKey] != nil diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift index dd46b7a117..df0867c470 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift @@ -148,7 +148,7 @@ struct ChannelWebAccessView: View { let existingAccess = pg.publicGroupAccess pg.publicGroupAccess = PublicGroupAccess( groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage, - groupDomain: existingAccess?.groupDomain, + groupDomainClaim: existingAccess?.groupDomainClaim, domainWebPage: existingAccess?.domainWebPage ?? false, allowEmbedding: allowEmbedding ) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 41e24a6ced..358bf6c7d3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -112,6 +112,7 @@ struct GroupChatInfoView: View { // TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership) if groupInfo.isOwner && groupLink != nil { channelLinkButton() + channelSimplexNameButton() } else if let link = groupInfo.groupProfile.publicGroup?.groupLink { SimpleXLinkQRCode(uri: link) Button { @@ -331,6 +332,27 @@ struct GroupChatInfoView: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } + if let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess, + let domain = access.groupDomainClaim?.shortName, + groupInfo.groupDomainVerified != nil || access.groupDomainClaim?.proof != nil { + SimplexNameView( + simplexName: "#\(domain)", + verified: groupInfo.groupDomainVerified, + verify: { + do { + let (gInfo, reason) = try await apiVerifyGroupDomain(groupInfo.groupId) + await MainActor.run { + chatModel.updateGroup(gInfo) + groupInfo = gInfo + } + return (gInfo.groupDomainVerified, reason) + } catch { + logger.error("apiVerifyGroupDomain: \(responseError(error))") + return nil + } + } + ) + } if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage, let url = URL(string: webPage) { Link(destination: url) { @@ -674,6 +696,34 @@ struct GroupChatInfoView: View { } } + private func channelSimplexNameButton() -> some View { + NavigationLink { + let domain = if let d = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName { "#\(d)" } else { "" } + SetSimplexDomainView( + title: "SimpleX name", + footer: "Let people join via name registered with this channel link.", + prompt: "#channelname.testing", + simplexName: domain, + save: { domain in + do { + var access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?? PublicGroupAccess() + access.groupDomainClaim = domain.map { SimplexDomainClaim(domain: $0) } + let gInfo = try await apiSetPublicGroupAccess(groupInfo.groupId, access: access) + await MainActor.run { + chatModel.updateGroup(gInfo) + groupInfo = gInfo + } + return true + } catch { + return false + } + } + ) + } label: { + Label("SimpleX name", systemImage: "number") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d90149c7dd..01887c12be 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -683,8 +683,17 @@ struct ChatListSearchBar: View { searchShowingSimplexLink = true searchChatFilteredBySimplexLink = nil connect(text) - case let .name(nameInfo): - showUnsupportedNameAlert(nameInfo) + case let .name(text, _): + searchFocussed = false + planAndConnect( + text, + theme: theme, + dismiss: false, + cleanup: { + searchText = "" + searchFocussed = false + } + ) case .none: if t != "" { searchFocussed = true diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index f99b03086e..a3e6d2ee2f 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -389,8 +389,17 @@ struct ContactsListSearchBar: View { searchShowingSimplexLink = true searchChatFilteredBySimplexLink = nil connect(text) - case let .name(nameInfo): - showUnsupportedNameAlert(nameInfo) + case let .name(text, _): + searchFocussed = false + planAndConnect( + text, + theme: theme, + dismiss: true, + cleanup: { + searchText = "" + searchFocussed = false + } + ) case .none: if t != "" { searchFocussed = true diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 67fd353ebc..c2154b2f27 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -669,8 +669,9 @@ private struct ConnectView: View { case let .link(text, _, _): pastedLink = text connect(pastedLink) - case let .name(nameInfo): - showUnsupportedNameAlert(nameInfo) + case let .name(text, _): + pastedLink = text + connect(pastedLink) case .none: alert = .newChatSomeAlert(alert: SomeAlert( alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."), @@ -869,7 +870,7 @@ func strIsSimplexLink(_ str: String) -> Bool { enum ConnectTarget { case link(text: String, linkType: SimplexLinkType, linkText: String) - case name(SimplexNameInfo) + case name(text: String, nameInfo: SimplexNameInfo) } func strConnectTarget(_ str: String) -> ConnectTarget? { @@ -878,28 +879,14 @@ func strConnectTarget(_ str: String) -> ConnectTarget? { return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format { .link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) } else if links.isEmpty, - case let .simplexName(nameInfo) = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } })?.format { - .name(nameInfo) + let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }), + case let .simplexName(nameInfo) = nameFt.format { + .name(text: nameFt.text, nameInfo: nameInfo) } else { nil } } -func showUnsupportedNameAlert(_ nameInfo: SimplexNameInfo) { - let upgrade = " " + NSLocalizedString("Please upgrade the app.", comment: "alert message") - if nameInfo.nameType == .contact { - showAlert( - NSLocalizedString("Unsupported contact name", comment: "alert title"), - message: NSLocalizedString("Connecting via contact name requires a newer app version.", comment: "alert message") + upgrade - ) - } else { - showAlert( - NSLocalizedString("Unsupported channel name", comment: "alert title"), - message: NSLocalizedString("Connecting via channel name requires a newer app version.", comment: "alert message") + upgrade - ) - } -} - struct IncognitoToggle: View { @EnvironmentObject var theme: AppTheme @Binding var incognitoEnabled: Bool @@ -1145,6 +1132,7 @@ private func showPrepareContactAlert( connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, ownerVerification: OwnerVerification? = nil, + verifiedDomain: SimplexDomain? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? @@ -1171,7 +1159,7 @@ private func showPrepareContactAlert( onConfirm: { Task { do { - let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData) + let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData, verifiedDomain: verifiedDomain) await MainActor.run { ChatModel.shared.addChat(Chat(chat)) openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) @@ -1193,6 +1181,7 @@ private func showPrepareGroupAlert( groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, ownerVerification: OwnerVerification? = nil, + verifiedDomain: SimplexDomain? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? @@ -1221,7 +1210,7 @@ private func showPrepareGroupAlert( onConfirm: { Task { do { - let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData) + let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData, verifiedDomain: verifiedDomain) await MainActor.run { if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty, case let .group(gInfo, _) = chat.chatInfo { @@ -1319,10 +1308,6 @@ func planAndConnect( filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { switch strConnectTarget(shortOrFullLink) { - case let .name(nameInfo): - showUnsupportedNameAlert(nameInfo) - cleanup?() - return case let .link(_, linkType, _): if linkType == .relay { showAlert( @@ -1332,7 +1317,9 @@ func planAndConnect( cleanup?() return } - case .none: break + // A SimplexName falls through to apiConnectPlan, which resolves it on the + // core (the /_connect plan command accepts a name target, not only a link). + case .name, .none: break } ConnectProgressManager.shared.cancelConnectProgress() let inProgress = BoxedValue(true) @@ -1416,7 +1403,7 @@ func planAndConnect( } case let .contactAddress(cap): switch cap { - case let .ok(contactSLinkData_, ownerVerification): + case let .ok(contactSLinkData_, ownerVerification, verifiedDomain): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") await MainActor.run { @@ -1424,6 +1411,7 @@ func planAndConnect( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, ownerVerification: ownerVerification, + verifiedDomain: verifiedDomain, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1478,6 +1466,9 @@ func planAndConnect( case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known") await MainActor.run { + if ChatModel.shared.getContactChat(contact.contactId) == nil { + ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact))) + } if let f = filterKnownContact { f(contact) } else { @@ -1496,7 +1487,7 @@ func planAndConnect( } case let .groupLink(glp): switch glp { - case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification): + case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, verifiedDomain): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { @@ -1505,6 +1496,7 @@ func planAndConnect( groupShortLinkInfo: groupShortLinkInfo_, groupShortLinkData: groupSLinkData, ownerVerification: ownerVerification, + verifiedDomain: verifiedDomain, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1557,6 +1549,9 @@ func planAndConnect( case let .known(groupInfo): logger.debug("planAndConnect, .groupLink, .known") await MainActor.run { + if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil { + ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil))) + } if let f = filterKnownGroup { f(groupInfo) } else { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 24cf088918..8095c0297e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -111,13 +111,16 @@ struct NetworkAndServers: View { Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) }) .disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors)) } footer: { - if let errStr = globalServersError(ss.servers.serverErrors) { - ServersErrorView(errStr: errStr) + let errs = globalServersErrors(ss.servers.serverErrors) + if !errs.isEmpty { + ForEach(errs, id: \.self) { err in + ServersErrorView(errStr: err) + } } else if !ss.servers.serverErrors.isEmpty { ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error")) } - if let warnStr = globalServersWarning(ss.servers.serverWarnings) { - ServersWarningView(warnStr: warnStr) + ForEach(globalServersWarnings(ss.servers.serverWarnings), id: \.self) { warn in + ServersWarningView(warnStr: warn) } } @@ -397,17 +400,12 @@ struct ServersWarningView: View { } } -func globalServersError(_ serverErrors: [UserServersError]) -> String? { - for err in serverErrors { - if let errStr = err.globalError { - return errStr - } - } - return nil +func globalServersErrors(_ serverErrors: [UserServersError]) -> [String] { + serverErrors.compactMap { $0.globalError } } -func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? { - for warn in serverWarnings { +func globalServersWarnings(_ serverWarnings: [UserServersWarning]) -> [String] { + serverWarnings.map { warn in switch warn { case let .noChatRelays(user): let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning") @@ -417,9 +415,16 @@ func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? { user.localDisplayName ) + " " + text } else { return text } + case let .noNamesServers(user): + let text = NSLocalizedString("No servers to resolve names.", comment: "servers warning") + if let user = user { + return String.localizedStringWithFormat( + NSLocalizedString("For chat profile %@:", comment: "servers warning"), + user.localDisplayName + ) + " " + text + } else { return text } } } - return nil } func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index 26f24f2f0f..4e2f1992d6 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -52,10 +52,16 @@ struct OperatorView: View { Text("Operator") .foregroundColor(theme.colors.secondary) } footer: { - if let errStr = globalServersError(serverErrors) { - ServersErrorView(errStr: errStr) - } else if let warnStr = globalServersWarning(serverWarnings) { - ServersWarningView(warnStr: warnStr) + let errs = globalServersErrors(serverErrors) + let warns = globalServersWarnings(serverWarnings) + if !errs.isEmpty { + ForEach(errs, id: \.self) { err in + ServersErrorView(errStr: err) + } + } else if !warns.isEmpty { + ForEach(warns, id: \.self) { warn in + ServersWarningView(warnStr: warn) + } } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { case let .accepted(acceptedAt, _): @@ -105,6 +111,10 @@ struct OperatorView: View { .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in validateServers_($userServers, $serverErrors, $serverWarnings) } + Toggle("To resolve names", isOn: $userServers[operatorIndex].operator_.smpRoles.names) + .onChange(of: userServers[operatorIndex].operator_.smpRoles.names) { _ in + validateServers_($userServers, $serverErrors, $serverWarnings) + } } header: { Text("Use for messages") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index b059be7cb0..a92491edef 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -169,10 +169,16 @@ struct YourServersView: View { .hidden() } } footer: { - if let errStr = globalServersError(serverErrors) { - ServersErrorView(errStr: errStr) - } else if let warnStr = globalServersWarning(serverWarnings) { - ServersWarningView(warnStr: warnStr) + let errs = globalServersErrors(serverErrors) + let warns = globalServersWarnings(serverWarnings) + if !errs.isEmpty { + ForEach(errs, id: \.self) { err in + ServersErrorView(errStr: err) + } + } else if !warns.isEmpty { + ForEach(warns, id: \.self) { warn in + ServersWarningView(warnStr: warn) + } } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index ad6b2d4454..f1bacee425 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -16,6 +16,7 @@ struct PrivacySettings: View { @AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true + @AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) private var verifySimplexNames = false @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true @@ -193,6 +194,9 @@ struct PrivacySettings: View { m.draftChatId = nil } } + settingsRow("number", color: theme.colors.secondary) { + Toggle("Verify SimpleX names", isOn: $verifySimplexNames) + } } header: { Text("Chats") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 135a90c65e..057ce5a227 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -32,6 +32,7 @@ let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_D let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" +let DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES = "privacyVerifySimplexNames" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen" let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet" @@ -99,6 +100,7 @@ let appDefaults: [String: Any] = [ DEFAULT_PRIVACY_LINK_PREVIEWS: true, DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue, DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true, + DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES: false, DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true, DEFAULT_PRIVACY_PROTECT_SCREEN: false, DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false, diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index e22042fa24..5d1bea6079 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -191,6 +191,29 @@ struct UserAddressView: View { } } + Section { + NavigationLink { + let simplexName = if let d = chatModel.currentUser?.profile.contactDomain?.shortName { "@\(d)" } else { "" } + SetSimplexDomainView( + title: "Your SimpleX name", + footer: "Let people connect to you via name registered with your SimpleX address.", + prompt: "@yourname.testing", + simplexName: simplexName, + save: { simplexDomain in + do { + let u = try await apiSetUserDomain(simplexDomain) + await MainActor.run { chatModel.updateUser(u) } + return true + } catch { + return false + } + } + ) + } label: { + Label("Your SimpleX name", systemImage: "at") + } + } + Section { createOneTimeLinkButton() } header: { @@ -688,6 +711,59 @@ private func saveAddressSettings(_ settings: AddressSettingsState, _ savedSettin } } +struct SetSimplexDomainView: View { + let title: LocalizedStringKey + let footer: LocalizedStringKey + let prompt: String + @State var simplexName: String + let save: (String?) async -> Bool + @Environment(\.dismiss) var dismiss + @EnvironmentObject var theme: AppTheme + @State private var saving = false + + var body: some View { + List { + Section { + TextField(prompt, text: $simplexName) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + } header: { + Text(verbatim: "") + } footer: { + Text(footer).foregroundColor(theme.colors.secondary) + } + Section { + Button { + saving = true + Task { + let ok = await save(normalized()) + await MainActor.run { + saving = false + if ok { dismiss() } + } + } + } label: { + Text("Save") + } + .disabled(saving) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.large) + } + + private func normalized() -> String? { + let s = simplexName.trimmingCharacters(in: .whitespacesAndNewlines) + return s.isEmpty + ? nil + : addSimplexTLD(s.hasPrefix("@") || s.hasPrefix("#") ? String(s.dropFirst()) : s) + } + + private func addSimplexTLD(_ d: String) -> String { + if d.contains(".") { d } else { "\(d).simplex" } + } +} + struct UserAddressView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 87e042be82..70606ccc0a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -563,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -735,8 +735,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -822,8 +822,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-IDb07VxlHBtGmeucUQceZv.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.6-67j0xDFDAaz6bMQcxTdG4Z.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5f1d8ef6c2..47522a5deb 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -84,10 +84,8 @@ extension ChatAPIResult { // Spec: spec/api.md#decodeAPIResult public func decodeAPIResult(_ d: Data) -> APIResult { -// print("decodeAPIResult \(String(describing: R.self))") do { -// return try withStackSizeLimit { try jsonDecoder.decode(APIResult.self, from: d) } - return try jsonDecoder.decode(APIResult.self, from: d) + return try withLargeStack { try jsonDecoder.decode(APIResult.self, from: d) } } catch {} if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { if let (_, jErr) = getOWSF(j, "error") { @@ -106,31 +104,50 @@ public func decodeAPIResult(_ d: Data) -> APIResult { // Default stack size for the main thread is 1mb, for secondary threads - 512 kb. // This function can be used to test what size is used (or to increase available stack size). // Stack size must be a multiple of system page size (16kb). -//private let stackSizeLimit: Int = 256 * 1024 -// -//private func withStackSizeLimit(_ f: @escaping () throws -> T) throws -> T { -// let semaphore = DispatchSemaphore(value: 0) -// var result: Result? -// let thread = Thread { -// do { -// result = .success(try f()) -// } catch { -// result = .failure(error) -// } -// semaphore.signal() -// } -// -// thread.stackSize = stackSizeLimit -// thread.qualityOfService = Thread.current.qualityOfService -// thread.start() -// -// semaphore.wait() -// -// switch result! { -// case let .success(r): return r -// case let .failure(e): throw e -// } -//} +private let stackSizeLimit: Int = 16 * 1024 * 1024 + +private final class LargeStackRunner: NSObject { + static let shared = LargeStackRunner() + + private let serialize = NSLock() // serialize submitters + private let jobReady = DispatchSemaphore(value: 0) + private let jobDone = DispatchSemaphore(value: 0) + private var job: (() -> Void)? + private var thread: Thread? = nil + + private override init() { + super.init() + let t = Thread(target: self, selector: #selector(loop), object: nil) + t.stackSize = stackSizeLimit + t.name = "chat.simplex.decoding" + t.qualityOfService = .default + t.start() + thread = t + } + + @objc private func loop() { + while true { + jobReady.wait() + job?() + jobDone.signal() + } + } + + func run(_ f: @escaping () throws -> T) throws -> T { + serialize.lock() + defer { serialize.unlock() } + var result: Result! + job = { result = Result(catching: f) } + jobReady.signal() + jobDone.wait() + job = nil + return try result.get() + } +} + +func withLargeStack(_ work: @escaping () throws -> T) throws -> T { + try LargeStackRunner.shared.run(work) +} public func parseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [ChatData])? { if let jApiChats = jResp["apiChats"] as? NSDictionary, @@ -167,6 +184,10 @@ public struct CreatedConnLink: Decodable, Hashable { public func simplexChatUri(short: Bool = true) -> String { short ? (connShortLink ?? simplexChatLink(connFullLink)) : simplexChatLink(connFullLink) } + + public var cmdString: String { + connFullLink + (connShortLink.map { " \($0)"} ?? "") + } } public func simplexChatLink(_ uri: String) -> String { @@ -741,6 +762,7 @@ public enum ChatErrorType: Decodable, Hashable { case chatNotStopped case chatStoreChanged case invalidConnReq + case simplexDomainNotReady(simplexDomain: SimplexDomain, simplexDomainError: SimplexDomainError) case unsupportedConnReq case invalidChatMessage(connection: Connection, message: String) case connReqMessageProhibited @@ -896,6 +918,13 @@ public enum AgentErrorType: Decodable, Hashable { case INTERNAL(internalErr: String) case CRITICAL(offerRestart: Bool, criticalErr: String) case INACTIVE + case NO_NAME_SERVERS +} + +public enum NameErrorType: Decodable, Hashable { + case NO_RESOLVER + case NOT_FOUND + case RESOLVER(resolverErr: String) } public enum CommandErrorType: Decodable, Hashable { @@ -937,6 +966,7 @@ public enum ProtocolErrorType: Decodable, Hashable { case LARGE_MSG case EXPIRED case INTERNAL + case NAME(nameErr: NameErrorType) } public enum ProxyError: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7a20a8d4ba..3acc2c7eec 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -119,7 +119,8 @@ public struct Profile: Codable, NamedChat, Hashable { image: String? = nil, contactLink: String? = nil, preferences: Preferences? = nil, - peerType: ChatPeerType? = nil + peerType: ChatPeerType? = nil, + contactDomain: SimplexDomainClaim? = nil ) { self.displayName = displayName self.fullName = fullName @@ -127,6 +128,7 @@ public struct Profile: Codable, NamedChat, Hashable { self.image = image self.contactLink = contactLink self.preferences = preferences + self.contactDomain = contactDomain } public var displayName: String @@ -139,6 +141,7 @@ public struct Profile: Codable, NamedChat, Hashable { // the badge proof from the wire profile - opaque to the UI, round-tripped to the core (apiPrepareContact) public var badge: BadgeProof? public var localAlias: String { get { "" } } + public var contactDomain: SimplexDomainClaim? var profileViewName: String { (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" @@ -161,7 +164,9 @@ public struct LocalProfile: Codable, NamedChat, Hashable { preferences: Preferences? = nil, peerType: ChatPeerType? = nil, localBadge: LocalBadge? = nil, - localAlias: String + localAlias: String, + contactDomain: SimplexDomainClaim? = nil, + contactDomainVerified: Bool? = nil ) { self.profileId = profileId self.displayName = displayName @@ -173,6 +178,8 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.peerType = peerType self.localBadge = localBadge self.localAlias = localAlias + self.contactDomain = contactDomain + self.contactDomainVerified = contactDomainVerified } public var profileId: Int64 @@ -185,6 +192,8 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var peerType: ChatPeerType? public var localBadge: LocalBadge? public var localAlias: String + public var contactDomain: SimplexDomainClaim? + public var contactDomainVerified: Bool? var profileViewName: String { localAlias == "" @@ -2507,7 +2516,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupId: Int64 public var useRelays: Bool public var relayOwnStatus: RelayStatus? = nil - var localDisplayName: GroupName + public var localDisplayName: GroupName public var groupProfile: GroupProfile public var businessChat: BusinessChatInfo? public var fullGroupPreferences: FullGroupPreferences @@ -2534,6 +2543,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatTags: [Int64] public var chatItemTTL: Int64? public var localAlias: String + public var groupDomainVerified: Bool? public var isOwner: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -2614,19 +2624,37 @@ public enum GroupType: Codable, Hashable { } public struct PublicGroupAccess: Codable, Hashable { - public init(groupWebPage: String? = nil, groupDomain: String? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) { + public init(groupWebPage: String? = nil, groupDomainClaim: SimplexDomainClaim? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) { self.groupWebPage = groupWebPage - self.groupDomain = groupDomain + self.groupDomainClaim = groupDomainClaim self.domainWebPage = domainWebPage self.allowEmbedding = allowEmbedding } public var groupWebPage: String? - public var groupDomain: String? + public var groupDomainClaim: SimplexDomainClaim? public var domainWebPage: Bool = false public var allowEmbedding: Bool = false } +public struct SimplexDomainClaim: Codable, Hashable { + public init(domain: String, proof: SimplexDomainProof? = nil) { + self.domain = domain + self.proof = proof + } + public var domain: String + public var proof: SimplexDomainProof? + + public var shortName: String { + domain.hasSuffix(".simplex") ? String(domain.dropLast(".simplex".count)) : domain + } +} + +public enum SimplexDomainError: Decodable, Hashable { + case noValidLink + case unknownDomain +} + public struct RelayCapabilities: Codable, Hashable { public var webDomain: String? } @@ -5258,28 +5286,60 @@ public enum SimplexLinkType: String, Decodable, Hashable { } } -public struct SimplexNameInfo: Decodable, Equatable, Hashable { +public struct SimplexNameInfo: Codable, Equatable, Hashable { public var nameType: SimplexNameType - public var nameDomain: SimplexNameDomain + public var nameDomain: SimplexDomain + + public init(nameType: SimplexNameType, nameDomain: SimplexDomain) { + self.nameType = nameType + self.nameDomain = nameDomain + } } -public struct SimplexNameDomain: Decodable, Equatable, Hashable { +public struct SimplexDomain: Codable, Equatable, Hashable { public var nameTLD: SimplexTLD public var domain: String public var subDomain: [String] + + // mirrors backend fullDomainName: reverse(subDomain) ++ [domain] ++ tld + public var fullDomainName: String { + let tld: [String] + switch nameTLD { + case .simplex: tld = ["simplex"] + case .testing: tld = ["testing"] + case .web: tld = [] + } + return (subDomain.reversed() + [domain] + tld).joined(separator: ".") + } + + public var cmdString: String { + "domain=\(fullDomainName)" + } + + public init(nameTLD: SimplexTLD, domain: String, subDomain: [String]) { + self.nameTLD = nameTLD + self.domain = domain + self.subDomain = subDomain + } } -public enum SimplexTLD: String, Decodable, Hashable { +public enum SimplexTLD: String, Codable, Hashable { case simplex case testing case web } -public enum SimplexNameType: String, Decodable, Hashable { +public enum SimplexNameType: String, Codable, Hashable { case publicGroup case contact } +public struct SimplexDomainProof: Codable, Hashable { + public var linkOwnerId: String? + public var presHeader: String + public var signature: String +} + public enum FormatColor: String, Decodable, Hashable { case red = "red" case green = "green" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 44361baa73..ef5fe334b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2039,14 +2039,15 @@ data class Profile( val peerType: ChatPeerType? = null, // the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge), // but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof - val badge: BadgeProof? = null + val badge: BadgeProof? = null, + val contactDomain: SimplexDomainClaim? = null ): NamedChat { val profileViewName: String get() { return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType) + fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain) companion object { val sampleData = Profile( @@ -2068,11 +2069,13 @@ data class LocalProfile( val contactLink: String? = null, val preferences: ChatPreferences? = null, val peerType: ChatPeerType? = null, - val localBadge: LocalBadge? = null + val localBadge: LocalBadge? = null, + val contactDomain: SimplexDomainClaim? = null, + val contactDomainVerified: Boolean? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType) + fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain) companion object { val sampleData = LocalProfile( @@ -2198,6 +2201,7 @@ data class GroupInfo ( val chatTags: List, val chatItemTTL: Long?, override val localAlias: String, + val groupDomainVerified: Boolean? = null, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -2319,10 +2323,18 @@ object GroupTypeSerializer : KSerializer { } } +@Serializable +data class SimplexDomainClaim( + val domain: String, + val proof: SimplexDomainProof? = null +) { + val shortName: String get() = domain.removeSuffix(".simplex") +} + @Serializable data class PublicGroupAccess( val groupWebPage: String? = null, - val groupDomain: String? = null, + val groupDomainClaim: SimplexDomainClaim? = null, val domainWebPage: Boolean = false, val allowEmbedding: Boolean = false ) @@ -4874,15 +4886,27 @@ enum class SimplexLinkType(val linkType: String) { @Serializable data class SimplexNameInfo( val nameType: SimplexNameType, - val nameDomain: SimplexNameDomain + val nameDomain: SimplexDomain ) @Serializable -data class SimplexNameDomain( +data class SimplexDomain( val nameTLD: SimplexTLD, val domain: String, val subDomain: List -) +) { + // mirrors backend fullDomainName: reverse(subDomain) + [domain] + tld + val fullDomainName: String get() { + val tld = when (nameTLD) { + SimplexTLD.simplex -> listOf("simplex") + SimplexTLD.testing -> listOf("testing") + SimplexTLD.web -> emptyList() + } + return (subDomain.reversed() + domain + tld).joinToString(".") + } + + val cmdString: String get() = "domain=$fullDomainName" +} @Serializable enum class SimplexTLD { @@ -4897,6 +4921,14 @@ enum class SimplexNameType { @SerialName("contact") contact } +// peer's signed name claim; UI only checks presence +@Serializable +data class SimplexDomainProof( + val linkOwnerId: String? = null, + val presHeader: String, + val signature: String +) + @Serializable enum class FormatColor(val color: String) { red("red"), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 02353e2c8a..bf0afe4546 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -122,6 +122,7 @@ class AppPreferences { val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true) val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) + val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, false) val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true) val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, false) // TODO remove @@ -397,6 +398,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" + private const val SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES = "PrivacyVerifySimplexNames" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "PrivacyLinkPreviewsShowAlert" private const val SHARED_PREFS_PRIVACY_SANITIZE_LINKS = "PrivacySanitizeLinks" private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove @@ -1555,6 +1557,46 @@ object ChatController { generalGetString(MR.strings.link_requires_newer_app_version_please_upgrade) ) } + r is API.Error && r.err is ChatError.ChatErrorChat + && r.err.errorType is ChatErrorType.SimplexDomainNotReady -> { + val domain = r.err.errorType.simplexDomain.fullDomainName + if (r.err.errorType.simplexDomainError is SimplexDomainError.NoValidLink) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.simplex_name_no_valid_link), + generalGetString(MR.strings.simplex_name_no_valid_link_desc).format(domain) + ) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.simplex_name_unconfirmed), + generalGetString(MR.strings.simplex_name_unconfirmed_desc).format(domain) + ) + } + } + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.NO_NAME_SERVERS -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.simplex_name_error), + generalGetString(MR.strings.simplex_name_no_servers_desc) + ) + } + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.NAME -> { + when (val nameErr = r.err.agentError.smpErr.nameErr) { + is NameErrorType.NOT_FOUND -> AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.simplex_name_not_found), + generalGetString(MR.strings.simplex_name_not_found_desc) + ) + is NameErrorType.NO_RESOLVER -> AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.simplex_name_error), + generalGetString(MR.strings.simplex_name_server_no_resolver_desc).format(r.err.agentError.serverAddress) + ) + is NameErrorType.RESOLVER -> AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.simplex_name_error), + generalGetString(MR.strings.simplex_name_resolver_error_desc).format(nameErr.resolverErr) + ) + } + } r is API.Error && r.err is ChatError.ChatErrorAgent && r.err.agentError is AgentErrorType.SMP && r.err.agentError.smpErr is SMPErrorType.AUTH -> { @@ -1587,11 +1629,29 @@ object ChatController { } } + // owner-specific wording for setting one's own/channel name; null for other errors (handled by apiConnectResponseAlert) + fun simplexNameOwnerError(err: ChatError, isChannel: Boolean): String? = + if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.SimplexDomainNotReady && err.errorType.simplexDomainError is SimplexDomainError.NoValidLink) { + val domain = err.errorType.simplexDomain.fullDomainName + if (isChannel) generalGetString(MR.strings.simplex_name_owner_no_channel_link).format(domain) + else generalGetString(MR.strings.simplex_name_owner_no_address).format(domain) + } else null + fun connErrorText(e: ChatError): String = when { e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.InvalidConnReq -> generalGetString(MR.strings.invalid_connection_link) e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UnsupportedConnReq -> generalGetString(MR.strings.unsupported_connection_link) + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.SimplexDomainNotReady -> + if (e.errorType.simplexDomainError is SimplexDomainError.NoValidLink) + generalGetString(MR.strings.simplex_name_no_valid_link) + else generalGetString(MR.strings.simplex_name_unconfirmed) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.NO_NAME_SERVERS -> + generalGetString(MR.strings.simplex_name_error) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.NAME -> + if (e.agentError.smpErr.nameErr is NameErrorType.NOT_FOUND) + generalGetString(MR.strings.simplex_name_not_found) + else generalGetString(MR.strings.simplex_name_error) e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH -> generalGetString(MR.strings.connection_error_auth) e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.BLOCKED -> @@ -1604,18 +1664,18 @@ object ChatController { "${generalGetString(MR.strings.error_prefix)}: ${e.string}" } - suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData): Chat? { + suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain? = null): Chat? { val userId = try { currentUserId("apiPrepareContact") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIPrepareContact(userId, connLink, contactShortLinkData)) + val r = sendCmd(rh, CC.APIPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain)) if (r is API.Result && r.res is CR.NewPreparedChat) return if (rh == null) r.res.chat else r.res.chat.copy(remoteHostId = rh) Log.e(TAG, "apiPrepareContact bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_contact), "${r.responseType}: ${r.details}") return null } - suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData): Chat? { + suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain? = null): Chat? { val userId = try { currentUserId("apiPrepareGroup") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData)) + val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain)) if (r is API.Result && r.res is CR.NewPreparedChat) return if (rh == null) r.res.chat else r.res.chat.copy(remoteHostId = rh) Log.e(TAG, "apiPrepareGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_group), "${r.responseType}: ${r.details}") @@ -1762,6 +1822,38 @@ object ChatController { } } + // name is the encoded SimplexName (e.g. "@alice.simplex"); null clears it. Throws on rejection. + suspend fun apiSetUserDomain(rh: Long?, simplexDomain: String?): User { + val userId = currentUserId("apiSetUserDomain") + val r = sendCmd(rh, CC.ApiSetUserDomain(userId, simplexDomain)) + return when { + r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh) + r is API.Result && r.res is CR.UserProfileNoChange -> r.res.user.updateRemoteHostId(rh) + else -> { + if (r is API.Error) { + val ownerMsg = simplexNameOwnerError(r.err, isChannel = false) + if (ownerMsg != null) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), ownerMsg) + else apiConnectResponseAlert(r) + } + throw Exception("failed to set SimpleX name: ${r.responseType} ${r.details}") + } + } + } + + suspend fun apiVerifyContactDomain(rh: Long?, contactId: Long): Pair? { + val r = sendCmd(rh, CC.ApiVerifyContactDomain(contactId)) + if (r is API.Result && r.res is CR.ContactDomainVerified) return r.res.contact to r.res.verificationFailure + Log.e(TAG, "apiVerifyContactDomain bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiVerifyGroupDomain(rh: Long?, groupId: Long): Pair? { + val r = sendCmd(rh, CC.ApiVerifyGroupDomain(groupId)) + if (r is API.Result && r.res is CR.GroupDomainVerified) return r.res.groupInfo to r.res.verificationFailure + Log.e(TAG, "apiVerifyGroupDomain bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? { val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs)) if (r is API.Result && r.res is CR.ContactPrefsUpdated) return r.res.toContact @@ -2289,7 +2381,7 @@ object ChatController { return when { r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup r is API.Error -> { - AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}") + AlertManager.shared.showAlertMsg(generalGetString(errorTitle), r.err.string) null } else -> { @@ -2303,6 +2395,23 @@ object ChatController { } } + suspend fun apiSetPublicGroupAccess(rh: Long?, groupId: Long, access: PublicGroupAccess): GroupInfo? { + val r = sendCmd(rh, CC.ApiSetPublicGroupAccess(groupId, access)) + return when { + r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup + r is API.Error -> { + val ownerMsg = simplexNameOwnerError(r.err, isChannel = true) + if (ownerMsg != null) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), ownerMsg) + else apiConnectResponseAlert(r) + null + } + else -> { + Log.e(TAG, "apiSetPublicGroupAccess bad response: ${r.responseType} ${r.details}") + null + } + } + } + suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? { val r = sendCmdWithRetry(rh, CC.APICreateGroupLink(groupId, memberRole)) if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.groupLink @@ -3705,6 +3814,7 @@ sealed class CC { class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() + class ApiSetPublicGroupAccess(val groupId: Long, val access: PublicGroupAccess): CC() class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC() class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC() class APIDeleteGroupLink(val groupId: Long): CC() @@ -3752,8 +3862,8 @@ sealed class CC { class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() class APIConnectPlan(val userId: Long, val connLink: String, val linkOwnerSig: LinkOwnerSig? = null): CC() - class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData): CC() - class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData): CC() + class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData, val verifiedDomain: SimplexDomain? = null): CC() + class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData, val verifiedDomain: SimplexDomain? = null): CC() class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC() class APIChangePreparedGroupUser(val groupId: Long, val newUserId: Long): CC() class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent?): CC() @@ -3775,6 +3885,9 @@ sealed class CC { class ApiShowMyAddress(val userId: Long): CC() class ApiAddMyAddressShortLink(val userId: Long): CC() class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC() + class ApiSetUserDomain(val userId: Long, val simplexDomain: String?): CC() + class ApiVerifyContactDomain(val contactId: Long): CC() + class ApiVerifyGroupDomain(val groupId: Long): CC() class ApiSetAddressSettings(val userId: Long, val addressSettings: AddressSettings): CC() class ApiGetCallInvitations: CC() class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC() @@ -3960,8 +4073,8 @@ sealed class CC { val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else "" "/_connect plan $userId $connLink$sigStr" } - is APIPrepareContact -> "/_prepare contact $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(contactShortLinkData)}" - is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} direct=${onOff(directLink)} ${json.encodeToString(groupShortLinkData)}" + is APIPrepareContact -> "/_prepare contact $userId ${connLink.cmdString}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(contactShortLinkData)}" + is APIPrepareGroup -> "/_prepare group $userId ${connLink.cmdString} direct=${onOff(directLink)}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId" is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)}${maybeContent(msg)}" @@ -3983,6 +4096,10 @@ sealed class CC { is ApiShowMyAddress -> "/_show_address $userId" is ApiAddMyAddressShortLink -> "/_short_link_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" + is ApiSetUserDomain -> "/_set domain $userId" + (if (simplexDomain != null) " $simplexDomain" else "") + is ApiSetPublicGroupAccess -> "/_public group access #$groupId ${json.encodeToString(access)}" + is ApiVerifyContactDomain -> "/_verify domain @$contactId" + is ApiVerifyGroupDomain -> "/_verify domain #$groupId" is ApiSetAddressSettings -> "/_address_settings $userId ${json.encodeToString(addressSettings)}" is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" @@ -4164,6 +4281,10 @@ sealed class CC { is ApiShowMyAddress -> "apiShowMyAddress" is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink" is ApiSetProfileAddress -> "apiSetProfileAddress" + is ApiSetUserDomain -> "apiSetUserDomain" + is ApiSetPublicGroupAccess -> "apiSetPublicGroupAccess" + is ApiVerifyContactDomain -> "apiVerifyContactDomain" + is ApiVerifyGroupDomain -> "apiVerifyGroupDomain" is ApiSetAddressSettings -> "apiSetAddressSettings" is ApiAcceptContact -> "apiAcceptContact" is ApiRejectContact -> "apiRejectContact" @@ -4443,8 +4564,8 @@ data class ServerOperator( serverDomains = listOf("simplex.im"), conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false), enabled = true, - smpRoles = ServerRoles(storage = true, proxy = true), - xftpRoles = ServerRoles(storage = true, proxy = true) + smpRoles = ServerRoles(storage = true, proxy = true, names = true), + xftpRoles = ServerRoles(storage = true, proxy = true, names = false) ) } @@ -4504,7 +4625,8 @@ data class ServerOperator( @Serializable data class ServerRoles( val storage: Boolean, - val proxy: Boolean + val proxy: Boolean, + val names: Boolean ) @Serializable @@ -4526,8 +4648,8 @@ data class UserOperatorServers( serverDomains = emptyList(), conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false), enabled = false, - smpRoles = ServerRoles(storage = true, proxy = true), - xftpRoles = ServerRoles(storage = true, proxy = true) + smpRoles = ServerRoles(storage = true, proxy = true, names = true), + xftpRoles = ServerRoles(storage = true, proxy = true, names = false) ) companion object { @@ -4613,6 +4735,7 @@ sealed class UserServersError { @Serializable sealed class UserServersWarning { @Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning() + @Serializable @SerialName("noNamesServers") data class NoNamesServers(val user: UserRef? = null): UserServersWarning() val globalWarning: String? get() = when (this) { @@ -4622,6 +4745,12 @@ sealed class UserServersWarning { String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text } else text } + is NoNamesServers -> { + val text = generalGetString(MR.strings.no_names_servers_enabled) + if (user != null) { + String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text + } else text + } } } @@ -6462,6 +6591,8 @@ sealed class CR { @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() + @Serializable @SerialName("contactDomainVerified") class ContactDomainVerified(val user: UserRef, val contact: Contact, val verificationFailure: String? = null): CR() + @Serializable @SerialName("groupDomainVerified") class GroupDomainVerified(val user: UserRef, val groupInfo: GroupInfo, val verificationFailure: String? = null): CR() @Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List, val relaysChanged: Boolean): CR() @Serializable @SerialName("groupRelayUpdated") class GroupRelayUpdated(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val groupRelay: GroupRelay): CR() @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR() @@ -6653,6 +6784,8 @@ sealed class CR { is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" is GroupUpdated -> "groupUpdated" + is ContactDomainVerified -> "contactDomainVerified" + is GroupDomainVerified -> "groupDomainVerified" is GroupLinkDataUpdated -> "groupLinkDataUpdated" is GroupRelayUpdated -> "groupRelayUpdated" is GroupLinkCreated -> "groupLinkCreated" @@ -6837,6 +6970,8 @@ sealed class CR { is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) + is ContactDomainVerified -> withUser(user, "contact: ${json.encodeToString(contact)}\nverificationFailure: $verificationFailure") + is GroupDomainVerified -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nverificationFailure: $verificationFailure") is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged") is GroupRelayUpdated -> withUser(user, "groupInfo: $groupInfo\nmember: $member\ngroupRelay: $groupRelay") is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") @@ -6948,6 +7083,8 @@ data class CreatedConnLink(val connFullLink: String, val connShortLink: String?) fun simplexChatUri(short: Boolean): String = if (short) connShortLink ?: simplexChatLink(connFullLink) else simplexChatLink(connFullLink) + + val cmdString: String get() = connFullLink + (if (connShortLink == null) "" else " $connShortLink") } fun simplexChatLink(uri: String): String = @@ -6960,6 +7097,12 @@ sealed class OwnerVerification { @Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification() } +@Serializable +sealed class SimplexDomainError { + @Serializable @SerialName("noValidLink") object NoValidLink : SimplexDomainError() + @Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError() +} + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() @@ -6978,7 +7121,7 @@ sealed class InvitationLinkPlan { @Serializable sealed class ContactAddressPlan { - @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan() + @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedDomain: SimplexDomain? = null): ContactAddressPlan() @Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() @@ -6988,7 +7131,7 @@ sealed class ContactAddressPlan { @Serializable sealed class GroupLinkPlan { - @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan() + @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedDomain: SimplexDomain? = null): GroupLinkPlan() @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() @@ -7297,6 +7440,7 @@ sealed class ChatErrorType { is ChatStoreChanged -> "chatStoreChanged" is ConnectionPlanChatError -> "connectionPlan" is InvalidConnReq -> "invalidConnReq" + is SimplexDomainNotReady -> "simplexDomainNotReady" is UnsupportedConnReq -> "unsupportedConnReq" is InvalidChatMessage -> "invalidChatMessage" is ConnReqMessageProhibited -> "connReqMessageProhibited" @@ -7379,6 +7523,7 @@ sealed class ChatErrorType { @Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType() @Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() + @Serializable @SerialName("simplexDomainNotReady") class SimplexDomainNotReady(val simplexDomain: SimplexDomain, val simplexDomainError: SimplexDomainError): ChatErrorType() @Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType() @@ -7647,6 +7792,7 @@ sealed class AgentErrorType { is INTERNAL -> "INTERNAL $internalErr" is CRITICAL -> "CRITICAL $offerRestart $criticalErr" is INACTIVE -> "INACTIVE" + is NO_NAME_SERVERS -> "NO_NAME_SERVERS" } @Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType, val errContext: String): AgentErrorType() @Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType, val errContext: String): AgentErrorType() @@ -7661,6 +7807,19 @@ sealed class AgentErrorType { @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType() @Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType() + @Serializable @SerialName("NO_NAME_SERVERS") object NO_NAME_SERVERS: AgentErrorType() +} + +@Serializable +sealed class NameErrorType { + val string: String get() = when (this) { + is NO_RESOLVER -> "NO_RESOLVER" + is NOT_FOUND -> "NOT_FOUND" + is RESOLVER -> "RESOLVER $resolverErr" + } + @Serializable @SerialName("NO_RESOLVER") object NO_RESOLVER: NameErrorType() + @Serializable @SerialName("NOT_FOUND") object NOT_FOUND: NameErrorType() + @Serializable @SerialName("RESOLVER") class RESOLVER(val resolverErr: String): NameErrorType() } @Serializable @@ -7730,6 +7889,7 @@ sealed class SMPErrorType { is LARGE_MSG -> "LARGE_MSG" is EXPIRED -> "EXPIRED" is INTERNAL -> "INTERNAL" + is NAME -> "NAME ${nameErr.string}" } @Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType() @Serializable @SerialName("SESSION") class SESSION: SMPErrorType() @@ -7744,6 +7904,7 @@ sealed class SMPErrorType { @Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType() @Serializable @SerialName("EXPIRED") class EXPIRED: SMPErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType() + @Serializable @SerialName("NAME") class NAME(val nameErr: NameErrorType): SMPErrorType() } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index a099bf333b..e43cc357a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -757,6 +757,20 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) ) ChatInfoDescription(cInfo, displayName, copyNameToClipboard) + val domain = contact.profile.contactDomain + if (domain != null && (contact.profile.contactDomainVerified != null || domain.proof != null)) { + SimplexNameView( + simplexName = "@${domain.shortName}", + verified = contact.profile.contactDomainVerified, + verify = { + val rhId = chatModel.remoteHostId() + chatModel.controller.apiVerifyContactDomain(rhId, contact.contactId)?.let { (ct, reason) -> + chatModel.chatsContext.updateContact(rhId, ct) + ct.profile.contactDomainVerified to reason + } + } + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt new file mode 100644 index 0000000000..ccee6a9035 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt @@ -0,0 +1,94 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.SimplexNameInfo +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* + +// Renders a contact's / channel's SimpleX name with its 3-state verification indicator. +// `verification`: null = not attempted, false = failed, true = verified. +// `verify` runs the verify API, updates the model and returns (newVerification, failureReason); +// null on network error. With `autoVerify`, it runs once on open when state is null. +@Composable +fun SimplexNameView( + simplexName: String, + verified: Boolean?, + verify: suspend () -> Pair? +) { + val scope = rememberCoroutineScope() + val inFlight = remember { mutableStateOf(false) } + val showSpinner = remember { mutableStateOf(false) } + + fun runVerify(manual: Boolean) { + if (inFlight.value) return + inFlight.value = true + scope.launch { + // delay the spinner so a fast result on open doesn't flash it + val spinner = launch { delay(300); if (inFlight.value) showSpinner.value = true } + val res = try { + verify() + } catch (e: Exception) { + Log.e(TAG, "verify SimplexName: ${e.stackTraceToString()}") + null + } + spinner.cancel() + inFlight.value = false + showSpinner.value = false + if (res != null) { + val (newV, reason) = res + // show the reason on a manual run, or on an inconclusive auto run (state stayed null) + if (reason != null && (manual || newV == null)) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.simplex_name_not_verified), reason) + } + } + } + } + + LaunchedEffect(Unit) { + if (chatModel.controller.appPrefs.privacyVerifySimplexNames.get() && verified == null) runVerify(manual = false) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) { + Text( + simplexName, + style = MaterialTheme.typography.body2.copy( + color = if (verified == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + fontFamily = if (verified == true) FontFamily.Default else FontFamily.Monospace + ) + ) + when { + showSpinner.value -> + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary) + verified == true -> + Icon(painterResource(MR.images.ic_check_filled), null, Modifier.size(18.dp), tint = MaterialTheme.colors.onBackground) + verified == false -> + Icon( + painterResource(MR.images.ic_close), null, tint = Color.Red, + modifier = Modifier.size(18.dp).clickable { runVerify(manual = true) } + ) + else -> + Text( + stringResource(MR.strings.verify_simplex_name_action), + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable { runVerify(manual = true) } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt index 262a78b3c8..18a944f671 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -49,7 +49,7 @@ fun ChannelWebPageView( val trimmedPage = webPage.value.trim() val newAccess = PublicGroupAccess( groupWebPage = trimmedPage.ifEmpty { null }, - groupDomain = access?.groupDomain, + groupDomainClaim = access?.groupDomainClaim, domainWebPage = access?.domainWebPage ?: false, allowEmbedding = allowEmbedding.value ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 2b1c7bcd09..c54ca148d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -178,6 +178,27 @@ fun ModalData.GroupChatInfoView( manageWebPage = { ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) } }, + setSimplexName = { + ModalManager.end.showCustomModal { close -> + val domain = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName + SetSimplexDomainView( + title = generalGetString(MR.strings.set_simplex_name), + footer = generalGetString(MR.strings.set_channel_simplex_name_footer), + placeholder = "#channelname.testing", + simplexName = if (domain == null) "" else "#$domain", + save = { domain -> + val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?: PublicGroupAccess() + val newAccess = access.copy(groupDomainClaim = domain?.let { SimplexDomainClaim(it) }) + val gInfo = chatModel.controller.apiSetPublicGroupAccess(rhId, groupInfo.groupId, newAccess) + if (gInfo != null) { + withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, gInfo) } + true + } else false + }, + close = close + ) + } + }, onSearchClicked = onSearchClicked, deletingItems = deletingItems ) @@ -510,6 +531,7 @@ fun ModalData.GroupChatInfoLayout( leaveGroup: () -> Unit, manageGroupLink: () -> Unit, manageWebPage: () -> Unit, + setSimplexName: () -> Unit, close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, onSearchClicked: () -> Unit, deletingItems: State @@ -616,6 +638,12 @@ fun ModalData.GroupChatInfoLayout( if (groupInfo.isOwner && groupLink != null) { anyTopSectionRowShow = true ChannelLinkButton(manageGroupLink) + SettingsActionItem( + painterResource(MR.images.ic_tag), + stringResource(MR.strings.simplex_name), + setSimplexName, + iconColor = MaterialTheme.colors.secondary + ) } else if (channelLink != null) { anyTopSectionRowShow = true ChannelLinkQRCodeSection(channelLink) @@ -945,6 +973,21 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) ) ChatInfoDescription(cInfo, displayName, copyNameToClipboard) + val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + val domain = access?.groupDomainClaim?.shortName + if (domain != null && (groupInfo.groupDomainVerified != null || access.groupDomainClaim?.proof != null)) { + SimplexNameView( + simplexName = "#${domain}", + verified = groupInfo.groupDomainVerified, + verify = { + val rhId = chatModel.remoteHostId() + chatModel.controller.apiVerifyGroupDomain(rhId, groupInfo.groupId)?.let { (gInfo, reason) -> + chatModel.chatsContext.updateGroup(rhId, gInfo) + gInfo.groupDomainVerified to reason + } + } + ) + } val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage if (webPage != null) { val uriHandler = LocalUriHandler.current @@ -1436,6 +1479,7 @@ fun PreviewGroupChatInfoLayout() { manageGroupLink = {}, manageWebPage = {}, onSearchClicked = {}, + setSimplexName = {}, deletingItems = remember { mutableStateOf(true) } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index c9f7d96f39..c7c96cd731 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -338,13 +338,10 @@ fun MarkdownText ( withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) } withAnnotation("SIMPLEX_NAME") { a -> val idx = a.item.toIntOrNull() - val nameInfo = (idx?.let { formattedText.getOrNull(it) }?.format as? Format.SimplexName)?.nameInfo - val (title, msg) = if (nameInfo?.nameType == SimplexNameType.contact) { - generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version) - } else { - generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version) - } - AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}") + val nameText = idx?.let { formattedText.getOrNull(it) }?.text + // The name string is routed through the same connect path as a + // link; planAndConnect resolves it on the core (name target). + if (nameText != null) uriHandler.openVerifiedSimplexUri(nameText) } } if (hasSecrets) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3012525f9b..99dec506e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -800,7 +800,20 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState searchChatFilteredBySimplexLink.value = null connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() } } - is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo) + is ConnectTarget.Name -> { + // A name lookup means "take me to this contact": open the chat if + // it's already known (visible prompt), unlike a pasted link which + // filters the list. So no filterKnownContact here. + hideKeyboard(view) + withBGApi { + planAndConnect( + chatModel.remoteHostId(), + target.text, + close = null, + cleanup = { searchText.value = TextFieldValue() }, + ) + } + } null -> if (!searchShowingSimplexLink.value || it.isEmpty()) { if (it.isNotEmpty()) { focusRequester.requestFocus() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index e5dbe01d68..3776a8d44d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -31,11 +31,6 @@ suspend fun planAndConnect( filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { when (val target = strConnectTarget(shortOrFullLink.trim())) { - is ConnectTarget.Name -> { - showUnsupportedNameAlert(target.nameInfo) - cleanup?.invoke() - return CompletableDeferred(false) - } is ConnectTarget.Link -> { if (target.linkType == SimplexLinkType.relay) { AlertManager.privacySensitive.showAlertMsg( @@ -46,7 +41,9 @@ suspend fun planAndConnect( return CompletableDeferred(false) } } - null -> {} + // A SimplexName falls through to apiConnectPlan, which resolves it on the + // core (the /_connect plan command accepts a name target, not only a link). + is ConnectTarget.Name, null -> {} } connectProgressManager.cancelConnectProgress() val inProgress = mutableStateOf(true) @@ -94,8 +91,8 @@ private suspend fun planAndConnectTask( connectionLink, connectionPlan.invitationLinkPlan.contactSLinkData_, ownerVerification = connectionPlan.invitationLinkPlan.ownerVerification, - close, - cleanup + close = close, + cleanup = cleanup ) } else { Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, no short link data") @@ -157,6 +154,7 @@ private suspend fun planAndConnectTask( connectionLink, connectionPlan.contactAddressPlan.contactSLinkData_, ownerVerification = connectionPlan.contactAddressPlan.ownerVerification, + verifiedDomain = connectionPlan.contactAddressPlan.verifiedDomain, close, cleanup ) @@ -204,6 +202,12 @@ private suspend fun planAndConnectTask( is ContactAddressPlan.Known -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Known") val contact = connectionPlan.contactAddressPlan.contact + // A name-resolved contact is prepared in the store but not yet in the + // chat list (link-prepared chats arrive via NewPreparedChat). Surface it + // so it's visible and openable; no-op if already present. + if (chatModel.getContactChat(contact.contactId) == null) { + chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList())) + } if (filterKnownContact != null) { filterKnownContact(contact) } else { @@ -228,6 +232,7 @@ private suspend fun planAndConnectTask( connectionPlan.groupLinkPlan.groupSLinkInfo_, connectionPlan.groupLinkPlan.groupSLinkData_, ownerVerification = connectionPlan.groupLinkPlan.ownerVerification, + verifiedDomain = connectionPlan.groupLinkPlan.verifiedDomain, close, cleanup ) @@ -288,6 +293,11 @@ private suspend fun planAndConnectTask( is GroupLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .GroupLink, .Known") val groupInfo = connectionPlan.groupLinkPlan.groupInfo + // Same as ContactAddress.Known: surface a name-resolved (prepared) + // group in the chat list so it's visible and openable. + if (chatModel.getGroupChat(groupInfo.groupId) == null) { + chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(groupInfo, groupChatScope = null), chatItems = emptyList())) + } if (filterKnownGroup != null) { filterKnownGroup(groupInfo) } else { @@ -619,6 +629,7 @@ fun showPrepareContactAlert( connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, ownerVerification: OwnerVerification? = null, + verifiedDomain: SimplexDomain? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { @@ -642,7 +653,7 @@ fun showPrepareContactAlert( AlertManager.privacySensitive.hideAlert() ModalManager.closeAllModalsEverywhere() withBGApi { - val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData) + val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData, verifiedDomain) if (chat != null) { withContext(Dispatchers.Main) { ChatController.chatModel.chatsContext.addChat(chat) @@ -664,6 +675,7 @@ fun showPrepareGroupAlert( groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, ownerVerification: OwnerVerification? = null, + verifiedDomain: SimplexDomain? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { @@ -686,7 +698,7 @@ fun showPrepareGroupAlert( AlertManager.privacySensitive.hideAlert() withBGApi { val directLink = groupShortLinkInfo?.direct ?: true - val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData) + val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData, verifiedDomain) if (chat != null) { withContext(Dispatchers.Main) { val relays = groupShortLinkInfo?.groupRelays diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 68f42f4186..41df280a8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -536,7 +536,20 @@ private fun ContactsSearchBar( cleanup = { searchText.value = TextFieldValue() } ) } - is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo) + is ConnectTarget.Name -> { + // A name lookup means "take me to this contact": open the chat if + // it's already known (visible prompt), unlike a pasted link which + // filters the list. So no filterKnownContact here. + hideKeyboard(view) + withBGApi { + planAndConnect( + chatModel.remoteHostId(), + target.text, + close = close, + cleanup = { searchText.value = TextFieldValue() }, + ) + } + } null -> if (!searchShowingSimplexLink.value || it.isEmpty()) { if (it.isNotEmpty()) { focusRequester.requestFocus() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 6799fa1300..d3bca178aa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -679,7 +679,11 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC showQRCodeScanner.value = false withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } } } - is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo) + is ConnectTarget.Name -> { + pastedLink.value = target.text + showQRCodeScanner.value = false + withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } } + } null -> AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.invalid_contact_link), text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) @@ -824,7 +828,7 @@ fun strIsSimplexLink(str: String): Boolean { sealed class ConnectTarget { class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget() - class Name(val nameInfo: SimplexNameInfo) : ConnectTarget() + class Name(val text: String, val nameInfo: SimplexNameInfo) : ConnectTarget() } fun strConnectTarget(str: String): ConnectTarget? { @@ -835,21 +839,13 @@ fun strConnectTarget(str: String): ConnectTarget? { return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText) } if (links.isEmpty()) { - val nameInfo = parsedMd.firstNotNullOfOrNull { (it.format as? Format.SimplexName)?.nameInfo } - if (nameInfo != null) return ConnectTarget.Name(nameInfo) + val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName } + val nameInfo = (nameFt?.format as? Format.SimplexName)?.nameInfo + if (nameFt != null && nameInfo != null) return ConnectTarget.Name(nameFt.text, nameInfo) } return null } -fun showUnsupportedNameAlert(nameInfo: SimplexNameInfo) { - val (title, msg) = if (nameInfo.nameType == SimplexNameType.contact) { - generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version) - } else { - generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version) - } - AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}") -} - @Composable fun IncognitoToggle( incognitoPref: SharedPreference, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 0b698b2c5d..f8bb2d64f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -134,6 +134,11 @@ fun MorePrivacyView(chatModel: ChatModel) { chatModel.draftChatId.value = null } }) + SettingsPreferenceItem( + painterResource(MR.images.ic_tag), + stringResource(MR.strings.verify_simplex_names), + chatModel.controller.appPrefs.privacyVerifySimplexNames + ) } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt new file mode 100644 index 0000000000..c4cf0d1714 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -0,0 +1,79 @@ +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import chat.simplex.common.platform.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* + +// Set the user's own (prefix "@") or a channel's (prefix "#") SimpleX name. +// The field is prefilled with the full prefixed name; `save` receives the encoded name (or null to +// clear) and returns true on success (it shows its own error alert otherwise). +@Composable +fun SetSimplexDomainView( + title: String, + footer: String, + placeholder: String, + simplexName: String, + save: suspend (String?) -> Boolean, + close: () -> Unit +) { + val name = rememberSaveable { mutableStateOf(simplexName) } + val saving = remember { mutableStateOf(false) } + val unchanged = name.value.trim() == simplexName.trim() + + fun addSimplexTLD(s: String): String { + return if (s.contains(".")) s else "$s.simplex" + } + + fun normalized(): String? { + val s = name.value.trim() + return when { + s.isEmpty() -> null + s.startsWith("@") || s.startsWith("#") -> addSimplexTLD(s.substring(1)) + else -> addSimplexTLD(s) + } + } + + val doSave: () -> Unit = { + withBGApi { + saving.value = true + val ok = try { save(normalized()) } catch (e: Exception) { + Log.e(TAG, "SetSimplexDomainView save: ${e.stackTraceToString()}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), e.message ?: "") + false + } + saving.value = false + if (ok) withContext(Dispatchers.Main) { close() } + } + } + + ModalView(close = close) { + ColumnWithScrollBar { + AppBarTitle(title) + SectionView { + PlainTextEditor(name, placeholder) + } + SectionTextFooter(footer) + SectionDividerSpaced() + SectionView { + SectionItemView(doSave, disabled = unchanged || saving.value) { + Text( + stringResource(MR.strings.save_verb), + color = if (unchanged || saving.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + SectionBottomSpacer() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 36c74cceb6..9d0efad152 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -34,6 +34,8 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.* import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun UserAddressView( @@ -360,6 +362,37 @@ private fun UserAddressLayout( SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) } + SectionDividerSpaced() + SectionView { + SettingsActionItem( + painterResource(MR.images.ic_at), + stringResource(MR.strings.your_simplex_name), + click = { + ModalManager.start.showCustomModal { close -> + val domain = user?.profile?.contactDomain?.shortName + SetSimplexDomainView( + title = generalGetString(MR.strings.set_simplex_name), + footer = generalGetString(MR.strings.set_user_simplex_name_footer), + placeholder = "@yourname.testing", + simplexName = if (domain == null) "" else "@$domain", + save = { simplexDomain -> + try { + val u = chatModel.controller.apiSetUserDomain(user?.remoteHostId, simplexDomain) + withContext(Dispatchers.Main) { chatModel.updateUser(u) } + true + } catch (e: Exception) { + Log.e(TAG, "apiSetUserDomain: ${e.message}") + false + } + }, + close = close + ) + } + }, + iconColor = MaterialTheme.colors.secondary + ) + } + SectionDividerSpaced() SectionView(generalGetString(MR.strings.or_to_share_privately)) { CreateOneTimeLinkButton() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 892a252a9d..556c7270cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -268,28 +268,32 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (currentRemoteHost == null && networkUseSocksProxy.value) { SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) } - val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value) - SectionItemView( - { scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } }, - disabled = saveDisabled, - ) { - Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + SectionDividerSpaced() + SectionView { + val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value) + SectionItemView( + { scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } }, + disabled = saveDisabled, + ) { + Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + } } - val serversErr = globalServersError(serverErrors.value) - if (serversErr != null) { - SectionCustomFooter { - ServersErrorFooter(serversErr) + val serversErrs = globalServersErrors(serverErrors.value) + if (serversErrs.isNotEmpty()) { + serversErrs.forEach { err -> + SectionCustomFooter { + ServersErrorFooter(err) + } } } else if (serverErrors.value.isNotEmpty()) { SectionCustomFooter { ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration)) } } - val serversWarn = globalServersWarning(serverWarnings.value) - if (serversWarn != null) { + globalServersWarnings(serverWarnings.value).forEach { warn -> SectionCustomFooter { - ServersWarningFooter(serversWarn) + ServersWarningFooter(warn) } } @@ -951,23 +955,11 @@ fun serversCanBeSaved( return userServers != currUserServers && serverErrors.isEmpty() } -fun globalServersError(serverErrors: List): String? { - for (err in serverErrors) { - if (err.globalError != null) { - return err.globalError - } - } - return null -} +fun globalServersErrors(serverErrors: List): List = + serverErrors.mapNotNull { it.globalError } -fun globalServersWarning(serverWarnings: List): String? { - for (warn in serverWarnings) { - if (warn.globalWarning != null) { - return warn.globalWarning - } - } - return null -} +fun globalServersWarnings(serverWarnings: List): List = + serverWarnings.mapNotNull { it.globalWarning } fun globalSMPServersError(serverErrors: List): String? { for (err in serverErrors) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index f5bceabbf1..53277c9ccd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -211,15 +211,19 @@ fun OperatorViewLayout( rhId = rhId ) } - val serversErr = globalServersError(serverErrors.value) - val serversWarn = globalServersWarning(serverWarnings.value) - if (serversErr != null) { - SectionCustomFooter { - ServersErrorFooter(serversErr) + val serversErrs = globalServersErrors(serverErrors.value) + val serversWarns = globalServersWarnings(serverWarnings.value) + if (serversErrs.isNotEmpty()) { + serversErrs.forEach { err -> + SectionCustomFooter { + ServersErrorFooter(err) + } } - } else if (serversWarn != null) { - SectionCustomFooter { - ServersWarningFooter(serversWarn) + } else if (serversWarns.isNotEmpty()) { + serversWarns.forEach { warn -> + SectionCustomFooter { + ServersWarningFooter(warn) + } } } else { val footerText = when (val c = operator.conditionsAcceptance) { @@ -267,7 +271,7 @@ fun OperatorViewLayout( userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( operator = this[operatorIndex].operator?.copy( - smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false) + smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false) ) ) } @@ -287,7 +291,27 @@ fun OperatorViewLayout( userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( operator = this[operatorIndex].operator?.copy( - smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled) + smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled, names = false) + ) + ) + } + } + ) + } + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_for_names), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator_.smpRoles.names, + onCheckedChange = { enabled -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy( + smpRoles = this[operatorIndex].operator?.smpRoles?.copy(names = enabled) ?: ServerRoles(storage = false, proxy = false, names = enabled) ) ) } @@ -371,7 +395,7 @@ fun OperatorViewLayout( userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( operator = this[operatorIndex].operator?.copy( - xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false) + xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false) ) ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 280cd7bedb..63365bd080 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -185,16 +185,14 @@ fun YourServersViewLayout( iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - val serversErr = globalServersError(serverErrors.value) - if (serversErr != null) { + globalServersErrors(serverErrors.value).forEach { err -> SectionCustomFooter { - ServersErrorFooter(serversErr) + ServersErrorFooter(err) } } - val serversWarn = globalServersWarning(serverWarnings.value) - if (serversWarn != null) { + globalServersWarnings(serverWarnings.value).forEach { warn -> SectionCustomFooter { - ServersWarningFooter(serversWarn) + ServersWarningFooter(warn) } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 9262bd9dac..96c7226c36 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -146,6 +146,7 @@ For chat profile %s: Errors in servers configuration. No chat relays enabled. + No servers to resolve names. Server warning Error accepting conditions Spam @@ -199,6 +200,16 @@ Connecting via channel name requires a newer app version. Connecting via contact name requires a newer app version. Please upgrade the app. + SimpleX name error + None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link. + Server %1$s does not support name resolution. Configure servers, or use a connection link. + Name not found + This SimpleX name is not registered. Please check the name. + Resolver error: %1$s + No valid link + The SimpleX name %1$s is registered, but it has no valid link. + Unconfirmed name + The SimpleX name %1$s is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner. Channel temporarily unavailable Channel has no active relays. Please try to join later. App update required @@ -930,6 +941,17 @@ One-time invitation link 1-time link SimpleX address + Verify name + Verify SimpleX names + SimpleX name not verified + SimpleX name + Your SimpleX name + Set SimpleX name + Error saving name + The SimpleX name %1$s is registered without channel link. Add channel link to the name via the registration page. + The SimpleX name %1$s is registered without SimpleX address. Add your SimpleX address to the name via the registration page. + Let people connect to you via name registered with your SimpleX address. + Let people join via name registered with this channel link. Or show this code Full link Short link @@ -2153,6 +2175,7 @@ Use for messages To receive For private routing + To resolve names Added message servers Use for files To send diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index bedcb87da3..d1467d48fd 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -560,7 +560,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName <> ".\nIt is hidden from the directory until approved." notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " is updated" <> byMember <> "." sendToApprove g' gr' n' - sendChatCmd cc (APIConnectPlan userId (Just link) True Nothing) >>= \case + sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) True Nothing) >>= \case Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) -> case dbOwnerMemberId gr of Just ownerGMId -> @@ -818,7 +818,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName forM_ pg_ $ \pg@PublicGroupProfile {groupLink} -> when (groupRegStatus == GRSActive || pendingApproval groupRegStatus) $ do let link = ACL SCMContact $ CLShort groupLink - sendChatCmd cc (APIConnectPlan userId (Just link) True Nothing) >>= \case + sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) True Nothing) >>= \case Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated = BoolDef updated, linkOwners = ListDef owners}))) -> checkValidOwner dbOwnerMemberId owners $ do when updated $ reapprove pg gr groupRegStatus g' @@ -954,7 +954,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let link = ACL SCMContact $ CLShort connLink mId = MemberId oIdBytes gt' = groupTypeStr gt - sendChatCmd cc (APIConnectPlan userId (Just link) True (Just ownerSig)) >>= \case + sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) True (Just ownerSig)) >>= \case Right (CRConnectionPlan _ (ACCL SCMContact ccLink) plan) -> handleGroupLinkPlan ct ccLink mId ownerSig gt' plan _ -> sendMessage cc ct "Error: could not connect. Please report it to directory admins." @@ -1000,7 +1000,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let GroupShortLinkData {groupProfile = GroupProfile {displayName}} = groupSLinkData ownerContact = GroupOwnerContact {contactId = contactId' ct, memberId = mId} sendMessage cc ct $ "Joining the " <> gt <> " " <> displayName <> "…" - sendChatCmd cc (APIPrepareGroup userId ccLink False groupSLinkData) >>= \case + sendChatCmd cc (APIPrepareGroup userId ccLink False Nothing groupSLinkData) >>= \case Right (CRNewPreparedChat _ (AChat SCTGroup (Chat (GroupChat gInfo _) _ _))) -> do let gId = groupId' gInfo addGroupReg notifyAdminUsers st cc user ct gInfo GRSProposed $ \_ -> pure () diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index d14435cabd..8dc57dbfc7 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -1363,28 +1363,28 @@ ChatCmdError: Command error (only used in WebSockets API). ### APIConnectPlan -Determine SimpleX link type and if the bot is already connected via this link. +Determine SimpleX link type and if the bot is already connected via this link or name. *Network usage*: interactive. **Parameters**: - userId: int64 -- connectionLink: string? +- connectTarget: string? - resolveKnown: bool - linkOwnerSig: [LinkOwnerSig](./TYPES.md#linkownersig)? **Syntax**: ``` -/_connect plan +/_connect plan ``` ```javascript -'/_connect plan ' + userId + ' ' + connectionLink // JavaScript +'/_connect plan ' + userId + ' ' + connectTarget // JavaScript ``` ```python -'/_connect plan ' + str(userId) + ' ' + connectionLink # Python +'/_connect plan ' + str(userId) + ' ' + connectTarget # Python ``` **Responses**: @@ -1455,26 +1455,26 @@ ChatCmdError: Command error (only used in WebSockets API). ### Connect -Connect via SimpleX link as string in the active user profile. +Connect via SimpleX link or name as string in the active user profile. *Network usage*: interactive. **Parameters**: - incognito: bool -- connLink_: string? +- connTarget_: string? **Syntax**: ``` -/connect[ ] +/connect[ ] ``` ```javascript -'/connect' + (connLink_ ? ' ' + connLink_ : '') // JavaScript +'/connect' + (connTarget_ ? ' ' + connTarget_ : '') // JavaScript ``` ```python -'/connect' + ((' ' + connLink_) if connLink_ is not None else '') # Python +'/connect' + ((' ' + connTarget_) if connTarget_ is not None else '') # Python ``` **Responses**: diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index b38e3f6c6e..d9dfa45961 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -139,6 +139,7 @@ This file is generated automatically. - [MsgReaction](#msgreaction) - [MsgReceiptStatus](#msgreceiptstatus) - [MsgSigStatus](#msgsigstatus) +- [NameErrorType](#nameerrortype) - [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) @@ -172,8 +173,11 @@ This file is generated automatically. - [SMPAgentError](#smpagenterror) - [SecurityCode](#securitycode) - [SimplePreference](#simplepreference) +- [SimplexDomain](#simplexdomain) +- [SimplexDomainClaim](#simplexdomainclaim) +- [SimplexDomainError](#simplexdomainerror) +- [SimplexDomainProof](#simplexdomainproof) - [SimplexLinkType](#simplexlinktype) -- [SimplexNameDomain](#simplexnamedomain) - [SimplexNameInfo](#simplexnameinfo) - [SimplexNameType](#simplexnametype) - [SimplexTLD](#simplextld) @@ -313,6 +317,9 @@ FILE: - type: "FILE" - fileErr: [FileErrorType](#fileerrortype) +NO_NAME_SERVERS: +- type: "NO_NAME_SERVERS" + PROXY: - type: "PROXY" - proxyServer: string @@ -1104,6 +1111,11 @@ ChatStoreChanged: InvalidConnReq: - type: "invalidConnReq" +SimplexDomainNotReady: +- type: "simplexDomainNotReady" +- simplexDomain: [SimplexDomain](#simplexdomain) +- simplexDomainError: [SimplexDomainError](#simplexdomainerror) + UnsupportedConnReq: - type: "unsupportedConnReq" @@ -1784,6 +1796,7 @@ Ok: - type: "ok" - contactSLinkData_: [ContactShortLinkData](#contactshortlinkdata)? - ownerVerification: [OwnerVerification](#ownerverification)? +- verifiedDomain: [SimplexDomain](#simplexdomain)? OwnLink: - type: "ownLink" @@ -1976,6 +1989,10 @@ EXPIRED: INTERNAL: - type: "INTERNAL" +NAME: +- type: "NAME" +- nameErr: [NameErrorType](#nameerrortype) + DUPLICATE_: - type: "DUPLICATE_" @@ -2330,6 +2347,7 @@ MemberSupport: - membersRequireAttention: int - viaGroupLinkUri: string? - groupKeys: [GroupKeys](#groupkeys)? +- groupDomainVerified: bool? --- @@ -2375,6 +2393,7 @@ Ok: - groupSLinkInfo_: [GroupShortLinkInfo](#groupshortlinkinfo)? - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? - ownerVerification: [OwnerVerification](#ownerverification)? +- verifiedDomain: [SimplexDomain](#simplexdomain)? OwnLink: - type: "ownLink" @@ -2753,6 +2772,8 @@ Unknown: - peerType: [ChatPeerType](#chatpeertype)? - localBadge: [LocalBadge](#localbadge)? - localAlias: string +- contactDomain: [SimplexDomainClaim](#simplexdomainclaim)? +- contactDomainVerified: bool? --- @@ -2928,6 +2949,23 @@ Unknown: - "signedNoKey" +--- + +## NameErrorType + +**Discriminated union type**: + +NO_RESOLVER: +- type: "NO_RESOLVER" + +NOT_FOUND: +- type: "NOT_FOUND" + +RESOLVER: +- type: "RESOLVER" +- resolverErr: string + + --- ## NetworkError @@ -3098,6 +3136,7 @@ count= - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? - badge: [BadgeProof](#badgeproof)? +- contactDomain: [SimplexDomainClaim](#simplexdomainclaim)? --- @@ -3146,7 +3185,7 @@ NO_SESSION: **Record type**: - groupWebPage: string? -- groupDomain: string? +- groupDomainClaim: [SimplexDomainClaim](#simplexdomainclaim)? - domainWebPage: bool - allowEmbedding: bool @@ -3530,6 +3569,48 @@ A_QUEUE: - allow: [FeatureAllowed](#featureallowed) +--- + +## SimplexDomain + +**Record type**: +- nameTLD: [SimplexTLD](#simplextld) +- domain: string +- subDomain: [string] + + +--- + +## SimplexDomainClaim + +**Record type**: +- domain: string +- proof: [SimplexDomainProof](#simplexdomainproof)? + + +--- + +## SimplexDomainError + +**Discriminated union type**: + +NoValidLink: +- type: "noValidLink" + +UnknownDomain: +- type: "unknownDomain" + + +--- + +## SimplexDomainProof + +**Record type**: +- linkOwnerId: string? +- presHeader: string +- signature: string + + --- ## SimplexLinkType @@ -3542,23 +3623,13 @@ A_QUEUE: - "relay" ---- - -## SimplexNameDomain - -**Record type**: -- nameTLD: [SimplexTLD](#simplextld) -- domain: string -- subDomain: [string] - - --- ## SimplexNameInfo **Record type**: - nameType: [SimplexNameType](#simplexnametype) -- nameDomain: [SimplexNameDomain](#simplexnamedomain) +- nameDomain: [SimplexDomain](#simplexdomain) --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 8ebd510a55..ddfc817fa2 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -135,10 +135,10 @@ chatCommandsDocsData = ( "Connection commands", "These commands may be used to create connections. Most bots do not need to use them - bot users will connect via bot address with auto-accept enabled.", [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), - -- `Maybe` in `connectionLink :: Maybe AConnectionLink` is used to signal link parsing error to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers. - ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectionLink"), + -- `Maybe` in `connectTarget :: Maybe ConnectTarget` is used to signal parse failure to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers. + ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link or name.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectTarget"), ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), - ("Connect", [], "Connect via SimpleX link as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connLink_"), + ("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"), ("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"), ("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId") ] @@ -410,9 +410,11 @@ undocumentedCommands = "APISetMemberSettings", "APISetNetworkConfig", "APISetNetworkInfo", + "APISetPublicGroupAccess", "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", + "APISetUserDomain", "APISetUserServers", "APISetUserUIThemes", "APIShareChatMsgContent", @@ -432,7 +434,9 @@ undocumentedCommands = "APIUserRead", "APIValidateServers", "APIVerifyContact", + "APIVerifyContactDomain", "APIVerifyGroupMember", + "APIVerifyGroupDomain", "APIVerifyToken", "CheckChatRunning", "ConfirmRemoteCtrl", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index ddd127241b..f897fc3908 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -148,6 +148,7 @@ undocumentedResponses = "CRContactAliasUpdated", "CRContactCode", "CRContactInfo", + "CRContactDomainVerified", "CRContactRatchetSyncStarted", "CRContactSwitchAborted", "CRContactSwitchStarted", @@ -167,6 +168,7 @@ undocumentedResponses = "CRGroupMemberRatchetSyncStarted", "CRGroupMemberSwitchAborted", "CRGroupMemberSwitchStarted", + "CRGroupDomainVerified", "CRGroupProfile", "CRGroupUserChanged", "CRItemsReadForChat", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 6313c68838..b28c71d8fd 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -34,7 +34,8 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) -import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), BadgeType (..), JSONBadge (..)) +import Simplex.Chat.Badges +import Simplex.Chat.Names import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -45,7 +46,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Client import Simplex.Messaging.Crypto.File import Simplex.Messaging.Parsers (dropPrefix, fstToLower) -import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NetworkError (..), ProxyError (..)) +import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NameErrorType (..), NetworkError (..), ProxyError (..)) import Simplex.Messaging.Protocol.Types (ClientNotice (..)) import Simplex.Messaging.Transport import Simplex.RemoteControl.Types @@ -321,6 +322,7 @@ chatTypesDocsData = (sti @MsgReaction, STUnion, "MR", [], "", ""), (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), (sti @MsgSigStatus, STEnum, "MSS", [], "", ""), + (sti @NameErrorType, STUnion, "", [], "", ""), (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), @@ -353,8 +355,11 @@ chatTypesDocsData = (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), + (sti @SimplexDomain, STRecord, "", [], "", ""), + (sti @SimplexDomainClaim, STRecord, "", [], "", ""), + (sti @SimplexDomainError, STUnion, "SDE", [], "", ""), + (sti @SimplexDomainProof, STRecord, "", [], "", ""), (sti @SimplexLinkType, STEnum, "XL", [], "", ""), - (sti @SimplexNameDomain, STRecord, "", [], "", ""), (sti @SimplexNameInfo, STRecord, "", [], "", ""), (sti @SimplexNameType, STEnum, "NT", [], "", ""), (sti @SimplexTLD, STEnum, "TLD", [], "", ""), @@ -548,6 +553,7 @@ deriving instance Generic MsgFilter deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus deriving instance Generic MsgSigStatus +deriving instance Generic NameErrorType deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder @@ -578,8 +584,11 @@ deriving instance Generic RelayProfile deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode +deriving instance Generic SimplexDomain +deriving instance Generic SimplexDomainClaim +deriving instance Generic SimplexDomainError +deriving instance Generic SimplexDomainProof deriving instance Generic SimplexLinkType -deriving instance Generic SimplexNameDomain deriving instance Generic SimplexNameInfo deriving instance Generic SimplexNameType deriving instance Generic SimplexTLD diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index c5a3b11953..f949a2f92f 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -194,6 +194,7 @@ toTypeInfo tr = primitiveToLower st@(ST t ps) = let t' = fstToLower t in if t' `elem` primitiveTypes then ST t' ps else st stringTypes = [ "AConnectionLink", + "AConnectTarget", "AProtocolType", "AgentConnId", "AgentInvId", @@ -215,11 +216,13 @@ toTypeInfo tr = "Text", "MREmojiChar", "PrivateKey", + "ProofPresHeader", "PublicKey", "ProtocolServer", "SbKey", "SharedMsgId", "Signature", + "StrJSON", "TransportHost", "UIColor", "UserPwd", diff --git a/cabal.project b/cabal.project index 516d029b11..bd8e4ac384 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 98391fd677f547f610a17c48cc96d8c4b2d5e96e + tag: 836254a4c6bd5e17b40acbcaf77a83802d44919e source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index d1b89ffe27..606b09845d 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -495,11 +495,11 @@ export namespace APIAddContact { } } -// Determine SimpleX link type and if the bot is already connected via this link. +// Determine SimpleX link type and if the bot is already connected via this link or name. // Network usage: interactive. export interface APIConnectPlan { userId: number // int64 - connectionLink?: string + connectTarget?: string resolveKnown: boolean linkOwnerSig?: T.LinkOwnerSig } @@ -508,7 +508,7 @@ export namespace APIConnectPlan { export type Response = CR.ConnectionPlan | CR.ChatCmdError export function cmdString(self: APIConnectPlan): string { - return '/_connect plan ' + self.userId + ' ' + self.connectionLink + return '/_connect plan ' + self.userId + ' ' + self.connectTarget } } @@ -528,18 +528,18 @@ export namespace APIConnect { } } -// Connect via SimpleX link as string in the active user profile. +// Connect via SimpleX link or name as string in the active user profile. // Network usage: interactive. export interface Connect { incognito: boolean - connLink_?: string + connTarget_?: string } export namespace Connect { export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError export function cmdString(self: Connect): string { - return '/connect' + (self.connLink_ ? ' ' + self.connLink_ : '') + return '/connect' + (self.connTarget_ ? ' ' + self.connTarget_ : '') } } diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 323f174f91..8bd9e7797e 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -66,6 +66,7 @@ export type AgentErrorType = | AgentErrorType.NTF | AgentErrorType.XFTP | AgentErrorType.FILE + | AgentErrorType.NO_NAME_SERVERS | AgentErrorType.PROXY | AgentErrorType.RCP | AgentErrorType.BROKER @@ -84,6 +85,7 @@ export namespace AgentErrorType { | "NTF" | "XFTP" | "FILE" + | "NO_NAME_SERVERS" | "PROXY" | "RCP" | "BROKER" @@ -136,6 +138,10 @@ export namespace AgentErrorType { fileErr: FileErrorType } + export interface NO_NAME_SERVERS extends Interface { + type: "NO_NAME_SERVERS" + } + export interface PROXY extends Interface { type: "PROXY" proxyServer: string @@ -1036,6 +1042,7 @@ export type ChatErrorType = | ChatErrorType.ChatNotStopped | ChatErrorType.ChatStoreChanged | ChatErrorType.InvalidConnReq + | ChatErrorType.SimplexDomainNotReady | ChatErrorType.UnsupportedConnReq | ChatErrorType.ConnReqMessageProhibited | ChatErrorType.ContactNotReady @@ -1113,6 +1120,7 @@ export namespace ChatErrorType { | "chatNotStopped" | "chatStoreChanged" | "invalidConnReq" + | "simplexDomainNotReady" | "unsupportedConnReq" | "connReqMessageProhibited" | "contactNotReady" @@ -1267,6 +1275,12 @@ export namespace ChatErrorType { type: "invalidConnReq" } + export interface SimplexDomainNotReady extends Interface { + type: "simplexDomainNotReady" + simplexDomain: SimplexDomain + simplexDomainError: SimplexDomainError + } + export interface UnsupportedConnReq extends Interface { type: "unsupportedConnReq" } @@ -2035,6 +2049,7 @@ export namespace ContactAddressPlan { type: "ok" contactSLinkData_?: ContactShortLinkData ownerVerification?: OwnerVerification + verifiedDomain?: SimplexDomain } export interface OwnLink extends Interface { @@ -2157,6 +2172,7 @@ export type ErrorType = | ErrorType.LARGE_MSG | ErrorType.EXPIRED | ErrorType.INTERNAL + | ErrorType.NAME | ErrorType.DUPLICATE_ export namespace ErrorType { @@ -2175,6 +2191,7 @@ export namespace ErrorType { | "LARGE_MSG" | "EXPIRED" | "INTERNAL" + | "NAME" | "DUPLICATE_" interface Interface { @@ -2241,6 +2258,11 @@ export namespace ErrorType { type: "INTERNAL" } + export interface NAME extends Interface { + type: "NAME" + nameErr: NameErrorType + } + export interface DUPLICATE_ extends Interface { type: "DUPLICATE_" } @@ -2608,6 +2630,7 @@ export interface GroupInfo { membersRequireAttention: number // int viaGroupLinkUri?: string groupKeys?: GroupKeys + groupDomainVerified?: boolean } export interface GroupKeys { @@ -2658,6 +2681,7 @@ export namespace GroupLinkPlan { groupSLinkInfo_?: GroupShortLinkInfo groupSLinkData_?: GroupShortLinkData ownerVerification?: OwnerVerification + verifiedDomain?: SimplexDomain } export interface OwnLink extends Interface { @@ -2984,6 +3008,8 @@ export interface LocalProfile { peerType?: ChatPeerType localBadge?: LocalBadge localAlias: string + contactDomain?: SimplexDomainClaim + contactDomainVerified?: boolean } export enum MemberCriteria { @@ -3177,6 +3203,29 @@ export enum MsgSigStatus { SignedNoKey = "signedNoKey", } +export type NameErrorType = NameErrorType.NO_RESOLVER | NameErrorType.NOT_FOUND | NameErrorType.RESOLVER + +export namespace NameErrorType { + export type Tag = "NO_RESOLVER" | "NOT_FOUND" | "RESOLVER" + + interface Interface { + type: Tag + } + + export interface NO_RESOLVER extends Interface { + type: "NO_RESOLVER" + } + + export interface NOT_FOUND extends Interface { + type: "NOT_FOUND" + } + + export interface RESOLVER extends Interface { + type: "RESOLVER" + resolverErr: string + } +} + export type NetworkError = | NetworkError.ConnectError | NetworkError.TLSError @@ -3335,6 +3384,7 @@ export interface Profile { preferences?: Preferences peerType?: ChatPeerType badge?: BadgeProof + contactDomain?: SimplexDomainClaim } export type ProxyClientError = @@ -3395,7 +3445,7 @@ export namespace ProxyError { export interface PublicGroupAccess { groupWebPage?: string - groupDomain?: string + groupDomainClaim?: SimplexDomainClaim domainWebPage: boolean allowEmbedding: boolean } @@ -3898,6 +3948,41 @@ export interface SimplePreference { allow: FeatureAllowed } +export interface SimplexDomain { + nameTLD: SimplexTLD + domain: string + subDomain: string[] +} + +export interface SimplexDomainClaim { + domain: string + proof?: SimplexDomainProof +} + +export type SimplexDomainError = SimplexDomainError.NoValidLink | SimplexDomainError.UnknownDomain + +export namespace SimplexDomainError { + export type Tag = "noValidLink" | "unknownDomain" + + interface Interface { + type: Tag + } + + export interface NoValidLink extends Interface { + type: "noValidLink" + } + + export interface UnknownDomain extends Interface { + type: "unknownDomain" + } +} + +export interface SimplexDomainProof { + linkOwnerId?: string + presHeader: string + signature: string +} + export enum SimplexLinkType { Contact = "contact", Invitation = "invitation", @@ -3906,15 +3991,9 @@ export enum SimplexLinkType { Relay = "relay", } -export interface SimplexNameDomain { - nameTLD: SimplexTLD - domain: string - subDomain: string[] -} - export interface SimplexNameInfo { nameType: SimplexNameType - nameDomain: SimplexNameDomain + nameDomain: SimplexDomain } export enum SimplexNameType { diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 3847f44811..90f9801b42 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -434,17 +434,17 @@ def APIAddContact_cmd_string(self: APIAddContact) -> str: APIAddContact_Response = CR.Invitation | CR.ChatCmdError -# Determine SimpleX link type and if the bot is already connected via this link. +# Determine SimpleX link type and if the bot is already connected via this link or name. # Network usage: interactive. class APIConnectPlan(TypedDict): userId: int # int64 - connectionLink: NotRequired[str] + connectTarget: NotRequired[str] resolveKnown: bool linkOwnerSig: NotRequired["T.LinkOwnerSig"] def APIConnectPlan_cmd_string(self: APIConnectPlan) -> str: - return '/_connect plan ' + str(self['userId']) + ' ' + self.get('connectionLink') + return '/_connect plan ' + str(self['userId']) + ' ' + self.get('connectTarget') APIConnectPlan_Response = CR.ConnectionPlan | CR.ChatCmdError @@ -463,15 +463,15 @@ def APIConnect_cmd_string(self: APIConnect) -> str: APIConnect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError -# Connect via SimpleX link as string in the active user profile. +# Connect via SimpleX link or name as string in the active user profile. # Network usage: interactive. class Connect(TypedDict): incognito: bool - connLink_: NotRequired[str] + connTarget_: NotRequired[str] def Connect_cmd_string(self: Connect) -> str: - return '/connect' + ((' ' + self.get('connLink_')) if self.get('connLink_') is not None else '') + return '/connect' + ((' ' + self.get('connTarget_')) if self.get('connTarget_') is not None else '') Connect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 25825d8f98..4003260ad6 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -78,6 +78,9 @@ class AgentErrorType_FILE(TypedDict): type: Literal["FILE"] fileErr: "FileErrorType" +class AgentErrorType_NO_NAME_SERVERS(TypedDict): + type: Literal["NO_NAME_SERVERS"] + class AgentErrorType_PROXY(TypedDict): type: Literal["PROXY"] proxyServer: str @@ -123,6 +126,7 @@ AgentErrorType = ( | AgentErrorType_NTF | AgentErrorType_XFTP | AgentErrorType_FILE + | AgentErrorType_NO_NAME_SERVERS | AgentErrorType_PROXY | AgentErrorType_RCP | AgentErrorType_BROKER @@ -133,7 +137,7 @@ AgentErrorType = ( | AgentErrorType_INACTIVE ) -AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FILE", "PROXY", "RCP", "BROKER", "AGENT", "NOTICE", "INTERNAL", "CRITICAL", "INACTIVE"] +AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FILE", "NO_NAME_SERVERS", "PROXY", "RCP", "BROKER", "AGENT", "NOTICE", "INTERNAL", "CRITICAL", "INACTIVE"] class AutoAccept(TypedDict): acceptIncognito: bool @@ -784,6 +788,11 @@ class ChatErrorType_chatStoreChanged(TypedDict): class ChatErrorType_invalidConnReq(TypedDict): type: Literal["invalidConnReq"] +class ChatErrorType_simplexDomainNotReady(TypedDict): + type: Literal["simplexDomainNotReady"] + simplexDomain: "SimplexDomain" + simplexDomainError: "SimplexDomainError" + class ChatErrorType_unsupportedConnReq(TypedDict): type: Literal["unsupportedConnReq"] @@ -1014,6 +1023,7 @@ ChatErrorType = ( | ChatErrorType_chatNotStopped | ChatErrorType_chatStoreChanged | ChatErrorType_invalidConnReq + | ChatErrorType_simplexDomainNotReady | ChatErrorType_unsupportedConnReq | ChatErrorType_connReqMessageProhibited | ChatErrorType_contactNotReady @@ -1070,7 +1080,7 @@ ChatErrorType = ( | ChatErrorType_exception ) -ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] @@ -1418,6 +1428,7 @@ class ContactAddressPlan_ok(TypedDict): type: Literal["ok"] contactSLinkData_: NotRequired["ContactShortLinkData"] ownerVerification: NotRequired["OwnerVerification"] + verifiedDomain: NotRequired["SimplexDomain"] class ContactAddressPlan_ownLink(TypedDict): type: Literal["ownLink"] @@ -1553,6 +1564,10 @@ class ErrorType_EXPIRED(TypedDict): class ErrorType_INTERNAL(TypedDict): type: Literal["INTERNAL"] +class ErrorType_NAME(TypedDict): + type: Literal["NAME"] + nameErr: "NameErrorType" + class ErrorType_DUPLICATE_(TypedDict): type: Literal["DUPLICATE_"] @@ -1571,10 +1586,11 @@ ErrorType = ( | ErrorType_LARGE_MSG | ErrorType_EXPIRED | ErrorType_INTERNAL + | ErrorType_NAME | ErrorType_DUPLICATE_ ) -ErrorType_Tag = Literal["BLOCK", "SESSION", "CMD", "PROXY", "AUTH", "BLOCKED", "SERVICE", "CRYPTO", "QUOTA", "STORE", "NO_MSG", "LARGE_MSG", "EXPIRED", "INTERNAL", "DUPLICATE_"] +ErrorType_Tag = Literal["BLOCK", "SESSION", "CMD", "PROXY", "AUTH", "BLOCKED", "SERVICE", "CRYPTO", "QUOTA", "STORE", "NO_MSG", "LARGE_MSG", "EXPIRED", "INTERNAL", "NAME", "DUPLICATE_"] FeatureAllowed = Literal["always", "yes", "no"] @@ -1828,6 +1844,7 @@ class GroupInfo(TypedDict): membersRequireAttention: int # int viaGroupLinkUri: NotRequired[str] groupKeys: NotRequired["GroupKeys"] + groupDomainVerified: NotRequired[bool] class GroupKeys(TypedDict): publicGroupId: str @@ -1851,6 +1868,7 @@ class GroupLinkPlan_ok(TypedDict): groupSLinkInfo_: NotRequired["GroupShortLinkInfo"] groupSLinkData_: NotRequired["GroupShortLinkData"] ownerVerification: NotRequired["OwnerVerification"] + verifiedDomain: NotRequired["SimplexDomain"] class GroupLinkPlan_ownLink(TypedDict): type: Literal["ownLink"] @@ -2089,6 +2107,8 @@ class LocalProfile(TypedDict): peerType: NotRequired["ChatPeerType"] localBadge: NotRequired["LocalBadge"] localAlias: str + contactDomain: NotRequired["SimplexDomainClaim"] + contactDomainVerified: NotRequired[bool] MemberCriteria = Literal["all"] @@ -2221,6 +2241,20 @@ MsgReceiptStatus = Literal["ok", "badMsgHash"] MsgSigStatus = Literal["verified", "signedNoKey"] +class NameErrorType_NO_RESOLVER(TypedDict): + type: Literal["NO_RESOLVER"] + +class NameErrorType_NOT_FOUND(TypedDict): + type: Literal["NOT_FOUND"] + +class NameErrorType_RESOLVER(TypedDict): + type: Literal["RESOLVER"] + resolverErr: str + +NameErrorType = NameErrorType_NO_RESOLVER | NameErrorType_NOT_FOUND | NameErrorType_RESOLVER + +NameErrorType_Tag = Literal["NO_RESOLVER", "NOT_FOUND", "RESOLVER"] + class NetworkError_connectError(TypedDict): type: Literal["connectError"] connectError: str @@ -2340,6 +2374,7 @@ class Profile(TypedDict): preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] badge: NotRequired["BadgeProof"] + contactDomain: NotRequired["SimplexDomainClaim"] class ProxyClientError_protocolError(TypedDict): type: Literal["protocolError"] @@ -2381,7 +2416,7 @@ ProxyError_Tag = Literal["PROTOCOL", "BROKER", "BASIC_AUTH", "NO_SESSION"] class PublicGroupAccess(TypedDict): groupWebPage: NotRequired[str] - groupDomain: NotRequired[str] + groupDomainClaim: NotRequired["SimplexDomainClaim"] domainWebPage: bool allowEmbedding: bool @@ -2724,16 +2759,35 @@ class SecurityCode(TypedDict): class SimplePreference(TypedDict): allow: "FeatureAllowed" -SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"] - -class SimplexNameDomain(TypedDict): +class SimplexDomain(TypedDict): nameTLD: "SimplexTLD" domain: str subDomain: list[str] +class SimplexDomainClaim(TypedDict): + domain: str + proof: NotRequired["SimplexDomainProof"] + +class SimplexDomainError_noValidLink(TypedDict): + type: Literal["noValidLink"] + +class SimplexDomainError_unknownDomain(TypedDict): + type: Literal["unknownDomain"] + +SimplexDomainError = SimplexDomainError_noValidLink | SimplexDomainError_unknownDomain + +SimplexDomainError_Tag = Literal["noValidLink", "unknownDomain"] + +class SimplexDomainProof(TypedDict): + linkOwnerId: NotRequired[str] + presHeader: str + signature: str + +SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"] + class SimplexNameInfo(TypedDict): nameType: "SimplexNameType" - nameDomain: "SimplexNameDomain" + nameDomain: "SimplexDomain" SimplexNameType = Literal["publicGroup", "contact"] diff --git a/plans/2026-06-25-name-resolution.md b/plans/2026-06-25-name-resolution.md new file mode 100644 index 0000000000..737de7fe99 --- /dev/null +++ b/plans/2026-06-25-name-resolution.md @@ -0,0 +1,506 @@ +# SimpleX names — simplification plan + +Status: design agreed while reviewing branch `sh/namespace`. This supersedes the +name handling currently on that branch. Line refs are to the working tree at +review time; re-check before editing. + +## 1. Goal + +Reduce the feature to the minimum coherent, secure shape: + +- **One** name per entity, on the **profile** (the entity's claimed identity), + typed `Maybe SimplexNameInfo`. No second "locally-known" copy on the entity row. +- A local **verification status** as a 3-state `Maybe Bool` (not a timestamp): + not-attempted / failed / verified. A failed check never blocks connecting. +- Connect-by-name requires the entity to claim the name, and the result is created as verified. +- The **proof** (address-key signature, context-bound) is **in scope for link + contexts** — connect-by-name, 1-time invitations, contact addresses, channel + join links — so those names are verifiable in this release. Only a name with + **no link context** (a group member, or a name on an `XInfo` profile over an + established connection) is deferred: stored, but not shown until that gap closes. + +Removed from the current branch: the `*.simplex_name` entity columns (the +"locally-known/ct" copy), the `connections.simplex_name` carrier, the four +partial UNIQUE indexes + "newer-claim-wins" clearing, and the `_verified_at` +timestamps. + +## 2. Why (trust model) + +A name resolves to a link via the agent (`resolveSimplexName`); a `NameRecord` +has **lists** of links (`nrSimplexContact`, `nrSimplexChannel`), so +verification matches against **any** of them. "Match any" is safe **only because +the namespace is an on-chain, ENS-style registry**: `nrOwner`/`nrResolver` are +Ethereum addresses (`Names/Record.hs:22-23,36`), so each name has a **single +owner** who sets all its links. An attacker can register *their* name → your +address (the offensive-name case, handled by the claim check below) but **cannot +add a link to your name** — on-chain ownership blocks impersonation. Everything +here depends on that; if names were not single-owner, "match any" would be exploitable. + +The registrant of a name controls what it points to, with no proof that the +target address agreed to it — anyone can publish `@offensive → your address` or +`#offensive → your channel`. Therefore: + +- One-directional verification ("does `resolve(name)` equal a stored link") is + insufficient: it confirms the registrant's assertion, not the address owner's. +- A profile **claiming** a name (and even a link) proves nothing — anyone can + copy a link into a profile. **Control of a link is proven only by an actual + connection/join through it.** +- Sound verification is the intersection: `resolve(name) → link`, **and** the + entity advertises that name in its profile (it claims the name), **and** that link is one + we connected/joined through (control-proven). +- Verifying a name without having connected through its link needs a **signature + by the address key over the name, bound to the presentation context** (§4.8). + This is **in scope** for link contexts: a 1-time invitation includes such a proof + bound to the invite, so resolving the name → address → address key verifies it. + +Consequence: names are verifiable in this release for **channels** (join link), +**contacts connected via their address/name** (the link connected through), and +**1-time-invite contacts** (the in-scope address-key proof). The only case still +deferred is a name with **no link context at all** — a group member, or a name on +an `XInfo` profile over an established connection — which stays stored-but-not-shown. + +## 3. Current branch state (to be changed) + +Migration `M20260603_simplex_name` currently adds, on both SQLite and Postgres: + +- `contacts.simplex_name`, `contacts.simplex_name_verified_at` +- `groups.simplex_name`, `groups.simplex_name_verified_at` +- `connections.simplex_name` +- `contact_profiles.simplex_name`, `group_profiles.simplex_name` +- UNIQUE indexes `idx_contacts_simplex_name`, `idx_groups_simplex_name`, + `idx_contact_profiles_simplex_name`, `idx_group_profiles_simplex_name` +- `server_operators.smp_role_names` (= 1 for `'simplex'`) + +…and threads two names per entity through the types: `Contact.simplexName` / +`GroupInfo.simplexName` (from `*.simplex_name`, "locally known"), and +`LocalProfile.simplexName` (from `*_profiles.simplex_name`, "peer claim"), plus +a `verified_at` timestamp on the entity. `apiVerifySimplexName` verifies the +entity (ct) name against the **profile's self-asserted `contactLink`** for +contacts (wrong link), and against `preparedGroup.connLinkToConnect` for groups +(correct). The `connections.simplex_name` carrier is plumbed through +`createConnection_` but **never written** (all callers pass `Nothing`). + +## 4. Target design + +### 4.1 Name type and JSON encoding + +The name type is **`SimplexNameInfo`** (simplexmq `Simplex/Messaging/SimplexName.hs:37`), +which has: + +- `StrEncoding` (`SimplexName.hs:89`) — canonical `simplex:/name@…` / `#…` string, +- a text `ToField` (`SimplexName.hs:146`) — stores as TEXT, +- a JSON **object** instance: `$(J.deriveJSON defaultJSON ''SimplexNameInfo)` + (`SimplexName.hs:154`) → `{nameType, nameDomain}`. + +**Keep the object JSON** — the UI/API needs the structured form (it reads the name +off `LocalProfile`, `CRSimplexNameVerified`, …). The conflict is only on the +**wire**: `PublicGroupAccess.groupDomain` is a **released** field typed +`Maybe Text` (a JSON string), so the wire form of the name must stay a string. + +Resolve by wrapping **only the wire fields** with simplexmq's generic newtype +`StrJSON` (`Simplex/Messaging/Encoding/String.hs:265`), whose `ToJSON`/`FromJSON` +go through `StrEncoding` → a JSON string (`String.hs:267–272`; idiom +`deriving (ToJSON, FromJSON) via (StrJSON "X" T)`): + +- wire (`Profile`, `GroupProfile`/`PublicGroupAccess`): + `Maybe (StrJSON "SimplexName" SimplexNameInfo)` → JSON **string**, byte-identical + on the wire to the released `Maybe Text`. +- local/UI (`LocalProfile`): `Maybe SimplexNameInfo`, **unwrapped** → JSON **object**. + +Wrap/unwrap at the `Profile` ⇄ `LocalProfile` boundary (`toLocalProfile` / +`fromLocalProfile`). `SimplexNameInfo`'s own JSON instance is untouched, so +`CRSimplexNameVerified` and any other UI-facing use stay object. + +Inherent asymmetry: there is no `LocalGroupProfile`, so the channel name reaches +the UI as a **string** (via `GroupProfile`, which is `StrJSON`), while the contact +name reaches it as an **object** (via `LocalProfile`). If the UI needs the channel +name as an object too, add a decoded field on `GroupInfo` (follow-up). The bot-API +binding generator must render `StrJSON`-wrapped fields as `string`. + +DB stays TEXT: `ToField` (`SimplexName.hs:146`) on write; on read a **hard** +`FromField SimplexNameInfo` (add it — `SimplexName.hs:141-145` says to define it +"when a consumer requires the row-fail behaviour"), so an invalid stored name fails the +row — matching the wire, where a name that won't `strDecode` fails the profile. **No +soft-decode** (`decodeSimplexName` dropped for the name columns). + +### 4.2 Types (field changes) + +- `Profile.contactDomain :: Maybe (StrJSON "SimplexName" SimplexNameInfo)` — NEW; + JSON string (§4.1). The entity's advertised contact name. (Replaces the branch's + `Profile.simplexName`.) +- `Profile.contactDomainProof :: Maybe ClaimProof` — NEW; a flat sibling of + `contactDomain`/`contactLink`, a **wire profile field like `Profile.badge`**. The + **stored** own profile (`contact_profiles`) has the name but **no proof**; the + proof is generated and **added to the outgoing profile at send/save** (§4.8), exactly + as the badge is. `PHSimplexLink` (verifiable) when the profile is saved to a contact + address / 1-time invite; `PHTest` over an established connection (the gap). +- `LocalProfile.contactDomain :: Maybe SimplexNameInfo` (unwrapped → object JSON) + and `LocalProfile.contactDomainVerification :: Maybe Bool` — local status, **not** + on wire `Profile`. `toLocalProfile` unwraps the `StrJSON`; `fromLocalProfile` + re-wraps `contactDomain` and drops the status (mirrors how `localBadge`'s status + is dropped). +- `PublicGroupAccess.groupDomain :: Maybe (StrJSON "SimplexName" SimplexNameInfo)` + — RETYPE from `Maybe Text` (`Types.hs:857`); JSON string, wire-identical to the + released text (§4.1). Owner-set, broadcast in `XGrpInfo`. **Only public, relay-backed + groups** (`groups.use_relays`) can have a name — they have a channel join link to + resolve to and an owner key (`groups.member_priv_key`, `chat_schema.sql:193`) to sign + with; p2p groups have neither. (`contactDomain` on a member is a contact attribute, unaffected.) +- `GroupInfo.groupDomainVerification :: Maybe Bool` — local status, read from the + `groups` table (there is no `LocalGroupProfile`, and `GroupProfile` is the wire + type, so the status cannot be sent with the profile). +- DROP `Contact.simplexName`, `GroupInfo.simplexName`, `Connection.simplexName`, + and both `*VerifiedAt` timestamps. + +Asymmetry, intentional: contact name + status both live on `contact_profiles` +(exposed via `LocalProfile`); the group name lives on `group_profiles` +(exposed via `GroupProfile`/`PublicGroupAccess`) but its status lives on +`groups` (exposed via `GroupInfo`). This is forced — `group_profiles` is the +shared wire profile with no local columns, while `contact_profiles` already +holds local state (`local_alias`). + +### 4.3 Schema — rewrite `M20260603_simplex_name` (branch unreleased) + +Add: +- `contact_profiles`: `contact_domain TEXT`, `contact_domain_verification` + (nullable `INTEGER` SQLite / `SMALLINT` Postgres). +- `groups`: `group_domain_verification` (nullable `INTEGER`/`SMALLINT`). +- `server_operators.smp_role_names` (= 1 for `'simplex'`) — keep. +- `user_contact_links`: the contact-address **root signing key** (BLOB, mirroring + `groups.root_priv_key`) — captured from the 2-step short-link creation — so the + contact-address / 1-time-invite proof can be signed chat-side. (Not present today.) + +No DB change for `group_profiles.group_domain` — it already exists (from +`M20260515_public_group_access`); only the Haskell type and JSON change. + +Remove (vs the current branch migration): `contacts.simplex_name`, +`contacts.simplex_name_verified_at`, `groups.simplex_name`, +`groups.simplex_name_verified_at`, `connections.simplex_name`, +`contact_profiles.simplex_name`, `group_profiles.simplex_name`, and all four +UNIQUE indexes. No name uniqueness is enforced at the DB level — identity comes +from verification, not a constraint. + +Verification column decode: `Maybe BoolInt → Maybe Bool` (`NULL` = not attempted, +`0` = failed, `1` = verified). + +### 4.4 Storage functions (`Store/Shared.hs`, `Store/Direct.hs`, `Store/Groups.hs`) + +- Read the name columns as `Maybe SimplexNameInfo` via the **hard** `FromField` + (§4.1) — an invalid name fails the row; no soft `decodeSimplexName`. +- `toContact` / `toGroupInfo`: read `contact_domain` → `LocalProfile.contactDomain` + and `contact_domain_verification` → `LocalProfile.contactDomainVerification`; + `group_profiles.group_domain` → `PublicGroupAccess.groupDomain` and + `groups.group_domain_verification` → `GroupInfo.groupDomainVerification`. Delete + the ct/cp split and the entity-`simplex_name` reads. +- `createContact_` / `createGroup_` / `createPreparedContact` / `createPreparedGroup`: + set the name on the **profile** columns only; drop the entity-`simplex_name` + argument and the `connections.simplex_name` carrier param on `createConnection_`. +- `updateContactProfile` / `updateGroupProfile`: write `contact_domain` / + `group_domain` from the received profile. Reset the verification to `NULL` + (not-attempted) **only when the name changes**; an XInfo/XGrpInfo with the + **same** name keeps the existing status (a verified name stays verified), exactly as + a badge does. No conflict clearing (no UNIQUE index). +- `getContactBySimplexName` / `getGroupIdBySimplexName`: look up by the **verified** + profile name (the name column joined with verification = `Just True`); on a miss + or unverified, fall through to resolve-and-connect. Consistent with the existing + by-address lookup `getContactViaShortLinkToConnect` (`Direct.hs:963`), which + matches the link with no verification check — a link is the identity, a name is a + claim that only becomes a usable pointer once verified. + +### 4.5 Redaction (`Library/Internal.hs:1246`) + +```haskell +redactedMemberProfile :: GroupInfo -> GroupMember -> Profile -> Profile +redactedMemberProfile g m Profile {…, contactLink, contactDomain} = + let allowDirect = groupFeatureMemberAllowed SGFDirectMessages m g + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g && allowDirect + in Profile { … + , shortDescr = removeSimplexLink =<< shortDescr -- via allowSimplexLinks + , contactLink = if allowSimplexLinks then contactLink else Nothing + , contactDomain = if allowDirect then contactDomain else Nothing + , contactDomainProof = Nothing } -- member profiles are contextless; never include a proof +``` + +- `allowDirect` is the single primitive (the `DirectMessages` permission); it + controls the name and is reused inside `allowSimplexLinks`. No `allowName` flag, + no second lookup. +- **Behavior changes vs current:** `contactLink` flips from unconditionally + dropped to controlled by `allowSimplexLinks` (a member's contact address becomes + visible whenever links+DMs are allowed — the meaning of "links allowed"); the + name follows the looser `allowDirect`. Rationale: a link is one-tap-to-connect + (low friction), a name only resolves if the recipient deliberately looks it up + (higher friction), so a group can forbid links yet allow name discovery, with + "DMs allowed" the floor for both. +- Signature takes `(GroupInfo, GroupMember)` and derives both flags inside, so + the rule lives in one place. Callers pass `(g, m)`; the own-profile path passes + `(g, membership g)` — behavior-preserving because + `groupFeatureUserAllowed f g ≡ groupFeatureMemberAllowed f (membership g) g` + (both reduce to `groupFeatureMemberAllowed' f (memberRole (membership g)) + (fullGroupPreferences g)`, `Types.hs:646–652`). +- Collapses `groupUserAllowSimplexLinks` (`Types.hs:656`) and the pre-computed + `allowSimplexLinks` wiring at the call sites (`Internal.hs:1244`, + `Subscriber.hs:842,2817,3239`, `Commands.hs:3748,4057`). `Commands.hs:3748` + has `Maybe GroupInfo` → "no group ⇒ no redaction" at the call site. + +### 4.6 Resolution + verification (`Library/Commands.hs`) + +Two cases that differ in whether verification can *fail*: + +**Connect-by-name** (`connectPlanName` / `dispatchResolvedRecord`) — verification is +a **precondition** of connecting. After decoding the resolved short link's embedded +profile, **add the claim check**: require that profile's `contactDomain` / +`groupDomain` to equal the resolved name, else fail with `CESimplexNameNotFound` +("name unknown") and **do not connect**. (The current branch decodes the profile but +never compares the name — this is the missing check.) On success the prepared +contact/group is created with the name on the profile, `connLinkToConnect` = the +resolved link, verification = `Just True` (**created as verified**). There is no "failed" +outcome here — failing to resolve or to claim the name just means no connection. + +**Connected NOT by name** (via an address link or a 1-time link) — the peer's profile +may *claim* a name; it starts **unverified** (`Nothing`). Verification is **post-hoc +and non-blocking**: keep `apiVerifySimplexName` (`/_verify simplex name`). + - **Contacts** verify by a **single path** — check `contactDomainProof` (§4.8): + resolve the claimed name → its link, validate the owner chain and **select the key by + the proof's `linkOwnerId`** (that owner's `ownerKey`, or the root key if `Nothing` — + the usual contact-address case), check the `ClaimProof` signature over + `name <> presHeader`, and check the proof's `presHeader` link == + `preparedContact.connLinkToConnect` (**not** `profile.contactLink` — the branch bug). + Contact addresses and 1-time invites both include the proof. + - **Channels** verify by **presence / link-match**: `resolve(#name)` includes + `preparedGroup.connLinkToConnect` (the join link), whose owner-signed data already + has `groupDomain` (no `ClaimProof` — §4.8). + Result is `Just True` (holds) or `Just False` (fails) — and **`Just False` must NOT + prevent the connection**, exactly as a failed *badge* verification doesn't. **This is + why the status must be 3-state** (`Nothing` not attempted / `Just False` failed / + `Just True` verified). Names that stay `Nothing` have **no proof/link context** — + group members, and names on an `XInfo` profile over an established connection. + +Open option: **auto-verify on connect** (run the same non-blocking check +automatically when connecting via address/1-time link), instead of only on demand. +Either way the status stays 3-state — auto-verification can fail without blocking. + +Keep the pure helpers `firstNameLink` (per-type link pick, cross-type rejection) and +`linksMatch` (scheme-normalized compare). + +### 4.7 Display (`View.hs`) + +A name is shown **when there is a proof to verify**, together with its status — +verified, failed, or not-yet-verified. All three states are shown (a failed or +pending check still shows the name, flagged), so the user sees the name and its +trust level rather than a silent omission. Rendering those three states is **out of +scope for this PR** (UI work), but the core stores the data — the 3-state status and +the proof — to drive it. A name with **no proof to verify** — a group member, or a +name on an `XInfo` profile over an established connection — is **not shown**. + +### 4.8 Proof — a flat profile field, signed by the address key, context-bound + +A name resolves to a **contact address** (the persistent identity link). The proof +asserts "the owner of that address asserts this name", so it is **signed by the +address's key** (the resolved address's own key) — **not** by the +per-connection or 1-time-link key. Every proof is **tied to the link it's shown +through**, so a proof made for one link can't be reused on another; these are +distinct proofs. Store the link in a presentation header: +**rename `BadgePresHeader` (`Badges.hs:212`) → `ProofPresHeader`** (now shared by +badge and name proofs) and add a **`PHSimplexLink AConnShortLink`** constructor. +`AConnShortLink` (`Agent.Protocol.hs:1536`, +`forall m. ConnectionModeI m => ACSL (SConnectionMode m) (ConnShortLink m)`) is the +existing existential, so the context is either a 1-time invitation or a contact +address. The signed payload includes this header, and the **verifier compares the +header's link against the link the proof is presented through** (the current +invite/address) — that comparison is what makes a proof non-replayable across +links. The tag enum (`Badges.hs:200`), the `StrEncoding` (`:216`), and +`badgePresHeaderAccepted` (`:226`, → `proofPresHeaderAccepted`) each gain the new +variant. + +**Type & wire** — a JSON object like `BadgeProof` (`Badges.hs:393`): + + data ClaimProof = ClaimProof + { linkOwnerId :: Maybe OwnerId, -- which owner signed; Nothing = root key (see below) + presHeader :: ProofPresHeader, -- context: PHSimplexLink | PHTest + signature :: C.Signature 'C.Ed25519 -- by that owner's key, over: smpEncode name <> smpEncode presHeader + } + +The signature is by the key of the signer's **owner identity** in the link's owner +chain (`OwnerAuth`, `Agent/Protocol.hs:1829`); `ClaimProof` has +**`linkOwnerId :: Maybe OwnerId`** (`OwnerId`, `:1827`) to name it, so the verifier +checks exactly that key (no iterating the owner list): + +- **Channels** (which can have owners other than the address) sign with the user's **owner key**, + `linkOwnerId = Just oid` — **not** the root key — `groups.member_priv_key` + (`chat_schema.sql:193`). +- A **contact address** has a single owner = its creator, so it signs with the **root + key**, `linkOwnerId = Nothing`. + +The root key (`ShortLinkCreds.linkPrivSigKey`/`linkRootSigKey`, `:1482-83`) otherwise +only *authorizes* owners (`validateOwners`/`validateLinkOwners`, `:1846–1859`); +`Nothing` (root) is **allowed at validation**. Both keys live **chat-side** so signing +is in the chat layer — **but only channels store theirs today** (`groups`); a contact +address (`user_contact_links`, `:386`) has **no key column**, so we must add one +(mirroring `groups.root_priv_key`), captured from the 2-step short-link creation, to +sign the contact-address / 1-time-invite proofs. Signed payload = +`smpEncode name <> smpEncode presHeader` (`name` from the profile's `contactDomain`). +`presHeader` serialises as its `StrEncoding` string, `signature` base64url. + +Per presentation context: + +- **Contact address** (the name's own resolved link): presence in the address link's + owner-signed data already proves the name — but we **include the explicit `ClaimProof` + here too** (bound to the address) so contact verification is + one uniform path (always check the proof) rather than presence-for-addresses / + proof-for-invites. The address can also serve as the **badge** proof's context. +- **1-time invitation**: include a proof **signed by the address key, bound to the + 1-time link** as context. Feasible **now** — the 1-time link is a unique, + single-use context; no general mechanism required. This is what makes + a 1-time-invite contact verifiable. +- **`XInfo` over an established connection**: no link context → the step that adds the proof sets + `PHTest` (unbound) → unverifiable → not shown. Left for later, same as the badge's + `PHTest`. (For **group members** the proof is dropped entirely by redaction, §4.5.) + +Home: **inside the profile** — `Profile.contactDomainProof :: Maybe ClaimProof`, a flat +sibling of `contactDomain`/`contactLink`, exactly like `Profile.badge`. It is **not** +stored on the own profile; like the badge, it is **generated fresh and added to the +outgoing profile at send/save** — when the profile is sent to a peer (`XInfo`) or saved +to a link — by signing `name <> presHeader` with the address root key and setting the +**destination as the `presHeader` context**. Because `presentUserBadge` +(`Internal.hs:2037`) already adds the badge proof to the outgoing profile at *both* +peer-sends and link-data writes, the name-proof step belongs in the **same +function**. The context decides whether a proof is useful: saving to a contact address / +1-time invite sets `PHSimplexLink(that link)` (verifiable — the in-scope cases); an +established-connection peer-send sets `PHTest` (unbound — left for later, same as the +badge). The receiver verifies and stores the 3-state status on +`LocalProfile.contactDomainVerification`, as `localBadge` holds the badge status. +Connect-by-name is **created as verified**. + +**Scope:** the **useful** (`PHSimplexLink`) proofs — set when the profile is saved to +a contact address or 1-time invite — are in this change; the verifier resolves the name → +address → address key → checks the signature and the `presHeader` link. Channels use +presence (below). The contextless established-connection case (a `PHTest` name proof) and +group members are left for later. + +Badges stay **in the profile** — person-scoped, presented per connection +(`presentUserBadge`, `Internal.hs:2037`), including over established connections via +`XInfo`. They share the presentation-header context type with name proofs but sign +with the **badge credential key**, not the address key. Two scopes, two homes. + +The shared step sets **both** proof kinds the same way: `PHSimplexLink(link)` +when the destination is a link (contact address, 1-time invite — verifiable), `PHTest` +over an established connection (unbound — the gap). So name proofs and badges both use +`PHTest` only for the contextless established-connection case. + +No group proof field: a channel is always joined via its **join link** (its own +address), whose owner-signed data already has `groupDomain`, and there is no +"advertised link ≠ resolved address" case for channels — so channels verify by +presence/link-match and need no `ClaimProof`. (If full symmetry is wanted later, add +`GroupProfile.groupDomainProof` the same way.) + +### 4.9 Set-name API (in scope — currently missing) + +Names are **pre-registered out of band** — the app does **not** call RNAME. The API +only **verifies** the name and **adds it to the profile**. The user must be able to +add/change/remove their own name from the UI; the branch has no command for the +**contact** name. + +- **Contact name** — add `APISetUserName :: Maybe SimplexNameInfo -> ChatCommand` + (`Nothing` clears). On set: + 1. Require an **address** — fail if none (the UI won't offer the action without one). + 2. Require it to be a **short link** — if only a long link exists, create the short + link (the name resolves to a short link, and the check below is short-link-based). + 3. Ensure that short link is **in the profile** (`contactLink`) — add it if missing. + 4. **Verify**: resolve the name and compare the short link it points to against the + profile's `contactLink`; fail if they don't match (name not preregistered to this + address). + 5. Set `LocalProfile.contactDomain` and **re-publish the contact-address link data**. + The `ClaimProof` is added when the profile is saved to that link (the send/save + proof-adding step, §4.8) — **not** produced by the API itself. (Steps 1–4 are the verify; + this step is set + re-publish.) + Needs a `ChatCommand` constructor + parser + handler. +- **Channel name** (public, relay-backed groups only) — a **separate** + `APISetPublicGroupName`, parallel to the contact one (not folded into + `SetPublicGroupAccess`): same require-address / require-short-link + / link-in-profile / verify / set-`groupDomain` flow against the channel's **join + link**. Rationale: it mirrors the contact API, and the name's fail-able verify + + preconditions don't mix cleanly with the plain `web=`/`embed=`/`domain_page=` writes. + **Drop `domain=` from `SetPublicGroupAccess`** (`Commands.hs:5461`) so there's a single + verified path; factor out the shared `GroupProfile`-update + `XGrpInfo` broadcast so + both commands reuse it. Verify is **TLD-dependent**: resolve+compare for `TLDSimplex`, + a different/no check for `TLDWeb` (web domains don't resolve through the namespace). +- The own name has **no stored verification status** — the verify step checks it at + add time; the 3-state status is only for peers' names. No RNAME wiring (out of scope). + +## 5. Removal checklist (from the current branch) + +- `Contact.simplexName`, `GroupInfo.simplexName`, `Connection.simplexName`. +- `*VerifiedAt` timestamps (→ `Maybe Bool` status fields). +- `connections.simplex_name` column + the `createConnection_` carrier param + the + `XInfo` carrier consumption in `Subscriber.hs`. +- `contacts.simplex_name`, `groups.simplex_name` columns. +- The four partial UNIQUE indexes + `clearConflictingContactProfileSimplexName_` + / `clearConflictingGroupProfileSimplexName_` + their call sites. +- `getContactBySimplexName` / `getGroupIdBySimplexName` against entity columns + (re-point or remove per 6.b). + +## 6. Resolved decisions + +a. **Wire-string encoding (§4.1):** `SimplexNameInfo` keeps object JSON; wire + fields are wrapped in `StrJSON` (string), `LocalProfile` stays unwrapped + (object). Channel name reaches the UI as a **string** (via `GroupProfile`) and + that is fine — no decoded-object field on `GroupInfo` (no reason to re-connect + to a channel you're in; channel names are only shown verified). The object form + matters for **contacts** (connect-from-groups, sharing), which `LocalProfile` + provides. Residual chore: teach the bot-API binding generator to emit `string` + for `StrJSON` fields and pick the `StrJSON` `name` Symbol. +b. **Lookup (§4.4):** re-point `getContactBySimplexName` (its one caller is + connect-by-name, `Commands.hs:4241`) to the **verified** `contact_profiles. + contact_domain`; miss/unverified ⇒ resolve-and-connect. Keeps the no-network + shortcut for already-known contacts. (`getGroupIdBySimplexName` has no external + caller — drop it.) +c. **Proof (§4.8):** a flat **`Profile.contactDomainProof :: Maybe ClaimProof`**, a wire + profile field like `Profile.badge`. Not stored on the own profile; **generated fresh and + added to the outgoing profile at send/save** (peer `XInfo` or save-to-link) by the + **same function** as the badge (`presentUserBadge`, which already runs at + peer-sends *and* link-data writes). Signed by the signer's **owner-identity key** — a + channel's **owner key** (`groups.member_priv_key`, `linkOwnerId = Just oid`) + or a contact address's **root key** (sole owner, `linkOwnerId = Nothing`) — over + `name <> presHeader`; `linkOwnerId` selects the verification key (`Nothing` = root, + allowed at validation). `PHSimplexLink` for address/invite saves, `PHTest` over + established connections (the gap). Verify checks the signature **and `presHeader`'s link + == the link actually used** (`connLinkToConnect`). Channels use presence (owner-signed + link data). **Gap:** the contact-address key isn't stored chat-side today + (`user_contact_links` has no key column) — add one to sign chat-side. Receiver status + on `LocalProfile.contactDomainVerification`. +d. **`redactedMemberProfile` contactLink exposure (§4.5):** intended — a member's + contact address becomes group-visible when links + DMs are allowed. +e. **Verify command + status (§4.6):** keep `apiVerifySimplexName` — required to + verify a claimed name for entities connected **not** by name (address / 1-time + link). Status is **3-state** because a failed name (or badge) verification must + **not** block the connection; connect-by-name is the only created-as-verified path + (and there a failure to resolve or to claim the name means no connection, not a failed state). + **Auto-verify on connect** (vs. on-demand only) is left open; it doesn't change + the 3-state requirement. + +## 7. Rollout & scope + +The **claim check** (§4.6) — a name resolves only if the **resolved link's own +data claims it** — is the anti-stray-names protection and **must ship regardless**: +a name someone registers against a real address must not "work" without that +address owner's agreement. It needs no signature (the address-case presence suffices), +so it lands with the core change. + +The signed proof (§4.8) can ship in the **same** release (add + verify together) or +be **staged** — implemented at the core level with the user-facing name-addition +hidden in the UI until ready. Either way it stays a *core* change. + +The `ProofPresHeader` rename + `PHSimplexLink` + letting badges opt into the link +context ripples through the badge code — accepted, and bounded: + +- `Badges.hs`: `BadgePresHeader` → `ProofPresHeader`, `BadgePresHeaderTag` → + `ProofPresHeaderTag`, `badgePresHeaderAccepted` → `proofPresHeaderAccepted` + (`:200, :212, :216, :226`); add the `PHSimplexLink AConnShortLink` + tag/constructor/`StrEncoding`/accepted-case; `badgeProof` (`:314`) takes the + renamed type. +- `presentUserBadge` (`Internal.hs:2037`) + its ~15 call sites — the **shared point that adds + proofs to the outgoing profile** (already runs at peer-sends *and* link-data writes): generalize it + to set **both** the badge proof and the name `ClaimProof` onto the outgoing profile, + using `PHSimplexLink link` where the destination is a link, `PHTest` otherwise. + +The pure rename can land as a **standalone prep commit** ahead of the proof; the +`PHSimplexLink` wiring + name proof land with the proof work. diff --git a/plans/2026-06-27-namespace-ui-display-set.md b/plans/2026-06-27-namespace-ui-display-set.md new file mode 100644 index 0000000000..1f01cc4311 --- /dev/null +++ b/plans/2026-06-27-namespace-ui-display-set.md @@ -0,0 +1,243 @@ +# SimpleX name UI: display + verify + set (iOS + Android/desktop) + +Branch: `sh/namespace-ui` (rebased onto core `fc0582cf0` — the finalized verify API). This pass adds +displaying a contact's / channel's SimpleX name with verification state, verifying names (manual + auto), +and two screens for setting the user's own name and a channel's name. + +**Upstream sync (2026-06-27):** core `sh/namespace` is at `5008b4e62` ("refactor setting user name" + +test/comment cleanup, on top of the verify-API split `fc0582cf0`). UI branch rebased onto it (backup tag +`backup-namespace-ui-pre-rebase5`); pure-frontend, rebase clean. All dep-touching changes across these +syncs were **wire-neutral**: cosmetic StrJSON label on `Profile.contactDomain`; `APISetUserName` handler +refactor (constructor `{userId, simplexName}`, `/_set_name` parser, `CRUserProfileUpdated`/`NoChange` +response unchanged); `Internal.hs` record-field reordering in bot profiles; store/view/test cleanup. +`Types.hs` model fields/JSON, verify/set commands, responses, `NameVerifyOutcome`, `NameClaimProof`, and +`SimplexNameInfo` are unchanged. **No UI code change required.** + +## Scope + +Ships (both iOS and Android/desktop): +- Models: decode name / proof / verification fields, add `NameClaimProof` + `SimplexNameInfo` helpers. +- Name display + 3-state verification indicator on contact info and channel info. +- Verify API calls + new response handling. +- "Verify SimpleX names" privacy toggle (default ON). +- Two "Set SimpleX name" screens (own name; channel name). + +Out: no change to the core verify algorithm — the UI only triggers it and renders the result. + +## Decisions + +Single source of truth. The UX walkthrough shows the visuals; the implementation sections give file:line. + +### Product / UX (confirmed) +- **A. Title** — rename to "SimpleX address and name". +- **B. Screen body copy** — placeholders for now (final copy TBD): + - Own: *"Set a SimpleX name so people can connect to you using @yourname instead of a link. The name + must already be registered to your address."* + - Channel: *"Set a SimpleX name so people can find this channel as #name. The name must be registered + to this channel's address."* +- **C. Own name read-only** — No; the current own name appears only inside the set screen. +- **D. Rename scope** — rename the shared string everywhere (settings-menu row + screen title). +- **E. Auto-verify trigger** — with the toggle ON, auto-verify on open only if stored state is `null`; + verified/failed shows the stored result, and tapping the indicator re-verifies. +- **F. Failure reason** — shown on tap of the red cross (alert); an inconclusive result shows a brief + alert on completion. Not shown inline. + +### Technical approach +- **T1. Show the name only when a proof exists** (`contactDomainProof` / `groupDomainProof` != nil). +- **T2. One formatter + one parser.** `SimplexNameInfo.shortName` (display, mirrors `shortNameInfoStr`), + `editDomain` (prefix-less, prefills set fields), `SimplexNameInfo(parsing:)` (decode the encoded + `groupDomain` string). Contact display uses the decoded object; channel display parses `groupDomain`. +- **T3. Set screens send a name string; UI fixes only the type prefix.** `strP` reads `@`->contact / + `#`->group first, so the UI prepends `@` (own) / `#` (channel); the backend canonicalises the domain. + Empty input clears. +- **T4. Channel uses a raw `APIUpdateGroupProfile`** (documented at the call site): core has + `APISetUserName` but no `APISetGroupName`, so the channel name is set by re-sending the cloned + `GroupProfile` with `publicGroup.publicGroupAccess.groupDomain` updated. +- **T5. Gating.** Channel "Set SimpleX name" only when `useRelays && isOwner && publicGroup?.publicGroupAccess != nil`; + own "Set SimpleX name" lives in the existing-address branch (inherently requires an address). + +Minor defaults (flag if wrong): iOS toggle in the first "Chats" Section (PrivacySettings.swift:85); +spinner delay ~300ms; channel name cleared via empty input; set-name fields rely on backend rejection +for validation (+ helper text). + +## UX walkthrough + +Same on both platforms. Nothing existing moves. + +### Name + verification indicator (contact info / channel info) + +A new line under the name, above the description, shown per T1. + +``` + Contact info Channel info + +------------------+ +------------------+ + | [ photo ] | | [ photo ] | + | Alice | | My Team | + | @alice.simplex v| <- new | #myteam Verify | <- new + | "description..."| | "description..."| + +------------------+ +------------------+ + (v = check) (Verify = action) +``` + +Name on the left, indicator on the right; the indicator depends on state: + +| State (`*Verification`) | Name style | Indicator | +|---|---|---| +| Verified (`true`) | accent color | check mark in regular color | +| Failed (`false`) | code style | cross mark in red (tap -> failure reason, per F) | +| Not verified (`null`), toggle OFF | code style | "Verify name" action (accent) | +| Not verified (`null`), toggle ON | code style | auto-verify on open (per E) -> spinner -> result | +| Verifying (in-flight) | code style | delayed spinner (~300ms, so a fast result doesn't flash) | + +### Set screens (2 new buttons -> 2 new screens) + +1. **Own name** — on the "SimpleX address and name" screen, a new "Set SimpleX name" button just above + the "Or to share privately" section: + ``` + [ QR code ] + Share address + Business address (toggle) + Address settings + ------------------ + Set SimpleX name -> <- new + ------------------ + Or to share privately + Create 1-time link + ``` +2. **Channel name** — on channel info, in the existing "Advanced options" section (owners only): + ``` + Advanced options + Web access + Set SimpleX name -> <- new + ``` + +Each opens the same view (parameterised by prefix / body text / save action): explanation + a text field +with a fixed prefix adornment (`@` own -> user types `alice.simplex`; `#` channel -> user types `myteam`) ++ Save. Empty + Save clears the name. + +### Settings toggle +"Verify SimpleX names" toggle, default ON, in the privacy settings "Chats" section (iOS: +PrivacySettings.swift:85, no `MorePrivacyView`; Kotlin: `MorePrivacyView` Chats section, PrivacySettings.kt:118). + +## Backend reference (core @ fc0582cf0) + +Single source for wire/JSON facts. + +- Display: `shortNameInfoStr` (simplexmq Protocol.hs:1594) — public group on default `.simplex` TLD with + empty subdomain -> `#myteam`; else prefix (`@`/`#`) + full domain (`@alice.simplex`, `#myteam.testing`). +- Encoded form: `strEncode SimplexNameInfo = "simplex:/name" <> ("@"|"#") <> fullDomain` (Protocol.hs:1565). +- JSON shapes: `LocalProfile.contactDomain :: Maybe SimplexNameInfo` -> JSON **object**; + `PublicGroupAccess.groupDomain :: Maybe (StrJSON SimplexNameInfo)` -> JSON **string** (encoded form). +- Verification: `LocalProfile.contactDomainVerification` / `GroupInfo.groupDomainVerification :: Maybe Bool` + -> JSON `true`/`false`/absent (decodes as Swift `Bool?` / Kotlin `Boolean?`); null=not attempted, + false=failed, true=verified. +- Proof: `LocalProfile.contactDomainProof` / `PublicGroupAccess.groupDomainProof :: Maybe NameClaimProof` + -> JSON object `{linkOwnerId?: string, presHeader: string, signature: string}` (all strings). +- Verify commands (manual; network/resolver errors are retryable `ChatErrorAgent`): + - `APIVerifyContactName {contactId}` -> `/_verify name @`. + - `APIVerifyPublicGroupName {groupId}` -> `/_verify name #`. + - Outcome `NVOVerified | NVOFailed Text | NVOInconclusive Text` -> persists `Just True` / `Just False` / + leaves unchanged. Responses `CRContactNameVerified {user, contact, verificationResult :: Maybe Text}` / + `CRGroupNameVerified {user, groupInfo, verificationResult :: Maybe Text}` return the **updated** entity + plus `Nothing`=verified / `Just reason`=failure-or-inconclusive text. +- Set own name: `APISetUserName userId (Maybe SimplexNameInfo)` -> `/_set_name []` (parsed by + `strP`, NOT json; Commands.hs:5438). Rejects `name is not registered to your address`. Response + `CRUserProfileUpdated` / `CRUserProfileNoChange`. +- Set channel name: `APIUpdateGroupProfile` (`/_group_profile # `, Commands.hs:5571) with + `groupDomain` set. Rejects `name is not registered to this channel`. Response `CRGroupUpdated`. + +## Gotchas + +1. **iOS Codable (compile-blocking).** `SimplexNameInfo`/`SimplexNameDomain`/`SimplexTLD`/`SimplexNameType` + are `Decodable`-only (ChatTypes.swift:5261-5281); adding `contactDomain` to the `Codable` `LocalProfile` + breaks `Encodable` synthesis -> make all four `Codable`. (Kotlin already `@Serializable`.) +2. **`groupDomain` string carries the `simplex:/name` prefix** -> strip it in `parsing`. +3. **`NameClaimProof` decoded for presence only.** UI just checks `!= nil`; if any field's JSON shape is + uncertain at implementation, decode permissively. +4. **Delayed spinner — no existing helper.** iOS: `@State var verifying` set true after `Task.sleep(~300ms)`, + guarded by an `inFlight` flag. Android: a small `LaunchedEffect(inFlight){ delay(300); show=true }` composable. +5. **Enum JSON parity** — backend `enumJSON (dropPrefix "TLD"/"NT")` lowercases -> matches the Swift/Kotlin + enum raw values. +6. **Navigation.** iOS `NavigationLink`; Android `ModalManager.start.showModalCloseable { ... }` + (template: PrivacySettings.kt:162). + +--- + +## iOS implementation + +### Models — `apps/ios/SimpleXChat/ChatTypes.swift` +- Make `SimplexNameInfo`/`SimplexNameDomain`/`SimplexTLD`/`SimplexNameType` (5261-5281) `Codable`. +- Add `NameClaimProof: Codable, Hashable { presHeader: String; signature: String; linkOwnerId: String? }`. +- `SimplexNameInfo` (5261): add `init?(parsing: String)`, `var shortName: String`, `var editDomain: String`. +- `LocalProfile` (153): add `contactDomain: SimplexNameInfo?`, `contactDomainProof: NameClaimProof?`, + `contactDomainVerification: Bool?`. +- `GroupInfo` (2506): add `groupDomainVerification: Bool?`. +- `PublicGroupAccess` (2616): keep `groupDomain: String?`; add `groupDomainProof: NameClaimProof?`. + +### API — `apps/ios/Shared/Model/{AppAPITypes,SimpleXAPI}.swift` +- `ChatCommand`: `apiSetUserName(userId: Int64, name: String?)` -> `"/_set_name \(userId)" + (name.map{" "+$0} ?? "")`; + `apiVerifyContactName(contactId: Int64)` -> `"/_verify name @\(contactId)"`; + `apiVerifyPublicGroupName(groupId: Int64)` -> `"/_verify name #\(groupId)"`. +- `ChatResponse1`: add `contactNameVerified(user:contact:verificationResult:)` / + `groupNameVerified(user:groupInfo:verificationResult:)` cases (+ `responseType`/`details` entries). + Templates: `contactUpdated` AppAPITypes.swift:1114 / responseType:1193 / details:1267; `groupUpdated`:1140. +- Wrappers: `apiSetUserName(_:)`; `apiVerifyContactName(_:)` (updated `Contact` + reason); + `apiVerifyPublicGroupName(_:)` (updated `GroupInfo` + reason). Channel set reuses `apiUpdateGroup(_:_:)`. + +### Views +- Reusable `SimplexNameView` subview rendering the 5-row state table (incl. delayed spinner + verify action). +- Contact: `ChatInfoView.swift` `contactInfoHeader()` — before the `shortDescr` block (~395), per T1 render + `SimplexNameView` from `contactDomain`/`contactDomainProof`/`contactDomainVerification`; onAppear auto-verify (E). +- Channel: `GroupChatInfoView.swift` `groupInfoHeader()` — before webPage (~334), same from parsed + `groupDomain` + `groupDomainProof` + `groupDomainVerification`; "Set SimpleX name" button in Advanced options (~248). +- Address view: `UserAddressView.swift` — "Set SimpleX name" Section above "Or to share privately" (194). +- New `SetSimplexNameView.swift` (own + channel modes). +- Title rename (A/D): the address screen's title is set by the presenter, not `UserAddressView` + (literal also at UserAddressLearnMore.swift:71) — locate the title source and rename everywhere. +- Toggle: `PrivacySettings.swift` main "Chats" Section (~85) `Toggle("Verify SimpleX names", isOn:)` + bound to `@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES)`; declare the constant + default `true` in + the `appDefaults` dict (SettingsView.swift:88-105). + +## Android/desktop implementation (multiplatform Kotlin) + +### Models — `model/ChatModel.kt` +- Add `@Serializable data class NameClaimProof(presHeader: String, signature: String, linkOwnerId: String? = null)`. +- `SimplexNameInfo` (4875): add `companion fun parse(encoded): SimplexNameInfo?`, `val shortName`, `val editDomain`. +- `LocalProfile` (2061): add `contactDomain: SimplexNameInfo? = null`, `contactDomainProof: NameClaimProof? = null`, + `contactDomainVerification: Boolean? = null`. +- `GroupInfo` (2181): add `groupDomainVerification: Boolean? = null`. +- `PublicGroupAccess` (2323): keep `groupDomain: String?`; add `groupDomainProof: NameClaimProof? = null`. + +### API — `model/SimpleXAPI.kt` +- `CC`: `ApiSetUserName(userId, name: String?)`, `ApiVerifyContactName(contactId)` -> `"/_verify name @$contactId"`, + `ApiVerifyPublicGroupName(groupId)` -> `"/_verify name #$groupId"` (+ cmdString entries). +- `CR`: `@SerialName("contactNameVerified") ContactNameVerified(user, contact, verificationResult: String?)`, + `GroupNameVerified(user, groupInfo, verificationResult: String?)` (+ `responseType`). Templates: + `ContactUpdated` SimpleXAPI.kt:6454 / responseType:6646; `GroupUpdated`:6500. +- Wrappers `apiSetUserName`, `apiVerifyContactName`, `apiVerifyPublicGroupName`; channel set reuses `apiUpdateGroupProfile`. +- Pref: `val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, true)` (~123). + +### Views +- Reusable `SimplexNameView` composable (the 5-row state table). +- Contact: `views/chat/ChatInfoView.kt` — after `ChatInfoDescription(...)` (~759), per T1; onAppear auto-verify (E). +- Channel: `views/chat/group/GroupChatInfoView.kt` — after `ChatInfoDescription(...)` (~947), per T1; + "Set SimpleX name" button in Advanced options `SectionView` (~805). +- New `views/usersettings/SetSimplexNameView.kt` (own + channel modes). +- `UserAddressView.kt`: "Set SimpleX name" button above the one-time-link section (~347). +- Toggle: `PrivacySettings.kt` `MorePrivacyView` "Chats" Section (~118) `SettingsPreferenceItem(..., appPrefs.privacyVerifySimplexNames)`. +- `strings.xml`: `set_simplex_name`, `verify_simplex_name`, `verify_simplex_names`, screen titles/body; + rename `simplex_address` -> "SimpleX address and name" (D). + +## Build / verify +- iOS: Xcode build of SimpleXChat + app (or swift build of the package targets). +- Android/desktop: `./gradlew` compile of `:common` (desktop target fastest). +- Manual: a peer with a verified name shows accent + check; tampered/failed shows red cross + reason on tap; + toggle OFF shows "Verify name"; toggle ON auto-verifies on open with a delayed spinner. Set own/channel + name; clearing works; core rejection surfaces as an alert. + +## Commit plan (conventional) +1. `feat(names): decode name/proof/verification; add SimplexNameInfo helpers + NameClaimProof` (models, both) +2. `feat(names): verify API (contact/group) + response handling` (CC/CR, wrappers) +3. `feat(names): show name + verification state on contact and channel info` (SimplexNameView + headers) +4. `feat(names): "Verify SimpleX names" privacy toggle + auto-verify on open` +5. `feat(names): set user and channel SimpleX name screens` diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8dca57a258..4ca9cefc7a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."98391fd677f547f610a17c48cc96d8c4b2d5e96e" = "1ajgidba08dq8yj7xyr9i47rfrlkzbr75j5nyyjcv0zf778sgv0m"; + "https://github.com/simplex-chat/simplexmq.git"."836254a4c6bd5e17b40acbcaf77a83802d44919e" = "1g4385sv7i1h63yk3ch2kw8hprak1dzvr34v9mn6xz0fy10g8xlg"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f8c599c80b..cb36e989da 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -40,6 +40,7 @@ library Simplex.Chat.AppSettings Simplex.Chat.Badges Simplex.Chat.Badges.CLI + Simplex.Chat.Names Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Delivery @@ -144,6 +145,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster + Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name else exposed-modules: Simplex.Chat.Archive @@ -306,6 +308,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster + Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name other-modules: Paths_simplex_chat hs-source-dirs: @@ -579,12 +582,14 @@ test-suite simplex-chat-test ChatTests.Forward ChatTests.Groups ChatTests.Local + ChatTests.Names ChatTests.Profiles ChatTests.Utils JSONFixtures JSONTests MarkdownTests MemberRelationsTests + NameResolver MessageBatching OperatorTests ProtocolTests @@ -665,6 +670,8 @@ test-suite simplex-chat-test , unicode-transforms ==0.4.* , unliftio ==0.2.* , uri-bytestring >=0.3.3.1 && <0.4 + , wai ==3.2.* + , warp ==3.3.* default-language: Haskell2010 if flag(client_postgres) other-modules: diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs index e861d27f11..6e35517ed3 100644 --- a/src/Simplex/Chat/Badges.hs +++ b/src/Simplex/Chat/Badges.hs @@ -1,6 +1,7 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DerivingVia #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE ExistentialQuantification #-} {-# LANGUAGE FlexibleInstances #-} @@ -27,8 +28,8 @@ module Simplex.Chat.Badges maxXFTPFileSize, maxFileSizeSupporter, maxFileSizeLegend, - BadgePresHeaderTag (..), - BadgePresHeader (..), + ProofPresHeaderTag (..), + ProofPresHeader (..), BadgePurchase (..), BadgeMasterKey (..), BadgeRequest (..), @@ -197,9 +198,9 @@ maxXFTPFileSize = \case -- presentation, not bound to any context; the 'T' tag marks it so master rejects it. -- PHUnknown is the forward-compat catch-all for tags this version does not interpret. -data BadgePresHeaderTag = PHTestTag | PHUnknownTag Char +data ProofPresHeaderTag = PHTestTag | PHUnknownTag Char -instance StrEncoding BadgePresHeaderTag where +instance StrEncoding ProofPresHeaderTag where strEncode = B.singleton . \case PHTestTag -> 'T' PHUnknownTag c -> c @@ -209,11 +210,13 @@ instance StrEncoding BadgePresHeaderTag where 'T' -> PHTestTag c -> PHUnknownTag c -data BadgePresHeader +data ProofPresHeader = PHTest ByteString | PHUnknown Char ByteString + deriving (Eq, Show) + deriving (ToJSON, FromJSON) via (StrJSON "ProofPresHeader" ProofPresHeader) -instance StrEncoding BadgePresHeader where +instance StrEncoding ProofPresHeader where strEncode = \case PHTest nonce -> strEncode PHTestTag <> nonce PHUnknown c b -> strEncode (PHUnknownTag c) <> b @@ -223,8 +226,8 @@ instance StrEncoding BadgePresHeader where PHUnknownTag c -> PHUnknown c <$> A.takeByteString -- v6.5.x accepts both; v7 will reject PHTest/PHUnknown -badgePresHeaderAccepted :: BadgePresHeader -> Bool -badgePresHeaderAccepted = \case +proofPresHeaderAccepted :: ProofPresHeader -> Bool +proofPresHeaderAccepted = \case PHTest _ -> True PHUnknown _ _ -> True @@ -311,7 +314,7 @@ generateBadgeProof pk (BadgeCredential keyIdx masterKey signature badgeInfo) ph fmap (\p -> BadgeProof keyIdx ph p badgeInfo) <$> bbsProofGen pk signature bbsBadgeHeader ph bbsBadgeDisclosedIndexes (badgeMessages masterKey badgeInfo) -- application-level proof generation with a semantic presentation header -badgeProof :: BBSPublicKey -> BadgeCredential -> BadgePresHeader -> IO (Either String BadgeProof) +badgeProof :: BBSPublicKey -> BadgeCredential -> ProofPresHeader -> IO (Either String BadgeProof) badgeProof pk cred ph = generateBadgeProof pk cred (BBSPresHeader $ strEncode ph) -- Recipient-side: verify a badge proof with the configured key its index points to. @@ -324,7 +327,7 @@ verifyBadge keys b@(BadgeProof keyIdx _ _ _) = case M.lookup keyIdx keys of verifyBadgeWith :: BBSPublicKey -> BadgeProof -> IO Bool verifyBadgeWith pk (BadgeProof _ ph@(BBSPresHeader phBytes) proof badgeInfo) - | either (const False) badgePresHeaderAccepted (strDecode phBytes) = + | either (const False) proofPresHeaderAccepted (strDecode phBytes) = bbsProofVerify pk proof bbsBadgeHeader ph bbsBadgeDisclosedIndexes bbsBadgeMessageCount (badgeInfoMessages badgeInfo) | otherwise = pure False diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 48913af9a5..50b862758b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -417,6 +417,7 @@ data ChatCommand | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile {userId :: UserId, profile :: Profile} + | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe SimplexDomain} | APISetContactPrefs {contactId :: ContactId, preferences :: Preferences} | APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias} | APISetGroupAlias {groupId :: GroupId, localAlias :: LocalAlias} @@ -442,6 +443,7 @@ data ChatCommand | APILeaveGroup {groupId :: GroupId} | APIListMembers {groupId :: GroupId} | APIUpdateGroupProfile {groupId :: GroupId, groupProfile :: GroupProfile} + | APISetPublicGroupAccess GroupId PublicGroupAccess | APICreateGroupLink {groupId :: GroupId, memberRole :: GroupMemberRole} | APIGroupLinkMemberRole {groupId :: GroupId, memberRole :: GroupMemberRole} | APIDeleteGroupLink {groupId :: GroupId} @@ -527,15 +529,17 @@ data ChatCommand | AddContact IncognitoEnabled | APISetConnectionIncognito Int64 IncognitoEnabled | APIChangeConnectionUser Int64 UserId -- new user id to switch connection to - | APIConnectPlan {userId :: UserId, connectionLink :: Maybe AConnectionLink, resolveKnown :: Bool, linkOwnerSig :: Maybe LinkOwnerSig} -- Maybe AConnectionLink is used to report link parsing failure as special error - | APIPrepareContact UserId ACreatedConnLink ContactShortLinkData - | APIPrepareGroup UserId CreatedLinkContact DirectLink GroupShortLinkData + | APIConnectPlan {userId :: UserId, connectTarget :: Maybe AConnectTarget, resolveKnown :: Bool, linkOwnerSig :: Maybe LinkOwnerSig} -- Maybe AConnectTarget is used to report parsing failure as special error + | APIPrepareContact UserId ACreatedConnLink (Maybe SimplexDomain) ContactShortLinkData + | APIPrepareGroup UserId CreatedLinkContact DirectLink (Maybe SimplexDomain) GroupShortLinkData | APIChangePreparedContactUser ContactId UserId | APIChangePreparedGroupUser GroupId UserId | APIConnectPreparedContact {contactId :: ContactId, incognito :: IncognitoEnabled, msgContent_ :: Maybe MsgContent} | APIConnectPreparedGroup {groupId :: GroupId, incognito :: IncognitoEnabled, ownerContact :: Maybe GroupOwnerContact, msgContent_ :: Maybe MsgContent} | APIConnect {userId :: UserId, incognito :: IncognitoEnabled, preparedLink_ :: Maybe ACreatedConnLink} -- Maybe is used to report link parsing failure as special error - | Connect {incognito :: IncognitoEnabled, connLink_ :: Maybe AConnectionLink} + | Connect {incognito :: IncognitoEnabled, connTarget_ :: Maybe AConnectTarget} + | APIVerifyContactDomain {contactId :: ContactId} + | APIVerifyGroupDomain {groupId :: GroupId} | APIConnectContactViaAddress UserId IncognitoEnabled ContactId | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) | DeleteContact ContactName ChatDeleteMode @@ -774,6 +778,8 @@ data ChatResponse | CRContactCode {user :: User, contact :: Contact, connectionCode :: Text} | CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} | CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text} + | CRContactDomainVerified {user :: User, contact :: Contact, verificationFailure :: Maybe Text} + | CRGroupDomainVerified {user :: User, groupInfo :: GroupInfo, verificationFailure :: Maybe Text} | CRTagsUpdated {user :: User, userTags :: [ChatTag], chatTags :: [ChatTagId]} | CRNewChatItems {user :: User, chatItems :: [AChatItem]} | CRChatItemUpdated {user :: User, chatItem :: AChatItem} @@ -1090,7 +1096,7 @@ data InvitationLinkPlan deriving (Show) data ContactAddressPlan - = CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification} + = CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, verifiedDomain :: Maybe SimplexDomain} | CAPOwnLink | CAPConnectingConfirmReconnect | CAPConnectingProhibit {contact :: Contact} @@ -1099,7 +1105,7 @@ data ContactAddressPlan deriving (Show) data GroupLinkPlan - = GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification} + = GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification, verifiedDomain :: Maybe SimplexDomain} | GLPOwnLink {groupInfo :: GroupInfo} | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} @@ -1399,6 +1405,12 @@ data ChatError | ChatErrorRemoteHost {rhKey :: RHKey, remoteHostError :: RemoteHostError} deriving (Show, Exception) +-- why a resolved SimpleX name could not be used (the name itself resolved; an unregistered name is the agent's NAME NOT_FOUND) +data SimplexDomainError + = SDENoValidLink -- the name's record has no usable contact/channel link + | SDEUnknownDomain -- the resolved link's profile has no name, or a different name + deriving (Eq, Show) + data ChatErrorType = CENoActiveUser | CENoConnectionUser {agentConnId :: AgentConnId} @@ -1420,6 +1432,7 @@ data ChatErrorType | CEChatNotStopped | CEChatStoreChanged | CEInvalidConnReq + | CESimplexDomainNotReady {simplexDomain :: SimplexDomain, simplexDomainError :: SimplexDomainError} | CEUnsupportedConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEConnReqMessageProhibited @@ -1751,6 +1764,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SDE") ''SimplexDomainError) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index ac05550939..cc3e49beb6 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -140,7 +140,7 @@ createActiveUser cc CoreChatOpts {chatRelay} = \case displayName <- T.pack <$> withPrompt "display name" getLine createUser loop False $ mkProfile displayName where - mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} + mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} createUser onError clientService p = execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = BoolDef chatRelay, clientService = BoolDef clientService}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index eada7e5a1b..836f4e72fd 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -13,6 +13,7 @@ {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Library.Commands where @@ -56,6 +57,7 @@ import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -91,7 +93,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U import Simplex.Chat.Web (webPreviewWorker) -import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) +import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHard) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol @@ -105,11 +107,11 @@ import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.ShortLink as SL import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF -import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQEncOff, pattern PQSupportOff, pattern PQSupportOn) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQSupportOff, pattern PQSupportOn) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NameRecord (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxyWithAuth) @@ -129,7 +131,7 @@ import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose) import UnliftIO.STM #if defined(dbPostgres) -import Data.Bifunctor (bimap, second) +import Data.Bifunctor (bimap, first, second) import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) #else import Data.Bifunctor (bimap, first, second) @@ -381,14 +383,14 @@ processChatCommand cxt nm = \case user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) (isTrue userChatRelay) service p True ts mapM_ (setUserServers db user ts) uss - createPresetContactCards db cxt user `catchAllErrors` \_ -> pure () + createPresetContactCards db user `catchAllErrors` \_ -> pure () createNoteFolder db user pure user atomically . writeTVar u $ Just user pure $ CRActiveUser user where - createPresetContactCards :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO () - createPresetContactCards db cxt user = do + createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () + createPresetContactCards db user = do createContact db cxt user simplexStatusContactProfile createContact db cxt user simplexTeamContactProfile chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) @@ -1491,6 +1493,24 @@ processChatCommand cxt nm = \case withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) + APISetUserDomain userId domain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> + if (claimDomain <$> contactDomain) == domain_ + then pure $ CRUserProfileNoChange user + else do + cl' <- case domain_ of + Nothing -> pure contactLink + Just domain -> do + UserContactLink {shortLinkDataSet, connLinkContact = CCLink _ sl_} <- withFastStore (`getUserAddress` user) + case sl_ of + Just sl | shortLinkDataSet -> do + NameRecord {nrSimplexContact} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) domain + unless (nameResolvesTo sl nrSimplexContact) $ throwChatError $ CESimplexDomainNotReady domain SDENoValidLink + pure $ Just (CLShort sl) + _ -> throwCmdError "create the address short link and add it to name" + let p' = (fromLocalProfile p :: Profile) {contactDomain = mkDomainClaim <$> domain_, contactLink = cl'} + updateProfile_ user p' True $ withFastStore $ \db -> do + user' <- updateUserProfile db user p' + liftIO $ setUserSimplexDomain db user' domain_ APISetContactPrefs contactId prefs' -> withUser $ \user -> do ct <- withFastStore $ \db -> getContact db cxt user contactId updateContactPrefs user ct prefs' @@ -1829,7 +1849,7 @@ processChatCommand cxt nm = \case APIGroupInfo gId -> withUser $ \user -> CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db cxt user gId) APIGetUpdatedGroupLinkData groupId -> withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} <- withFastStore $ \db -> getGroupInfo db cxt user groupId + gInfo@GroupInfo {groupProfile = p} <- withFastStore $ \db -> getGroupInfo db cxt user groupId case p of GroupProfile {publicGroup = Just PublicGroupProfile {groupLink = sLnk}} | useRelays' gInfo -> do (_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks})) <- getShortLinkConnReq' nm user sLnk @@ -1983,8 +2003,9 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode + -- TODO [badges] bind link and badge to handshake context linkProfile <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True - let userData = contactShortLinkData linkProfile Nothing + let userData = contactShortLinkData linkProfile {contactDomain = Nothing} Nothing userLinkData = UserInvLinkData userData (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink @@ -2037,10 +2058,10 @@ processChatCommand cxt nm = \case createDirectConnection db newUser agConnId ccLink' Nothing ConnNew Nothing subMode initialChatVersion PQSupportOn deleteAgentConnectionAsync (aConnId' conn) pure conn' - APIConnectPlan userId (Just cLink) resolveKnown linkOwnerSig_ -> withUserId userId $ \user -> - uncurry (CRConnectionPlan user) <$> connectPlan user cLink resolveKnown linkOwnerSig_ + APIConnectPlan userId (Just ct) resolveKnown linkOwnerSig_ -> withUserId userId $ \user -> + uncurry (CRConnectionPlan user) <$> connectPlan user ct resolveKnown linkOwnerSig_ APIConnectPlan _ Nothing _ _ -> throwChatError CEInvalidConnReq - APIPrepareContact userId accLink contactSLinkData -> withUserId userId $ \user -> do + APIPrepareContact userId accLink verifiedDomain contactSLinkData -> withUserId userId $ \user -> do let ContactShortLinkData {profile, message, business} = contactSLinkData welcomeSharedMsgId <- forM message $ \_ -> getSharedMsgId case accLink of @@ -2050,7 +2071,7 @@ processChatCommand cxt nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing Nothing hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember @@ -2063,7 +2084,7 @@ processChatCommand cxt nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do - ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId + ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId (True <$ verifiedDomain) void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing @@ -2075,14 +2096,10 @@ processChatCommand cxt nm = \case Just (AChatItem SCTDirect dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTDirect chat - APIPrepareGroup userId ccLink direct groupSLinkData -> withUserId userId $ \user -> do - let GroupShortLinkData {groupProfile = gp@GroupProfile {description}, publicGroupData = publicGroupData_} = groupSLinkData - publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ + APIPrepareGroup userId ccLink direct verifiedDomain groupSLinkData -> withUserId userId $ \user -> do + let GroupShortLinkData {groupProfile = GroupProfile {description}} = groupSLinkData welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId - let useRelays = not direct - subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember - gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ + (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId (True <$ verifiedDomain) void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing @@ -2265,10 +2282,28 @@ processChatCommand cxt nm = \case CVRConnectedContact ct -> pure $ CRContactAlreadyExists user ct CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq - Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do - (ccLink, plan) <- connectPlan user cLink False Nothing `catchAllErrors` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing Nothing)); _ -> throwError e + Connect incognito (Just ct) -> withUser $ \user -> do + let con m cReq = pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing Nothing)) + (ccLink, plan) <- connectPlan user ct False Nothing `catchAllErrors` \e -> case ct of + ACTarget m (CTFullContact cReq) -> con m cReq + ACTarget m (CTInv (CLFull cReq)) -> con m cReq + _ -> throwError e connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq + APIVerifyContactDomain contactId -> withUser $ \user -> do + ct@Contact {profile = LocalProfile {contactDomain}, preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId + let connLink_ = preparedContact >>= \PreparedContact {connLinkToConnect = ACCL m (CCLink _ sLnk_)} -> ACSL m <$> sLnk_ + domain <- maybe (throwCmdError "contact has no name to verify") pure contactDomain + (verified, reason) <- verifyEntityDomain user nm NTContact domain connLink_ + ct' <- maybe (pure ct) (\v -> withFastStore' $ \db -> setContactDomainVerified db user ct v) verified + pure $ CRContactDomainVerified user ct' reason + APIVerifyGroupDomain groupId -> withUser $ \user -> do + g@GroupInfo {groupProfile = GroupProfile {publicGroup}, preparedGroup} <- withFastStore $ \db -> getGroupInfo db cxt user groupId + let connLink_ = preparedGroup >>= \PreparedGroup {connLinkToConnect = CCLink _ sLnk_} -> ACSL SCMContact <$> sLnk_ + domain <- maybe (throwCmdError "group has no name to verify") pure $ publicGroup >>= publicGroupAccess >>= groupDomainClaim + (verified, reason) <- verifyEntityDomain user nm NTPublicGroup domain connLink_ + g' <- maybe (pure g) (\v -> withFastStore' $ \db -> setGroupDomainVerified db user g v) verified + pure $ CRGroupDomainVerified user g' reason APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db cxt user contactId ccLink <- case contactLink of @@ -2284,7 +2319,7 @@ processChatCommand cxt nm = \case toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e ConnectSimplex incognito -> withUser $ \user -> do - plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing)) + plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing Nothing)) connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) plan DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing @@ -2298,16 +2333,20 @@ processChatCommand cxt nm = \case Left e -> throwError $ ChatErrorStore e Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode + gVar <- asks random + rootKey@(rootPubKey, rootPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar + let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey + (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True Nothing + ccLink' <- shortenCreatedLink ccLink -- TODO [relays] relay: add identity, key to link data? userData <- if isTrue userChatRelay then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) let userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} - (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode - ccLink' <- shortenCreatedLink ccLink + connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData IKPQOn subMode let ccLink'' = if isTrue userChatRelay then setShortLinkType CCTRelay ccLink' else ccLink' - withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode + withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode rootPrivKey pure $ CRUserContactLinkCreated user ccLink'' CreateMyAddress -> withUser $ \User {userId} -> processChatCommand cxt nm $ APICreateMyAddress userId @@ -2538,7 +2577,7 @@ processChatCommand cxt nm = \case then throwError e else do let relayResults = map toRelayResult results - toRelayResult (r, Left e) = AddRelayResult r (Just e) + toRelayResult (r, Left e') = AddRelayResult r (Just e') toRelayResult (r, Right _) = AddRelayResult r Nothing pure $ CRPublicGroupCreationFailed user relayResults where @@ -3094,11 +3133,15 @@ processChatCommand cxt nm = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) - SetPublicGroupAccess gName access -> withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroupInfo db cxt user + APISetPublicGroupAccess gId access@PublicGroupAccess {groupDomainClaim = newClaim} -> withUser $ \user -> do + gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> getGroupInfo db cxt user gId case publicGroup of - Just pg -> runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} + Just pg@PublicGroupProfile {groupLink, publicGroupAccess = existingAccess} -> do + forM_ (claimDomain <$> newClaim) $ \newDomain -> + when (Just newDomain /= (claimDomain <$> (existingAccess >>= groupDomainClaim))) $ do + NameRecord {nrSimplexChannel} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) newDomain + unless (nameResolvesTo groupLink nrSimplexChannel) $ throwChatError $ CESimplexDomainNotReady newDomain SDENoValidLink + runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} Nothing -> throwChatError $ CECommandError "not a public group" APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId @@ -3213,6 +3256,9 @@ processChatCommand cxt nm = \case sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined void $ withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus + SetPublicGroupAccess gName access -> withUser $ \user -> do + groupId <- withFastStore $ \db -> getGroupIdByName db user gName + processChatCommand cxt nm $ APISetPublicGroupAccess groupId access CreateGroupLink gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand cxt nm $ APICreateGroupLink groupId mRole @@ -3741,9 +3787,7 @@ processChatCommand cxt nm = \case -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile profileToSend <- presentUserBadge user incognitoProfile $ case gInfo_ of - Just gInfo_' -> - let allowSimplexLinks = maybe True groupUserAllowSimplexLinks gInfo_' - in userProfileInGroup' user allowSimplexLinks incognitoProfile + Just gInfo_' -> userProfileInGroup' user gInfo_' incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True dm <- case gInfo_ of Just (Just gInfo) | useRelays' gInfo -> case relayMemberId_ of @@ -3819,16 +3863,16 @@ processChatCommand cxt nm = \case -- non-incognito (filtered above), so the user's badge is presented; a profile update keeps the badge instead of clearing it ctSndEvent :: ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = do - p <- presentUserBadge user' Nothing mergedProfile' - pure (ConnectionId connId, Nothing, XInfo p) + p'' <- presentUserBadge user' Nothing mergedProfile' + pure (ConnectionId connId, Nothing, XInfo p'') ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> (conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId])) setMyAddressData :: User -> UserContactLink -> CM UserContactLink - setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do + setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _, addressSettings} = do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user - shortLinkProfile <- presentUserBadge user Nothing $ userProfileDirect user Nothing Nothing True + shortLinkProfile <- presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) -- TODO [short links] do not save address to server if data did not change, spinners, error handling let userData | isTrue userChatRelay = relayShortLinkData shortLinkProfile @@ -4051,9 +4095,8 @@ processChatCommand cxt nm = \case conn <- createRelayConnection db cxt user (groupMemberId' relayMember) connId ConnPrepared chatV subMode pure (relayMember, conn, groupRelay) let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - allowSimplexLinks = groupUserAllowSimplexLinks gInfo GroupMember {memberId = relayMemberId} = relayMember - membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership let relayInv = GroupRelayInvitation { fromMember = MemberIdRole userMemberId userRole, fromMemberProfile = membershipProfile, @@ -4129,8 +4172,8 @@ processChatCommand cxt nm = \case pure (gId, chatSettings) _ -> throwCmdError "not supported" processChatCommand cxt nm $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings - connectPlan :: User -> AConnectionLink -> Bool -> Maybe LinkOwnerSig -> CM (ACreatedConnLink, ConnectionPlan) - connectPlan user (ACL SCMInvitation cLink) _ sig_ = case cLink of + connectPlan :: User -> AConnectTarget -> Bool -> Maybe LinkOwnerSig -> CM (ACreatedConnLink, ConnectionPlan) + connectPlan user (ACTarget SCMInvitation (CTInv cLink)) _ sig_ = case cLink of CLFull cReq -> invitationReqAndPlan cReq Nothing Nothing Nothing CLShort l -> do let l' = serverShortLink l @@ -4151,51 +4194,80 @@ processChatCommand cxt nm = \case invitationReqAndPlan cReq sLnk_ cld ov = do plan <- invitationRequestPlan user cReq cld ov `catchAllErrors` (pure . CPError) pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan) - connectPlan user (ACL SCMContact cLink) resolveKnown sig_ = case cLink of - CLFull cReq -> do + connectPlan user (ACTarget SCMContact ct) resolveKnown sig_ = case ct of + CTFullContact cReq -> do plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError) pure (ACCL SCMContact $ CCLink cReq Nothing, plan) - CLShort l@(CSLContact _ ct _ _) -> - case ct of + CTShortContact nl -> + case ctType of CCTContact -> knownLinkPlans >>= \case Just r -> pure r Nothing -> do + l' <- resolveSLink (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) + let linkProfile_ = (\ContactShortLinkData {profile} -> profile) <$> contactSLinkData_ + linkDomain_ = linkProfile_ >>= \Profile {contactDomain} -> claimDomain <$> contactDomain + verifiedDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing + refreshContact ct' = case (verifiedDomain, linkProfile_) of + (Just _, Just p) -> updateContactFromLinkData user ct' p + _ -> pure ct' + forM_ verifiedDomain $ \nameDomain -> + unless (linkDomain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case - Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) + Just ct' | not (contactDeleted ct') -> do + ct'' <- refreshContact ct' + pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct'')) _ -> do - contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ContactLinkData _ UserContactData {owners} = cData ov = verifyLinkOwner rootKey owners l' sig_ plan <- contactRequestPlan user cReq contactSLinkData_ ov - pure (con cReq, plan) + case plan of + CPContactAddress cap@(CAPOk {}) -> pure (con l' cReq, CPContactAddress cap {verifiedDomain}) + CPContactAddress (CAPKnown ct') -> do + ct'' <- refreshContact ct' + pure (con l' cReq, CPContactAddress (CAPKnown ct'')) + CPContactAddress (CAPContactViaAddress ct') -> do + ct'' <- refreshContact ct' + pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct'')) + _ -> pure (con l' cReq, plan) where knownLinkPlans = withFastStore $ \db -> - liftIO (getUserContactLinkViaShortLink db user l') >>= \case - Just UserContactLink {connLinkContact = CCLink cReq _} -> pure $ Just (con cReq, CPContactAddress CAPOwnLink) + liftIO (getUserContactLinkViaTarget db user nl') >>= \case + Just UserContactLink {connLinkContact} -> pure $ Just (ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink) Nothing -> - getContactViaShortLinkToConnect db cxt user l' >>= \case - Just (cReq, ct') -> pure $ if contactDeleted ct' then Nothing else Just (con cReq, CPContactAddress (CAPKnown ct')) - Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db cxt user l' + getContactToConnect db cxt user nl' >>= \case + Just (ccl, ct') -> pure $ if contactDeleted ct' then Nothing else Just (ACCL SCMContact ccl, CPContactAddress (CAPKnown ct')) + Nothing -> (gPlan =<<) <$> getGroupToConnect db cxt user nl' CCTGroup -> groupShortLinkPlan CCTChannel -> groupShortLinkPlan CCTRelay -> throwCmdError "chat relay links are not supported in this version" where - l' = serverShortLink l - con cReq = ACCL SCMContact $ CCLink cReq (Just l') - gPlan (cReq, g) = if memberRemoved (membership g) then Nothing else Just (con cReq, CPGroupLink (GLPKnown g (BoolDef False) Nothing (ListDef []))) + nl' = case nl of + CTLink sl -> CTLink (serverShortLink sl) + CTName _ -> nl + ctType = case nl of + CTLink (CSLContact _ t _ _) -> t + CTName SimplexNameInfo {nameType = NTContact} -> CCTContact + CTName SimplexNameInfo {nameType = NTPublicGroup} -> CCTChannel + resolveSLink = case nl' of + CTLink l' -> pure l' + CTName n -> serverShortLink <$> resolveNameLink user n + con l' cReq = ACCL SCMContact $ CCLink cReq (Just l') + gPlan (ccl, g) = if memberRemoved (membership g) then Nothing else Just (ACCL SCMContact ccl, CPGroupLink (GLPKnown g (BoolDef False) Nothing (ListDef []))) groupShortLinkPlan = knownLinkPlans >>= \case Just (_, CPGroupLink (GLPKnown g _ _ _)) | resolveKnown -> resolveKnownGroup g Just r -> pure r Nothing -> do + l' <- resolveSLink (fd, cData@(ContactLinkData _ UserContactData {direct, owners, relays})) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData if - | not direct && unsupportedGroupType groupSLinkData_ -> pure (con (linkConnReq fd), CPGroupLink (GLPUpdateRequired groupSLinkData_)) - | not direct && null relays -> pure (con (linkConnReq fd), CPGroupLink (GLPNoRelays groupSLinkData_)) + | not direct && unsupportedGroupType groupSLinkData_ -> pure (con l' (linkConnReq fd), CPGroupLink (GLPUpdateRequired groupSLinkData_)) + | not direct && null relays -> pure (con l' (linkConnReq fd), CPGroupLink (GLPNoRelays groupSLinkData_)) | otherwise -> do let FixedLinkData {linkConnReq = cReq, linkEntityId, rootKey} = fd linkInfo = GroupShortLinkInfo {direct, groupRelays = relays, publicGroupId = B64UrlByteString <$> linkEntityId} @@ -4206,17 +4278,29 @@ processChatCommand cxt nm = \case (Nothing, Nothing) -> pure () _ -> throwChatError CEInvalidConnReq let ov = verifyLinkOwner rootKey owners l' sig_ + verifiedDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing plan <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov - pure (con cReq, plan) + forM_ verifiedDomain $ \nameDomain -> + let domain_ = (\GroupProfile {publicGroup} -> claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) =<< case plan of + CPGroupLink (GLPOk _ (Just GroupShortLinkData {groupProfile}) _ _) -> Just groupProfile + CPGroupLink (GLPKnown GroupInfo {groupProfile} _ _ _) -> Just groupProfile + CPGroupLink (GLPOwnLink GroupInfo {groupProfile}) -> Just groupProfile + CPGroupLink (GLPConnectingProhibit (Just GroupInfo {groupProfile})) -> Just groupProfile + _ -> (\GroupShortLinkData {groupProfile} -> groupProfile) <$> groupSLinkData_ + in unless (domain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain + pure $ case plan of + CPGroupLink glp@(GLPOk {}) -> (con l' cReq, CPGroupLink glp {verifiedDomain}) + _ -> (con l' cReq, plan) where unsupportedGroupType = \case Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel _ -> False knownLinkPlans = withFastStore $ \db -> - liftIO (getGroupInfoViaUserShortLink db cxt user l') >>= \case - Just (cReq, g) -> pure $ Just (con cReq, CPGroupLink (GLPOwnLink g)) - Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db cxt user l' + liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case + Just (ccl, g) -> pure $ Just (ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g)) + Nothing -> (gPlan =<<) <$> getGroupToConnect db cxt user nl' resolveKnownGroup g = do + l' <- resolveSLink (fd@FixedLinkData {rootKey = rk}, cData@(ContactLinkData _ UserContactData {owners})) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData let ov = verifyLinkOwner rk owners l' sig_ @@ -4224,7 +4308,16 @@ processChatCommand cxt nm = \case (g', updated) <- case groupSLinkData_ of Just sLinkData -> updateGroupFromLinkData user g sLinkData _ -> pure (g, False) - pure (con (linkConnReq fd), CPGroupLink (GLPKnown g' (BoolDef updated) ov (ListDef glOwners))) + pure (con l' (linkConnReq fd), CPGroupLink (GLPKnown g' (BoolDef updated) ov (ListDef glOwners))) + -- resolve a name to its first contact/channel short link + resolveNameLink :: User -> SimplexNameInfo -> CM (ConnShortLink 'CMContact) + resolveNameLink user SimplexNameInfo {nameType, nameDomain} = do + NameRecord {nrSimplexContact, nrSimplexChannel} <- + withAgent $ \a -> resolveSimplexName a nm (aUserId user) nameDomain + let (candidates, ctType) = case nameType of + NTContact -> (nrSimplexContact, CCTContact) + NTPublicGroup -> (nrSimplexChannel, CCTChannel) + maybe (throwChatError $ CESimplexDomainNotReady nameDomain SDENoValidLink) pure $ firstNameLink ctType candidates connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse connectWithPlan user@User {userId} incognito ccLink plan | connectionPlanProceed plan = do @@ -4232,13 +4325,14 @@ processChatCommand cxt nm = \case case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) -> processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId - CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _) - | ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld + CPContactAddress (CAPOk (Just sld) _ vName@(Just _)) -> connectContactViaName sld vName + CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _ vName) + | ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld vName _ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink | otherwise = pure $ CRConnectionPlan user ccLink plan where - joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> CM ChatResponse - joinChannelViaRelays ccl gld = do + joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> Maybe SimplexDomain -> CM ChatResponse + joinChannelViaRelays ccl gld vName = do GroupInfo {groupId} <- prepareChannelGroup processChatCommand cxt nm APIConnectPreparedGroup {groupId, incognito, ownerContact = Nothing, msgContent_ = Nothing} `catchAllErrors` \e -> do @@ -4246,13 +4340,19 @@ processChatCommand cxt nm = \case throwError e where prepareChannelGroup = - processChatCommand cxt nm (APIPrepareGroup userId ccl False gld) >>= \case + processChatCommand cxt nm (APIPrepareGroup userId ccl False vName gld) >>= \case CRNewPreparedChat _ (AChat SCTGroup (Chat (GroupChat gInfo _) _ _)) -> pure gInfo _ -> throwChatError $ CEException "joinChannelViaRelays: unexpected response from APIPrepareGroup" deletePreparedChannel groupId = do gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId deleteGroupConnections user gInfo False withFastStore' $ \db -> deleteGroup db user gInfo + connectContactViaName :: ContactShortLinkData -> Maybe SimplexDomain -> CM ChatResponse + connectContactViaName sld vName = + processChatCommand cxt nm (APIPrepareContact userId ccLink vName sld) >>= \case + CRNewPreparedChat _ (AChat SCTDirect (Chat (DirectChat Contact {contactId}) _ _)) -> + processChatCommand cxt nm (APIConnectPreparedContact contactId incognito Nothing) + _ -> throwChatError $ CEException "connectContactViaName: unexpected response from APIPrepareContact" invitationRequestPlan :: User -> ConnReqInvitation -> Maybe ContactShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan invitationRequestPlan user cReq cld ov = do maybe (CPInvitationLink (ILPOk cld ov)) (invitationEntityPlan cld ov) @@ -4292,13 +4392,13 @@ processChatCommand cxt nm = \case Nothing -> withFastStore' (\db -> getContactWithoutConnViaAddress db cxt user cReqSchemas) >>= \case Just ct | not (contactDeleted ct) -> pure $ CPContactAddress (CAPContactViaAddress ct) - _ -> pure $ CPContactAddress (CAPOk cld ov) + _ -> pure $ CPContactAddress (CAPOk cld ov Nothing) Just (RcvDirectMsgConnection Connection {connStatus} Nothing) - | connStatus == ConnPrepared -> pure $ CPContactAddress (CAPOk cld ov) + | connStatus == ConnPrepared -> pure $ CPContactAddress (CAPOk cld ov Nothing) | otherwise -> pure $ CPContactAddress CAPConnectingConfirmReconnect Just (RcvDirectMsgConnection _ (Just ct)) | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnectingProhibit ct) - | contactDeleted ct -> pure $ CPContactAddress (CAPOk cld ov) + | contactDeleted ct -> pure $ CPContactAddress (CAPOk cld ov Nothing) | otherwise -> pure $ CPContactAddress (CAPKnown ct) -- TODO [short links] RcvGroupMsgConnection branch is deprecated? (old group link protocol?) Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing Nothing Nothing @@ -4313,12 +4413,12 @@ processChatCommand cxt nm = \case connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db cxt user cReqHashes gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db cxt user cReqHashes case (gInfo_, connEnt_) of - (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk linkInfo gld ov) + (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk linkInfo gld ov Nothing) -- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?) (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) - | otherwise -> pure $ CPGroupLink (GLPOk linkInfo gld ov) + | otherwise -> pure $ CPGroupLink (GLPOk linkInfo gld ov Nothing) (Nothing, Just _) -> throwCmdError "found connection entity is not RcvDirectMsgConnection" (Just gInfo, _) -> groupPlan gInfo linkInfo gld ov groupPlan :: GroupInfo -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan @@ -4327,7 +4427,7 @@ processChatCommand cxt nm = \case | not (memberActive membership) && not (memberRemoved membership) = pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo) | memberActive membership = pure $ CPGroupLink (GLPKnown gInfo (BoolDef False) ov (ListDef [])) - | otherwise = pure $ CPGroupLink (GLPOk linkInfo gld ov) + | otherwise = pure $ CPGroupLink (GLPOk linkInfo gld ov Nothing) contactCReqSchemas :: ConnReqUriData -> (ConnReqContact, ConnReqContact) contactCReqSchemas crData = ( CRContactUri crData {crScheme = SSSimplex}, @@ -4665,11 +4765,56 @@ processChatCommand cxt nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId a $ SRGroup gId scope (sendAsGroup' gInfo scope) _ -> throwCmdError "not supported" + preparedGroupFromLink :: User -> CreatedLinkContact -> DirectLink -> GroupShortLinkData -> Maybe SharedMsgId -> Maybe Bool -> CM (GroupInfo, Maybe GroupMember) + preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId nameVerified = do + let GroupShortLinkData {groupProfile = gp, publicGroupData = publicGroupData_} = groupSLinkData + publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ + useRelays = not direct + subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember + gVar <- asks random + withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ nameVerified + getSharedMsgId :: CM SharedMsgId getSharedMsgId = do gVar <- asks random liftIO $ SharedMsgId <$> encodedRandomBytes gVar 12 +firstNameLink :: ContactConnType -> [Text] -> Maybe (ConnShortLink 'CMContact) +firstNameLink ctType = foldr (\t r -> nameLink t <|> r) Nothing + where + nameLink t = case strDecode @(ConnShortLink 'CMContact) (encodeUtf8 t) of + Right sl@(CSLContact _ ct _ _) | ct == ctType -> Just sl + _ -> Nothing + +nameResolvesTo :: ConnShortLink 'CMContact -> [Text] -> Bool +nameResolvesTo sLnk = any (either (const False) (sameShortLinkContact sLnk) . strDecode . encodeUtf8) + +verifyEntityDomain :: User -> NetworkRequestMode -> SimplexNameType -> SimplexDomainClaim -> Maybe AConnShortLink -> CM (Maybe Bool, Maybe Text) +verifyEntityDomain user nm nameType SimplexDomainClaim {domain = StrJSON domain, proof = proof_} connLink_ = case (proof_, connLink_) of + (Nothing, _) -> pure (Nothing, Just "no name proof to verify") + (_, Nothing) -> pure (Nothing, Just "no connection link to check the name against") + (Just proof, Just (ACSL SCMContact profileSLnk)) -> do + NameRecord {nrSimplexContact, nrSimplexChannel} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) domain + let resolvedLinks = case nameType of + NTContact -> nrSimplexContact + NTPublicGroup -> nrSimplexChannel + if not (nameResolvesTo profileSLnk resolvedLinks) + then pure (Just False, Just "the name does not resolve to this address") + else do + ok <- verifyDomainProof proof profileSLnk + pure (Just ok, if ok then Nothing else Just "the name proof was not signed by this address's owner") + (Just _, Just _) -> pure (Nothing, Just "unexpected connection link type for name verification") + where + verifyDomainProof :: SimplexDomainProof -> ShortLinkContact -> CM Bool + verifyDomainProof SimplexDomainProof {linkOwnerId, presHeader, signature} sLnk@(CSLContact _ ct srv key) = do + (FixedLinkData {rootKey}, ContactLinkData _ UserContactData {owners}) <- getShortLinkConnReq nm user sLnk + let ownerKey_ = case linkOwnerId of + Nothing -> Just rootKey + Just (StrJSON oid) -> ownerKey <$> find (\OwnerAuth {ownerId} -> ownerId == oid) owners + pure $ maybe False (\k -> C.verify' k signature proofPayload) ownerKey_ + where + proofPayload = strEncode (Str "simplex_domain_v1", presHeader, domain, ct, srv, key) + data ConnectViaContactResult = CVRConnectedContact Contact | CVRSentInvitation Connection (Maybe Profile) @@ -5146,6 +5291,7 @@ chatCommandP = "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), + "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> strP)), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), @@ -5286,6 +5432,7 @@ chatCommandP = "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayNameP), + "/_public group access #" *> (APISetPublicGroupAccess <$> A.decimal <* A.space <*> jsonP), "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), @@ -5303,8 +5450,8 @@ chatCommandP = "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing) <*> ((" resolve=" *> onOffP) <|> pure False) <*> optional (" sig=" *> jsonP)), - "/_prepare contact " *> (APIPrepareContact <$> A.decimal <* A.space <*> connLinkP <* A.space <*> jsonP), - "/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP' <*> (" direct=" *> onOffP <|> pure True) <* A.space <*> jsonP), + "/_prepare contact " *> (APIPrepareContact <$> A.decimal <* A.space <*> connLinkP <*> optional (" domain=" *> strP) <* A.space <*> jsonP), + "/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP' <*> (" direct=" *> onOffP <|> pure True) <*> optional (" domain=" *> strP) <* A.space <*> jsonP), "/_set contact user @" *> (APIChangePreparedContactUser <$> A.decimal <* A.space <*> A.decimal), "/_set group user #" *> (APIChangePreparedGroupUser <$> A.decimal <* A.space <*> A.decimal), "/_connect contact @" *> (APIConnectPreparedContact <$> A.decimal <*> incognitoOnOffP <*> optional (A.space *> msgContentP)), @@ -5315,6 +5462,8 @@ chatCommandP = "/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), + "/_verify domain @" *> (APIVerifyContactDomain <$> A.decimal), + "/_verify domain #" *> (APIVerifyGroupDomain <$> A.decimal), ForwardMessage <$> chatNameP <* " <- @" <*> displayNameP <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, @@ -5490,10 +5639,10 @@ chatCommandP = onOffP = ("on" $> True) <|> ("off" $> False) publicGroupAccessP = do groupWebPage <- optional (" web=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) - groupDomain <- optional (" domain=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + groupDomain <- optional (" domain=" *> strP) domainWebPage <- (" domain_page=" *> onOffP) <|> pure False allowEmbedding <- (" embed=" *> onOffP) <|> pure False - pure PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} + pure PublicGroupAccess {groupWebPage, groupDomainClaim = mkDomainClaim <$> groupDomain, domainWebPage, allowEmbedding} profileNameDescr = (,) <$> displayNameP <*> shortDescrP -- 'Help with bot':'link ','Menu of commands':[...] botCommandsP :: Parser [ChatBotCommand] @@ -5514,7 +5663,7 @@ chatCommandP = newUserP relay = do (cName, shortDescr) <- profileNameDescr service <- (" service=" *> onOffP) <|> pure False - let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} + let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef relay, clientService = BoolDef service} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space @@ -5523,7 +5672,7 @@ chatCommandP = let preferences = case files_ of Just True -> Nothing _ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}} - profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing} + profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing, contactDomain = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef False, clientService = BoolDef service} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString @@ -5635,9 +5784,9 @@ chatCommandP = srvRolesP = srvRoles <$?> A.takeTill (\c -> c == ':' || c == ',') where srvRoles = \case - "off" -> Right $ ServerRoles False False - "proxy" -> Right ServerRoles {storage = False, proxy = True} - "storage" -> Right ServerRoles {storage = True, proxy = False} + "off" -> Right $ ServerRoles False False False + "proxy" -> Right ServerRoles {storage = False, proxy = True, names = False} + "storage" -> Right ServerRoles {storage = True, proxy = False, names = False} "on" -> Right allRoles _ -> Left "bad ServerRoles" netCfgP = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index c7f44d125d..d34e08401b 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -53,7 +53,8 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds, secondsToDiffTime) -import Simplex.Chat.Badges (BadgeCredential (..), BadgePresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) +import Simplex.Chat.Badges (BadgeCredential (..), ProofPresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) +import Simplex.Chat.Names (SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files @@ -1223,13 +1224,14 @@ introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn sendGroupMemberMessages user gInfo conn evts userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile -userProfileInGroup user = userProfileInGroup' user . groupUserAllowSimplexLinks +userProfileInGroup user g = userProfileInGroup' user (Just g) {-# INLINE userProfileInGroup #-} -userProfileInGroup' :: User -> Bool -> Maybe Profile -> Profile -userProfileInGroup' User {profile = p} allowSimplexLinks incognitoProfile = +-- Nothing group ⇒ no redaction (e.g. joining via a link with no group profile yet). +userProfileInGroup' :: User -> Maybe GroupInfo -> Maybe Profile -> Profile +userProfileInGroup' User {profile = p} mg incognitoProfile = let p' = fromMaybe (fromLocalProfile p) incognitoProfile - in redactedMemberProfile allowSimplexLinks p' + in maybe p' (\g -> redactedMemberProfile g (membership g) p') mg memberInfo :: GroupInfo -> GroupMember -> MemberInfo memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, activeConn} = @@ -1237,16 +1239,18 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a { memberId, memberRole, v = ChatVersionRange . peerChatVRange <$> activeConn, - profile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile memberProfile, + profile = redactedMemberProfile g m $ fromLocalProfile memberProfile, memberKey = MemberKey <$> memberPubKey } - where - allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g && groupFeatureMemberAllowed SGFDirectMessages m g -redactedMemberProfile :: Bool -> Profile -> Profile -redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge} = - Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType, badge} +redactedMemberProfile :: GroupInfo -> GroupMember -> Profile -> Profile +redactedMemberProfile g m Profile {displayName, fullName, shortDescr, image, contactLink = lnk, peerType, badge, contactDomain} = + Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink, preferences = Nothing, peerType, badge, contactDomain = redactedDomain} where + contactLink = if allowSimplexLinks then lnk else Nothing + redactedDomain = if allowDirect then (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain else Nothing + allowDirect = groupFeatureMemberAllowed SGFDirectMessages m g + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g && allowDirect removeSimplexLink s | allowSimplexLinks = Just s | hasObfuscatedSimplexLink s = Nothing @@ -1463,6 +1467,19 @@ updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = G Just PublicGroupData {publicMemberCount} -> Just publicMemberCount /= localCount _ -> False +updateContactFromLinkData :: User -> Contact -> Profile -> CM Contact +updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {contactDomain = prevClaim, contactDomainVerified}} linkProfile@Profile {contactDomain = newClaim} + | profileChanged || verifyChanged = do + cxt <- chatStoreCxt + withFastStore $ \db -> do + ct' <- updateContactProfile db cxt user ct linkProfile + if verifyChanged then liftIO $ setContactDomainVerified db user ct' True else pure ct' + | otherwise = pure ct + where + profileChanged = fromLocalProfile profile /= linkProfile + claimChanged = (claimDomain <$> prevClaim) /= (claimDomain <$> newClaim) + verifyChanged = contactDomainVerified /= Just True || claimChanged + -- TODO [relays] owner: set owners on updating link data (multi-owner) groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}, groupKeys} GroupLink {groupLinkId} groupRelays = @@ -2050,6 +2067,7 @@ presentUserBadge User {profile = LocalProfile {localBadge}} incognitoProfile p = Left e -> p <$ logError ("presentUserBadge: proof generation failed: " <> T.pack e) _ -> pure p + -- receiving side of contact/invitation link data: verify the badge proof from the link profile -- and set the crypto-free display badge for the UI (the raw proof stays in profile for APIPrepareContact) linkDataBadge :: ContactShortLinkData -> CM ContactShortLinkData @@ -2349,9 +2367,8 @@ sendGroupMessages user gInfo scope asGroup members events = do _ -> False sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members - allowSimplexLinks = groupUserAllowSimplexLinks gInfo -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented - profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p + profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile gInfo (membership gInfo) $ fromLocalProfile p void $ sendGroupMessage' user gInfo members' $ XInfo profileUpdate currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -3091,7 +3108,8 @@ simplexTeamContactProfile = contactLink = Just $ CLFull adminContactReq, peerType = Nothing, preferences = Nothing, - badge = Nothing + badge = Nothing, + contactDomain = Nothing } simplexStatusContactProfile :: Profile @@ -3104,7 +3122,8 @@ simplexStatusContactProfile = contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), peerType = Just CPTBot, preferences = Nothing, - badge = Nothing + badge = Nothing, + contactDomain = Nothing } timeItToView :: String -> CM' a -> CM' a diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index c964df2b3d..9cc6778122 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -381,8 +381,6 @@ processAgentMessageConn :: StoreCxt -> User -> ACorrId -> ConnId -> AEvent 'AECo processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = do -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, -- as in this case no need to ACK message - we can't process messages for this connection anyway. - -- SEDBException will be re-trown as CRITICAL as it is likely to indicate a temporary database condition - -- that will be resolved with app restart. entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db cxt user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of @@ -838,8 +836,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XGrpMemInfo memId _memProfile | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership - allowSimplexLinks = groupUserAllowSimplexLinks gInfo - membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile @@ -1400,13 +1397,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = CFSetShortLink -> case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do - (gInfo, gLink, relays, relaysChanged, newlyActiveLinks, newlyActiveGMIds) <- withStore $ \db -> do + (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do gInfo <- getGroupInfo db cxt user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo - (relays', changed, newlyActiveLinks, newlyActiveGMIds) <- liftIO $ foldrM (updateRelay db) ([], False, [], []) relays + (relays', changed, newlyActiveLinks) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays liftIO $ setGroupInProgressDone db gInfo - pure (gInfo, gLink, relays', changed, newlyActiveLinks, newlyActiveGMIds) + pure (gInfo, gLink, relays', changed, newlyActiveLinks) toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged let GroupSummary {publicMemberCount} = groupSummary gInfo -- Owner is counted in publicMemberCount; > 1 means at least one subscriber. @@ -1424,16 +1421,16 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (null recipients) $ void $ sendGroupMessages user gInfo Nothing False recipients events where - updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) -> IO ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) - updateRelay db relay@GroupRelay {groupMemberId, relayLink, relayStatus} (acc, changed, newlyActiveLinks, newlyActiveGMIds) = + updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact]) + updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActiveLinks) = case relayLink of Just rLink -- version is gated upstream at publish (getPublishableGroupRelays): an RSAccepted relay -- whose link is in the published data is necessarily pre-roster, so activate it too | rLink `elem` relayLinks && (relayStatus == RSAcknowledgedRoster || relayStatus == RSAccepted) -> do relay' <- updateRelayStatus db relay RSActive - pure (relay' : acc, True, rLink : newlyActiveLinks, groupMemberId : newlyActiveGMIds) - | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds) + pure (relay' : acc, True, rLink : newlyActiveLinks) + | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActiveLinks) | relayStatus == RSActive -> do -- Relay link absent from link data — deactivate. -- RSAccepted relays are not deactivated: their own link data update @@ -1442,8 +1439,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO the SMP server, but this owner won't receive a LINK callback for it -- TODO (LINK only fires in response to own setConnShortLink calls). relay' <- updateRelayStatus db relay RSInactive - pure (relay' : acc, True, newlyActiveLinks, newlyActiveGMIds) - _ -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds) + pure (relay' : acc, True, newlyActiveLinks) + _ -> pure (relay : acc, changed, newlyActiveLinks) _ -> throwChatError $ CECommandError "LINK event expected for a group link only" _ -> throwChatError $ CECommandError "unexpected cmdFunction" MERR _ err -> do @@ -2812,8 +2809,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure m where - contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p')) - allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo && groupFeatureMemberAllowed SGFDirectMessages m gInfo + contentChanged = not (sameProfileContent (redactedMemberProfile gInfo m (fromLocalProfile p)) (redactedMemberProfile gInfo m p')) updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' @@ -3228,8 +3224,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure toMember subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let allowSimplexLinks = groupUserAllowSimplexLinks gInfo - membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode @@ -3340,7 +3335,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- transfer record + its scratch file in one transaction (file owned by the transfer, keyed per source) rft@RcvFileTransfer {fileId} <- withStore $ \db -> do transferId <- liftIO $ createRosterTransfer db gInfo (groupMemberId' fromMember) newVer fileDigest (groupMemberId' author) brokerTs relayHdr - createRosterRcvFile db userId gInfo fromMember transferId sharedMsgId rosterFInv (Just IFMSent) (fromIntegral chSize) + createRosterRcvFile db userId gInfo fromMember transferId sharedMsgId rosterFInv (Just IFMSent) chSize -- accept the chat-item-free file before chunk 1 (FIFO before it) so chunk 1 isn't rejected on RFSNew -- transient scratch file (consumed into roster_blob, then deleted): temp folder, not the user's files folder / Downloads tmpDir <- lift getChatTempDirectory @@ -3366,10 +3361,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just RcvRosterTransfer {rosterTransferId = transferId, rosterTransferVersion = pendingVer, rosterTransferDigest = pendingDigest, rosterTransferOwnerGMId = ownerGMId, rosterTransferBrokerTs = rosterBrokerTs, rosterTransferHeader = header_} -> do owner_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberById db cxt user ownerGMId) blob <- readAssembledRoster - let isRelay = isUserGrpFwdRelay gInfo + let isRelay' = isUserGrpFwdRelay gInfo ackErr err = do cleanupRosterTransferById transferId - when isRelay $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err) + when isRelay' $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err) if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest then ackErr "relay could not verify the roster blob" else case parseAll rosterBlobP blob of @@ -3393,7 +3388,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = forM_ results_ $ \results -> do emitRosterResults gInfo author rosterBrokerTs results -- ack while setting up (own status accepted/acknowledged); a serving (active) relay must not ack broadcasts. - when (isRelay && (relayOwnStatus gInfo == Just RSAccepted || relayOwnStatus gInfo == Just RSAcknowledgedRoster)) $ do + when (isRelay' && (relayOwnStatus gInfo == Just RSAccepted || relayOwnStatus gInfo == Just RSAcknowledgedRoster)) $ do sendRosterAck gInfo author pendingVer Nothing withStore' $ \db -> void $ updateRelayOwnStatusFromTo db gInfo RSAccepted RSAcknowledgedRoster where diff --git a/src/Simplex/Chat/Names.hs b/src/Simplex/Chat/Names.hs new file mode 100644 index 0000000000..081d7129a5 --- /dev/null +++ b/src/Simplex/Chat/Names.hs @@ -0,0 +1,61 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Names + ( SimplexDomainClaim (..), + SimplexDomainProof (..), + mkDomainClaim, + claimDomain, + ) +where + +import qualified Data.Aeson.TH as JQ +import Simplex.Chat.Badges (ProofPresHeader) +import Simplex.Messaging.Agent.Protocol (OwnerId, SimplexDomain) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON) +import Simplex.Messaging.Util (decodeJSON, encodeJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif + +-- A name claim proof: signed by the address owner's key over proof payload - see verifyDomainProof. +data SimplexDomainProof = SimplexDomainProof + { linkOwnerId :: Maybe (StrJSON "OwnerId" OwnerId), + presHeader :: ProofPresHeader, + signature :: C.Signature 'C.Ed25519 + } + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''SimplexDomainProof) + +instance ToField SimplexDomainProof where toField = toField . encodeJSON + +instance FromField SimplexDomainProof where fromField = fromTextField_ decodeJSON + +data SimplexDomainClaim = SimplexDomainClaim + { domain :: StrJSON "SimplexDomain" SimplexDomain, + proof :: Maybe SimplexDomainProof + } + deriving (Eq, Show) + +mkDomainClaim :: SimplexDomain -> SimplexDomainClaim +mkDomainClaim = (`SimplexDomainClaim` Nothing) . StrJSON + +claimDomain :: SimplexDomainClaim -> SimplexDomain +claimDomain (SimplexDomainClaim n _) = unStrJSON n + +$(JQ.deriveJSON defaultJSON ''SimplexDomainClaim) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 6816a5f692..ad03529613 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -511,7 +511,9 @@ data UserServersError | USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ShortLinkContact} deriving (Show) -data UserServersWarning = USWNoChatRelays {user :: Maybe User} +data UserServersWarning + = USWNoChatRelays {user :: Maybe User} + | USWNoNamesServers {user :: Maybe User} deriving (Show) validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> ([UserServersError], [UserServersWarning]) @@ -552,15 +554,19 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other addAddress (xs, dups) x | any (sameShortLinkContact x) xs = (xs, x : dups) | otherwise = (x : xs, dups) - currUserWarns = noChatRelaysWarns Nothing curr - otherUserWarns (user, uss) = noChatRelaysWarns (Just user) uss + currUserWarns = noChatRelaysWarns Nothing curr <> noNamesServersWarns Nothing curr + otherUserWarns (user, uss) = noChatRelaysWarns (Just user) uss <> noNamesServersWarns (Just user) uss noChatRelaysWarns :: UserServersClass u => Maybe User -> [u] -> [UserServersWarning] - noChatRelaysWarns user uss - | noChatRelays opEnabled = [USWNoChatRelays user] - | otherwise = [] + noChatRelaysWarns user uss = [USWNoChatRelays user | noChatRelays opEnabled] where noChatRelays cond = not $ any relayEnabled $ userChatRelays $ filter cond uss relayEnabled (AUCR _ UserChatRelay {deleted, enabled}) = enabled && not deleted + noNamesServersWarns :: UserServersClass u => Maybe User -> [u] -> [UserServersWarning] + noNamesServersWarns user uss = [USWNoNamesServers user | noNamesServers] + where + noNamesServers = not $ any srvEnabled $ userServers SPSMP $ filter namesEnabled uss + srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted + namesEnabled = maybe True (\op@ServerOperator {enabled} -> enabled && names (operatorRoles SPSMP op)) . operator' userChatRelays :: UserServersClass u => [u] -> [AUserChatRelay] userChatRelays = map aUserChatRelay' . concatMap chatRelays' opEnabled :: UserServersClass u => u -> Bool diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index 53f31e005d..d55d23e2b9 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -39,8 +39,8 @@ operatorFlux = serverDomains = ["simplexonflux.com"], conditionsAcceptance = CARequired Nothing, enabled = True, - smpRoles = ServerRoles {storage = False, proxy = True}, - xftpRoles = ServerRoles {storage = False, proxy = True} + smpRoles = ServerRoles {storage = False, proxy = True, names = True}, + xftpRoles = allRoles } -- Please note: if any servers are removed from the lists below, they MUST be added here. diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index 7d272481f6..4d10945ab6 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -10,7 +10,7 @@ generateRandomProfile :: IO Profile generateRandomProfile = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 2953c1de1f..74d574b643 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -116,15 +116,16 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 |] (userId, contactId, CSActive) toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} + toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn @@ -143,26 +144,26 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.group_domain_verified, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 9c5fe0cd91..3bd481d7db 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -116,7 +116,7 @@ createOrUpdateContactRequest cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection 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, @@ -152,7 +152,7 @@ createOrUpdateContactRequest cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 5068c5c61c..71e5c80a63 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -46,10 +46,12 @@ module Simplex.Chat.Store.Direct deleteContactWithoutGroups, getDeletedContacts, getContactByName, + getContactToConnect, getContact, getContactViaShortLinkToConnect, getContactIdByName, updateContactProfile, + setContactDomainVerified, updateContactUserPreferences, updateContactAlias, updateContactConnectionAlias, @@ -98,6 +100,7 @@ where import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Data.Bifunctor (first) import Data.Either (rights) import Data.Functor (($>)) import Data.Int (Int64) @@ -108,16 +111,18 @@ import Data.Type.Equality import Simplex.Chat.Badges (badgeToRow) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared +import Simplex.Chat.Names (SimplexDomainClaim (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionModeI (..), ConnectionRequestUri, CreatedConnLink (..), UserId) +import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionModeI (..), ConnectionRequestUri, CreatedConnLink (..), SConnectionMode (..), 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 Simplex.Messaging.Crypto.Ratchet (PQSupport, pattern PQSupportOff) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) +import Simplex.Messaging.Util ((<$$>)) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) @@ -321,6 +326,7 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection 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, @@ -397,13 +403,13 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createPreparedContact :: DB.Connection -> StoreCxt -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> ExceptT StoreError IO Contact -createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId = do +createPreparedContact :: DB.Connection -> StoreCxt -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> Maybe Bool -> ExceptT StoreError IO Contact +createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId verified_ = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db cxt user p ctUserPreferences prepared "" currentTs - getContact db cxt user contactId + ct <- getContact db cxt user =<< createContact_ db cxt user p ctUserPreferences prepared "" currentTs + liftIO $ maybe (pure ct) (setContactDomainVerified db user ct) verified_ updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact updatePreparedContactUser @@ -559,22 +565,41 @@ updateContactProfile :: DB.Connection -> StoreCxt -> User -> Contact -> Profile updateContactProfile db cxt user@User {userId} c p' = do currentTs <- liftIO getCurrentTime badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) lp p' - let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + let nameVerified = if claimChanged then Nothing else prevVerification + profile = toLocalProfile profileId p'' localAlias currentTs badgeVerified nameVerified updateContactProfile' currentTs badgeVerified profile where - Contact {contactId, localDisplayName, profile = lp@LocalProfile {profileId, displayName, localAlias}, userPreferences} = c - Profile {displayName = newName, preferences} = p' + Contact {contactId, localDisplayName, profile = lp@LocalProfile {profileId, displayName, localAlias, contactDomain = prevClaim, contactDomainVerified = prevVerification}, userPreferences} = c + Profile {displayName = newName, contactDomain, preferences} = p' mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c + claimChanged = (domain <$> prevClaim) /= (domain <$> contactDomain) + p'' = (p' :: Profile) {contactDomain = (\d -> d {proof = if claimChanged then Nothing else proof =<< prevClaim}) <$> contactDomain} + clearVerificationIfClaimChanged = + when claimChanged $ + DB.execute db "UPDATE contact_profiles SET contact_domain_verified = NULL WHERE user_id = ? AND contact_profile_id = ?" (userId, profileId) updateContactProfile' currentTs badgeVerified profile | displayName == newName = do - liftIO $ updateContactProfile_' db userId profileId p' badgeVerified currentTs + liftIO $ updateContactProfile_' db userId profileId p'' badgeVerified currentTs + liftIO clearVerificationIfClaimChanged pure c {profile, mergedPreferences} | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - updateContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactProfile_' db userId profileId p'' badgeVerified currentTs updateContactLDN_ db user contactId localDisplayName ldn currentTs + clearVerificationIfClaimChanged pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} +setContactDomainVerified :: DB.Connection -> User -> Contact -> Bool -> IO Contact +setContactDomainVerified db User {userId} ct@Contact {contactId, profile = p} verified = do + DB.execute + db + [sql| + UPDATE contact_profiles SET contact_domain_verified = ? + WHERE contact_profile_id IN (SELECT contact_profile_id FROM contacts WHERE user_id = ? AND contact_id = ?) + |] + (BI verified, userId, contactId) + pure (ct {profile = p {contactDomainVerified = Just verified}} :: Contact) + updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do updatedAt <- getCurrentTime @@ -706,16 +731,17 @@ updateContactProfile_ db userId profileId profile badgeVerified = do updateContactProfile_' db userId profileId profile badgeVerified currentTs updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () -updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} badgeVerified updatedAt = +updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, contactDomain, preferences, peerType, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, - badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) -- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs) updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () @@ -724,16 +750,17 @@ updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactDomain, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, - badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () @@ -742,16 +769,17 @@ updateMemberContactProfile_ db userId profileId profile badgeVerified = do updateMemberContactProfile_' db userId profileId profile badgeVerified currentTs updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () -updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = +updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactDomain, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, - badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do @@ -770,6 +798,22 @@ getContactByName db cxt user localDisplayName = do cId <- getContactIdByName db user localDisplayName getContact db cxt user cId +getContactToConnect :: DB.Connection -> StoreCxt -> User -> ContactNameOrLink -> ExceptT StoreError IO (Maybe (CreatedLinkContact, Contact)) +getContactToConnect db cxt user@User {userId} = \case + CTLink sl -> first (`CCLink` Just sl) <$$> getContactViaShortLinkToConnect db cxt user sl + CTName ni -> + liftIO (maybeFirstRow id $ DB.query db byNameQuery (userId, nameDomain ni)) >>= \case + Just (ctId :: Int64, Just (ACR cMode cReq), Just (sLnk :: ShortLinkContact)) | Just Refl <- testEquality cMode SCMContact -> + Just . (CCLink cReq (Just sLnk),) <$> getContact db cxt user ctId + _ -> pure Nothing + where + byNameQuery = + [sql| + SELECT ct.contact_id, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + WHERE ct.user_id = ? AND cp.contact_domain = ? AND cp.contact_domain_verified = 1 AND ct.deleted = 0 + |] + getUserContacts :: DB.Connection -> StoreCxt -> User -> IO [Contact] getUserContacts db cxt user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) @@ -809,7 +853,8 @@ contactRequestQuery = cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) |] @@ -930,6 +975,7 @@ getContact_ db cxt user@User {userId} contactId deleted = do ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection 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, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 60531cc1dc..7ed599007c 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -42,16 +42,18 @@ module Simplex.Chat.Store.Groups setGroupInvitationChatItemId, getGroup, getGroupInfoByUserContactLinkConnReq, - getGroupInfoViaUserShortLink, + getGroupInfoViaUserTarget, getGroupViaShortLinkToConnect, getGroupInfoByGroupLinkHash, updateGroupProfile, + setGroupDomainVerified, updateGroupPreferences, updateGroupProfileFromMember, getGroupIdByName, getGroupMemberIdByName, getActiveMembersByName, getGroupInfoByName, + getGroupToConnect, getGroupMember, getHostMember, getMentionedGroupMember, @@ -205,7 +207,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) -import Data.Bifunctor (second) +import Data.Bifunctor (first, second) import Data.ByteString (ByteString) import qualified Data.ByteString as B import Data.Char (toLower) @@ -220,6 +222,7 @@ import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Badges (BadgeRow, badgeToRow, verifyBadge_) +import Simplex.Chat.Names (SimplexDomainClaim (..)) import Simplex.Chat.Messages import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) @@ -230,7 +233,7 @@ import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRel import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), UserId) +import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), SimplexNameInfo (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) import qualified Simplex.FileTransfer.Description as FD import Simplex.Messaging.Encoding (smpDecode, smpEncode) @@ -251,11 +254,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) toMaybeGroupMember :: UTCTime -> Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) +toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow :. domainRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow :. domainRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) toMaybeGroupMember _ _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -395,9 +398,9 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, - group_web_page, group_domain, domain_web_page, allow_embedding, + group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) @@ -443,7 +446,8 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, - groupKeys + groupKeys, + groupDomainVerified = Nothing } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -521,7 +525,8 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, - groupKeys = Nothing + groupKeys = Nothing, + groupDomainVerified = Nothing }, groupMemberId ) @@ -637,8 +642,8 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) -createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ = do +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> Maybe Bool -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) +createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ verified_ = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs @@ -657,7 +662,8 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b forM_ hostMember_ $ \hostMember -> when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember g <- getGroupInfo db cxt user groupId - pure (g, hostMember_) + g' <- liftIO $ maybe (pure g) (setGroupDomainVerified db user g) verified_ + pure (g', hostMember_) where insertHost_ currentTs groupId groupLDN = do randHostId <- liftIO $ encodedRandomBytes gVar 12 @@ -899,9 +905,9 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus p INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, - group_web_page, group_domain, domain_web_page, allow_embedding, + group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) @@ -1068,6 +1074,21 @@ getGroupInfoByName db cxt user gName = do gId <- getGroupIdByName db user gName getGroupInfo db cxt user gId +getGroupToConnect :: DB.Connection -> StoreCxt -> User -> ContactNameOrLink -> ExceptT StoreError IO (Maybe (CreatedLinkContact, GroupInfo)) +getGroupToConnect db cxt user@User {userId} = \case + CTLink sl -> first (`CCLink` Just sl) <$$> getGroupViaShortLinkToConnect db cxt user sl + CTName ni -> + liftIO (maybeFirstRow id $ DB.query db byNameQuery (userId, nameDomain ni)) >>= \case + Just (gId :: Int64, Just cReq, Just (sLnk :: ShortLinkContact)) -> Just . (CCLink cReq (Just sLnk),) <$> getGroupInfo db cxt user gId + _ -> pure Nothing + where + byNameQuery = + [sql| + SELECT g.group_id, g.conn_full_link_to_connect, g.conn_short_link_to_connect FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.group_domain = ? AND g.group_domain_verified = 1 + |] + getGroupMember :: DB.Connection -> StoreCxt -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMember db cxt user@User {userId} groupId groupMemberId = do currentTs <- liftIO getCurrentTime @@ -1929,7 +1950,7 @@ getRelayPublishableGroups db User {userId, userContactId} = db [sql| SELECT g.group_id, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ? @@ -2432,7 +2453,7 @@ createNewMember_ invitedBy, invitedByGroupMemberId = memInvitedByGroupMemberId, localDisplayName, - memberProfile = toLocalProfile memberContactProfileId memberProfile "" createdAt badgeVerified, + memberProfile = toLocalProfile memberContactProfileId memberProfile "" createdAt badgeVerified Nothing, memberContactId, memberContactProfileId, activeConn, @@ -2619,19 +2640,27 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, publicGroup = oldPublicGroup}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs - pure (g :: GroupInfo) {groupProfile = p', fullGroupPreferences} + clearVerificationIfClaimChanged + pure $ (g' :: GroupInfo) {groupProfile = p', fullGroupPreferences} | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime updateGroupProfile_ currentTs updateGroup_ ldn currentTs - pure $ Right (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences} + clearVerificationIfClaimChanged + pure $ Right $ (g' :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences} where fullGroupPreferences = mergeGroupPreferences groupPreferences + groupClaim pg = domain <$> (pg >>= publicGroupAccess >>= groupDomainClaim) + claimChanged = groupClaim oldPublicGroup /= groupClaim publicGroup + g' = if claimChanged then (g :: GroupInfo) {groupDomainVerified = Nothing} else g + clearVerificationIfClaimChanged = + when claimChanged $ + DB.execute db "UPDATE groups SET group_domain_verified = NULL WHERE user_id = ? AND group_id = ?" (userId, groupId) (groupType_, groupLink_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink} -> (Just groupType, Just groupLink) Nothing -> (Nothing, Nothing) @@ -2642,7 +2671,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, UPDATE group_profiles SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, - group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, + group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id @@ -2658,6 +2687,14 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, (ldn, currentTs, userId, groupId) safeDeleteLDN db user localDisplayName +setGroupDomainVerified :: DB.Connection -> User -> GroupInfo -> Bool -> IO GroupInfo +setGroupDomainVerified db User {userId} g@GroupInfo {groupId} verified = do + DB.execute + db + "UPDATE groups SET group_domain_verified = ? WHERE user_id = ? AND group_id = ?" + (BI verified, userId, groupId) + pure g {groupDomainVerified = Just verified} + updateGroupPreferences :: DB.Connection -> User -> GroupInfo -> GroupPreferences -> IO GroupInfo updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} ps = do currentTs <- getCurrentTime @@ -2690,7 +2727,7 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName [sql| SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id @@ -2716,24 +2753,36 @@ getGroupInfoByUserContactLinkConnReq db cxt user@User {userId} (cReqSchema1, cRe (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db cxt user) groupId_ -getGroupInfoViaUserShortLink :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> IO (Maybe (ConnReqContact, GroupInfo)) -getGroupInfoViaUserShortLink db cxt user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do - (cReq, groupId) <- ExceptT getConnReqGroup - (cReq,) <$> getGroupInfo db cxt user groupId +getGroupInfoViaUserTarget :: DB.Connection -> StoreCxt -> User -> ContactNameOrLink -> IO (Maybe (CreatedLinkContact, GroupInfo)) +getGroupInfoViaUserTarget db cxt user@User {userId} target = fmap eitherToMaybe $ runExceptT $ do + (cReq, sLnk, groupId) <- ExceptT getConnReqGroup + (CCLink cReq (Just sLnk),) <$> getGroupInfo db cxt user groupId where getConnReqGroup = - firstRow' toConnReqGroupId (SEInternalError "group link not found") $ - DB.query - db - [sql| - SELECT conn_req_contact, group_id - FROM user_contact_links - WHERE user_id = ? AND short_link_contact = ? - |] - (userId, shortLink) + firstRow' toConnReqGroupId (SEInternalError "group link not found") $ case target of + CTLink shortLink -> + DB.query + db + [sql| + SELECT conn_req_contact, short_link_contact, group_id + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? + |] + (userId, shortLink) + CTName ni -> + DB.query + db + [sql| + SELECT ucl.conn_req_contact, ucl.short_link_contact, ucl.group_id + FROM user_contact_links ucl + JOIN groups g ON g.group_id = ucl.group_id + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE ucl.user_id = ? AND gp.group_domain = ? + |] + (userId, nameDomain ni) toConnReqGroupId = \case -- cReq is "not null", group_id is nullable - (cReq, Just groupId) -> Right (cReq, groupId) + (cReq, Just (sLnk :: ShortLinkContact), Just groupId) -> Right (cReq, sLnk, groupId) _ -> Left $ SEInternalError "no conn req or group ID" getGroupViaShortLinkToConnect :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> ExceptT StoreError IO (Maybe (ConnReqContact, GroupInfo)) @@ -3286,7 +3335,7 @@ updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profi updateMemberProfile db cxt user@User {userId} m p' = do currentTs <- liftIO getCurrentTime badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' - let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified + let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified Nothing updateMemberProfile' currentTs badgeVerified memberProfile where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m @@ -3309,7 +3358,7 @@ updateContactMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember - updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} p' = do currentTs <- liftIO getCurrentTime badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' - let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified Nothing updateContactMemberProfile' currentTs badgeVerified profile where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 644d73137d..3ddc04253a 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -715,7 +715,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -1139,7 +1139,7 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = do cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -3070,7 +3070,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -3079,14 +3079,14 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, - rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rp.contact_domain, rp.contact_domain_proof, rp.contact_domain_verified, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, - dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbp.contact_domain, dbp.contact_domain_proof, dbp.contact_domain_verified, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 4b814d0434..e17129cd1b 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -38,6 +38,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain import Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster +import Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -75,7 +76,8 @@ schemaMigrations = ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), - ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster) + ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster), + ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260603_simplex_name.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260603_simplex_name.hs new file mode 100644 index 0000000000..14a0ae2a05 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260603_simplex_name.hs @@ -0,0 +1,38 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260603_simplex_name :: Text +m20260603_simplex_name = + [r| +ALTER TABLE contact_profiles ADD COLUMN contact_domain TEXT; +ALTER TABLE contact_profiles ADD COLUMN contact_domain_proof TEXT; +ALTER TABLE contact_profiles ADD COLUMN contact_domain_verified SMALLINT; + +ALTER TABLE group_profiles ADD COLUMN group_domain_proof TEXT; +ALTER TABLE groups ADD COLUMN group_domain_verified SMALLINT; + +ALTER TABLE user_contact_links ADD COLUMN link_priv_sig_key BYTEA; + +ALTER TABLE server_operators ADD COLUMN smp_role_names SMALLINT NOT NULL DEFAULT 0; +UPDATE server_operators SET smp_role_names = 1 WHERE server_operator_tag = 'simplex' OR server_operator_tag = 'flux'; +|] + +down_m20260603_simplex_name :: Text +down_m20260603_simplex_name = + [r| +ALTER TABLE contact_profiles DROP COLUMN contact_domain; +ALTER TABLE contact_profiles DROP COLUMN contact_domain_proof; +ALTER TABLE contact_profiles DROP COLUMN contact_domain_verified; + +ALTER TABLE group_profiles DROP COLUMN group_domain_proof; +ALTER TABLE groups DROP COLUMN group_domain_verified; + +ALTER TABLE user_contact_links DROP COLUMN link_priv_sig_key; + +ALTER TABLE server_operators DROP COLUMN smp_role_names; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 861224ff56..2799a79b81 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -540,7 +540,10 @@ CREATE TABLE test_chat_schema.contact_profiles ( badge_extra text, badge_master_key bytea, badge_signature bytea, - badge_key_idx bigint + badge_key_idx bigint, + contact_domain text, + contact_domain_proof text, + contact_domain_verified smallint ); @@ -866,7 +869,8 @@ CREATE TABLE test_chat_schema.group_profiles ( group_web_page text, group_domain text, domain_web_page bigint, - allow_embedding bigint + allow_embedding bigint, + group_domain_proof text ); @@ -980,7 +984,7 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone, relay_sent_web_domain text, roster_version bigint, @@ -989,7 +993,8 @@ CREATE TABLE test_chat_schema.groups ( roster_msg_signatures bytea, roster_sending_owner_gm_id bigint, roster_broker_ts timestamp with time zone, - roster_blob bytea + roster_blob bytea, + group_domain_verified smallint ); @@ -1377,7 +1382,8 @@ CREATE TABLE test_chat_schema.server_operators ( xftp_role_storage smallint DEFAULT 1 NOT NULL, xftp_role_proxy smallint DEFAULT 1 NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + updated_at timestamp with time zone DEFAULT now() NOT NULL, + smp_role_names smallint DEFAULT 0 NOT NULL ); @@ -1454,7 +1460,8 @@ CREATE TABLE test_chat_schema.user_contact_links ( business_address smallint DEFAULT 0, short_link_contact bytea, short_link_data_set smallint DEFAULT 0 NOT NULL, - short_link_large_data_set smallint DEFAULT 0 NOT NULL + short_link_large_data_set smallint DEFAULT 0 NOT NULL, + link_priv_sig_key bytea ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 043897a995..522f966214 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -50,10 +50,11 @@ module Simplex.Chat.Store.Profiles getUserAddressConnection, deleteUserAddress, getUserAddress, + setUserSimplexDomain, getUserContactLinkById, getGroupLinkInfo, getUserContactLinkByConnReq, - getUserContactLinkViaShortLink, + getUserContactLinkViaTarget, setUserContactLinkShortLink, getContactWithoutConnViaAddress, getContactWithoutConnViaShortAddress, @@ -105,12 +106,13 @@ 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 (..), UserId) +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 @@ -161,7 +163,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di (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 + 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] @@ -332,7 +334,7 @@ updateUserProfile db user p' currentTs <- getCurrentTime updateUserProfileFields_' db userId profileId p' currentTs userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs - pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime @@ -344,7 +346,7 @@ updateUserProfile db user p' (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)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs | userMemberProfileChanged = do @@ -384,6 +386,15 @@ setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBa 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 @@ -406,24 +417,24 @@ getUserContactProfiles db User {userId} = <$> DB.query db [sql| - SELECT display_name, full_name, short_descr, image, contact_link, chat_peer_type, preferences + 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 Preferences) -> Profile - toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences, badge = Nothing} + 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 -> ExceptT StoreError IO () -createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = +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, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (userId, cReq, shortLink, slDataSet, slDataSet, currentTs, currentTs) + "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 @@ -546,10 +557,16 @@ getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = maybeFirstRow toUserContactLink $ DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND conn_req_contact IN (?,?)") (userId, cReqSchema1, cReqSchema2) -getUserContactLinkViaShortLink :: DB.Connection -> User -> ShortLinkContact -> IO (Maybe UserContactLink) -getUserContactLinkViaShortLink db User {userId} shortLink = - maybeFirstRow toUserContactLink $ - DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND short_link_contact = ?") (userId, shortLink) +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 = @@ -752,10 +769,10 @@ updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, db [sql| UPDATE server_operators - SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? + 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 (storage xftpRoles), BI (proxy xftpRoles), currentTs, operatorId) + (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 @@ -790,20 +807,20 @@ getUpdateServerOperators db presetOps newUser = do db [sql| UPDATE server_operators - SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ? + 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 (storage xftpRoles), BI (proxy xftpRoles), operatorId) + (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, xftp_role_storage, xftp_role_proxy) - VALUES (?,?,?,?,?,?,?,?,?) + (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 (storage xftpRoles), BI (proxy xftpRoles)) + (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 = @@ -814,14 +831,14 @@ serverOperatorQuery :: Query serverOperatorQuery = [sql| SELECT server_operator_id, server_operator_tag, trade_name, legal_name, - server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy + 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) -> ServerOperator +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, @@ -831,11 +848,12 @@ toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, BI en serverDomains = T.splitOn "," domains, conditionsAcceptance = CARequired Nothing, enabled, - smpRoles = serverRoles smpRoles', - xftpRoles = serverRoles xftpRoles' + smpRoles = serverRolesSMP smpRoles', + xftpRoles = serverRolesXFTP xftpRoles' } where - serverRoles (BI storage, BI proxy) = ServerRoles {storage, proxy} + 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 diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index c9dd316aee..0cf78db595 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -161,6 +161,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain import Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster +import Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -321,7 +322,8 @@ schemaMigrations = ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), - ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster) + ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster), + ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260603_simplex_name.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260603_simplex_name.hs new file mode 100644 index 0000000000..112f06a0a1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260603_simplex_name.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260603_simplex_name :: Query +m20260603_simplex_name = + [sql| +ALTER TABLE contact_profiles ADD COLUMN contact_domain TEXT; +ALTER TABLE contact_profiles ADD COLUMN contact_domain_proof TEXT; +ALTER TABLE contact_profiles ADD COLUMN contact_domain_verified INTEGER; + +ALTER TABLE group_profiles ADD COLUMN group_domain_proof TEXT; +ALTER TABLE groups ADD COLUMN group_domain_verified INTEGER; + +ALTER TABLE user_contact_links ADD COLUMN link_priv_sig_key BLOB; + +ALTER TABLE server_operators ADD COLUMN smp_role_names INTEGER NOT NULL DEFAULT 0; +UPDATE server_operators SET smp_role_names = 1 WHERE server_operator_tag = 'simplex' OR server_operator_tag = 'flux'; +|] + +down_m20260603_simplex_name :: Query +down_m20260603_simplex_name = + [sql| +ALTER TABLE contact_profiles DROP COLUMN contact_domain; +ALTER TABLE contact_profiles DROP COLUMN contact_domain_proof; +ALTER TABLE contact_profiles DROP COLUMN contact_domain_verified; + +ALTER TABLE group_profiles DROP COLUMN group_domain_proof; +ALTER TABLE groups DROP COLUMN group_domain_verified; + +ALTER TABLE user_contact_links DROP COLUMN link_priv_sig_key; + +ALTER TABLE server_operators DROP COLUMN smp_role_names; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index eefcb8de57..af2fb0162d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -125,7 +125,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection 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, @@ -144,26 +144,26 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.group_domain_verified, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -401,7 +401,7 @@ Query: cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -443,6 +443,14 @@ Query: Plan: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_server_operator_id (server_operator_id=?) +Query: + SELECT conn_req_contact, short_link_contact, group_id + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? + +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + Query: SELECT timed_ttl FROM chat_items @@ -451,6 +459,18 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT ucl.conn_req_contact, ucl.short_link_contact, ucl.group_id + FROM user_contact_links ucl + JOIN groups g ON g.group_id = ucl.group_id + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE ucl.user_id = ? AND gp.group_domain = ? + +Plan: +SEARCH ucl USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET user_id = ?, updated_at = ? @@ -687,7 +707,8 @@ Query: p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 @@ -907,14 +928,6 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_groups_item_viewed (user_id=?) USE TEMP B-TREE FOR ORDER BY -Query: - SELECT conn_req_contact, group_id - FROM user_contact_links - WHERE user_id = ? AND short_link_contact = ? - -Plan: -SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) - Query: SELECT conn_req_contact, group_id FROM user_contact_links @@ -994,7 +1007,7 @@ SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? A Query: SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id @@ -1038,7 +1051,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -1269,9 +1282,9 @@ Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, - group_web_page, group_domain, domain_web_page, allow_embedding, + group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1303,8 +1316,8 @@ Plan: Query: INSERT INTO server_operators - (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) - VALUES (?,?,?,?,?,?,?,?,?) + (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 (?,?,?,?,?,?,?,?,?,?) Plan: @@ -1347,7 +1360,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -1356,14 +1369,14 @@ Query: rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, - rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rp.contact_domain, rp.contact_domain_proof, rp.contact_domain_verified, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, - dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbp.contact_domain, dbp.contact_domain_proof, dbp.contact_domain_verified, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i @@ -1417,6 +1430,7 @@ Query: ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection 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, @@ -1810,7 +1824,7 @@ Query: UPDATE group_profiles SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, - group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, + group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id @@ -1997,6 +2011,7 @@ Query: ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection 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, @@ -2082,7 +2097,7 @@ Query: cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2112,7 +2127,7 @@ Query: cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2142,7 +2157,7 @@ Query: cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -3672,8 +3687,8 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, - cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, -- , ct.user_preferences + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? @@ -3693,6 +3708,15 @@ Plan: SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT ct.contact_id, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + WHERE ct.user_id = ? AND cp.contact_domain = ? AND cp.contact_domain_verified = 1 AND ct.deleted = 0 + +Plan: +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT d.file_descr_id, d.file_descr_text, d.file_descr_part_no, d.file_descr_complete FROM xftp_file_descriptions d @@ -3716,7 +3740,7 @@ SEARCH f USING PRIMARY KEY (file_id=?) SEARCH d USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT display_name, full_name, short_descr, image, contact_link, chat_peer_type, preferences + SELECT display_name, full_name, short_descr, image, contact_link, chat_peer_type, contact_domain, preferences FROM contact_profiles WHERE user_id = ? @@ -3768,9 +3792,18 @@ Query: Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT g.group_id, g.conn_full_link_to_connect, g.conn_short_link_to_connect FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.group_domain = ? AND g.group_domain_verified = 1 + +Plan: +SEARCH g USING INDEX sqlite_autoindex_groups_2 (user_id=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT g.group_id, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ? @@ -5110,7 +5143,8 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, - badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5119,7 +5153,8 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, - badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5128,7 +5163,8 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, - badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5142,6 +5178,15 @@ Query: Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles SET contact_domain_verified = ? + WHERE contact_profile_id IN (SELECT contact_profile_id FROM contacts WHERE user_id = ? AND contact_id = ?) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contacts SET contact_group_member_id = NULL, contact_grp_inv_sent = 0, updated_at = ? @@ -5385,7 +5430,7 @@ SEARCH remote_hosts USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE server_operators - SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? + SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, smp_role_names = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? WHERE server_operator_id = ? Plan: @@ -5468,19 +5513,19 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.group_domain_verified, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5506,19 +5551,19 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.group_domain_verified, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5537,19 +5582,19 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.group_domain_verified, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5572,7 +5617,8 @@ Query: cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.business_group_id = ? @@ -5588,7 +5634,8 @@ Query: cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? AND cr.contact_request_id = ? @@ -5600,7 +5647,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5628,7 +5675,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5649,7 +5696,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5669,7 +5716,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5689,7 +5736,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5709,7 +5756,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5729,7 +5776,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5749,7 +5796,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5769,7 +5816,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5789,7 +5836,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5809,7 +5856,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5829,7 +5876,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5849,7 +5896,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5869,7 +5916,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -5889,7 +5936,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -6073,7 +6120,7 @@ SEARCH remote_hosts USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT server_operator_id, server_operator_tag, trade_name, legal_name, - server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy + server_domains, enabled, smp_role_storage, smp_role_proxy, smp_role_names, xftp_role_storage, xftp_role_proxy FROM server_operators Plan: @@ -6081,7 +6128,7 @@ SCAN server_operators Query: SELECT server_operator_id, server_operator_tag, trade_name, legal_name, - server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy + server_domains, enabled, smp_role_storage, smp_role_proxy, smp_role_names, xftp_role_storage, xftp_role_proxy FROM server_operators WHERE server_operator_id = ? Plan: @@ -6090,7 +6137,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6103,7 +6150,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6117,7 +6164,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6131,7 +6178,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6146,7 +6193,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6160,7 +6207,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6174,7 +6221,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6188,7 +6235,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6202,7 +6249,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6215,7 +6262,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6848,7 +6895,7 @@ Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) @@ -6938,7 +6985,7 @@ Query: INSERT INTO snd_files (file_id, file_status, file_descr_id, group_member_ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, created_at, updated_at) VALUES (?,?,?,?,?,?,?) +Query: 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 (?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) @@ -7426,6 +7473,10 @@ Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1 Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET image = ? WHERE display_name = ? Plan: SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) @@ -7630,6 +7681,14 @@ Query: UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE use Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET group_domain_verified = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET group_domain_verified = NULL WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index d4d395c1dc..954660dce5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -28,7 +28,10 @@ CREATE TABLE contact_profiles( badge_extra TEXT, badge_master_key BLOB, badge_signature BLOB, - badge_key_idx INTEGER + badge_key_idx INTEGER, + contact_domain TEXT, + contact_domain_proof TEXT, + contact_domain_verified INTEGER ) STRICT; CREATE TABLE users( user_id INTEGER PRIMARY KEY, @@ -139,7 +142,8 @@ CREATE TABLE group_profiles( group_web_page TEXT, group_domain TEXT, domain_web_page INTEGER, - allow_embedding INTEGER + allow_embedding INTEGER, + group_domain_proof TEXT ) STRICT; CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID @@ -199,7 +203,8 @@ CREATE TABLE groups( roster_msg_signatures BLOB, roster_sending_owner_gm_id INTEGER, roster_broker_ts TEXT, - roster_blob BLOB, -- received + roster_blob BLOB, + group_domain_verified INTEGER, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -393,6 +398,7 @@ CREATE TABLE user_contact_links( short_link_contact BLOB, short_link_data_set INTEGER NOT NULL DEFAULT 0, short_link_large_data_set INTEGER NOT NULL DEFAULT 0, + link_priv_sig_key BLOB, UNIQUE(user_id, local_display_name) ) STRICT; CREATE TABLE contact_requests( @@ -700,6 +706,8 @@ CREATE TABLE server_operators( xftp_role_proxy INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + smp_role_names INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE TABLE usage_conditions( usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 9096be3a86..0d01b2ac1d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -33,13 +33,14 @@ import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality import Simplex.Chat.Badges (BadgeRow, badgeToRow, rowToBadge, verifyBadge_) +import Simplex.Chat.Names (SimplexDomainProof, SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Messages import Simplex.Chat.Remote.Types import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), UserId, connMode) +import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), SimplexDomain, UserId, connMode) import Simplex.Messaging.Agent.Store (AnyStoreError (..)) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.Common (withSavepoint) @@ -48,6 +49,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Encoding.String (StrJSON (..)) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (AnyError (..)) @@ -413,13 +415,13 @@ createContact db cxt user profile = do void $ createContact_ db cxt user profile emptyChatPrefs Nothing "" currentTs createContact_ :: DB.Connection -> StoreCxt -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs = +createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, contactDomain, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain) profileId <- insertedRowId db DB.execute db @@ -486,13 +488,15 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow :. ContactDomainRow type ContactRow = Only ContactId :. ContactRow' +type ContactDomainRow = (Maybe SimplexDomain, Maybe SimplexDomainProof, Maybe BoolInt) + toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} +toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) :. connRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn @@ -501,6 +505,15 @@ toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName groupDirectInv = toGroupDirectInvitation groupDirectInvRow in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} +rowToContactDomain :: ContactDomainRow -> Maybe SimplexDomainClaim +rowToContactDomain (domain_, domainProof_, _) = (`SimplexDomainClaim` domainProof_) . StrJSON <$> domain_ + +rowToDomainVerified :: ContactDomainRow -> Maybe Bool +rowToDomainVerified (_, _, domainVerification_) = unBI <$> domainVerification_ + +contactDomainToRow :: Maybe SimplexDomainClaim -> (Maybe SimplexDomain, Maybe SimplexDomainProof) +contactDomainToRow d = (claimDomain <$> d, proof =<< d) + toPreparedContact :: PreparedContactRow -> Maybe PreparedContact toPreparedContact (connFullLink, connShortLink, welcomeSharedMsgId, requestSharedMsgId) = (\cl@(ACCL m _) -> PreparedContact {connLinkToConnect = cl, uiConnLinkType = connMode m, welcomeSharedMsgId, requestSharedMsgId}) @@ -524,18 +537,18 @@ getProfileById db userId profileId = do DB.query db [sql| - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, - cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, -- , ct.user_preferences + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? |] (userId, profileId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow :. ContactDomainRow toContactRequest :: UTCTime -> ContactRequestRow -> UserContactRequest -toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow) = do - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias} +toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow :. domainRow) = do + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias} cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in UserContactRequest {contactRequestId, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, createdAt, updatedAt} @@ -544,17 +557,17 @@ userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, - ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow -> User -toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow) = +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where - profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} + profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ @@ -670,16 +683,16 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact, Maybe BoolInt) :. GroupKeysRow :. GroupMemberRow -type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolInt) +type PublicGroupAccessRow = (Maybe Text, Maybe SimplexDomain, Maybe BoolInt, Maybe BoolInt, Maybe SimplexDomainProof) type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow +type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri, groupDomainVerified) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -689,7 +702,7 @@ toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayN businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} - in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys} + in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, groupDomainVerified = unBI <$> groupDomainVerified} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -704,14 +717,14 @@ toPublicGroupProfile _ _ _ _ = Nothing publicGroupAccessRow :: Maybe PublicGroupProfile -> PublicGroupAccessRow publicGroupAccessRow pgp = case pgp >>= publicGroupAccess of - Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} -> - (groupWebPage, groupDomain, Just (BI domainWebPage), Just (BI allowEmbedding)) - Nothing -> (Nothing, Nothing, Nothing, Nothing) + Just PublicGroupAccess {groupWebPage, groupDomainClaim, domainWebPage, allowEmbedding} -> + (groupWebPage, claimDomain <$> groupDomainClaim, Just (BI domainWebPage), Just (BI allowEmbedding), proof =<< groupDomainClaim) + Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing) toPublicGroupAccess :: PublicGroupAccessRow -> Maybe PublicGroupAccess -toPublicGroupAccess (groupWebPage, groupDomain, domainWebPage_, allowEmbedding_) - | isJust groupWebPage || isJust groupDomain || domainWebPage || allowEmbedding = - Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} +toPublicGroupAccess (groupWebPage, groupDomain_, domainWebPage_, allowEmbedding_, groupDomainProof_) + | isJust groupWebPage || isJust groupDomain_ || domainWebPage || allowEmbedding = + Just PublicGroupAccess {groupWebPage, groupDomainClaim = (`SimplexDomainClaim` groupDomainProof_) . StrJSON <$> groupDomain_, domainWebPage, allowEmbedding} | otherwise = Nothing where domainWebPage = maybe False unBI domainWebPage_ @@ -750,7 +763,7 @@ groupMemberQuery = SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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, @@ -767,8 +780,8 @@ toContactMember now cxt User {userContactId} (memberRow :. connRow) = (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile -rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow) = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} +rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow :. domainRow) = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId} @@ -783,19 +796,19 @@ groupInfoQueryFields = SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.group_domain_verified, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link |] diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 538faf8cac..25f8f378d2 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -4,6 +4,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DerivingVia #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -19,6 +20,7 @@ {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilyDependencies #-} +{-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableInstances #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -34,6 +36,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.Attoparsec.Combinator (lookAhead) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString, pack, unpack) import qualified Data.ByteString.Char8 as B @@ -46,16 +49,18 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) +import Data.Type.Equality (testEquality, (:~:) (Refl)) import Data.Typeable (Typeable) import Data.Word (Word16) import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), localBadgeInfo, localBadgeStatus, mkBadgeStatus, verifyBadge) +import Simplex.Chat.Names (SimplexDomainClaim (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) -import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AEventTag (..), AEvtTag (..), ConnId, ConnShortLink (..), ConnectionLink, ConnectionMode (..), ConnectionRequestUri, ContactConnType (..), CreatedConnLink (..), InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AConnectionLink (..), AEventTag (..), AEvtTag (..), ConnId, ConnShortLink (..), ConnectionLink (..), ConnectionMode (..), ConnectionModeI, ConnectionRequestUri, ContactConnType (..), CreatedConnLink (..), InvitationId, SAEntity (..), SConnectionMode (..), SimplexNameInfo, UserId) import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) @@ -493,7 +498,8 @@ data GroupInfo = GroupInfo rosterVersion :: Maybe VersionRoster, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact, - groupKeys :: Maybe GroupKeys + groupKeys :: Maybe GroupKeys, + groupDomainVerified :: Maybe Bool } deriving (Eq, Show) @@ -641,12 +647,6 @@ groupFeatureUserAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupInfo - groupFeatureUserAllowed feature GroupInfo {membership = GroupMember {memberRole}, fullGroupPreferences} = groupFeatureMemberAllowed' feature memberRole fullGroupPreferences --- A connection link in a profile description enables a direct connection, so a description --- keeps its links only when both SimpleX links and direct messages are allowed. -groupUserAllowSimplexLinks :: GroupInfo -> Bool -groupUserAllowSimplexLinks g = - groupFeatureUserAllowed SGFSimplexLinks g && groupFeatureUserAllowed SGFDirectMessages g - mergeUserChatPrefs :: User -> Contact -> FullPreferences mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct) @@ -698,7 +698,8 @@ data Profile = Profile contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, peerType :: Maybe ChatPeerType, - badge :: Maybe BadgeProof + badge :: Maybe BadgeProof, + contactDomain :: Maybe SimplexDomainClaim -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id -- - incognito @@ -731,21 +732,22 @@ instance TextEncoding ChatPeerType where profileFromName :: ContactName -> Profile profileFromName displayName = - Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing} + Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing, contactDomain = Nothing} -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool profilesMatch - LocalProfile {displayName = n1, fullName = fn1, image = i1} - LocalProfile {displayName = n2, fullName = fn2, image = i2} = - n1 == n2 && fn1 == fn2 && i1 == i2 + LocalProfile {displayName = n1, fullName = fn1, image = i1, shortDescr = d1} + LocalProfile {displayName = n2, fullName = fn2, image = i2, shortDescr = d2} = + n1 == n2 && fn1 == fn2 && i1 == i2 && d1 == d2 -- equal for profile-update detection: badge proofs are re-generated for every presentation, -- so compare badges by disclosed info (not proof bytes) - a re-presentation of the same badge is a no-op sameProfileContent :: Profile -> Profile -> Bool sameProfileContent p@Profile {badge = b} p'@Profile {badge = b'} = - p {badge = Nothing} == p' {badge = Nothing} && (proofInfo <$> b) == (proofInfo <$> b') + clearProofs p == clearProofs p' && (proofInfo <$> b) == (proofInfo <$> b') where + clearProofs pr@Profile {contactDomain} = pr {badge = Nothing, contactDomain = (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain} proofInfo :: BadgeProof -> BadgeInfo proofInfo (BadgeProof _ _ _ info) = info @@ -781,29 +783,31 @@ data LocalProfile = LocalProfile preferences :: Maybe Preferences, peerType :: Maybe ChatPeerType, localBadge :: Maybe LocalBadge, - localAlias :: LocalAlias + localAlias :: LocalAlias, + contactDomain :: Maybe SimplexDomainClaim, + contactDomainVerified :: Maybe Bool } deriving (Eq, Show) localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile {profileId} = profileId -toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> LocalProfile -toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} localAlias now verified = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias} +toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> Maybe Bool -> LocalProfile +toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge, contactDomain} localAlias now badgeVerified contactDomainVerified = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias, contactDomain, contactDomainVerified} where - localBadge = (\b@(BadgeProof _ _ _ info) -> PeerBadge b (mkBadgeStatus now verified info)) <$> badge + localBadge = (\b@(BadgeProof _ _ _ info) -> PeerBadge b (mkBadgeStatus now badgeVerified info)) <$> badge fromLocalProfile :: LocalProfile -> Profile -fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge} = - Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge} +fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, contactDomain} = + -- the name proof is re-signed on each send + Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge, contactDomain = (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain} where - -- any stored peer proof rides the wire (receivers verify independently); the own credential is presented fresh, and a display-only badge never sends wireBadge :: LocalBadge -> Maybe BadgeProof wireBadge = \case - PeerBadge b _ -> Just b - OwnBadge _ _ -> Nothing - ShownBadge _ _ -> Nothing + PeerBadge b _ -> Just b -- stored peer proof sent as is + OwnBadge _ _ -> Nothing -- the own credential is not sent, proof is generated on send + ShownBadge _ _ -> Nothing -- a display-only badge is not sent profileBadgeVerified :: Map Int BBSPublicKey -> LocalProfile -> Profile -> IO (Maybe Bool) profileBadgeVerified keys LocalProfile {localBadge} Profile {badge = newBadge} = @@ -842,7 +846,7 @@ instance ToField GroupType where toField = toField . textEncode data PublicGroupAccess = PublicGroupAccess { groupWebPage :: Maybe Text, - groupDomain :: Maybe Text, + groupDomainClaim :: Maybe SimplexDomainClaim, domainWebPage :: Bool, allowEmbedding :: Bool } @@ -1782,6 +1786,46 @@ type ConnReqInvitation = ConnectionRequestUri 'CMInvitation type ConnReqContact = ConnectionRequestUri 'CMContact +data ConnectTarget (m :: ConnectionMode) where + CTFullContact :: ConnectionRequestUri 'CMContact -> ConnectTarget 'CMContact + CTShortContact :: ContactNameOrLink -> ConnectTarget 'CMContact + CTInv :: ConnectionLink 'CMInvitation -> ConnectTarget 'CMInvitation + +data ContactNameOrLink = CTName SimplexNameInfo | CTLink (ConnShortLink 'CMContact) + deriving (Eq, Show) + +deriving instance Eq (ConnectTarget m) + +deriving instance Show (ConnectTarget m) + +data AConnectTarget = forall m. ConnectionModeI m => ACTarget (SConnectionMode m) (ConnectTarget m) + deriving (ToJSON, FromJSON) via (StrJSON "AConnectTarget" AConnectTarget) + +instance Eq AConnectTarget where + ACTarget m t == ACTarget m' t' = case testEquality m m' of + Just Refl -> t == t' + _ -> False + +deriving instance Show AConnectTarget + +instance StrEncoding AConnectTarget where + strEncode (ACTarget _ t) = case t of + CTFullContact cr -> strEncode cr + CTShortContact (CTName n) -> strEncode n + CTShortContact (CTLink sl) -> strEncode sl + CTInv l -> strEncode l + strP = + (ACTarget SCMContact . CTShortContact . CTName <$> (lookAhead nameStart *> strP)) + <|> (aConnectTarget <$> strP) + where + nameStart = "@" <|> "#" <|> "simplex:/name" + +aConnectTarget :: AConnectionLink -> AConnectTarget +aConnectTarget (ACL SCMInvitation cl) = ACTarget SCMInvitation (CTInv cl) +aConnectTarget (ACL SCMContact cl) = ACTarget SCMContact $ case cl of + CLFull cr -> CTFullContact cr + CLShort sl -> CTShortContact (CTLink sl) + type CreatedLinkInvitation = CreatedConnLink 'CMInvitation type CreatedLinkContact = CreatedConnLink 'CMContact diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index cd7a5daea9..763cd1a963 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -52,6 +52,7 @@ import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled +import Simplex.Chat.Names (SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -147,6 +148,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRContactRatchetSyncStarted {} -> ["connection synchronization started"] CRGroupMemberRatchetSyncStarted {} -> ["connection synchronization started"] CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] + CRContactDomainVerified u (Contact {profile = LocalProfile {contactDomain}}) result -> ttyUser u $ viewDomainVerified NTContact (claimDomain <$> contactDomain) result + CRGroupDomainVerified u g result -> ttyUser u $ viewDomainVerified NTPublicGroup (groupSimplexDomain g) result CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView CRNewChatItems u chatItems -> viewChatItems ttyUser unmuted u chatItems ts tz testView @@ -1125,6 +1128,31 @@ simplexChatContact' = \case CLFull (CRContactUri crData) -> CLFull $ CRContactUri crData {crScheme = simplexChat} l@(CLShort _) -> l +groupSimplexDomain :: GroupInfo -> Maybe SimplexDomain +groupSimplexDomain GroupInfo {groupProfile = GroupProfile {publicGroup}} = + claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim) + +viewDomainVerified :: SimplexNameType -> Maybe SimplexDomain -> Maybe Text -> [StyledString] +viewDomainVerified nameType domain_ result = + let nameStr = maybe "name" (\d -> "SimpleX name " <> shortNameInfoStr (SimplexNameInfo nameType d)) domain_ + in case result of + Nothing -> [plain nameStr <> " verified"] + Just reason -> [plain nameStr <> " not verified: " <> plain reason] + +-- §4.7: show a peer's claimed name only with its verification context — "verified" / "verification +-- failed" when a status is recorded, "unverified" when there is a proof but no status yet, and nothing +-- at all when there is neither (an unproven, unverifiable claim is not shown). +simplexDomainLine :: SimplexNameType -> Maybe SimplexDomainClaim -> Maybe Bool -> [StyledString] +simplexDomainLine _ Nothing _ = [] +simplexDomainLine nameType (Just SimplexDomainClaim {domain, proof}) status = case status of + Just True -> [line "verified"] + Just False -> [line "verification failed"] + Nothing + | isJust proof -> [line "unverified"] + | otherwise -> [] + where + line s = plain $ "SimpleX name: " <> shortNameInfoStr (SimplexNameInfo nameType (unStrJSON domain)) <> " (" <> s <> ")" + -- TODO [short links] show all settings viewAddressSettings :: AddressSettings -> [StyledString] viewAddressSettings AddressSettings {businessAddress, autoAccept, autoReply} = case autoAccept of @@ -1142,12 +1170,14 @@ groupLink_ :: StyledString -> GroupInfo -> GroupLink -> [StyledString] groupLink_ intro g GroupLink {connLinkContact = CCLink cReq shortLink, acceptMemberRole} = [ intro, "", - plain $ maybe cReqStr strEncode shortLink, - "", - "Anybody can connect to you and join group as " <> showRole acceptMemberRole <> " with: " <> highlight' "/c ", - "to show it again: " <> highlight ("/show link #" <> viewGroupName g), - "to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)" + plain $ maybe cReqStr strEncode shortLink ] + <> [plain ("SimpleX name: " <> shortNameInfoStr (SimplexNameInfo NTPublicGroup d)) | Just d <- [groupSimplexDomain g]] + <> [ "", + "Anybody can connect to you and join group as " <> showRole acceptMemberRole <> " with: " <> highlight' "/c ", + "to show it again: " <> highlight ("/show link #" <> viewGroupName g), + "to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)" + ] <> ["The group link for old clients: " <> plain cReqStr | isJust shortLink] where cReqStr = strEncode $ simplexChatContact cReq @@ -1223,6 +1253,7 @@ viewGroupLinkRelaysUpdated g groupLink relays = [ "group link:", plain $ maybe cReqStr strEncode shortLink ] + <> [plain ("SimpleX name: " <> shortNameInfoStr (SimplexNameInfo NTPublicGroup d)) | Just d <- [groupSimplexDomain g]] where GroupLink {connLinkContact = CCLink cReq shortLink} = groupLink cReqStr = strEncode $ simplexChatContact cReq @@ -1778,11 +1809,12 @@ viewContactBadge = maybe [] $ \lb -> in [plain (textEncode badgeType <> " badge - " <> st), plain expiry] viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge}, activeConn, uiThemes, customData} stats incognitoProfile = +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> viewContactBadge localBadge <> maybe [] viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink + <> maybe [] (\l -> ["contact address: " <> plain (strEncode (simplexChatContact' l))]) contactLink + <> simplexDomainLine NTContact contactDomain contactDomainVerified <> maybe ["you've shared main profile with this contact"] (\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p]) @@ -2017,9 +2049,9 @@ viewGroupUpdated access = pg >>= publicGroupAccess access' = pg' >>= publicGroupAccess viewAccess Nothing = " removed" - viewAccess (Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}) = + viewAccess (Just PublicGroupAccess {groupWebPage, groupDomainClaim, domainWebPage, allowEmbedding}) = maybe "" (\u -> " web=" <> plain u) groupWebPage - <> maybe "" (\d -> " domain=" <> plain d) groupDomain + <> maybe "" (\ni -> " domain=" <> plain (strEncode ni)) (claimDomain <$> groupDomainClaim) <> (if domainWebPage then " domain_page=on" else "") <> (if allowEmbedding then " embed=on" else "") @@ -2120,12 +2152,12 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case ILPConnecting Nothing -> [invLink "connecting"] ILPConnecting (Just ct) -> [invLink ("connecting to contact " <> ttyContact' ct)] ILPKnown ct - | nextConnectPrepared ct -> [invLink ("known prepared contact " <> ttyContact' ct)] - | contactDeleted ct -> [invLink ("known deleted contact " <> ttyContact' ct)] + | nextConnectPrepared ct -> [invLink ("known prepared contact " <> ttyContact' ct)] <> contactDomainLine ct + | contactDeleted ct -> [invLink ("known deleted contact " <> ttyContact' ct)] <> contactDomainLine ct | otherwise -> - [ invLink ("known contact " <> ttyContact' ct), - "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" - ] + [invLink ("known contact " <> ttyContact' ct)] + <> contactDomainLine ct + <> ["use " <> ttyToContact' ct <> highlight' "" <> " to send messages"] where invLink = ("invitation link: " <>) invOrBiz = \case @@ -2133,17 +2165,17 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case | business -> ("business address: " <>) _ -> ("invitation link: " <>) CPContactAddress cap -> case cap of - CAPOk contactSLinkData ov -> [addrOrBiz contactSLinkData "ok to connect"] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView] + CAPOk contactSLinkData ov _ -> [addrOrBiz contactSLinkData "ok to connect"] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView] CAPOwnLink -> [ctAddr "own address"] CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"] CAPConnectingProhibit ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] CAPKnown ct - | nextConnectPrepared ct -> [ctAddr ("known prepared contact " <> ttyContact' ct)] + | nextConnectPrepared ct -> [ctAddr ("known prepared contact " <> ttyContact' ct)] <> contactDomainLine ct | otherwise -> - [ ctAddr ("known contact " <> ttyContact' ct), - "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" - ] - CAPContactViaAddress ct -> [ctAddr ("known contact without connection " <> ttyContact' ct)] + [ctAddr ("known contact " <> ttyContact' ct)] + <> contactDomainLine ct + <> ["use " <> ttyToContact' ct <> highlight' "" <> " to send messages"] + CAPContactViaAddress ct -> [ctAddr ("known contact without connection " <> ttyContact' ct)] <> contactDomainLine ct where ctAddr = ("contact address: " <>) addrOrBiz = \case @@ -2151,7 +2183,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case | business -> ("business address: " <>) _ -> ("contact address: " <>) CPGroupLink glp -> case glp of - GLPOk groupSLinkInfo_ groupSLinkData ov -> + GLPOk groupSLinkInfo_ groupSLinkData ov _ -> let direct = maybe True (\(GroupShortLinkInfo {direct = d}) -> d) groupSLinkInfo_ in [grpLink $ if direct then "ok to connect directly" else "ok to connect via relays"] <> viewSigVerification ov @@ -2164,17 +2196,17 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case Just PreparedGroup {connLinkStartedConnection} -> case memberStatus m of GSMemUnknown | connLinkStartedConnection -> connecting g - | otherwise -> [knownGroup "prepared "] + | otherwise -> [knownGroup "prepared "] <> groupDomainLine g GSMemAccepted -> connecting g _ - | memberRemoved m -> [knownGroup "deleted "] -- it should not get here, as this plan is returned as GLPOk + | memberRemoved m -> [knownGroup "deleted "] <> groupDomainLine g -- it should not get here, as this plan is returned as GLPOk | otherwise -> knownActive _ -> knownActive where knownActive = - [ knownGroup "", - "use " <> ttyToGroup g Nothing <> highlight' "" <> " to send messages" - ] + [knownGroup ""] + <> groupDomainLine g + <> ["use " <> ttyToGroup g Nothing <> highlight' "" <> " to send messages"] knownGroup prepared = grpOrBizLink g <> ": known " <> prepared <> grpOrBiz g <> " " <> ttyGroup' g GLPNoRelays _ -> [grpLink "channel has no active relays, please try to join later"] GLPUpdateRequired _ -> [grpLink "this group requires a newer version of the app, please upgrade"] @@ -2192,6 +2224,13 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case nextConnectPrepared Contact {preparedContact, activeConn} = case preparedContact of Just _ -> maybe True (\c -> connStatus c == ConnPrepared) activeConn _ -> False + contactDomainLine :: Contact -> [StyledString] + contactDomainLine Contact {profile = LocalProfile {contactDomain, contactDomainVerified}} = + simplexDomainLine NTContact contactDomain contactDomainVerified + groupDomainLine :: GroupInfo -> [StyledString] + groupDomainLine GroupInfo {groupDomainVerified, groupProfile = GroupProfile {publicGroup}} = do + let domain = publicGroup >>= publicGroupAccess >>= groupDomainClaim + in simplexDomainLine NTPublicGroup domain groupDomainVerified viewSigVerification = \case Just OVVerified -> ["owner signature: verified"] Just (OVFailed r) -> ["owner signature: FAILED (" <> plain r <> ")"] @@ -2643,6 +2682,11 @@ viewChatError isCmd logLevel testView = \case CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] CEInvalidConnReq -> viewInvalidConnReq + CESimplexDomainNotReady domain domainErr -> + let reason = case domainErr of + SDENoValidLink -> "has no valid connection link" + SDEUnknownDomain -> "is not included in the connection link's profile" + in [plain $ "SimpleX name " <> strEncode domain <> " " <> reason] CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr] CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> [ plain $ diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 051ee6b304..bfe7b96c7d 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -33,7 +33,7 @@ withBroadcastBot opts test = bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile -broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} +broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts mkBotOpts ps publishers = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 438a3f9802..1c6da2e1ed 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -101,7 +101,7 @@ directoryServiceTests = do it "should update subscriber count periodically" testLinkCheckUpdatesCount directoryProfile :: Profile -directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} +directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c955ca1353..c803570d99 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -57,9 +57,9 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) import qualified Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Protocol (sndAuthKeySMPClientVersion) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) +import NameResolver (NameRegistry, resolverNamesConfig, withNameResolver) import Simplex.Messaging.Server.MsgStore.STM (STMMsgStore) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Server (ServerCredentials (..), mkTransportServerConfig) @@ -202,13 +202,6 @@ testAgentCfg = where RetryInterval2 {riFast, riSlow} = messageRetryInterval aCfg -testAgentCfgNoShortLinks :: AgentConfig -testAgentCfgNoShortLinks = - testAgentCfg - { smpClientVRange = mkVersionRange (Version 1) sndAuthKeySMPClientVersion, -- v3 - smpCfg = (smpCfg testAgentCfg) {serverVRange = mkVersionRange minClientSMPRelayVersion (Version 14)} -- before shortLinksSMPVersion - } - testCfg :: ChatConfig testCfg = defaultChatConfig @@ -221,9 +214,6 @@ testCfg = confirmMigrations = MCYesUp } -testCfgNoShortLinks :: ChatConfig -testCfgNoShortLinks = testCfg {agentConfig = testAgentCfgNoShortLinks} - testAgentCfgVPrev :: AgentConfig testAgentCfgVPrev = testAgentCfg @@ -586,6 +576,8 @@ smpServerCfg = smpAgentCfg = defaultSMPClientAgentConfig, allowSMPProxy = True, serverClientConcurrency = 16, + serverResolverConcurrency = 1000, + namesConfig = Nothing, information = Nothing, startOptions = StartOptions {maintenance = False, compactLog = False, logLevel = LogError, skipWarnings = False, confirmMigrations = MCYesUp} } @@ -599,6 +591,13 @@ withSmpServer = withSmpServer' smpServerCfg withSmpServer' :: ServerConfig STMMsgStore -> IO a -> IO a withSmpServer' cfg = serverBracket (\started -> runSMPServerBlocking started cfg Nothing) +-- | SMP server with a local names resolver attached; the action gets the resolver +-- registry to map names to the addresses it creates. +withSmpServerAndNames :: (NameRegistry -> IO a) -> IO a +withSmpServerAndNames action = + withNameResolver $ \port reg -> + withSmpServer' smpServerCfg {namesConfig = Just (resolverNamesConfig port)} (action reg) + xftpTestPort :: ServiceName xftpTestPort = "7002" diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7acfae1a95..cdc78e2ec8 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1210,20 +1210,20 @@ testOperators = alice <##. "Current conditions: 2." alice ##> "/_operators" alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" - alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled proxy, conditions: required" + alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled, conditions: required" alice <##. "The new conditions will be accepted for SimpleX Chat Ltd, InFlux Technologies Limited at " -- set conditions notified alice ##> "/_conditions_notified 2" alice <## "ok" alice ##> "/_operators" alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" - alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled proxy, conditions: required" + alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled, conditions: required" alice ##> "/_conditions" alice <##. "Current conditions: 2 (notified)." -- accept conditions alice ##> "/_accept_conditions 2 1,2" alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted (" - alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled proxy, conditions: accepted (" + alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled, conditions: accepted (" -- update operators alice ##> "/operators 2:on:smp=proxy:xftp=off" alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted (" diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs new file mode 100644 index 0000000000..7c1ef40155 --- /dev/null +++ b/tests/ChatTests/Names.hs @@ -0,0 +1,143 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PostfixOperators #-} + +module ChatTests.Names where + +import ChatClient +import ChatTests.DBUtils +import ChatTests.Groups (prepareChannel1Relay) +import ChatTests.Utils +import Control.Concurrent.Async (concurrently_) +import qualified Data.Text as T +import NameResolver +import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) +import Test.Hspec hiding (it) + +chatNamesTests :: SpecWith TestParams +chatNamesTests = do + it "connect by resolved name" testConnectByName + it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed + it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed + it "connect by unregistered name fails to resolve" testConnectByNameNotFound + it "set name not resolving to own address is rejected" testSetNameNotOwnAddress + it "connect by channel name" testConnectByChannelName + +testConnectByName :: HasCallStack => TestParams -> IO () +testConnectByName ps = withSmpServerAndNames $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> "/c @alice.simplex" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + _ <- getTermLine bob + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + _ <- getTermLine bob + pure () + +testConnectByNameNotClaimed :: HasCallStack => TestParams -> IO () +testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) + bob ##> "/c @alice.simplex" + bob <## "SimpleX name alice.simplex is not included in the connection link's profile" + +testConnectByNameKnownContactNotClaimed :: HasCallStack => TestParams -> IO () +testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + bob ##> ("/c " <> shortLink) + bob <## "connection request sent!" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) + bob ##> "/c @alice.simplex" + bob <## "SimpleX name alice.simplex is not included in the connection link's profile" + +testConnectByNameNotFound :: HasCallStack => TestParams -> IO () +testConnectByNameNotFound ps = withSmpServerAndNames $ \_reg -> + testChat2 aliceProfile bobProfile test ps + where + test _alice bob = do + bob ##> "/c @nobody.simplex" + bob .<## "smpErr = NAME {nameErr = NOT_FOUND}}" + +testSetNameNotOwnAddress :: HasCallStack => TestParams -> IO () +testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + bob ##> "/ad" + (bobShortLink, _) <- getContactLinks bob True + registerName reg aliceName (contactNameRecord "alice" (T.pack bobShortLink)) + alice ##> "/ad" + _ <- getContactLinks alice True + alice ##> "/_set domain 1 alice.simplex" + alice <## "SimpleX name alice.simplex has no valid connection link" + +testConnectByChannelName :: HasCallStack => TestParams -> IO () +testConnectByChannelName ps = withSmpServerAndNames $ \reg -> + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + (shortLink, _) <- prepareChannel1Relay "team" alice cath + registerName reg teamName (channelNameRecord "team" (T.pack shortLink)) + alice ##> "/public group access #team domain=team.simplex" + alice <## "updated public group access: domain=team.simplex" + cath <## "alice updated group #team: (signed)" + cath <## "updated public group access: domain=team.simplex" + bob ##> "/c #team.simplex" + bob <## "#team: connection started" + concurrentlyN_ + [ bob + <### [ "#team: joining the group (connecting to relay cath)...", + "#team: you joined the group (connected to relay cath)" + ] + , do + cath <## "bob (Bob): accepting request to join group #team..." + cath <## "#team: bob joined the group" + , alice <### [EndsWith "introduced bob (Bob) in the channel"] + ] + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: known group #team" + bob <## "SimpleX name: #team (verified)" + bob <## "use #team to send messages" + where + teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index fb56d39aae..581aaec879 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -65,12 +65,9 @@ chatProfileTests = do it "reject contact and delete contact link" testRejectContactAndDeleteUserContact it "keep connection requests when contact link deleted" testKeepConnectionRequests it "connected contact works when contact link deleted" testContactLinkDeletedConnectedContactWorks - -- TODO [short links] test auto-reply with current version, with connecting client not preparing contact it "auto-reply message" testAutoReplyMessage - it "auto-reply message in incognito" testAutoReplyMessageInIncognito describe "business address" $ do it "create and connect via business address" testBusinessAddress - -- TODO [short links] test business auto-reply with current version, with connecting client not preparing contact it "update profiles with business address" testBusinessUpdateProfiles describe "contact address connection plan" $ do it "contact address ok to connect; known contact" testPlanAddressOkKnown @@ -82,7 +79,6 @@ chatProfileTests = do describe "incognito" $ do it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via contact address" testConnectIncognitoContactAddress - it "accept contact request incognito" testAcceptContactRequestIncognito it "set connection incognito" testSetConnectionIncognito it "reset connection incognito" testResetConnectionIncognito it "set connection incognito prohibited during negotiation" testSetConnectionIncognitoProhibitedDuringNegotiation @@ -501,7 +497,7 @@ testMultiWordProfileNames = aliceProfile' = baseProfile {displayName = "Alice Jones"} bobProfile' = baseProfile {displayName = "Bob James"} cathProfile' = baseProfile {displayName = "Cath Johnson"} - baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} + baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, contactDomain = Nothing} testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = @@ -974,10 +970,10 @@ testContactLinkDeletedConnectedContactWorks = testChat2 aliceProfile bobProfile bob @@@ [("@alice", "hey")] testAutoReplyMessage :: HasCallStack => TestParams -> IO () -testAutoReplyMessage = testChatCfg2 testCfgNoShortLinks aliceProfile bobProfile $ +testAutoReplyMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" - cLink <- getContactLinkNoShortLink alice True + cLink <- getContactLink alice True alice ##> "/auto_accept on incognito=off text hello!" alice <## "auto_accept on" alice <## "auto reply:" @@ -995,31 +991,6 @@ testAutoReplyMessage = testChatCfg2 testCfgNoShortLinks aliceProfile bobProfile alice <## "bob (Bob): contact is connected" ] -testAutoReplyMessageInIncognito :: HasCallStack => TestParams -> IO () -testAutoReplyMessageInIncognito = testChatCfg2 testCfgNoShortLinks aliceProfile bobProfile $ - \alice bob -> do - alice ##> "/ad" - cLink <- getContactLinkNoShortLink alice True - alice ##> "/auto_accept on incognito=on text hello!" - alice <## "auto_accept on, incognito" - alice <## "auto reply:" - alice <## "hello!" - - bob ##> ("/c " <> cLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting contact request..." - alice <## "bob (Bob): you can send messages to contact" - alice <# "i @bob hello!" - aliceIncognito <- getTermLine alice - concurrentlyN_ - [ do - bob <# (aliceIncognito <> "> hello!") - bob <## (aliceIncognito <> ": contact is connected"), - do - alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) - alice <## "use /i bob to print out this incognito profile again" - ] - testBusinessAddress :: HasCallStack => TestParams -> IO () testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice @ Biz"} bobProfile $ \biz alice bob -> do @@ -1075,10 +1046,10 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice (biz <# "#bob bob_1> hey there") testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO () -testBusinessUpdateProfiles = testChatCfg4 testCfgNoShortLinks businessProfile aliceProfile bobProfile cathProfile $ +testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $ \biz alice bob cath -> do biz ##> "/ad" - cLink <- getContactLinkNoShortLink biz True + cLink <- getContactLink biz True biz ##> "/auto_accept on business text Welcome" biz <## "auto_accept on, business" biz <## "auto reply:" @@ -1108,7 +1079,7 @@ testBusinessUpdateProfiles = testChatCfg4 testCfgNoShortLinks businessProfile al biz ##> "/mr alisa alisa_1 admin" biz <## "#alisa: you changed the role of alisa_1 to admin" alice <## "#biz: biz_1 changed your role from member to admin" - connectUsersNoShortLink alice bob + connectUsers alice bob alice ##> "/a #biz bob" alice <## "invitation to join the group #biz sent to bob" bob <## "#biz (Biz Inc): alisa invites you to join the group as member" @@ -1139,7 +1110,7 @@ testBusinessUpdateProfiles = testChatCfg4 testCfgNoShortLinks businessProfile al alice <# "#biz robert> hi there" biz <# "#alisa robert> hi there" -- add business team member - connectUsersNoShortLink biz cath + connectUsers biz cath biz ##> "/a #alisa cath" biz <## "invitation to join the group #alisa sent to cath" cath <## "#alisa: biz invites you to join the group as member" @@ -1628,54 +1599,6 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ (bob TestParams -> IO () -testAcceptContactRequestIncognito = testChatCfg3 testCfgNoShortLinks aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - alice ##> "/ad" - cLink <- getContactLinkNoShortLink alice True - -- GUI /_accept api - bob ##> ("/c " <> cLink) - alice <#? bob - alice ##> "/_accept incognito=on 1" - alice <## "bob (Bob): accepting contact request, you can send messages to contact" - aliceIncognitoBob <- getTermLine alice - concurrentlyN_ - [ bob <## (aliceIncognitoBob <> ": contact is connected"), - do - alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognitoBob) - alice <## "use /i bob to print out this incognito profile again" - ] - -- conversation is incognito - alice ?#> "@bob my profile is totally inconspicuous" - bob <# (aliceIncognitoBob <> "> my profile is totally inconspicuous") - bob #> ("@" <> aliceIncognitoBob <> " I know!") - alice ?<# "bob> I know!" - -- list contacts - alice ##> "/contacts" - alice <## "i bob (Bob)" - alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognitoBob] - -- delete contact, incognito profile is deleted - alice ##> "/d bob" - alice <## "bob: contact is deleted" - bob <## (aliceIncognitoBob <> " deleted contact with you") - alice ##> "/contacts" - (alice ("/c " <> cLink) - alice <#? cath - alice ##> "/accept incognito cath" - alice <## "cath (Catherine): accepting contact request, you can send messages to contact" - aliceIncognitoCath <- getTermLine alice - concurrentlyN_ - [ cath <## (aliceIncognitoCath <> ": contact is connected"), - do - alice <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> aliceIncognitoCath) - alice <## "use /i cath to print out this incognito profile again" - ] - alice `hasContactProfiles` ["alice", "cath", T.pack aliceIncognitoCath] - cath `hasContactProfiles` ["cath", T.pack aliceIncognitoCath] - testSetConnectionIncognito :: HasCallStack => TestParams -> IO () testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index e0e694f0cb..a613a13df6 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -88,7 +88,7 @@ serviceProfile :: Profile serviceProfile = mkProfile "service_user" "Service user" Nothing mkProfile :: T.Text -> T.Text -> Maybe ImageData -> Profile -mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} +mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, contactDomain = Nothing} it :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expectation)) it name test = diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 2a5328ff26..e315b59f5e 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -10,7 +10,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown -import Simplex.Messaging.Agent.Protocol (SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) +import Simplex.Messaging.Agent.Protocol (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$$>)) import System.Console.ANSI.Types @@ -400,7 +400,7 @@ command' :: Text -> Text -> FormattedText command' = FormattedText . Just . Command sname :: SimplexNameType -> SimplexTLD -> Text -> [Text] -> Text -> Markdown -sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt (SimplexNameDomain ns dom sub)) (pfx <> txt) +sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt (SimplexDomain ns dom sub)) (pfx <> txt) where pfx = case nt of NTPublicGroup -> "#"; NTContact -> "@" diff --git a/tests/NameResolver.hs b/tests/NameResolver.hs new file mode 100644 index 0000000000..b7c0aaf2fc --- /dev/null +++ b/tests/NameResolver.hs @@ -0,0 +1,83 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Local HTTP names resolver for chat tests, copied from simplexmq's +-- NamesResolverServer and made dynamic: it answers /resolve/ from a +-- mutable name -> NameRecord registry, so a test can resolve a name to the +-- address it just created. +module NameResolver + ( NameRegistry, + withNameResolver, + registerName, + contactNameRecord, + channelNameRecord, + resolverNamesConfig, + ) +where + +import Control.Concurrent.STM +import qualified Data.Aeson as J +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Text (Text) +import Network.HTTP.Types (hContentType, notFound404, ok200) +import Network.Wai (Application, pathInfo, responseLBS) +import qualified Network.Wai.Handler.Warp as Warp +import Simplex.Messaging.Names.Record (NameRecord (..)) +import Simplex.Messaging.Server.Names (NamesConfig (..)) +import Simplex.Messaging.SimplexName (SimplexNameInfo (..), fullDomainName) + +type NameRegistry = TVar (Map Text NameRecord) + +-- | Run an action with a local resolver on a free port and its registry (keyed +-- by full domain name, what the resolver looks the name up by). +withNameResolver :: (Int -> TVar (Map Text NameRecord) -> IO a) -> IO a +withNameResolver action = do + reg <- newTVarIO M.empty + Warp.withApplication (pure (app reg)) $ \port -> action port reg + where + app :: TVar (Map Text NameRecord) -> Application + app reg req send = do + (st, body) <- case pathInfo req of + ["health"] -> pure (ok200, "{}") + ["resolve", d] -> maybe (notFound404, "{}") (\r -> (ok200, J.encode r)) . M.lookup d <$> readTVarIO reg + _ -> pure (notFound404, "{}") + send $ responseLBS st [(hContentType, "application/json")] body + +-- | Register a name's domain to resolve to the given record. +registerName :: TVar (Map Text NameRecord) -> SimplexNameInfo -> NameRecord -> IO () +registerName reg SimplexNameInfo {nameDomain} r = + atomically $ modifyTVar' reg $ M.insert (fullDomainName nameDomain) r + +contactNameRecord :: Text -> Text -> NameRecord +contactNameRecord name link = (emptyRecord name) {nrSimplexContact = [link]} + +channelNameRecord :: Text -> Text -> NameRecord +channelNameRecord name link = (emptyRecord name) {nrSimplexChannel = [link]} + +emptyRecord :: Text -> NameRecord +emptyRecord name = + NameRecord + { nrName = name, + nrNickname = "", + nrWebsite = "", + nrLocation = "", + nrSimplexContact = [], + nrSimplexChannel = [], + nrEth = Nothing, + nrBtc = Nothing, + nrXmr = Nothing, + nrDot = Nothing, + nrOwner = "", + nrResolver = "" + } + +-- | NamesConfig for a chat test SMP server pointing at this resolver. +resolverNamesConfig :: Int -> NamesConfig +resolverNamesConfig port = + NamesConfig + { resolverEndpoint = "http://127.0.0.1:" <> show port, + resolverAuth = Nothing, + resolverTimeoutMs = 1000, + resolverMaxResponseBytes = 65536 + } diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 5e659dd82c..d0b48a9019 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -38,9 +38,9 @@ validateServersTest :: Spec validateServersTest = describe "validate user servers" $ do it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` ([], []) it "should fail without servers" $ do - validateUserServers [invalidNoServers] [] `shouldBe` ([USENoServers aSMP Nothing], []) - validateUserServers [invalidDisabled] [] `shouldBe` ([USENoServers aSMP Nothing], []) - validateUserServers [invalidDisabledOp] [] `shouldBe` ([USENoServers aSMP Nothing, USENoServers aXFTP Nothing], [USWNoChatRelays Nothing]) + validateUserServers [invalidNoServers] [] `shouldBe` ([USENoServers aSMP Nothing], [USWNoNamesServers Nothing]) + validateUserServers [invalidDisabled] [] `shouldBe` ([USENoServers aSMP Nothing], [USWNoNamesServers Nothing]) + validateUserServers [invalidDisabledOp] [] `shouldBe` ([USENoServers aSMP Nothing, USENoServers aXFTP Nothing], [USWNoChatRelays Nothing, USWNoNamesServers Nothing]) it "should fail without servers with storage role" $ do validateUserServers [invalidNoStorage] [] `shouldBe` ([USEStorageMissing aSMP Nothing], []) it "should fail with duplicate host" $ do diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 0cd79ac513..b36b7faeaf 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -80,5 +80,7 @@ skipComparisonForDownMigrations = -- group_member_intro_id field moves "20251128_migrate_member_relations", -- on down migration single_sender_group_member_id column is re-added at the end of the table - "20260529_delivery_job_senders" + "20260529_delivery_job_senders", + -- group_domain is removed + "20260603_simplex_name" ] diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2592420d01..3cfb367382 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -3,6 +3,7 @@ {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module ProtocolTests where @@ -107,7 +108,7 @@ testGroupPreferences :: Maybe GroupPreferences testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} testProfile :: Profile -testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing} +testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} testGroupProfile :: GroupProfile testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} @@ -240,7 +241,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XInfo testProfile it "x.info with empty full name" $ "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing} + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} it "x.contact with xContactId" $ "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing Nothing diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 2336fd56dd..783f6587bf 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -145,7 +145,9 @@ skipComparisonForDownMigrations = -- on down migration single_sender_group_member_id column and its index -- are re-added at the end of the table / file (ALTER TABLE ADD COLUMN -- appends; CREATE INDEX appends). - "20260529_delivery_job_senders" + "20260529_delivery_job_senders", + -- group_domain is removed + "20260603_simplex_name" ] getSchema :: FilePath -> FilePath -> IO String diff --git a/tests/Test.hs b/tests/Test.hs index 874428bc1f..515098c5d1 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -8,6 +8,7 @@ import Bots.DirectoryTests import ChatClient import ChatTests import ChatTests.DBUtils +import ChatTests.Names (chatNamesTests) import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System @@ -71,6 +72,9 @@ main = do describe "Message batching" batchingTests describe "Operators" operatorTests describe "Random servers" randomServersTests +#if !defined(dbPostgres) + around (tmpTestBracket chatQueryStats agentQueryStats) $ describe "names tests" chatNamesTests +#endif #if defined(dbPostgres) createdDropDb . around testBracket #else @@ -96,6 +100,8 @@ main = do #else testBracket chatQueryStats agentQueryStats test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats, printOutput = False} + tmpTestBracket chatQueryStats agentQueryStats test = + tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats, printOutput = False} #endif tmpBracket test = do t <- getSystemTime