core: render web previews for channels (#7029)

* 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: render channel preview data

* core: render channel preview data in relays

* website: use cpp to inject JS functions

* JSC files

* remove directory.js

* channel preview renderer

* Revert "cli: fix redraw slowness (#6735)"

This reverts commit b801d77c74.

* sample channel page

* default avatar

* rename options

* better layout

* layout

* images

* some fixes

* tails

* markdown colors

* image sizes

* reactions

* fix reactions

* fewer avatars

* forward icon

* command to change group access parameters

* view public group access changes in CLI

* media metadata color

* ios: group web access ui

* update ui

* add init

* kotlin, labels

* update page

* update relay base URL

* fix

* ios update channel web page info

* update kotlin layout

* use cards

* update layout

* use domains for relay data, path is fixed

* update embed code

* fix bots api

* include only history items and senders

* update preview JS/HTML

* show different error if link is different

* remove stale json files

* better layout

* layout fixes

* improve layout

* improve layout

* update embed code

* web cta

* better layout

* buttons

* layout

* paddings

* desktop cta

* desktop cta

* cta layout

* fonts

* paddings

* paddings

* more paddings

* copy link

* read more

* hide avatar and placeholder when all messages are from channel

* color scheme

* fix color

* improve

* layout

* welcome message

* dark mode colors

* padding

* font size

* overscroll

* font

* logo on button

* better join

* buttons

* refactor

* another logo

* text

* desktop button

* button text

* center

* fix svg

* padding

* smaller gap

* render channel on any message changes etc

* fixes

* atomic file updates, escape attributes

* fix tests

* more tests

* more efficient rendering

* improve security

* sanitize links, include mentioned members

* schema

* fixes

* improve rendering

* fix showing correct subscribers count

* fix member names

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-06-16 14:36:55 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent 43904dd0dc
commit adb3fb8cb2
42 changed files with 3234 additions and 175 deletions
+5 -2
View File
@@ -128,6 +128,7 @@ defaultChatConfig =
highlyAvailable = False,
deliveryWorkerDelay = 0,
deliveryBucketSize = 10000,
webPreviewConfig = Nothing,
channelSubscriberRole = GRObserver,
relayChecksInterval = 15 * 60, -- 15 minutes
relayInactiveTTL = nominalDay,
@@ -152,11 +153,11 @@ newChatController
ChatDatabase {chatStore, agentStore}
user
cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations}
ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize}
ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize}
backgroundMode = do
let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False}
confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations
config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'}
config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'}
randomPresetServers <- chooseRandomServers presetServers'
let rndSrvs = L.toList randomPresetServers
operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op
@@ -194,6 +195,7 @@ newChatController
deliveryJobWorkers <- TM.emptyIO
relayRequestWorkers <- TM.emptyIO
relayGroupLinkChecksAsync <- newTVarIO Nothing
webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState
chatRelayTests <- TM.emptyIO
expireCIThreads <- TM.emptyIO
expireCIFlags <- TM.emptyIO
@@ -238,6 +240,7 @@ newChatController
deliveryJobWorkers,
relayRequestWorkers,
relayGroupLinkChecksAsync,
webPreviewState,
chatRelayTests,
expireCIThreads,
expireCIFlags,
+41
View File
@@ -39,6 +39,7 @@ import Data.Char (ord)
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import Data.Set (Set)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import Data.String
@@ -162,6 +163,7 @@ data ChatConfig = ChatConfig
ciExpirationInterval :: Int64, -- microseconds
deliveryWorkerDelay :: Int64, -- microseconds
deliveryBucketSize :: Int,
webPreviewConfig :: Maybe WebPreviewConfig,
channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays
relayChecksInterval :: NominalDiffTime,
relayInactiveTTL :: NominalDiffTime,
@@ -173,6 +175,43 @@ data ChatConfig = ChatConfig
chatHooks :: ChatHooks
}
data WebPreviewConfig = WebPreviewConfig
{ webDomain :: Text,
webJsonDir :: FilePath,
webCorsFile :: Maybe FilePath,
webUpdateInterval :: Int, -- seconds
webPreviewItemCount :: Int
}
data PublishableGroup = PublishableGroup
{ pgFileName :: FilePath,
pgCorsEntry :: Maybe (Text, CorsOrigin)
}
data CorsOrigin = CorsAny | CorsOrigins [Text]
deriving (Show)
data WebPreviewState = WebPreviewState
{ publishableGroupIds :: TVar (Map Int64 PublishableGroup),
priorityRender :: TQueue Int64,
filesToRemove :: TQueue FilePath,
corsNeeded :: TVar Bool,
routinePending :: TVar (Set Int64),
wakeSignal :: TMVar (),
webPreviewWorkerAsync :: TVar (Maybe (Async ()))
}
newWebPreviewState :: IO WebPreviewState
newWebPreviewState = do
publishableGroupIds <- newTVarIO mempty
priorityRender <- newTQueueIO
filesToRemove <- newTQueueIO
corsNeeded <- newTVarIO False
routinePending <- newTVarIO mempty
wakeSignal <- newEmptyTMVarIO
webPreviewWorkerAsync <- newTVarIO Nothing
pure WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal, webPreviewWorkerAsync}
-- | Builds the read-only context threaded through store functions from chat config.
-- The single construction point, so new store-wide config (e.g. server keys) is added in one place.
mkStoreCxt :: ChatConfig -> StoreCxt
@@ -266,6 +305,7 @@ data ChatController = ChatController
deliveryJobWorkers :: TMap DeliveryWorkerKey Worker,
relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework
relayGroupLinkChecksAsync :: TVar (Maybe (Async ())),
webPreviewState :: Maybe WebPreviewState,
chatRelayTests :: TMap ConnId RelayTest,
expireCIThreads :: TMap UserId (Maybe (Async ())),
expireCIFlags :: TMap UserId Bool,
@@ -555,6 +595,7 @@ data ChatCommand
| ShowGroupProfile GroupName
| UpdateGroupDescription GroupName (Maybe Text)
| ShowGroupDescription GroupName
| SetPublicGroupAccess GroupName PublicGroupAccess
| CreateGroupLink GroupName GroupMemberRole
| GroupLinkMemberRole GroupName GroupMemberRole
| DeleteGroupLink GroupName
+40
View File
@@ -90,6 +90,7 @@ import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Util (liftIOEither, zipWith3')
import qualified Simplex.Chat.Util as U
import Simplex.Chat.Web (webPreviewWorker)
import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard)
import Simplex.Messaging.Agent
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
@@ -201,6 +202,7 @@ startChatController mainApp enableSndFiles = do
startCleanupManager
void $ forkIO $ mapM_ startExpireCIs users
startRelayChecks users
startWebPreview users
else when enableSndFiles $ startXFTP xftpStartSndWorkers
pure a1
startXFTP startWorkers = do
@@ -232,6 +234,20 @@ startChatController mainApp enableSndFiles = do
a <- Just <$> async (void $ runExceptT $ runRelayGroupLinkChecks relayUser)
atomically $ writeTVar relayAsync a
_ -> pure ()
startWebPreview users = do
let relayUsers = filter (\User {userChatRelay} -> isTrue userChatRelay) users
ChatConfig {webPreviewConfig = cfg_} <- asks config
case (relayUsers, cfg_) of
(_ : _, Just cfg) -> do
wps_ <- asks webPreviewState
forM_ wps_ $ \WebPreviewState {webPreviewWorkerAsync} ->
readTVarIO webPreviewWorkerAsync >>= \case
Nothing -> do
cc <- ask
a <- Just <$> async (liftIO $ webPreviewWorker cfg cc relayUsers)
atomically $ writeTVar webPreviewWorkerAsync a
_ -> pure ()
_ -> pure ()
startExpireCIs user = whenM shouldExpireChats $ do
startExpireCIThread user
setExpireCIFlag user True
@@ -3060,6 +3076,12 @@ processChatCommand cxt nm = \case
updateGroupProfileByName gName $ \p -> p {description}
ShowGroupDescription gName -> withUser $ \user ->
CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName)
SetPublicGroupAccess gName access -> withUser $ \user -> do
gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db ->
getGroupIdByName db user gName >>= getGroupInfo db cxt user
case publicGroup of
Just pg -> runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}}
Nothing -> throwChatError $ CECommandError "not a public group"
APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do
gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId
assertUserGroupRole gInfo GRAdmin
@@ -4896,6 +4918,17 @@ runRelayGroupLinkChecks user = do
else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive
_ -> pure ()
_ -> pure ()
sendRelayCapIfNeeded cxt gInfo
sendRelayCapIfNeeded cxt gInfo = do
ChatConfig {webPreviewConfig} <- asks config
let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig
sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo)
when (currentWebDomain /= sentWebDomain) $ do
owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo
let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners
unless (null capableOwners) $ do
void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain})
withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain
checkRelayInactiveGroups = do
cxt <- chatStoreCxt
ttl <- asks (relayInactiveTTL . config)
@@ -5219,6 +5252,7 @@ chatCommandP =
"/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP),
("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile),
("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP),
"/public group access " *> char_ '#' *> (SetPublicGroupAccess <$> displayNameP <*> publicGroupAccessP),
"/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)),
"/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)),
"/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing),
@@ -5425,6 +5459,12 @@ chatCommandP =
clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False
pure UserMsgReceiptSettings {enable, clearOverrides}
onOffP = ("on" $> True) <|> ("off" $> False)
publicGroupAccessP = do
groupWebPage <- optional (" web=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace))
groupDomain <- optional (" domain=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace))
domainWebPage <- (" domain_page=" *> onOffP) <|> pure False
allowEmbedding <- (" embed=" *> onOffP) <|> pure False
pure PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}
profileNameDescr = (,) <$> displayNameP <*> shortDescrP
-- 'Help with bot':'link <ID>','Menu of commands':[...]
botCommandsP :: Parser [ChatBotCommand]
+3 -2
View File
@@ -1046,8 +1046,9 @@ acceptRelayJoinRequestAsync
cReqInvId
cReqChatVRange
relayLink = do
-- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions)
let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities
ChatConfig {webPreviewConfig} <- asks config
let webDomain_ = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig
msg = XGrpRelayAcpt relayLink RelayCapabilities {webDomain = webDomain_}
subMode <- chatReadVar subscriptionMode
cxt <- chatStoreCxt
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
+16 -1
View File
@@ -47,6 +47,7 @@ import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery
import Simplex.Chat.Library.Internal
import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved)
import Simplex.Chat.Messages
import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize)
import Simplex.Chat.Messages.CIContent
@@ -870,6 +871,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
else pure gInfo
pure (m {memberStatus = GSMemConnected}, gInfo')
toView $ CEvtUserJoinedGroup user gInfo' m'
when (isRelay membership) $ do
cc <- ask
atomically $ channelProfileUpdated cc groupId groupProfile
(gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m'
-- Create e2ee, feature and group description chat items only on first connected relay
ifM
@@ -1018,6 +1022,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask)
processEvent gInfo' m' verifiedMsg = do
(m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
cc <- ask
let ctx js = DeliveryTaskContext js False
checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext)
checkSendAsGroup asGroup_ a
@@ -1074,7 +1079,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe
BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta
_ -> Nothing <$ messageError ("unsupported message: " <> tshow event)
forM deliveryTaskContext_ $ \taskContext ->
forM deliveryTaskContext_ $ \taskContext -> do
let contentChanged :: CM ()
contentChanged = atomically $ channelContentChanged cc groupId
case event of
XMsgNew {} -> contentChanged
XMsgUpdate {} -> contentChanged
XMsgDel {} -> contentChanged
XMsgReact {} -> contentChanged
XGrpInfo p' -> atomically $ channelProfileUpdated cc groupId p'
XGrpDel {} -> atomically $ channelRemoved cc groupId
_ -> pure ()
pure $ NewMessageDeliveryTask {messageId = msgId, taskContext}
checkSendRcpt :: [AParsedMsg] -> CM Bool
checkSendRcpt aMsgs = do
+1
View File
@@ -261,6 +261,7 @@ mobileChatOpts dbOptions =
tbqSize = 4096,
deviceName = Nothing,
chatRelay = False,
webPreviewConfig = Nothing,
highlyAvailable = False,
yesToUpMigrations = False,
migrationBackupPath = Just "",
+43 -1
View File
@@ -28,7 +28,7 @@ import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Numeric.Natural (Natural)
import Options.Applicative
import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), updateStr, versionNumber, versionString)
import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), WebPreviewConfig (..), updateStr, versionNumber, versionString)
import Simplex.FileTransfer.Description (mb)
import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode)
import Simplex.Messaging.Encoding.String
@@ -66,6 +66,7 @@ data CoreChatOpts = CoreChatOpts
tbqSize :: Natural,
deviceName :: Maybe Text,
chatRelay :: Bool,
webPreviewConfig :: Maybe WebPreviewConfig,
highlyAvailable :: Bool,
yesToUpMigrations :: Bool,
migrationBackupPath :: Maybe FilePath,
@@ -240,6 +241,46 @@ coreChatOptsP appDir defaultDbName = do
( long "relay"
<> help "Run as a chat relay client"
)
webPreviewConfig <- do
webDomain_ <-
optional $
strOption
( long "relay-web-domain"
<> metavar "DOMAIN"
<> help "Domain for channel web previews (relay only)"
)
webJsonDir_ <-
optional $
strOption
( long "relay-web-dir"
<> metavar "DIR"
<> help "Directory for channel web preview JSON files (relay only)"
)
webCorsFile <-
optional $
strOption
( long "relay-web-cors-file"
<> metavar "FILE"
<> help "Path to generated Caddy CORS config file (relay only)"
)
webUpdateInterval <-
option auto
( long "relay-web-interval"
<> metavar "SECONDS"
<> help "Interval between web preview regeneration in seconds (relay only)"
<> value 300
)
webPreviewItemCount <-
option auto
( long "relay-web-item-count"
<> metavar "COUNT"
<> help "Number of recent messages in channel web preview (relay only)"
<> value 50
)
pure $ case (webDomain_, webJsonDir_) of
(Just webDomain, Just webJsonDir) -> Just WebPreviewConfig {webDomain, webJsonDir, webCorsFile, webUpdateInterval, webPreviewItemCount}
(Nothing, Nothing) -> Nothing
_ -> errorWithoutStackTrace "--relay-web-domain and --relay-web-dir must both be provided"
highlyAvailable <-
switch
( long "ha"
@@ -283,6 +324,7 @@ coreChatOptsP appDir defaultDbName = do
tbqSize,
deviceName,
chatRelay,
webPreviewConfig,
highlyAvailable,
yesToUpMigrations,
migrationBackupPath,
+6 -1
View File
@@ -83,12 +83,13 @@ import Simplex.Messaging.Version hiding (version)
-- 15 - support specifying message scopes for group messages (2025-03-12)
-- 16 - support short link data (2025-06-10)
-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10)
-- 18 - relay web capabilities (2026-05-31)
-- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig.
-- This indirection is needed for backward/forward compatibility testing.
-- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code.
currentChatVersion :: VersionChat
currentChatVersion = VersionChat 17
currentChatVersion = VersionChat 18
-- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above)
supportedChatVRange :: VersionRangeChat
@@ -155,6 +156,10 @@ shortLinkDataVersion = VersionChat 16
memberSupportVoiceVersion :: VersionChat
memberSupportVoiceVersion = VersionChat 17
-- relay sends web preview capabilities to owner
relayWebCapVersion :: VersionChat
relayWebCapVersion = VersionChat 18
agentToChatVersion :: VersionSMPA -> VersionChat
agentToChatVersion v
| v < pqdrSMPAgentVersion = initialChatVersion
+39
View File
@@ -67,6 +67,7 @@ module Simplex.Chat.Store.Groups
getGroupMembersByIndexes,
getSupportScopeMembersByIndexes,
getGroupModerators,
getGroupOwners,
getGroupRelayMembers,
getGroupMembersForExpiration,
getRemovedMembersToCleanup,
@@ -98,9 +99,12 @@ module Simplex.Chat.Store.Groups
createRelayRequestGroup,
updateRelayOwnStatusFromTo,
updateRelayOwnStatus_,
getRelaySentWebDomain,
updateRelaySentWebDomain,
isRelayGroupRejected,
allowRelayGroup,
getRelayServedGroups,
getRelayPublishableGroups,
getRelayInactiveGroups,
createNewContactMemberAsync,
createJoiningMember,
@@ -1211,6 +1215,15 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId}
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)")
(userId, groupId, userContactId, GRModerator, GRAdmin, GROwner)
getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
ts <- getCurrentTime
map (toContactMember ts cxt user)
<$> DB.query
db
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?")
(userId, groupId, userContactId, GROwner)
getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
currentTs <- getCurrentTime
@@ -1650,6 +1663,14 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do
let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing
DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId)
getRelaySentWebDomain :: DB.Connection -> GroupInfo -> IO (Maybe Text)
getRelaySentWebDomain db GroupInfo {groupId} =
join <$> maybeFirstRow fromOnly (DB.query db "SELECT relay_sent_web_domain FROM groups WHERE group_id = ?" (Only groupId))
updateRelaySentWebDomain :: DB.Connection -> GroupInfo -> Maybe Text -> IO ()
updateRelaySentWebDomain db GroupInfo {groupId} webDomain_ =
DB.execute db "UPDATE groups SET relay_sent_web_domain = ? WHERE group_id = ?" (webDomain_, groupId)
-- Flip every RSRejected row sharing the targeted group's relay_request_group_link
-- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId.
allowRelayGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupInfo
@@ -1696,6 +1717,24 @@ getRelayServedGroups db cxt User {userId, userContactId} = do
)
(userId, userContactId, RSAccepted, RSActive)
getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)]
getRelayPublishableGroups db User {userId, userContactId} =
map toRow <$>
DB.query
db
[sql|
SELECT g.group_id, gp.public_group_id,
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ?
WHERE g.user_id = ? AND g.relay_own_status IN (?, ?)
AND gp.public_group_id IS NOT NULL
|]
(userContactId, userId, RSAccepted, RSActive)
where
toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow)
getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo]
getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do
currentTs <- getCurrentTime
+19
View File
@@ -137,6 +137,7 @@ module Simplex.Chat.Store.Messages
getGroupSndStatuses,
getGroupSndStatusCounts,
getGroupHistoryItems,
getGroupWebPreviewItems,
)
where
@@ -3716,3 +3717,21 @@ getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do
LIMIT ?
|]
(groupMemberId' m, userId, groupId, count)
getGroupWebPreviewItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)]
getGroupWebPreviewItems db user@User {userId} g@GroupInfo {groupId} count = do
ciIds <-
map fromOnly
<$> DB.query
db
[sql|
SELECT i.chat_item_id
FROM chat_items i
WHERE i.user_id = ? AND i.group_id = ?
AND i.include_in_history = 1
AND i.item_deleted = 0
ORDER BY i.item_ts DESC, i.chat_item_id DESC
LIMIT ?
|]
(userId, groupId, count)
reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds
@@ -36,6 +36,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges
import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders
import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services
import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at
import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -71,7 +72,8 @@ schemaMigrations =
("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges),
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at)
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at),
("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,19 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260601_relay_sent_web_domain :: Text
m20260601_relay_sent_web_domain =
[r|
ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT;
|]
down_m20260601_relay_sent_web_domain :: Text
down_m20260601_relay_sent_web_domain =
[r|
ALTER TABLE groups DROP COLUMN relay_sent_web_domain;
|]
@@ -978,7 +978,8 @@ CREATE TABLE test_chat_schema.groups (
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 01:00:00+01'::timestamp with time zone NOT NULL,
relay_inactive_at timestamp with time zone
relay_inactive_at timestamp with time zone,
relay_sent_web_domain text
);
+3 -1
View File
@@ -159,6 +159,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges
import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders
import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services
import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at
import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -317,7 +318,8 @@ schemaMigrations =
("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges),
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at)
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at),
("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260601_relay_sent_web_domain :: Query
m20260601_relay_sent_web_domain =
[sql|
ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT;
|]
down_m20260601_relay_sent_web_domain :: Query
down_m20260601_relay_sent_web_domain =
[sql|
ALTER TABLE groups DROP COLUMN relay_sent_web_domain;
|]
@@ -548,15 +548,6 @@ Plan:
SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?)
SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?)
Query:
UPDATE rcv_messages
SET receive_attempts = receive_attempts + 1
WHERE conn_id = ? AND internal_id = ?
RETURNING receive_attempts
Plan:
SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
Query:
DELETE FROM conn_confirmations
WHERE conn_id = ?
@@ -1618,6 +1618,18 @@ Plan:
SEARCH i USING INDEX idx_chat_items_group_id (group_id=?)
SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT i.chat_item_id
FROM chat_items i
WHERE i.user_id = ? AND i.group_id = ?
AND i.include_in_history = 1
AND i.item_deleted = 0
ORDER BY i.item_ts DESC, i.chat_item_id DESC
LIMIT ?
Plan:
SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?)
Query:
SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id
FROM chat_items i
@@ -5442,6 +5454,36 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?)
SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?)
SEARCH pu USING INTEGER PRIMARY KEY (rowid=?)
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,
g.business_chat, g.business_member_id, g.customer_member_id,
g.use_relays, g.relay_own_status,
g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
g.root_priv_key, g.root_pub_key, g.member_priv_key,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id)
WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?)
Plan:
SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?)
SEARCH g USING INTEGER PRIMARY KEY (rowid=?)
SEARCH gp USING INTEGER PRIMARY KEY (rowid=?)
SEARCH pu USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id,
@@ -5696,6 +5738,25 @@ Query:
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
LEFT JOIN connections c ON c.group_member_id = m.group_member_id
WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?
Plan:
SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
Query:
SELECT
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
LEFT JOIN connections c ON c.group_member_id = m.group_member_id
WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)
Plan:
SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
@@ -191,7 +191,8 @@ CREATE TABLE groups(
relay_request_retries INTEGER NOT NULL DEFAULT 0,
relay_request_delay INTEGER NOT NULL DEFAULT 0,
relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00',
relay_inactive_at TEXT, -- received
relay_inactive_at TEXT,
relay_sent_web_domain TEXT, -- received
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
+7 -2
View File
@@ -881,8 +881,13 @@ instance FromJSON ImageData where
parseJSON = fmap ImageData . J.parseJSON
instance ToJSON ImageData where
toJSON (ImageData t) = J.toJSON t
toEncoding (ImageData t) = J.toEncoding t
toJSON (ImageData t) = J.toJSON $ safeImageData t
toEncoding (ImageData t) = J.toEncoding $ safeImageData t
safeImageData :: Text -> Text
safeImageData t
| "data:" `T.isPrefixOf` t = t
| otherwise = ""
instance ToField ImageData where toField (ImageData t) = toField t
+17 -5
View File
@@ -1207,8 +1207,8 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} =
]
showRelay :: GroupRelay -> StyledString
showRelay GroupRelay {groupRelayId, relayStatus} =
" - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus)
showRelay GroupRelay {groupRelayId, relayStatus, relayCap = RelayCapabilities {webDomain}} =
" - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) <> maybe "" (\d -> ", web: " <> plain d) webDomain
viewGroupRelays :: GroupInfo -> [GroupRelay] -> [StyledString]
viewGroupRelays g relays =
@@ -1982,10 +1982,10 @@ countactUserPrefText cup = case cup of
viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> Maybe MsgSigStatus -> [StyledString]
viewGroupUpdated
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma}}
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}}
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma, publicGroup = pg}}
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma', publicGroup = pg'}}
m signed = do
let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated
let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated <> publicGroupAccessUpdated
if null update
then []
else memberUpdated <> update
@@ -2010,6 +2010,18 @@ viewGroupUpdated
memberAdmissionUpdated
| ma == ma' = []
| otherwise = ["changed member admission rules"]
publicGroupAccessUpdated
| access == access' = []
| otherwise = ["updated public group access:" <> viewAccess access']
where
access = pg >>= publicGroupAccess
access' = pg' >>= publicGroupAccess
viewAccess Nothing = " removed"
viewAccess (Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}) =
maybe "" (\u -> " web=" <> plain u) groupWebPage
<> maybe "" (\d -> " domain=" <> plain d) groupDomain
<> (if domainWebPage then " domain_page=on" else "")
<> (if allowEmbedding then " embed=on" else "")
viewGroupProfile :: GroupInfo -> [StyledString]
viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, description, image, groupPreferences = gps}} =
+430
View File
@@ -0,0 +1,430 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Web
( WebChannelPreview (..),
WebMessage (..),
WebMemberProfile (..),
WebFileInfo (..),
webPreviewWorker,
writeCorsConfig,
removeStaleFiles,
channelContentChanged,
channelProfileUpdated,
channelRemoved,
extractOrigin,
)
where
import Control.Concurrent.STM (check, flushTQueue)
import Control.Exception (SomeException, catch)
import Control.Logger.Simple
import Control.Monad (forM_, void, when)
import Control.Monad.Except (runExceptT)
import Data.Either (rights)
import Data.Int (Int64)
import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Map.Strict as M
import qualified Data.Set as S
import Data.Maybe (isJust, mapMaybe, maybeToList)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Time.Clock (UTCTime, getCurrentTime)
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt)
import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList)
import Simplex.Chat.Messages
( CChatItem (..),
CIDirection (..),
CIFile (..),
CIMeta (..),
CIQDirection (..),
CIQuote (..),
CIReactionCount,
ChatItem (..),
ChatType (..),
)
import Simplex.Chat.Messages.CIContent (ciMsgContent)
import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport)
import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups)
import Simplex.Chat.Store.Messages (getGroupWebPreviewItems)
import Simplex.Chat.Store.Shared (getGroupInfo)
import Simplex.Chat.Types
( B64UrlByteString,
GroupInfo (..),
GroupMember (..),
GroupProfile (..),
GroupSummary (..),
ImageData,
LocalProfile (..),
MemberId,
PublicGroupAccess (..),
PublicGroupProfile (..),
User (..),
)
import Simplex.Messaging.Agent.Store.Common (withTransaction)
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (safeDecodeUtf8)
import qualified URI.ByteString as U
import Simplex.Messaging.Parsers (defaultJSON)
import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile)
import System.FilePath (dropExtension, takeExtension, (</>))
import UnliftIO.STM
data WebFileInfo = WebFileInfo
{ fileName :: String,
fileSize :: Integer
}
deriving (Show)
data WebMemberProfile = WebMemberProfile
{ memberId :: MemberId,
displayName :: Text,
image :: Maybe ImageData
}
deriving (Show)
data WebMessage = WebMessage
{ sender :: Maybe MemberId,
ts :: UTCTime,
content :: MsgContent,
formattedText :: Maybe MarkdownList,
file :: Maybe WebFileInfo,
quote :: Maybe QuotedMsg,
reactions :: [CIReactionCount],
forward :: Maybe Bool,
edited :: Bool
}
deriving (Show)
data WebChannelPreview = WebChannelPreview
{ channel :: GroupProfile,
shortDescription :: Maybe MarkdownList,
welcomeMessage :: Maybe MarkdownList,
members :: [WebMemberProfile],
subscribers :: Maybe Int64,
messages :: [WebMessage],
updatedAt :: UTCTime
}
deriving (Show)
$(JQ.deriveJSON defaultJSON ''WebFileInfo)
$(JQ.deriveJSON defaultJSON ''WebMemberProfile)
$(JQ.deriveJSON defaultJSON ''WebMessage)
$(JQ.deriveJSON defaultJSON ''WebChannelPreview)
webPreviewWorker :: WebPreviewConfig -> ChatController -> [User] -> IO ()
webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterval} cc users =
forM_ (webPreviewState cc) $ \wps -> do
createDirectoryIfMissing True webJsonDir
initPublishableGroups wps
cleanStaleFiles wps
regenerateCors wps
seedRoutinePending wps
workerLoop wps
where
cxt = mkStoreCxt (config cc)
workerLoop wps@WebPreviewState {priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} = do
drainRemovals
drainPriority
handleCors
renderRoutine
noRoutine <- atomically $ S.null <$> readTVar routinePending
when noRoutine waitRefresh
workerLoop wps
where
drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case
Nothing -> pure ()
Just f -> do
removeFile (webJsonDir </> f) `catch` \(_ :: SomeException) -> pure ()
drainRemovals
-- flush the whole queue and render each group once: a burst of changes in one
-- channel enqueues its id many times, but only needs a single render
drainPriority = do
gIds <- atomically $ flushTQueue priorityRender
forM_ (S.fromList gIds) $ renderOneGroup wps
handleCors = do
needed <- atomically $ swapTVar corsNeeded False
when needed $ regenerateCors wps
-- render a single routine item; the main loop calls this once per iteration
renderRoutine = do
mGId <- atomically $ do
pending <- readTVar routinePending
case S.minView pending of
Nothing -> pure Nothing
Just (gId, rest) -> writeTVar routinePending rest >> pure (Just gId)
forM_ mGId $ renderOneGroup wps
-- routine list drained: wait for the refresh timer or a change signal; only the timer
-- seeds the next full sweep, a change just returns to let the main loop service it
waitRefresh = do
delay <- registerDelay (webUpdateInterval * 1000000)
timerFired <- atomically $
(True <$ (readTVar delay >>= check)) `orElse` (False <$ takeTMVar wakeSignal)
when timerFired $ seedRoutinePending wps
initPublishableGroups WebPreviewState {publishableGroupIds} = do
rows <- withTransaction (chatStore cc) $ \db ->
concat <$> mapM (getRelayPublishableGroups db) users
let gIds = M.fromList [(gId, toPublishableGroup pgId access) | (gId, pgId, access) <- rows]
atomically $ writeTVar publishableGroupIds gIds
cleanStaleFiles WebPreviewState {publishableGroupIds} = do
ids <- readTVarIO publishableGroupIds
let activeFiles = S.fromList $ map pgFileName $ M.elems ids
removeStaleFiles webJsonDir activeFiles
regenerateCors WebPreviewState {publishableGroupIds} = do
ids <- readTVarIO publishableGroupIds
let entries = mapMaybe pgCorsEntry $ M.elems ids
forM_ webCorsFile $ writeCorsConfig entries
seedRoutinePending WebPreviewState {publishableGroupIds, routinePending} =
atomically $ M.keysSet <$> readTVar publishableGroupIds >>= writeTVar routinePending
renderOneGroup WebPreviewState {publishableGroupIds} gId = do
publishable <- atomically $ M.member gId <$> readTVar publishableGroupIds
when publishable $
renderOrRemoveStale `catch` \(e :: SomeException) ->
logError $ "web preview: error rendering group " <> T.pack (show gId) <> ": " <> T.pack (show e)
where
renderOrRemoveStale = do
r <- withTransaction (chatStore cc) $ \db ->
findUser $ \u -> fmap (\g -> (u, g)) <$> runExceptT (getGroupInfo db cxt u gId)
case r of
Just (u, gInfo) | hasPublicGroup gInfo ->
void $ renderGroupPreview cfg cc u gInfo
_ -> do
fName <- atomically $ do
pg <- M.lookup gId <$> readTVar publishableGroupIds
modifyTVar' publishableGroupIds (M.delete gId)
pure $ pgFileName <$> pg
forM_ fName $ \f ->
removeFile (webJsonDir </> f) `catch` \(_ :: SomeException) -> pure ()
logInfo $ "web preview: group " <> T.pack (show gId) <> " no longer publishable"
findUser f = go users
where
go [] = pure Nothing
go (u : us) = f u >>= \case
Right a -> pure (Just a)
Left _ -> go us
renderGroupPreview :: WebPreviewConfig -> ChatController -> User -> GroupInfo -> IO (Maybe (Text, CorsOrigin))
renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gInfo@GroupInfo {groupProfile = gp@GroupProfile {shortDescr = sd, description = wd, publicGroup}, groupSummary = GroupSummary {publicMemberCount}} =
case publicGroup of
Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do
let fName = publicGroupIdFileName publicGroupId <> ".json"
(items, owners) <- withTransaction (chatStore cc) $ \db -> do
is <- getGroupWebPreviewItems db user gInfo webPreviewItemCount
os <- getGroupOwners db cxt user gInfo
pure (is, os)
ts <- getCurrentTime
let rendered = mapMaybe toRenderedItem $ rights items
msgs = map fst rendered
senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered
preview = WebChannelPreview
{ channel = gp,
shortDescription = toFormattedText =<< sd,
welcomeMessage = toFormattedText =<< wd,
members = senders,
subscribers = publicMemberCount,
messages = msgs,
updatedAt = ts
}
let destPath = webJsonDir </> fName
tmpPath = destPath <> ".tmp"
LB.writeFile tmpPath (J.encode preview)
renameFile tmpPath destPath
pure $ corsEntry publicGroupId <$> publicGroupAccess
Nothing -> pure Nothing
where
cxt = mkStoreCxt (config cc)
channelContentChanged :: ChatController -> Int64 -> STM ()
channelContentChanged cc gId =
forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, routinePending, wakeSignal} -> do
ids <- readTVar publishableGroupIds
when (M.member gId ids) $ do
writeTQueue priorityRender gId
modifyTVar' routinePending (S.delete gId)
void $ tryPutTMVar wakeSignal ()
channelProfileUpdated :: ChatController -> Int64 -> GroupProfile -> STM ()
channelProfileUpdated cc gId GroupProfile {publicGroup} =
forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} ->
case publicGroup of
Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do
let pg = PublishableGroup
{ pgFileName = publicGroupIdFileName publicGroupId <> ".json",
pgCorsEntry = corsEntry publicGroupId <$> publicGroupAccess
}
modifyTVar' publishableGroupIds (M.insert gId pg)
writeTQueue priorityRender gId
modifyTVar' routinePending (S.delete gId)
writeTVar corsNeeded True
void $ tryPutTMVar wakeSignal ()
Nothing -> do
ids <- readTVar publishableGroupIds
forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove
modifyTVar' publishableGroupIds (M.delete gId)
modifyTVar' routinePending (S.delete gId)
writeTVar corsNeeded True
void $ tryPutTMVar wakeSignal ()
channelRemoved :: ChatController -> Int64 -> STM ()
channelRemoved cc gId =
forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, filesToRemove, corsNeeded, routinePending, wakeSignal} -> do
ids <- readTVar publishableGroupIds
forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove
modifyTVar' publishableGroupIds (M.delete gId)
modifyTVar' routinePending (S.delete gId)
writeTVar corsNeeded True
void $ tryPutTMVar wakeSignal ()
toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile])
toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file})
| isJust itemTimed = Nothing
| otherwise = case ciMsgContent content of
Just mc | not (isReport mc) ->
let (sender, senderProfile) = case chatDir of
CIGroupRcv m@GroupMember {memberId} -> (Just memberId, [memberToProfile m])
_ -> (Nothing, [])
quotedProfile = case quotedItem of
Just CIQuote {chatDir = CIQGroupRcv (Just m)} -> [memberToProfile m]
_ -> []
in Just
( WebMessage
{ sender,
ts = itemTs,
content = mc,
formattedText,
file = webFileInfo <$> file,
quote = quotedItem >>= ciQuoteToQuotedMsg,
reactions,
forward = if isJust itemForwarded then Just True else Nothing,
edited = itemEdited
},
senderProfile <> quotedProfile
)
_ -> Nothing
ciQuoteToQuotedMsg :: CIQuote c -> Maybe QuotedMsg
ciQuoteToQuotedMsg CIQuote {chatDir = qDir, sharedMsgId, sentAt, content = qContent} =
Just QuotedMsg
{ msgRef = MsgRef
{ msgId = sharedMsgId,
sentAt,
sent = case qDir of
CIQDirectSnd -> True
CIQGroupSnd -> True
_ -> False,
memberId = case qDir of
CIQGroupRcv (Just GroupMember {memberId}) -> Just memberId
_ -> Nothing
},
content = qContent
}
webFileInfo :: CIFile d -> WebFileInfo
webFileInfo CIFile {fileName, fileSize} = WebFileInfo {fileName, fileSize}
collectSenders :: [WebMemberProfile] -> [WebMemberProfile]
collectSenders = M.elems . M.fromList . map (\p@WebMemberProfile {memberId} -> (memberId, p))
memberToProfile :: GroupMember -> WebMemberProfile
memberToProfile GroupMember {memberId, memberProfile = LocalProfile {displayName, image}} =
WebMemberProfile {memberId, displayName, image}
toPublishableGroup :: B64UrlByteString -> Maybe PublicGroupAccess -> PublishableGroup
toPublishableGroup pgId access =
PublishableGroup
{ pgFileName = publicGroupIdFileName pgId <> ".json",
pgCorsEntry = corsEntry pgId <$> access
}
corsEntry :: B64UrlByteString -> PublicGroupAccess -> (Text, CorsOrigin)
corsEntry publicGroupId PublicGroupAccess {groupWebPage, allowEmbedding} =
let fName = T.pack $ publicGroupIdFileName publicGroupId <> ".json"
origin
| allowEmbedding = CorsAny
| otherwise = CorsOrigins $ mapMaybe extractOrigin $ maybeToList groupWebPage
in (fName, origin)
extractOrigin :: Text -> Maybe Text
extractOrigin url =
case U.parseURI U.laxURIParserOptions (encodeUtf8 url) of
Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority = Just _}
| sch == "https" || sch == "http" ->
let originUri = uri {U.uriPath = "", U.uriQuery = U.Query [], U.uriFragment = Nothing}
origin = safeDecodeUtf8 $ U.serializeURIRef' originUri
in if T.all safeOriginChar origin then Just origin else Nothing
_ -> Nothing
where
-- percent-encoded bytes in the host (e.g. %22, %0a) are decoded by serializeURIRef',
-- so reject any origin with characters that could break out of the Caddy CORS config or header
safeOriginChar c =
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c `elem` (".-:/[]" :: [Char])
channelPath :: Text
channelPath = "/channel/"
writeCorsConfig :: [(Text, CorsOrigin)] -> FilePath -> IO ()
writeCorsConfig entries path =
TIO.writeFile path $ T.unlines $
["map {path} {cors_origin} {"]
<> map corsLine entries
<> [ " default \"\"",
"}",
"header " <> channelPath <> "*.json Access-Control-Allow-Origin {cors_origin}",
"header " <> channelPath <> "*.json Access-Control-Allow-Methods \"GET, OPTIONS\""
]
where
corsLine (fName, origin) = case origin of
CorsAny -> " " <> channelPath <> fName <> " \"*\""
CorsOrigins origins -> case origins of
[] -> " # " <> fName <> " (no origin configured)"
(o : _) -> " " <> channelPath <> fName <> " \"" <> o <> "\""
removeStaleFiles :: FilePath -> S.Set FilePath -> IO ()
removeStaleFiles dir activeFiles = do
let -- matches "<base64url>.json" and leftover "<base64url>.json.tmp" from an interrupted write
isPreviewFile f =
let f' = if takeExtension f == ".tmp" then dropExtension f else f
base = dropExtension f'
in takeExtension f' == ".json" && not (null base) && all isBase64Url base
isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
allFiles <- S.filter isPreviewFile . S.fromList <$> listDirectory dir
mapM_ (\f -> removeFile (dir </> f)) $ S.difference allFiles activeFiles
toFormattedText :: Text -> Maybe MarkdownList
toFormattedText t = case parseMaybeMarkdownList t of
Just fts | any hasFormat fts -> Just fts
_ -> Nothing
where
hasFormat (FormattedText fmt _) = isJust fmt
publicGroupIdFileName :: B64UrlByteString -> String
publicGroupIdFileName = B.unpack . strEncode
hasPublicGroup :: GroupInfo -> Bool
hasPublicGroup GroupInfo {groupProfile = GroupProfile {publicGroup}} = isJust publicGroup