diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 919f83d108..739d270060 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2533,10 +2533,22 @@ public enum GroupType: Codable, Hashable { } } +public struct PublicGroupAccess: Codable, Hashable { + public var groupWebPage: String? + public var groupDomain: String? + public var domainWebPage: Bool = false + public var allowEmbedding: Bool = false +} + +public struct RelayCapabilities: Codable, Hashable { + public var baseWebUrl: String? +} + public struct PublicGroupProfile: Codable, Hashable { public var groupType: GroupType public var groupLink: String public var publicGroupId: String + public var publicGroupAccess: PublicGroupAccess? } public struct GroupProfile: Codable, NamedChat, Hashable { @@ -2705,6 +2717,7 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { public var userChatRelay: UserChatRelay public var relayStatus: RelayStatus public var relayLink: String? + public var relayCap: RelayCapabilities public var id: Int64 { groupRelayId } } 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 f3abc6801e..f3da4334d3 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 @@ -2240,11 +2240,25 @@ object GroupTypeSerializer : KSerializer { } } +@Serializable +data class PublicGroupAccess( + val groupWebPage: String? = null, + val groupDomain: String? = null, + val domainWebPage: Boolean = false, + val allowEmbedding: Boolean = false +) + +@Serializable +data class RelayCapabilities( + val baseWebUrl: String? = null +) + @Serializable data class PublicGroupProfile( val groupType: GroupType, val groupLink: String, - val publicGroupId: String + val publicGroupId: String, + val publicGroupAccess: PublicGroupAccess? = null ) @Serializable @@ -2368,7 +2382,8 @@ data class GroupRelay( val groupMemberId: Long, val userChatRelay: UserChatRelay, val relayStatus: RelayStatus, - val relayLink: String? = null + val relayLink: String? = null, + val relayCap: RelayCapabilities ) { val id: Long get() = groupRelayId } diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index ac7da8e3e5..996749fd7d 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -146,6 +146,7 @@ This file is generated automatically. - [Profile](#profile) - [ProxyClientError](#proxyclienterror) - [ProxyError](#proxyerror) +- [PublicGroupAccess](#publicgroupaccess) - [PublicGroupData](#publicgroupdata) - [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) @@ -157,6 +158,7 @@ This file is generated automatically. - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) - [RcvMsgError](#rcvmsgerror) +- [RelayCapabilities](#relaycapabilities) - [RelayProfile](#relayprofile) - [RelayStatus](#relaystatus) - [ReportReason](#reportreason) @@ -2496,6 +2498,7 @@ UpdateRequired: - userChatRelay: [UserChatRelay](#userchatrelay) - relayStatus: [RelayStatus](#relaystatus) - relayLink: string? +- relayCap: [RelayCapabilities](#relaycapabilities) --- @@ -3066,6 +3069,17 @@ NO_SESSION: - type: "NO_SESSION" +--- + +## PublicGroupAccess + +**Record type**: +- groupWebPage: string? +- groupDomain: string? +- domainWebPage: bool +- allowEmbedding: bool + + --- ## PublicGroupData @@ -3082,6 +3096,7 @@ NO_SESSION: - groupType: [GroupType](#grouptype) - groupLink: string - publicGroupId: string +- publicGroupAccess: [PublicGroupAccess](#publicgroupaccess)? --- @@ -3339,6 +3354,14 @@ ParseError: - parseError: string +--- + +## RelayCapabilities + +**Record type**: +- baseWebUrl: string? + + --- ## RelayProfile diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index c759a7453c..8397503bbe 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -327,6 +327,7 @@ chatTypesDocsData = (sti @Profile, STRecord, "", [], "", ""), (sti @ProxyClientError, STUnion, "Proxy", [], "", ""), (sti @ProxyError, STUnion, "", [], "", ""), + (sti @PublicGroupAccess, STRecord, "", [], "", ""), (sti @PublicGroupData, STRecord, "", [], "", ""), (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), @@ -338,6 +339,7 @@ chatTypesDocsData = (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), (sti @RcvMsgError, STUnion, "RME", [], "", ""), + (sti @RelayCapabilities, STRecord, "", [], "", ""), (sti @RelayProfile, STRecord, "", [], "", ""), (sti @RelayStatus, STEnum, "RS", [], "", ""), (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), @@ -546,6 +548,7 @@ deriving instance Generic PreparedGroup deriving instance Generic Profile deriving instance Generic ProxyClientError deriving instance Generic ProxyError +deriving instance Generic PublicGroupAccess deriving instance Generic PublicGroupData deriving instance Generic PublicGroupProfile deriving instance Generic RatchetSyncState @@ -557,6 +560,7 @@ deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent deriving instance Generic RcvMsgError +deriving instance Generic RelayCapabilities deriving instance Generic RelayProfile deriving instance Generic RelayStatus deriving instance Generic ReportReason diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 851b4a4497..a0a5c8e778 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2770,6 +2770,7 @@ export interface GroupRelay { userChatRelay: UserChatRelay relayStatus: RelayStatus relayLink?: string + relayCap: RelayCapabilities } export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public @@ -3351,6 +3352,13 @@ export namespace ProxyError { } } +export interface PublicGroupAccess { + groupWebPage?: string + groupDomain?: string + domainWebPage: boolean + allowEmbedding: boolean +} + export interface PublicGroupData { publicMemberCount: number // int64 } @@ -3359,6 +3367,7 @@ export interface PublicGroupProfile { groupType: GroupType groupLink: string publicGroupId: string + publicGroupAccess?: PublicGroupAccess } export type RCErrorType = @@ -3747,6 +3756,10 @@ export namespace RcvMsgError { } } +export interface RelayCapabilities { + baseWebUrl?: string +} + export interface RelayProfile { displayName: string fullName: string 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 fdde144307..c422b10fd1 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1945,6 +1945,7 @@ class GroupRelay(TypedDict): userChatRelay: "UserChatRelay" relayStatus: "RelayStatus" relayLink: NotRequired[str] + relayCap: "RelayCapabilities" class GroupRootKey_private(TypedDict): type: Literal["private"] @@ -2353,6 +2354,12 @@ ProxyError = ProxyError_PROTOCOL | ProxyError_BROKER | ProxyError_BASIC_AUTH | P ProxyError_Tag = Literal["PROTOCOL", "BROKER", "BASIC_AUTH", "NO_SESSION"] +class PublicGroupAccess(TypedDict): + groupWebPage: NotRequired[str] + groupDomain: NotRequired[str] + domainWebPage: bool + allowEmbedding: bool + class PublicGroupData(TypedDict): publicMemberCount: int # int64 @@ -2360,6 +2367,7 @@ class PublicGroupProfile(TypedDict): groupType: "GroupType" groupLink: str publicGroupId: str + publicGroupAccess: NotRequired["PublicGroupAccess"] class RCErrorType_internal(TypedDict): type: Literal["internal"] @@ -2628,6 +2636,9 @@ RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError RcvMsgError_Tag = Literal["dropped", "parseError"] +class RelayCapabilities(TypedDict): + baseWebUrl: NotRequired[str] + class RelayProfile(TypedDict): displayName: str fullName: str diff --git a/plans/2026-05-25-channel-web-preview.md b/plans/2026-05-25-channel-web-preview.md new file mode 100644 index 0000000000..de0f02506b --- /dev/null +++ b/plans/2026-05-25-channel-web-preview.md @@ -0,0 +1,596 @@ +# Channel Web Preview + +## Context + +SimpleX channels are public - anybody with the link to join and chat relays rebroadcasting the messages can see content. To grow channels, owners need a public web preview (like Telegram's `t.me/s/channelname`) showing the last 50 messages. This lets potential subscribers browse before joining. + +The relay already stores all messages in its database. The web preview is a periodic read-and-render loop that writes JSON files served by Caddy, with CORS controlling which domains can embed the preview. + +This feature integrates with the `.simplex` namespace (ENS-based names resolving to channel links). A channel's registered domain (`groupDomain`) lives in `PublicGroupAccess` inside `PublicGroupProfile` and is disseminated with the profile. On-chain verification of the domain is deferred until RSLV resolution protocol ships. + +## Architecture + +``` +simplex-chat CLI (--relay --web-json-dir=... --web-base-url=...) + ├── Main chat loop (existing) + ├── Relay logic (existing, gated by --relay) + └── Web preview thread (new, gated by relayWebOptions) + ├── Periodic: load publishable groups → render JSON → write files + └── Regenerate Caddy CORS config → caddy reload + +Caddy (operator-configured) + ├── Serves JSON at /.json + └── Imports generated CORS config file + +Channel page (static HTML+JS, hosted by owner or on GitHub) + ├── Fetches JSON from relay(s) with fallback + └── Renders messages, shows join button +``` + +## Data Model Changes + +### 1. Extend `PublicGroupProfile` with domain and web access settings + +**File:** `src/Simplex/Chat/Types.hs` (line 796) + +Current: +```haskell +data PublicGroupProfile = PublicGroupProfile + { groupType :: GroupType, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString + } +``` + +New: +```haskell +data PublicGroupAccess = PublicGroupAccess + { groupWebPage :: Maybe Text, -- channel's web page URL (adds CORS origin) + groupDomain :: Maybe Text, -- domain for this channel (must have link set in domain record in the contract) + domainWebPage :: Bool, -- show on the domain's page (e.g. simplexnetwork.org site for simplex TLD domains, or domain site for web domains) + allowEmbeding :: Bool -- allow embedding from any origin (CORS: *) + } + +data PublicGroupProfile = PublicGroupProfile + { groupType :: GroupType, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, + publicGroupAccess :: Maybe PublicGroupAccess -- NEW: web preview settings + } +``` + +`groupDomain` stores the channel's registered `.simplex` domain name or another supported TLD. It is: +- Set by the owner after registering a name on-chain +- Disseminated to all members via `GroupProfile` (nested in `publicGroup`) +- Used by `simplexnetwork.org/c/` to route to the channel's web preview (for .simplex domain) + +JSON instances: TH-derived `$(JQ.deriveJSON defaultJSON ''PublicGroupAccess)`. Existing `$(JQ.deriveJSON defaultJSON ''PublicGroupProfile)` covers the new optional field. + +**Migration (SQLite/Postgres):** separate columns, same pattern as `group_type`/`group_link`/`public_group_id`: +```sql +ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT; +ALTER TABLE group_profiles ADD COLUMN group_domain TEXT; +ALTER TABLE group_profiles ADD COLUMN domain_web_page INTEGER; +ALTER TABLE group_profiles ADD COLUMN allow_embedding INTEGER; +ALTER TABLE group_profiles ADD COLUMN group_domain_verified_at TEXT; +``` + +`group_domain_verified_at` is relay-local verification state (nullable timestamp, NULL = unverified). + +**Store changes:** + +`src/Simplex/Chat/Store/Shared.hs` line 693 - new constructor alongside `toPublicGroupProfile`: +```haskell +toPublicGroupAccess :: Maybe Text -> Maybe Text -> Maybe BoolInt -> Maybe BoolInt -> Maybe PublicGroupAccess +toPublicGroupAccess groupWebPage groupDomain domainWebPage_ allowEmbeding_ + | isJust groupWebPage || isJust groupDomain || fromBI domainWebPage_ || fromBI allowEmbeding_ = + Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage = fromBI domainWebPage_, allowEmbeding = fromBI allowEmbeding_} + | otherwise = Nothing + where fromBI = maybe False unBI +``` + +Extend `toPublicGroupProfile` to accept and pass through `Maybe PublicGroupAccess`. + +`GroupInfoRow` type (line 668) gains columns for: `group_web_page`, `group_domain`, `domain_web_page`, `allow_embedding`, `group_domain_verified_at`. + +`src/Simplex/Chat/Store/Groups.hs`: +- INSERT (line 367): add all new columns +- SELECT (line 2375): add `gp.group_web_page`, `gp.group_domain`, `gp.domain_web_page`, `gp.allow_embedding`, `gp.group_domain_verified_at` +- UPDATE (line 1922): include new columns in `updateGroupProfile_` + +### 2. `RelayCapabilities` record, extend `XGrpRelayAcpt`, new `XGrpRelayCap` + +**File:** `src/Simplex/Chat/Protocol.hs` + +New record for relay capabilities (extensible for future fields): +```haskell +data RelayCapabilities = RelayCapabilities + { baseWebUrl :: Maybe Text + } +``` + +TH-derived JSON. All fields optional so old relays produce `{}` and new fields are backward compatible. + +**`XGrpRelayAcpt`** - carries capabilities at acceptance time: + +Current (line 444): `XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json` +New: `XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json` + +Parsing: `XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (p "relayCap" <|> pure defaultRelayCap)` +Encoding: `XGrpRelayAcpt relayLink cap -> o ["relayLink" .= relayLink, "relayCap" .= cap]` +Backward compatible: old relays omit `relayCap`, parsed as default (all `Nothing`). + +**`XGrpRelayCap`** - new message for ongoing capability updates: + +```haskell +XGrpRelayCap :: RelayCapabilities -> ChatMsgEvent 'Json +``` + +Tag: `"x.grp.relay.cap"` +Parsing: `XGrpRelayCap_ -> XGrpRelayCap <$> p "relayCap"` +Encoding: `XGrpRelayCap cap -> o ["relayCap" .= cap]` + +Sent by relay to owner only when capabilities change (not periodic). Relay detects change by comparing current config against persisted state on startup. + +### 3. Store `baseWebUrl` per relay + +**File:** `src/Simplex/Chat/Operators.hs` (line 278) + +Current: +```haskell +data GroupRelay = GroupRelay + { groupRelayId :: Int64, + groupMemberId :: Int64, + userChatRelay :: UserChatRelay, + relayStatus :: RelayStatus, + relayLink :: Maybe ShortLinkContact + } +``` + +Add: `relayCap :: Maybe RelayCapabilities` + +Stored as separate columns (same pattern as `PublicGroupAccess`): +**Migration:** `ALTER TABLE group_relays ADD COLUMN base_web_url TEXT` + +`relayCap` constructed from columns: `Just RelayCapabilities {baseWebUrl}` when any capability column is non-NULL, `Nothing` otherwise. + +**Handlers in `src/Simplex/Chat/Library/Subscriber.hs`:** +- `XGrpRelayAcpt` (line 770): store `RelayCapabilities` in relay record on acceptance +- `XGrpRelayCap` (new handler): update `RelayCapabilities` in relay record; only accepted from relay members (`isRelay m`), owner receives + +**Relay-side persistence:** relay persists its current `RelayCapabilities` (derived from `RelayWebOptions`) so it can detect config changes on restart. On startup, if persisted capabilities differ from config, relay sends `XGrpRelayCap` to all group owners it serves. + +### 4. CLI options for web preview + +**File:** `src/Simplex/Chat/Options.hs` + +New record bundling all web preview options: +```haskell +data RelayWebOptions = RelayWebOptions + { webJsonDir :: FilePath, -- --web-json-dir: where to write JSON files + webBaseUrl :: Text, -- --web-base-url: public URL prefix (sent in XGrpRelayAcpt) + webCorsFile :: FilePath, -- --web-cors-file: generated Caddy CORS config path + webUpdateInterval :: Int -- --web-update-interval: seconds (default 300) + } +``` + +Add as a proper field in `CoreChatOpts`: +```haskell +data CoreChatOpts = CoreChatOpts + { ...existing..., + relayWebOptions :: Maybe RelayWebOptions + } +``` + +Parsed from CLI: when `--web-json-dir` is provided, all other `--web-*` flags are required. `Nothing` when no web preview flags are set. Only meaningful when `--relay` is also set. + +### 5. Web preview thread startup + +**File:** `src/Simplex/Chat/Core.hs` (line 74) + +Current: +```haskell +runSimplexChat ... = do + a1 <- runReaderT (startChatController True True) cc + when (chatRelay && not testView) $ askCreateRelayAddress cc u + forM_ (postStartHook chatHooks) ($ cc) + a2 <- async $ chat u cc + waitEither_ a1 a2 +``` + +Add web preview thread as a third async when config is present: +```haskell +runSimplexChat ... = do + a1 <- runReaderT (startChatController True True) cc + when (chatRelay && not testView) $ askCreateRelayAddress cc u + forM_ (postStartHook chatHooks) ($ cc) + a2 <- async $ chat u cc + case relayWebOptions coreOptions of + Nothing -> waitEither_ a1 a2 + Just webOpts -> do + a3 <- async $ webPreviewThread webOpts cc + void $ waitAnyCancel [a1, a2, a3] +``` + +## New Types for JSON Serialization + +**File:** new module `src/Simplex/Chat/Web/Preview.hs` + +### Reuse as-is (existing ToJSON instances) + +- `GroupProfile` (Types.hs:803) - channel metadata (displayName, fullName, shortDescr, description, image, publicGroup incl. groupDomain) +- `MsgContent` (Protocol.hs:689) - tagged union: MCText, MCLink, MCImage, MCVideo, etc. +- `LinkPreview` (Protocol.hs:256) - `{uri, title, description, image, content}` +- `FormattedText` / `MarkdownList` (Markdown.hs:133/139) - parsed markdown +- `QuotedMsg` / `MsgRef` (Protocol.hs:589) - quoted message context +- `MsgMentions` = `Map MemberName CIMention` (Messages.hs:264) +- `CIMention` (Messages.hs:272) - `{memberId, memberRef}` +- `CIReactionCount` (Messages.hs:338) - `{reaction, userReacted, totalReacted}` + +### New types + +```haskell +data WebFileInfo = WebFileInfo + { fileName :: String, + fileSize :: Integer + } + +data WebMemberProfile = WebMemberProfile + { memberId :: MemberId, + displayName :: Text, + image :: Maybe ImageData + } + +data WebMessage = WebMessage + { sender :: Maybe MemberId, -- Nothing for CIChannelRcv (forwarded-from-channel) + ts :: UTCTime, + content :: MsgContent, + formattedText :: Maybe MarkdownList, + file :: Maybe WebFileInfo, + quote :: Maybe QuotedMsg, + mentions :: Map MemberName CIMention, + reactions :: [CIReactionCount], + forwarded :: Maybe CIForwardedFrom, + edited :: Bool + } + +data WebChannelPreview = WebChannelPreview + { channel :: GroupProfile, -- NOTE: render loop strips groupDomain until verified + subscriberCount :: Maybe Int, + members :: [WebMemberProfile], + messages :: [WebMessage], + updatedAt :: UTCTime + } +``` + +TH-derived JSON for `WebFileInfo`, `WebMemberProfile`, `WebMessage`, `WebChannelPreview`. + +## Render Loop + +**File:** new module `src/Simplex/Chat/Web.hs` + +Pattern from directory service's `updateListingsThread_` (Service.hs:185-194). + +```haskell +webPreviewThread :: RelayWebOptions -> ChatController -> IO () +webPreviewThread opts cc = forever $ do + u_ <- readTVarIO $ currentUser cc + forM_ u_ $ \user -> do + groups <- getWebPublishGroups cc user + corsEntries <- forM groups $ \gInfo -> do + renderGroupPreview opts cc user gInfo + pure (corsEntry gInfo) + writeCorsConfig opts corsEntries + threadDelay (webUpdateInterval opts * 1_000_000) +``` + +### Loading groups + +New store function `getWebPublishGroups`: +```sql +SELECT ... FROM groups g +JOIN group_profiles gp ON g.group_profile_id = gp.group_profile_id +WHERE gp.group_web_page IS NOT NULL + AND g.user_id = ? +``` + +Returns `[GroupInfo]`. For each, call `getGroupChat` with `CPLast 50` (Store/Messages.hs:1436) to get chat items. + +### Converting CChatItem to WebMessage + +For each `CChatItem SMDRcv (ChatItem {chatDir, meta, content, mentions, formattedText, quotedItem, reactions, file})`: + +1. **Skip if:** + - `itemDeleted meta` is `Just _` + - `itemTimed meta` is `Just _` + - `content` is not `CIRcvMsgContent mc` (skip `CIRcvGroupEvent`, `CIRcvIntegrityError`, etc.) + - `mc` is `MCReport` or `MCUnknown` + +2. **Extract sender:** + - `CIGroupRcv member` -> `Just (memberId member)`, collect member into profiles array + - `CIChannelRcv` -> `Nothing` (channel-forwarded message, no individual sender) + +3. **Extract file info:** + - `file :: Maybe (CIFile 'MDRcv)` has `fileName :: String`, `fileSize :: Integer` + - Strip `fileSource`, `fileStatus`, `fileProtocol` (download metadata irrelevant for web) + +4. **Build WebMessage:** + ```haskell + WebMessage + { sender = senderMemberId + , ts = itemTs meta + , content = mc + , formattedText = formattedText + , file = (\f -> WebFileInfo (fileName f) (fileSize f)) <$> file + , quote = quotedItem -- QuotedMsg reused directly + , mentions = mentions + , reactions = reactions + , forwarded = itemForwarded meta + , edited = itemEdited meta + } + ``` + +5. **Collect unique senders** into `[WebMemberProfile]` from `GroupMember` records in `CIGroupRcv`. + +Also include `CIGroupSnd` items (relay's own sent messages, if any - unlikely but possible for admin announcements). + +### Filtering unverified domains + +Before serializing, the render loop strips `groupDomain` from the `PublicGroupAccess` included in the profile when not verified: + +```haskell +stripUnverifiedDomain :: Maybe UTCTime -> GroupProfile -> GroupProfile +stripUnverifiedDomain verifiedAt gp = case verifiedAt of + Just _ -> gp -- domain verified, include as-is + Nothing -> gp {publicGroup = clearDomain <$> publicGroup gp} + where + clearDomain pgp = pgp {publicGroupAccess = clearAccess <$> publicGroupAccess pgp} + clearAccess acc = acc {groupDomain = ""} -- or strip the access record entirely +``` + +The `group_domain_verified_at` timestamp is loaded alongside the group info. Until RSLV ships, this column is always NULL, so all domains are stripped from web export. + +`domainWebPage` in CORS config is also gated on verified domain - unverified means no domain-site origin in CORS. + +### Writing JSON + +- Serialize `WebChannelPreview` to JSON via `Data.Aeson.encode` +- Write atomically (write to temp, rename) to `/.json` +- `publicGroupId` from `PublicGroupProfile` (base64url-encoded, existing field) + +### Generating Caddy CORS config + +Write a single file with Caddy `map` directive: + +```caddy +map {path} {cors_origin} { + /.json "https://owner-domain.com" + /.json "*" + default "" +} +header /*.json Access-Control-Allow-Origin {cors_origin} +header /*.json Access-Control-Allow-Methods "GET, OPTIONS" +``` + +CORS origin derivation from `PublicGroupAccess`: +- `allowEmbeding = True` -> `*` +- `groupWebPage = Just url` -> extract origin from URL (+ domain site origin if `domainWebPage` and domain verified) +- `groupWebPage = Nothing, domainWebPage = True` -> domain site origin only (when domain is verified) +- No web page, no embedding, no domain page -> omit from config + +After writing, run `caddy reload` if file content changed (compare hash before/after). + +## Namespace Integration + +`groupDomain` ships now in the profile (inside `PublicGroupAccess`). What's deferred is on-chain verification (RSLV protocol). + +### What ships now + +1. **`groupDomain :: Text` in `PublicGroupAccess`** - owner sets the registered domain, disseminated to all members +2. **`domainWebPage :: Bool` in `PublicGroupAccess`** - flag stored but has no effect until domain is verified +3. **Relay strips `groupDomain` from web export** - no verification means domain is cleared in JSON, no domain-site CORS origin + +### What ships with RSLV + +1. **RSLV protocol** - relay queries name servers via SMP proxy to verify domain ownership +2. **`domainWebPage` becomes functional** - enables domain-site hosting (e.g. `simplexnetwork.org/c/`) for verified domains +3. **In-app resolution** - `#name` markdown (already parsed by namespace branch) resolves and connects + +### Verification flow (relay-side) + +When owner updates profile with `groupDomain`: + +1. **Trigger:** Relay receives profile update on owner's connection containing `groupDomain` field +2. **Initiate:** Relay sends `RSLV ` through SMP proxy (async, on the same owner connection context) +3. **Pending state:** `group_domain_verified_at = NULL` in DB. Web export excludes domain while pending. +4. **Resolution arrives:** `NAME ` agent event arrives on the owner's connection (continuation bound to the connection that sent the profile update) +5. **Verify:** Check if `channelLinks` in the NAME response includes this group's `groupLink` +6. **Store result:** Set `group_domain_verified_at = ` on success, leave NULL on failure +7. **Effect:** Web render loop includes domain in JSON and enables domain-site CORS only when `group_domain_verified_at IS NOT NULL` + +Re-verification: periodic (e.g. daily or on each web update cycle) to catch expired/transferred domains. Clear `group_domain_verified_at` when re-verification fails. + +### What the namespace branch already provides + +- `SimplexNameInfo {nameType, namespace, domain, subDomain}` in Markdown.hs +- `SimplexName` variant in `Format` ADT +- Parser for `#name` / `#name.simplex` / `:name.simplex` syntax +- Forward-compatibility alerts in Kotlin/Swift UI (shows "requires newer app" until resolution is implemented) + +## UI Changes (Kotlin/Swift) + +### Kotlin types + +**File:** `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` + +```kotlin +@Serializable +data class PublicGroupAccess( + val groupWebPage: String? = null, + val groupDomain: String? = null, + val domainWebPage: Boolean = false, + val allowEmbeding: Boolean = false +) + +// Extend existing PublicGroupProfile (currently at line 2213): +@Serializable +data class PublicGroupProfile( + val groupType: GroupType, + val groupLink: String, + val publicGroupId: String, + val publicGroupAccess: PublicGroupAccess? = null // NEW +) + +@Serializable +data class RelayCapabilities( + val baseWebUrl: String? = null +) + +// Extend existing GroupRelay: +@Serializable +data class GroupRelay( + ...existing fields..., + val relayCap: RelayCapabilities? = null // NEW +) +``` + +### Owner: Channel info page + +**File:** `GroupChatInfoView.kt` (around line 604-606) + +After existing `ChannelLinkButton(manageGroupLink)`: +```kotlin +ChannelWebPageButton(openChannelWebPage) // owner only +``` + +New nav destination opens `ChannelWebPageView`. + +### Owner: Channel web page screen + +**File:** new `apps/multiplatform/.../views/chat/group/ChannelWebPageView.kt` + +- Text field: web page URL (`groupWebPage`) +- Text field: domain (`groupDomain`) +- Toggle: allow embedding (`allowEmbeding`) +- Toggle: show on domain's page (`domainWebPage`) - stored but inert until RSLV ships +- Section: embed snippet (read-only, auto-generated from relay `baseWebUrl` values + `publicGroupId`) +- Save button -> `apiUpdateGroup` with updated `GroupProfile` + +### Subscriber: Channel info page + +In the top section (around line 607-614), after channel link QR: +```kotlin +val webPageUrl = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage +if (webPageUrl != null) { + WebPageLinkRow(webPageUrl) // clickable, opens browser +} +``` + +## Build Configuration + +Web preview code compiles into the main `simplex-chat` library (not conditional). The thread only starts when `relayWebOptions` is set in `CoreChatOpts`. Mobile apps never set this. + +No cabal flag needed - the thread startup is gated by `Maybe RelayWebOptions` at runtime (same pattern as `chatRelay` gating relay behavior). + +## Caddy Setup (operator documentation) + +Main Caddyfile (operator writes once): +```caddy +relay.example.com { + import /etc/caddy/simplex-cors.conf + handle /preview/* { + root * /var/lib/simplex/web/preview + file_server + } +} +``` + +Relay CLI invocation: +``` +simplex-chat --relay \ + --web-json-dir /var/lib/simplex/web/preview \ + --web-base-url https://relay.example.com/preview \ + --web-cors-file /etc/caddy/simplex-cors.conf \ + --web-update-interval 300 +``` + +## Channel Page and Embed Code + +### Embed snippet (shown to owner) + +The "Channel web page" screen auto-generates this from the channel's relay `baseWebUrl` values and `publicGroupId`. Owner copies it into their page: + +```html +
+
+ +``` + +Example with real values: +```html +
+
+ +``` + +The script fetches `/a1b2c3d4.json`, renders the preview into the `div`. Tries relays in order, falls back on failure. The owner's domain must match the CORS origin configured by the relay (derived from `groupWebPage`), or `allowEmbeding` must be `True` for `*`. + +For iframe embedding (when allowed), the snippet is simpler - just an iframe pointing to the owner's hosted channel page. + +### Channel page (static JS) + +Separate repo or folder. `channel-preview.js` + minimal CSS: +- Reads config from `data-` attributes on the container div +- Fetches JSON from relays with fallback (try first, fall back to second) +- Renders: channel header (name, avatar, description, subscriber count), message list (text with FormattedText markdown, link previews, file indicators, reactions, quotes) +- Join button: `simplex://` deep link on mobile, QR code on desktop +- Reuses directory page's markdown rendering approach + +## Files to Create/Modify + +### New files +- `src/Simplex/Chat/Web/Preview.hs` - types: `WebChannelPreview`, `WebMessage`, `WebFileInfo`, `WebMemberProfile` +- `src/Simplex/Chat/Web.hs` - render loop, JSON writing, Caddy config generation +- `apps/multiplatform/.../views/chat/group/ChannelWebPageView.kt` +- `apps/ios/Shared/Views/Chat/Group/ChannelWebPageView.swift` +- Migration files (SQLite + Postgres): `group_web_page`, `group_domain`, `domain_web_page`, `allow_embedding`, `group_domain_verified_at` in group_profiles; `base_web_url` in group_relays +- Channel page static site (separate repo/folder) + +### Modified files +- `src/Simplex/Chat/Types.hs` - `PublicGroupAccess` type, extend `PublicGroupProfile` with `publicGroupAccess` +- `src/Simplex/Chat/Protocol.hs` - `RelayCapabilities` record, extend `XGrpRelayAcpt`, add `XGrpRelayCap` +- `src/Simplex/Chat/Options.hs` - `RelayWebOptions` record, `relayWebOptions :: Maybe RelayWebOptions` in `CoreChatOpts` +- `src/Simplex/Chat/Core.hs` - start web preview thread in `runSimplexChat` +- `src/Simplex/Chat/Operators.hs` - `baseWebUrl` in `GroupRelay` +- `src/Simplex/Chat/Store/Groups.hs` - read/write `PublicGroupAccess` columns; `getWebPublishGroups` +- `src/Simplex/Chat/Store/Shared.hs` - `toPublicGroupAccess`, extend `toPublicGroupProfile` and `GroupInfoRow` +- `src/Simplex/Chat/Library/Subscriber.hs` - handle `RelayCapabilities` in `XGrpRelayAcpt` and `XGrpRelayCap` +- `apps/multiplatform/.../model/ChatModel.kt` - `PublicGroupAccess`, `RelayCapabilities`, `PublicGroupProfile.publicGroupAccess`, `GroupRelay.relayCap` +- `apps/multiplatform/.../views/chat/group/GroupChatInfoView.kt` - nav link for web page +- `simplex-chat.cabal` - add `Simplex.Chat.Web.Preview`, `Simplex.Chat.Web` to exposed-modules + +## Implementation Order + +1. **Data model** - `PublicGroupAccess` in `PublicGroupProfile`, migrations (separate columns), store functions +2. **Protocol** - `RelayCapabilities`, extend `XGrpRelayAcpt`, add `XGrpRelayCap`, handlers in Subscriber.hs +3. **CLI options** - `RelayWebOptions` record, `relayWebOptions` field in `CoreChatOpts` +4. **Web types** - `WebChannelPreview`, `WebMessage`, etc. in new module +5. **Render loop** - thread startup in Core.hs, periodic JSON generation, Caddy config +6. **UI (owner)** - "Channel web page" settings screen +7. **UI (subscriber)** - web page link in channel info +8. **Channel page** - static HTML+JS template +9. **Documentation** - operator setup guide + +## Verification + +1. **Build**: `cabal build simplex-chat` with new modules compiles +2. **Unit test**: serialize `WebChannelPreview` with sample data, verify JSON matches expected structure +3. **Integration test**: create channel with `publicGroupAccess` set, run relay with `--web-json-dir`, verify JSON file appears at correct path with correct content +4. **CORS test**: verify generated config produces correct `Access-Control-Allow-Origin` for configured domains +5. **UI test**: owner can set web page URL and domain, see embed snippet; subscriber sees clickable link +6. **Channel page test**: serve static page locally against relay's JSON, verify rendering +7. **Domain stripping test**: set `groupDomain` on a channel, verify it is stripped from web export JSON (unverified, `group_domain_verified_at IS NULL`) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 0116ddbc56..166c31aa25 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -133,6 +133,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at @@ -291,6 +292,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4342a8821e..bd12c20924 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2535,7 +2535,8 @@ processChatCommand vr nm = \case -- generate owner key, OwnerAuth signed by root key memberId <- MemberId <$> liftIO (encodedRandomBytes gVar 12) (memberPrivKey, ownerAuth) <- liftIO $ SL.newOwnerAuth gVar (unMemberId memberId) rootPrivKey - let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId}} + -- TODO [channel web] pass publicGroupAccess from owner's profile + let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId, publicGroupAccess = Nothing}} userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just (PublicGroupData 1)} userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData} -- create connection with prepared link (single network call) @@ -2653,8 +2654,7 @@ processChatCommand vr nm = \case Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId - -- TODO check that user's role is > role, possibly restrict role to only observer and member - assertUserGroupRole gInfo GRModerator + assertUserGroupRole gInfo $ max GRModerator role case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index eb0fd564e3..1916e751d7 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1045,7 +1045,8 @@ acceptRelayJoinRequestAsync cReqInvId cReqChatVRange relayLink = do - let msg = XGrpRelayAcpt relayLink + -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) + let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities subMode <- chatReadVar subscriptionMode vr <- chatVersionRange let chatV = vr `peerConnChatVersion` cReqChatVRange diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index ca903e309d..93e6cdab26 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -774,9 +774,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" - XGrpRelayAcpt relayLink + XGrpRelayAcpt relayLink relayCap | memberRole' membership == GROwner && isRelay m -> do - withStore' $ \db -> setRelayLinkConfId db m confId relayLink + withStore' $ \db -> do + setRelayLinkConfId db m confId relayLink + updateRelayCapabilities db m relayCap void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" XGrpRelayReject reason @@ -1045,6 +1047,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs XGrpRelayNew rl -> fmap ctx <$> xGrpRelayNew gInfo' m'' rl + XGrpRelayCap relayCap + | memberRole' membership == GROwner && isRelay m'' -> + Nothing <$ withStore' (\db -> updateRelayCapabilities db m'' relayCap) + | otherwise -> Nothing <$ messageWarning "x.grp.relay.cap: only owner should receive relay capabilities" XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv @@ -2609,6 +2615,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs + | memberRole' m < GRModerator || memberRole' m < role = + messageError "x.grp.link.acpt with insufficient member permissions" | sameMemberId memberId membership = processUserAccepted | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 03cc38e12a..6816a5f692 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,7 +46,7 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions -import Simplex.Chat.Protocol (RelayProfile (..)) +import Simplex.Chat.Protocol (RelayCapabilities (..), RelayProfile (..)) import Simplex.Chat.Types (ShortLinkContact, User) import Simplex.Chat.Types.Shared (RelayStatus) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -280,7 +280,8 @@ data GroupRelay = GroupRelay groupMemberId :: Int64, userChatRelay :: UserChatRelay, relayStatus :: RelayStatus, - relayLink :: Maybe ShortLinkContact + relayLink :: Maybe ShortLinkContact, + relayCap :: RelayCapabilities } deriving (Eq, Show) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 902e919a7f..7c9776e7b8 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -262,6 +262,14 @@ data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknow data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text deriving (Eq, Show) +data RelayCapabilities = RelayCapabilities + { baseWebUrl :: Maybe Text + } + deriving (Eq, Show) + +defaultRelayCapabilities :: RelayCapabilities +defaultRelayCapabilities = RelayCapabilities {baseWebUrl = Nothing} + $(pure []) instance FromJSON LinkContent where @@ -281,6 +289,12 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) +$(JQ.deriveToJSON defaultJSON ''RelayCapabilities) + +instance FromJSON RelayCapabilities where + parseJSON = $(JQ.mkParseJSON defaultJSON ''RelayCapabilities) + omittedField = Just defaultRelayCapabilities + instance StrEncoding ReportReason where strEncode = \case RRSpam -> "spam" @@ -441,10 +455,11 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json - XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json + XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json XGrpRelayReject :: RelayRejectionReason -> ChatMsgEvent 'Json + XGrpRelayCap :: RelayCapabilities -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -991,6 +1006,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpRelayTest_ :: CMEventTag 'Json XGrpRelayNew_ :: CMEventTag 'Json XGrpRelayReject_ :: CMEventTag 'Json + XGrpRelayCap_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -1050,6 +1066,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpRelayTest_ -> "x.grp.relay.test" XGrpRelayNew_ -> "x.grp.relay.new" XGrpRelayReject_ -> "x.grp.relay.reject" + XGrpRelayCap_ -> "x.grp.relay.cap" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -1110,6 +1127,7 @@ instance StrEncoding ACMEventTag where "x.grp.relay.test" -> XGrpRelayTest_ "x.grp.relay.new" -> XGrpRelayNew_ "x.grp.relay.reject" -> XGrpRelayReject_ + "x.grp.relay.cap" -> XGrpRelayCap_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -1162,10 +1180,11 @@ toCMEventTag msg = case msg of XGrpLinkMem _ -> XGrpLinkMem_ XGrpLinkAcpt {} -> XGrpLinkAcpt_ XGrpRelayInv _ -> XGrpRelayInv_ - XGrpRelayAcpt _ -> XGrpRelayAcpt_ + XGrpRelayAcpt {} -> XGrpRelayAcpt_ XGrpRelayTest {} -> XGrpRelayTest_ XGrpRelayNew _ -> XGrpRelayNew_ XGrpRelayReject _ -> XGrpRelayReject_ + XGrpRelayCap _ -> XGrpRelayCap_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1316,7 +1335,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation" - XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" + XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (fromMaybe defaultRelayCapabilities <$> opt "relayCap") + XGrpRelayCap_ -> XGrpRelayCap <$> p "relayCap" XGrpRelayTest_ -> do B64UrlByteString challenge <- p "challenge" sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature" @@ -1388,7 +1408,8 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpLinkMem profile -> o ["profile" .= profile] XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv] - XGrpRelayAcpt relayLink -> o ["relayLink" .= relayLink] + XGrpRelayAcpt relayLink relayCap -> o ["relayLink" .= relayLink, "relayCap" .= relayCap] + XGrpRelayCap relayCap -> o ["relayCap" .= relayCap] XGrpRelayTest challenge sig_ -> o $ ("signature" .=? (B64UrlByteString <$> sig_)) ["challenge" .= B64UrlByteString challenge] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 62183a0313..60d865cb30 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -139,6 +139,7 @@ getConnectionEntity db vr 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, 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, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 37909a67b2..98881d38d0 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -91,6 +91,7 @@ module Simplex.Chat.Store.Groups updateRelayStatusFromTo, setRelayLinkAccepted, setRelayLinkConfId, + updateRelayCapabilities, getRelayConfId, updateRelayMemberData, setGroupInProgressDone, @@ -370,10 +371,11 @@ createNewGroup db vr 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, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) + ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute @@ -871,10 +873,11 @@ 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, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) + ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute @@ -1362,15 +1365,16 @@ groupRelayQuery = [sql| SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id |] -toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact) -> GroupRelay -toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink)) = +toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact, Maybe Text) -> GroupRelay +toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink, baseWebUrl)) = let userChatRelay = UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted} - in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink} + relayCap = RelayCapabilities {baseWebUrl} + in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap} createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do @@ -1510,6 +1514,18 @@ setRelayLinkConfId db m confId relayLink = do |] (relayLink, currentTs, groupMemberId' m) +updateRelayCapabilities :: DB.Connection -> GroupMember -> RelayCapabilities -> IO () +updateRelayCapabilities db m RelayCapabilities {baseWebUrl} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_relays + SET base_web_url = ?, updated_at = ? + WHERE group_member_id = ? + |] + (baseWebUrl, currentTs, groupMemberId' m) + getRelayConfId :: DB.Connection -> GroupMember -> ExceptT StoreError IO ConfirmationId getRelayConfId db m = ExceptT . firstRow fromOnly (SEGroupRelayNotFoundByMemberId $ groupMemberId' m) $ @@ -2358,6 +2374,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 = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id @@ -2365,7 +2382,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, WHERE user_id = ? AND group_id = ? ) |] - ((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. (groupPreferences, memberAdmission, currentTs, userId, groupId)) + ((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. publicGroupAccessRow publicGroup :. (groupPreferences, memberAdmission, currentTs, userId, groupId)) updateGroup_ ldn currentTs = do DB.execute db @@ -2405,14 +2422,16 @@ 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.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) - toGroupProfile (displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_, groupPreferences, memberAdmission) = - GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_, groupPreferences, memberAdmission} + toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (groupPreferences, memberAdmission)) = + let publicGroupAccess = toPublicGroupAccess accessRow + in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 792865a3d7..7b5fa52b9c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -31,6 +31,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at @@ -65,6 +66,7 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("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) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_public_group_access.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_public_group_access.hs new file mode 100644 index 0000000000..1fbb731626 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_public_group_access.hs @@ -0,0 +1,29 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260515_public_group_access :: Text +m20260515_public_group_access = + [r| +ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT; +ALTER TABLE group_profiles ADD COLUMN group_domain TEXT; +ALTER TABLE group_profiles ADD COLUMN domain_web_page BIGINT; +ALTER TABLE group_profiles ADD COLUMN allow_embedding BIGINT; + +ALTER TABLE group_relays ADD COLUMN base_web_url TEXT; +|] + +down_m20260515_public_group_access :: Text +down_m20260515_public_group_access = + [r| +ALTER TABLE group_relays DROP COLUMN base_web_url; + +ALTER TABLE group_profiles DROP COLUMN allow_embedding; +ALTER TABLE group_profiles DROP COLUMN domain_web_page; +ALTER TABLE group_profiles DROP COLUMN group_domain; +ALTER TABLE group_profiles DROP COLUMN group_web_page; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 286fc63a4c..0ff86c8981 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -850,7 +850,11 @@ CREATE TABLE test_chat_schema.group_profiles ( short_descr text, group_type text, group_link bytea, - public_group_id bytea + public_group_id bytea, + group_web_page text, + group_domain text, + domain_web_page bigint, + allow_embedding bigint ); @@ -875,7 +879,8 @@ CREATE TABLE test_chat_schema.group_relays ( relay_link bytea, conf_id bytea, created_at text DEFAULT now() NOT NULL, - updated_at text DEFAULT now() NOT NULL + updated_at text DEFAULT now() NOT NULL, + base_web_url text ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index a3c8e8eea7..58eed42e95 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -154,6 +154,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at @@ -311,6 +312,7 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("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) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_public_group_access.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_public_group_access.hs new file mode 100644 index 0000000000..69f1793d3b --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_public_group_access.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260515_public_group_access :: Query +m20260515_public_group_access = + [sql| +ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT; +ALTER TABLE group_profiles ADD COLUMN group_domain TEXT; +ALTER TABLE group_profiles ADD COLUMN domain_web_page INTEGER; +ALTER TABLE group_profiles ADD COLUMN allow_embedding INTEGER; + +ALTER TABLE group_relays ADD COLUMN base_web_url TEXT; +|] + +down_m20260515_public_group_access :: Query +down_m20260515_public_group_access = + [sql| +ALTER TABLE group_relays DROP COLUMN base_web_url; + +ALTER TABLE group_profiles DROP COLUMN allow_embedding; +ALTER TABLE group_profiles DROP COLUMN domain_web_page; +ALTER TABLE group_profiles DROP COLUMN group_domain; +ALTER TABLE group_profiles DROP COLUMN group_web_page; +|] 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 508d91dff5..724d1fdf3e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -141,6 +141,7 @@ 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, 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, @@ -970,6 +971,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.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id @@ -1217,8 +1219,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, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1741,6 +1744,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 = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id @@ -5107,6 +5111,14 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_relays + SET base_web_url = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?) + Query: UPDATE group_relays SET conf_id = ?, relay_link = ?, updated_at = ? @@ -5291,6 +5303,7 @@ 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, 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, @@ -5327,6 +5340,7 @@ 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, 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, @@ -5356,6 +5370,7 @@ 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, 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, @@ -5733,7 +5748,7 @@ SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id @@ -5750,7 +5765,7 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id WHERE gr.group_id = ? @@ -5761,7 +5776,7 @@ SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id WHERE gr.group_member_id = ? @@ -5772,7 +5787,7 @@ SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id WHERE gr.group_relay_id = ? diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index c710b2c5cb..97dab10ac7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -126,7 +126,11 @@ CREATE TABLE group_profiles( short_descr TEXT, group_type TEXT, group_link BLOB, - public_group_id BLOB + public_group_id BLOB, + group_web_page TEXT, + group_domain TEXT, + domain_web_page INTEGER, + allow_embedding INTEGER ) STRICT; CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID @@ -781,6 +785,8 @@ CREATE TABLE group_relays( conf_id BLOB, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + base_web_url TEXT ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index cf630eae02..2b55641c81 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -665,18 +665,20 @@ 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) :. (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 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 CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow + +type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolInt) 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) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = +toGroupInfo vr 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, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences - publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ + publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) groupKeys = toGroupKeys publicGroupId_ groupKeysRow groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow @@ -690,10 +692,25 @@ toPreparedGroup = \case Just PreparedGroup {connLinkToConnect = CCLink fullLink shortLink_, connLinkPreparedConnection, connLinkStartedConnection, welcomeSharedMsgId, requestSharedMsgId} _ -> Nothing -toPublicGroupProfile :: Maybe GroupType -> Maybe ShortLinkContact -> Maybe B64UrlByteString -> Maybe PublicGroupProfile -toPublicGroupProfile (Just groupType) (Just groupLink) (Just publicGroupId) = - Just PublicGroupProfile {groupType, groupLink, publicGroupId} -toPublicGroupProfile _ _ _ = Nothing +toPublicGroupProfile :: Maybe GroupType -> Maybe ShortLinkContact -> Maybe B64UrlByteString -> Maybe PublicGroupAccess -> Maybe PublicGroupProfile +toPublicGroupProfile (Just groupType) (Just groupLink) (Just publicGroupId) publicGroupAccess = + Just PublicGroupProfile {groupType, groupLink, publicGroupId, publicGroupAccess} +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) + +toPublicGroupAccess :: PublicGroupAccessRow -> Maybe PublicGroupAccess +toPublicGroupAccess (groupWebPage, groupDomain, domainWebPage_, allowEmbedding_) + | isJust groupWebPage || isJust groupDomain || domainWebPage || allowEmbedding = + Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} + | otherwise = Nothing + where + domainWebPage = maybe False unBI domainWebPage_ + allowEmbedding = maybe False unBI allowEmbedding_ toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) = @@ -760,6 +777,7 @@ 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, 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, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 145f3343f3..db9281585c 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -795,10 +795,19 @@ instance FromField GroupType where fromField = fromTextField_ textDecode instance ToField GroupType where toField = toField . textEncode +data PublicGroupAccess = PublicGroupAccess + { groupWebPage :: Maybe Text, + groupDomain :: Maybe Text, + domainWebPage :: Bool, + allowEmbedding :: Bool + } + deriving (Eq, Show) + data PublicGroupProfile = PublicGroupProfile { groupType :: GroupType, groupLink :: ShortLinkContact, - publicGroupId :: B64UrlByteString -- group identity = sha256(genesis root key), immutable + publicGroupId :: B64UrlByteString, -- group identity = sha256(genesis root key), immutable + publicGroupAccess :: Maybe PublicGroupAccess } deriving (Eq, Show) @@ -2086,6 +2095,8 @@ instance ToJSON GroupType where toJSON = textToJSON toEncoding = textToEncoding +$(JQ.deriveJSON defaultJSON ''PublicGroupAccess) + $(JQ.deriveJSON defaultJSON ''PublicGroupProfile) $(JQ.deriveJSON defaultJSON ''GroupProfile) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 7ab4234b86..200af95320 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3299,6 +3299,12 @@ testGLinkReviewMember = alice ##> "/_delete member chat #1 5" alice <## "bad chat command: member is pending" + -- moderator can't accept member with a role higher than their own + dan ##> "/_accept member #1 5 admin" + dan <## "#team: you have insufficient permissions for this action, the required role is admin" + dan ##> "/_accept member #1 5 owner" + dan <## "#team: you have insufficient permissions for this action, the required role is owner" + -- accept member dan ##> "/_accept member #1 5 member" concurrentlyN_