plan: web previews for channels (#7022)

* plan: web previews for channels

* types for recipient side to support channel web previews and domain names

* fix

* migrations

* update schema and api types

* update schema

* rename migrations

* core: check member role

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-05-31 17:12:12 +01:00
committed by GitHub
parent 68fc1b5d22
commit 9bb2bec3fa
25 changed files with 894 additions and 44 deletions
+13
View File
@@ -2531,10 +2531,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 {
@@ -2703,6 +2715,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 }
}
@@ -2209,11 +2209,25 @@ object GroupTypeSerializer : KSerializer<GroupType> {
}
}
@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
@@ -2337,7 +2351,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
}
+23
View File
@@ -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)
@@ -2499,6 +2501,7 @@ UpdateRequired:
- userChatRelay: [UserChatRelay](#userchatrelay)
- relayStatus: [RelayStatus](#relaystatus)
- relayLink: string?
- relayCap: [RelayCapabilities](#relaycapabilities)
---
@@ -3068,6 +3071,17 @@ NO_SESSION:
- type: "NO_SESSION"
---
## PublicGroupAccess
**Record type**:
- groupWebPage: string?
- groupDomain: string?
- domainWebPage: bool
- allowEmbedding: bool
---
## PublicGroupData
@@ -3084,6 +3098,7 @@ NO_SESSION:
- groupType: [GroupType](#grouptype)
- groupLink: string
- publicGroupId: string
- publicGroupAccess: [PublicGroupAccess](#publicgroupaccess)?
---
@@ -3341,6 +3356,14 @@ ParseError:
- parseError: string
---
## RelayCapabilities
**Record type**:
- baseWebUrl: string?
---
## RelayProfile
+4
View File
@@ -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
@@ -2776,6 +2776,7 @@ export interface GroupRelay {
userChatRelay: UserChatRelay
relayStatus: RelayStatus
relayLink?: string
relayCap: RelayCapabilities
}
export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public
@@ -3356,6 +3357,13 @@ export namespace ProxyError {
}
}
export interface PublicGroupAccess {
groupWebPage?: string
groupDomain?: string
domainWebPage: boolean
allowEmbedding: boolean
}
export interface PublicGroupData {
publicMemberCount: number // int64
}
@@ -3364,6 +3372,7 @@ export interface PublicGroupProfile {
groupType: GroupType
groupLink: string
publicGroupId: string
publicGroupAccess?: PublicGroupAccess
}
export type RCErrorType =
@@ -3752,6 +3761,10 @@ export namespace RcvMsgError {
}
}
export interface RelayCapabilities {
baseWebUrl?: string
}
export interface RelayProfile {
displayName: string
fullName: string
@@ -1949,6 +1949,7 @@ class GroupRelay(TypedDict):
userChatRelay: "UserChatRelay"
relayStatus: "RelayStatus"
relayLink: NotRequired[str]
relayCap: "RelayCapabilities"
class GroupRootKey_private(TypedDict):
type: Literal["private"]
@@ -2356,6 +2357,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
@@ -2363,6 +2370,7 @@ class PublicGroupProfile(TypedDict):
groupType: "GroupType"
groupLink: str
publicGroupId: str
publicGroupAccess: NotRequired["PublicGroupAccess"]
class RCErrorType_internal(TypedDict):
type: Literal["internal"]
@@ -2631,6 +2639,9 @@ RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError
RcvMsgError_Tag = Literal["dropped", "parseError"]
class RelayCapabilities(TypedDict):
baseWebUrl: NotRequired[str]
class RelayProfile(TypedDict):
displayName: str
fullName: str
+596
View File
@@ -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 <baseWebUrl>/<publicGroupId>.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/<name>` 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 `<webJsonDir>/<publicGroupId>.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} {
/<publicGroupId1>.json "https://owner-domain.com"
/<publicGroupId2>.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/<name>`) 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 <namehash>` 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 <record>` 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 = <current_time>` 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
<div id="simplex-channel"
data-channel-id="<publicGroupId>"
data-relays="<baseWebUrl1>,<baseWebUrl2>">
</div>
<script src="https://simplex.chat/channel-preview.js"></script>
```
Example with real values:
```html
<div id="simplex-channel"
data-channel-id="a1b2c3d4"
data-relays="https://relay1.example.com/preview,https://relay2.example.com/preview">
</div>
<script src="https://simplex.chat/channel-preview.js"></script>
```
The script fetches `<relay>/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`)
+2
View File
@@ -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
else
exposed-modules:
Simplex.Chat.Archive
@@ -288,6 +289,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
other-modules:
Paths_simplex_chat
hs-source-dirs:
+3 -3
View File
@@ -2524,7 +2524,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)
@@ -2643,8 +2644,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
+2 -1
View File
@@ -1048,7 +1048,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
+10 -2
View File
@@ -765,9 +765,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
@@ -1036,6 +1038,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
@@ -2601,6 +2607,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
+3 -2
View File
@@ -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)
+25 -4
View File
@@ -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_
@@ -1318,7 +1337,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"
@@ -1390,7 +1410,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]
+1
View File
@@ -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,
+30 -11
View File
@@ -89,6 +89,7 @@ module Simplex.Chat.Store.Groups
updateRelayStatusFromTo,
setRelayLinkAccepted,
setRelayLinkConfId,
updateRelayCapabilities,
getRelayConfId,
updateRelayMemberData,
setGroupInProgressDone,
@@ -367,10 +368,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
@@ -868,10 +870,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
@@ -1343,15 +1346,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
@@ -1491,6 +1495,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) $
@@ -2327,6 +2343,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
@@ -2334,7 +2351,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
@@ -2374,14 +2391,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
@@ -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.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -61,7 +62,8 @@ schemaMigrations =
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
("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)
("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)
]
-- | The list of migrations in ascending order by date
@@ -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;
|]
@@ -849,7 +849,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
);
@@ -874,7 +878,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
);
@@ -962,7 +967,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
);
+3 -1
View File
@@ -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.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -307,7 +308,8 @@ schemaMigrations =
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
("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)
("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)
]
-- | The list of migrations in ascending order by date
@@ -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;
|]
@@ -143,6 +143,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,
@@ -979,6 +980,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
@@ -1228,8 +1230,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:
@@ -1752,6 +1755,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
@@ -5119,6 +5123,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 = ?
@@ -5295,6 +5307,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,
@@ -5331,6 +5344,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,
@@ -5360,6 +5374,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,
@@ -5690,7 +5705,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
@@ -5707,7 +5722,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 = ?
@@ -5718,7 +5733,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 = ?
@@ -5729,7 +5744,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 = ?
@@ -125,7 +125,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
@@ -778,6 +782,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,
+25 -7
View File
@@ -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,
+12 -1
View File
@@ -793,10 +793,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)
@@ -2084,6 +2093,8 @@ instance ToJSON GroupType where
toJSON = textToJSON
toEncoding = textToEncoding
$(JQ.deriveJSON defaultJSON ''PublicGroupAccess)
$(JQ.deriveJSON defaultJSON ''PublicGroupProfile)
$(JQ.deriveJSON defaultJSON ''GroupProfile)
+6
View File
@@ -3251,6 +3251,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_