mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-04 08:11:57 +00:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+17
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_
|
||||
|
||||
Reference in New Issue
Block a user