cli: add name, image and relay server address flags (#6944)

* cli: add --relay-address-server option for chat relay

New CLI flag --relay-address-server SERVER selects the SMP server used
for the chat relay address link created at startup. Only valid together
with --relay; errors out otherwise.

Threads Maybe SMPServerWithAuth through APICreateMyAddress to the new
agent createConnection parameter.

* cli: add --user-display-name option

Selects or creates the active user non-interactively:
- no active user: create one with the given display name
- active user with matching localDisplayName: continue
- active user with different name: exit with error

Mutually exclusive with --create-bot-display-name.

* cli: add --user-image-file option

Sets the active user's profile image from a .png/.jpg/.jpeg file at
startup. Reads file, base64-encodes as data URL, and updates the user
profile directly in the DB - no notification is sent to existing
contacts. Skips the update if the stored image already matches.

Requires --user-display-name.

* cli: address PR review comments

- rename APICreateMyAddress field srv_ to server_
- extract repeated `loop` and putStrLn from createActiveUser via
  prompt where-clause
- fuse u_ inspection: validate active user display name in the same
  case that creates the user when missing

* cli: enforce profile image size limit in --user-image-file

Reject the file if the encoded data URL exceeds 12500 bytes - matches
the cap mobile and desktop UIs pass to resizeImageToStrSize for profile
images. Without this, oversized images would be silently set on the
user profile.

* core: validate profile image size in chat commands

Enforced in CreateActiveUser, updateProfile_, newGroup, runUpdateGroupProfile
via checkProfileImageSize; max 12500 bytes (matches mobile UIs).

* fix: thread new ChatOpts/CoreChatOpts fields through bot/test constructors

* bots/docs: filter hidden params before type introspection

Hide APICreateMyAddress server_ field so the bot doc generator does not
try to introspect SMPServerWithAuth (an unregistered type).

* core: validate full encoded profile size

Add checkProfileSize / checkGroupProfileSize that encode the full
ChatMessage and check against maxEncodedInfoLength, so a long
displayName/bio combined with a near-max image is also caught at
command time instead of failing later at send time with CEException.

Run alongside the existing checkProfileImageSize (image-only cap of
12500 bytes, matching mobile UIs) in CreateActiveUser, updateProfile_,
newGroup, runUpdateGroupProfile. Update genProfileImg to fit the cap.

* cli: add --headless option for chat relay

Skips interactive prompts (relay address creation, display name) so the
chat relay can run non-interactively as a service. Requires --relay;
creating a new profile also requires --user-display-name.

* test: cover profile image size limit and address server

Adds tests for two new capabilities:
- profile image size validation rejects oversized images
- /_address with a server pins the address to the requested SMP server

* core: update simplexmq (pass optional SMP server to prepareConnectionLink)

* cli: add /set profile image file, fix image flag

Add "/set profile image file <path>" command to set the profile image
from a .png/.jpg/.jpeg file in a running session.

Make --user-image-file create-only: for an existing user it now no-ops
with a note instead of failing with "chat not started" (the update ran
before the chat controller was started).

* core: unify image loading and profile size checks

- loadImageFile (CLI) and readProfileImageFile (command) now share one
  loadImageData; removes the duplicated mime/base64 encoding and its
  decodeUtf8/safeDecodeUtf8 divergence. A missing --user-image-file no
  longer crashes with an uncaught IOException, and empty files are rejected.
- checkProfileSize/checkGroupProfileSize share checkInfoSize.
- --relay-address-server/--headless requires-relay checks use
  errorWithoutStackTrace, matching the adjacent validation (no callstack dump).

* bots/docs: document UpdateProfileImageFromFile as a CLI command
This commit is contained in:
sh
2026-07-15 09:31:19 +01:00
committed by GitHub
parent 937c79bc3c
commit 6e2995cab6
14 changed files with 265 additions and 49 deletions
@@ -94,5 +94,7 @@ mkChatOpts BroadcastBotOpts {coreOptions, botDisplayName} =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = False,
createBot = Just CreateBotOpts {botDisplayName, allowFiles = False, clientService = False}
createBot = Just CreateBotOpts {botDisplayName, allowFiles = False, clientService = False},
userDisplayName = Nothing,
userImageFile = Nothing
}
@@ -249,7 +249,9 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName, clientService} =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = False,
createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False, clientService}
createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False, clientService},
userDisplayName = Nothing,
userImageFile = Nothing
}
parseMigrateLog :: ReadM MigrateLog
+3 -2
View File
@@ -28,7 +28,7 @@ chatCommandsDocs = map toCategory chatCommandsDocsData
CCCategory {categoryName, categoryDescr, commands = map toCmd commandsData}
toCmd (consName, hideParams, commandDescr, respNames, errors, network, syntax) = case find ((consName ==) . consName') chatCommandsTypeInfo of
Just RecordTypeInfo {fieldInfos} ->
let fields = filter ((`notElem` hideParams) . fieldName') $ map (toAPIField consName) fieldInfos
let fields = map (toAPIField consName) $ filter ((`notElem` hideParams) . fieldName) fieldInfos
commandType = ATUnionMember (fstToLower consName) fields
findResp name = case find ((name ==) . consName') chatResponsesDocs of
Just resp -> resp
@@ -77,7 +77,7 @@ chatCommandsDocsData :: [(String, String, [(ConsName, [String], Text, [ConsName]
chatCommandsDocsData =
[ ( "Address commands",
"Bots can use these commands to automatically check and create address when initialized",
[ ("APICreateMyAddress", [], "Create bot address.", ["CRUserContactLinkCreated", "CRChatCmdError"], [], Just UNInteractive, "/_address " <> Param "userId"),
[ ("APICreateMyAddress", ["server_"], "Create bot address.", ["CRUserContactLinkCreated", "CRChatCmdError"], [], Just UNInteractive, "/_address " <> Param "userId"),
("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"),
("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"),
("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"),
@@ -313,6 +313,7 @@ cliCommands =
"UpdateLiveMessage",
"UpdateProfile",
"UpdateProfileImage",
"UpdateProfileImageFromFile",
"UserRead",
"VerifyContact",
"VerifyGroupMember",
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 551de8039fbfcbc75d8e2685a4c631a55ae28fb2
tag: 399c5fe8c62efd3e0b47f1e2469542621362ef77
source-repository-package
type: git
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."551de8039fbfcbc75d8e2685a4c631a55ae28fb2" = "1gn0fkxp60cizh2v7vjsd4c2bab2si7fl2bdzikisvg0na8ffnhw";
"https://github.com/simplex-chat/simplexmq.git"."399c5fe8c62efd3e0b47f1e2469542621362ef77" = "1zdksdzzgjch9n6da8p26cj57l6ajdisjs7fpb1hvn4frlvwfhns";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+3 -2
View File
@@ -90,7 +90,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SMPServerWithAuth, SubscriptionMode (..), XFTPServer)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion)
import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost)
@@ -546,7 +546,7 @@ data ChatCommand
| ClearContact ContactName
| APIListContacts {userId :: UserId}
| ListContacts
| APICreateMyAddress {userId :: UserId}
| APICreateMyAddress {userId :: UserId, server_ :: Maybe SMPServerWithAuth}
| CreateMyAddress
| APIDeleteMyAddress {userId :: UserId}
| DeleteMyAddress
@@ -625,6 +625,7 @@ data ChatCommand
| SetBotCommands [ChatBotCommand]
| UpdateProfile ContactName (Maybe Text) -- UserId (not used in UI)
| UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI)
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
| ShowProfileImage
| SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI)
+45 -24
View File
@@ -19,6 +19,7 @@ import Control.Monad.Except
import Control.Monad.Reader
import qualified Data.ByteString.Char8 as B
import Data.List (find)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (getCurrentTime)
@@ -43,7 +44,7 @@ import Text.Read (readMaybe)
import UnliftIO.Async
simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO ()
simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath, maintenance}, createBot} chat =
simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath, maintenance}, createBot, userDisplayName, userImageFile} chat =
case logAgent of
Just level -> do
setLogLevel level
@@ -65,7 +66,20 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha
exitFailure
Right cc -> do
forM_ (preStartHook chatHooks) ($ cc)
u <- maybe (noMaintenance >> createActiveUser cc coreOptions createBot) pure u_
u <- case u_ of
Nothing -> do
noMaintenance
img_ <- mapM loadImageFile userImageFile
createActiveUser cc coreOptions createBot userDisplayName img_
Just u@User {localDisplayName} -> do
forM_ userDisplayName $ \name ->
when (localDisplayName /= name) $ do
putStrLn $ "Active user display name " <> show localDisplayName <> " does not match --user-display-name " <> show name
exitFailure
-- --user-image-file only applies when the profile is created; ignore it for an existing user
forM_ userImageFile $ \_ ->
putStrLn "Note: --user-image-file is ignored for an existing user (it only sets the image on profile creation); use \"/set profile image file <path>\" to change it"
pure u
unless testView $ putStrLn $ "Current user: " <> userStr u
runSimplexChat cfg opts u cc chat
noMaintenance = when maintenance $ do
@@ -73,11 +87,11 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha
exitFailure
runSimplexChat :: ChatConfig -> ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO ()
runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatRelay, maintenance}} u cc@ChatController {config = ChatConfig {chatHooks}} chat
runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatRelay, chatRelayServer, headless, maintenance}} u cc@ChatController {config = ChatConfig {chatHooks}} chat
| maintenance = wait =<< async (chat u cc)
| otherwise = do
a1 <- runReaderT (startChatController True True) cc
when (chatRelay && not testView) $ askCreateRelayAddress cc u
when (chatRelay && not testView) $ askCreateRelayAddress cc u chatRelayServer headless
forM_ (postStartHook chatHooks) ($ cc)
a2 <- async $ chat u cc
waitEither_ a1 a2
@@ -120,34 +134,38 @@ selectActiveUser CoreChatOpts {chatRelay} st users
let user = users !! (n - 1)
in Just <$> withTransaction st (`setActiveUser` user)
createActiveUser :: ChatController -> CoreChatOpts -> Maybe CreateBotOpts -> IO User
createActiveUser cc CoreChatOpts {chatRelay} = \case
createActiveUser :: ChatController -> CoreChatOpts -> Maybe CreateBotOpts -> Maybe Text -> Maybe ImageData -> IO User
createActiveUser cc CoreChatOpts {chatRelay, headless} createBot_ userDisplayName_ img_ = case createBot_ of
Just CreateBotOpts {botDisplayName, allowFiles, clientService} -> do
let preferences = if allowFiles then Nothing else Just emptyChatPrefs {files = Just FilesPreference {allow = FANo}}
createUser exitFailure clientService $ (mkProfile botDisplayName) {peerType = Just CPTBot, preferences}
Nothing -> putStrLn noProfile >> loop
where
noProfile
| chatRelay =
"No chat relay user profile found, it will be created now.\n\
\Please choose chat relay display name."
| otherwise =
"No user profiles found, it will be created now.\n\
\Please choose your display name.\n\
\It will be sent to your contacts when you connect.\n\
\It is only stored on your device and you can change it later."
loop = do
displayName <- T.pack <$> withPrompt "display name" getLine
createUser loop False $ mkProfile displayName
Nothing -> case userDisplayName_ of
Just displayName -> createUser exitFailure False $ (mkProfile displayName :: Profile) {image = img_}
Nothing
| headless -> putStrLn "No user profile found and no --user-display-name provided (required with --headless)" >> exitFailure
| otherwise -> putStrLn prompt >> loop
where
prompt
| chatRelay =
"No chat relay user profile found, it will be created now.\n\
\Please choose chat relay display name."
| otherwise =
"No user profiles found, it will be created now.\n\
\Please choose your display name.\n\
\It will be sent to your contacts when you connect.\n\
\It is only stored on your device and you can change it later."
where
loop = do
displayName <- T.pack <$> withPrompt "display name" getLine
createUser loop False $ mkProfile displayName
mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
createUser onError clientService p =
execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = BoolDef chatRelay, clientService = BoolDef clientService}) 0 `runReaderT` cc >>= \case
Right (CRActiveUser user) -> pure user
r -> printResponseEvent (Nothing, Nothing) (config cc) r >> onError
askCreateRelayAddress :: ChatController -> User -> IO ()
askCreateRelayAddress cc@ChatController {chatStore} user =
askCreateRelayAddress :: ChatController -> User -> Maybe SMPServerWithAuth -> Bool -> IO ()
askCreateRelayAddress cc@ChatController {chatStore} user@User {userId} server_ headless =
withTransaction chatStore (\db -> runExceptT $ getUserAddress db user) >>= \case
Right _ -> pure ()
Left SEUserContactLinkNotFound -> promptCreate
@@ -155,9 +173,9 @@ askCreateRelayAddress cc@ChatController {chatStore} user =
where
promptCreate :: IO ()
promptCreate = do
ok <- onOffPrompt "Create relay address" True
ok <- if headless then pure True else onOffPrompt "Create relay address" True
when ok $
execChatCommand' CreateMyAddress 0 `runReaderT` cc >>= \case
execChatCommand' (APICreateMyAddress userId server_) 0 `runReaderT` cc >>= \case
Right (CRUserContactLinkCreated _ address) -> do
putStrLn "Chat relay address is created:"
putStrLn $ addressStr address
@@ -192,6 +210,9 @@ onOffPrompt prompt def =
"N" -> pure False
_ -> putStrLn "Invalid input, please enter 'y' or 'n'" >> onOffPrompt prompt def
loadImageFile :: FilePath -> IO ImageData
loadImageFile path = loadImageData path >>= either (\e -> putStrLn ("--user-image-file: " <> e) >> exitFailure) pure
userStr :: User -> String
userStr User {localDisplayName, profile = LocalProfile {fullName}} =
T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")"
+69 -9
View File
@@ -154,9 +154,55 @@ _defaultNtfServers =
maxImageSize :: Integer
maxImageSize = 261120 * 2 -- auto-receive on mobiles
-- matches the cap mobile and desktop UIs pass to resizeImageToStrSize for profile images
maxProfileImageSize :: Int
maxProfileImageSize = 12500
checkProfileImageSize :: Maybe ImageData -> CM ()
checkProfileImageSize = mapM_ $ \(ImageData t) ->
let size = T.length t
in when (size > maxProfileImageSize) $ throwCmdError $ "Profile image is too large " <> show size
checkProfileSize :: Profile -> CM ()
checkProfileSize p = checkInfoSize "Profile" (XInfo p)
checkGroupProfileSize :: GroupProfile -> CM ()
checkGroupProfileSize p = checkInfoSize "Group profile" (XGrpInfo p)
-- validates that the profile update event fits into the connection info sent to peers
checkInfoSize :: String -> ChatMsgEvent 'Json -> CM ()
checkInfoSize what event = do
vr <- chatVersionRange
let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = event}
case encodeChatMessage maxEncodedInfoLength info of
ECMEncoded _ -> pure ()
ECMLarge -> throwCmdError $ what <> " is too large"
imageExtensions :: [String]
imageExtensions = [".jpg", ".jpeg", ".png", ".gif"]
-- read a .png/.jpg/.jpeg image file and encode it as a data: URL ImageData, or return an error message
loadImageData :: FilePath -> IO (Either String ImageData)
loadImageData path = case map toLower (takeExtension path) of
".png" -> readImage "image/png"
".jpg" -> readImage "image/jpg"
".jpeg" -> readImage "image/jpg"
_ -> pure $ Left $ "unsupported image extension in " <> path <> " (only .png, .jpg, .jpeg)"
where
readImage mime = do
exists <- doesFileExist path
if not exists
then pure $ Left $ "image file not found: " <> path
else do
bs <- B.readFile path
pure $
if B.null bs
then Left $ "image file is empty: " <> path
else Right $ ImageData $ "data:" <> mime <> ";base64," <> safeDecodeUtf8 (B64.encode bs)
readProfileImageFile :: FilePath -> CM ImageData
readProfileImageFile path = liftIO (loadImageData path) >>= either throwCmdError pure
fixedImagePreview :: ImageData
fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="
@@ -369,7 +415,10 @@ processChatCommand :: StoreCxt -> NetworkRequestMode -> ChatCommand -> CM ChatRe
processChatCommand cxt nm = \case
ShowActiveUser -> withUser' $ pure . CRActiveUser
CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay, clientService} -> do
forM_ profile $ \Profile {displayName} -> checkValidName displayName
forM_ profile $ \p@Profile {displayName, image} -> do
checkValidName displayName
checkProfileImageSize image
checkProfileSize p
p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile
u <- asks currentUser
users <- withFastStore' getUsers
@@ -2344,7 +2393,7 @@ processChatCommand cxt nm = \case
CRContactsList user <$> withFastStore' (\db -> getUserContacts db cxt user)
ListContacts -> withUser $ \User {userId} ->
processChatCommand cxt nm $ APIListContacts userId
APICreateMyAddress userId -> withUserId userId $ \user@User {userChatRelay} -> do
APICreateMyAddress userId server_ -> withUserId userId $ \user@User {userChatRelay} -> do
withFastStore' (\db -> runExceptT $ getUserAddress db user) >>= \case
Left SEUserContactLinkNotFound -> pure ()
Left e -> throwError $ ChatErrorStore e
@@ -2353,7 +2402,7 @@ processChatCommand cxt nm = \case
gVar <- asks random
rootKey@(rootPubKey, rootPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar
let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey
(ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True Nothing
(ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True Nothing server_
ccLink' <- shortenCreatedLink ccLink
-- TODO [relays] relay: add identity, key to link data?
userData <-
@@ -2366,7 +2415,7 @@ processChatCommand cxt nm = \case
withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode rootPrivKey
pure $ CRUserContactLinkCreated user ccLink''
CreateMyAddress -> withUser $ \User {userId} ->
processChatCommand cxt nm $ APICreateMyAddress userId
processChatCommand cxt nm $ APICreateMyAddress userId Nothing
APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do
conn <- withFastStore $ \db -> getUserAddressConnection db cxt user
withChatLock "deleteMyAddress" $ do
@@ -2612,7 +2661,7 @@ processChatCommand cxt nm = \case
let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey
crClientData = encodeJSON $ CRDataGroup groupLinkId
-- prepare link with entityId as linkEntityId (no server request)
(ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True (Just crClientData)
(ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True (Just crClientData) Nothing
ccLink' <- setShortLinkType CCTChannel <$> shortenCreatedLink ccLink
sLnk <- case connShortLink' ccLink' of
Just sl -> pure sl
@@ -3440,6 +3489,10 @@ processChatCommand cxt nm = \case
UpdateProfileImage image -> withUser $ \user@User {profile} -> do
let p = (fromLocalProfile profile :: Profile) {image}
updateProfile user p
UpdateProfileImageFromFile path -> withUser $ \user@User {profile} -> do
img <- readProfileImageFile path
let p = (fromLocalProfile profile :: Profile) {image = Just img}
updateProfile user p
ShowProfileImage -> withUser $ \user@User {profile} -> pure $ CRUserProfileImage user $ fromLocalProfile profile
SetUserFeature (ACF f) allowed -> withUser $ \user@User {profile} -> do
let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user}
@@ -3835,10 +3888,12 @@ processChatCommand cxt nm = \case
updateProfile :: User -> Profile -> CM ChatResponse
updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p'
updateProfile_ :: User -> Profile -> Bool -> CM User -> CM ChatResponse
updateProfile_ user@User {profile = p@LocalProfile {displayName = n}} p'@Profile {displayName = n'} shouldUpdateAddressData updateUser
updateProfile_ user@User {profile = p@LocalProfile {displayName = n}} p'@Profile {displayName = n', image = img'} shouldUpdateAddressData updateUser
| p' == fromLocalProfile p = pure $ CRUserProfileNoChange user
| otherwise = do
when (n /= n') $ checkValidName n'
checkProfileImageSize img'
checkProfileSize p'
-- read contacts before user update to correctly merge preferences
contacts <- withFastStore' $ \db -> getUserContacts db cxt user
user' <- updateUser
@@ -3924,9 +3979,11 @@ processChatCommand cxt nm = \case
lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct'
pure $ CRContactPrefsUpdated user ct ct'
runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> Bool -> CM ChatResponse
runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n'} domainVerified = do
runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n', image = img'} domainVerified = do
assertUserGroupRole gInfo GROwner
when (n /= n') $ checkValidName n'
checkProfileImageSize img'
checkGroupProfileSize p'
-- updateGroupProfile clears domain verification; re-set it when the caller already re-resolved the name
gInfo' <- withStore $ \db -> do
g <- updateGroupProfile db user gInfo p'
@@ -4078,8 +4135,10 @@ processChatCommand cxt nm = \case
groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName
pure (groupId, groupMemberId)
newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> CM GroupInfo
newGroup user incognito gProfile@GroupProfile {displayName} useRelays memberId groupKeys_ publicMemberCount_ = do
newGroup user incognito gProfile@GroupProfile {displayName, image} useRelays memberId groupKeys_ publicMemberCount_ = do
checkValidName displayName
checkProfileImageSize image
checkGroupProfileSize gProfile
-- [incognito] generate incognito profile for group membership
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_
@@ -5579,7 +5638,7 @@ chatCommandP =
("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal),
"/_connect contact " *> (APIConnectContactViaAddress <$> A.decimal <*> incognitoOnOffP <* A.space <*> A.decimal),
"/simplex" *> (ConnectSimplex <$> incognitoP),
"/_address " *> (APICreateMyAddress <$> A.decimal),
"/_address " *> (APICreateMyAddress <$> A.decimal <*> optional (A.space *> strP)),
("/address" <|> "/ad") $> CreateMyAddress,
"/_delete_address " *> (APIDeleteMyAddress <$> A.decimal),
("/delete_address" <|> "/da") $> DeleteMyAddress,
@@ -5594,6 +5653,7 @@ chatCommandP =
("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayNameP),
("/markdown" <|> "/m") $> ChatHelp HSMarkdown,
("/welcome" <|> "/w") $> Welcome,
"/set profile image file " *> (UpdateProfileImageFromFile <$> filePath),
"/set profile image " *> (UpdateProfileImage . Just . ImageData <$> imageP),
"/delete profile image" $> UpdateProfileImage Nothing,
"/show profile image" $> ShowProfileImage,
+1 -1
View File
@@ -4463,7 +4463,7 @@ runRelayRequestWorker a Worker {doWork} = do
sigKeys <- liftIO $ atomically $ C.generateKeyPair gVar
let crClientData = encodeJSON $ CRDataGroup groupLinkId
-- prepare link with relayMemId as linkEntityId (no server request)
(ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData)
(ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData) Nothing
ccLink' <- setShortLinkType CCTGroup <$> shortenCreatedLink ccLink
sLnk <- case connShortLink' ccLink' of
Just sl -> pure sl
+5 -1
View File
@@ -262,6 +262,8 @@ mobileChatOpts dbOptions =
deviceName = Nothing,
chatRelay = False,
webPreviewConfig = Nothing,
chatRelayServer = Nothing,
headless = False,
highlyAvailable = False,
yesToUpMigrations = False,
migrationBackupPath = Just "",
@@ -278,7 +280,9 @@ mobileChatOpts dbOptions =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = False,
createBot = Nothing
createBot = Nothing,
userDisplayName = Nothing,
userImageFile = Nothing
}
defaultMobileConfig :: ChatConfig
+47 -4
View File
@@ -22,7 +22,7 @@ where
import Control.Logger.Simple (LogLevel (..))
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B
import Data.Maybe (fromMaybe)
import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
@@ -50,7 +50,9 @@ data ChatOpts = ChatOpts
autoAcceptFileSize :: Integer,
muteNotifications :: Bool,
markRead :: Bool,
createBot :: Maybe CreateBotOpts
createBot :: Maybe CreateBotOpts,
userDisplayName :: Maybe Text,
userImageFile :: Maybe FilePath
}
data CoreChatOpts = CoreChatOpts
@@ -67,6 +69,8 @@ data CoreChatOpts = CoreChatOpts
deviceName :: Maybe Text,
chatRelay :: Bool,
webPreviewConfig :: Maybe WebPreviewConfig,
chatRelayServer :: Maybe SMPServerWithAuth,
headless :: Bool,
highlyAvailable :: Bool,
yesToUpMigrations :: Bool,
migrationBackupPath :: Maybe FilePath,
@@ -281,6 +285,19 @@ coreChatOptsP appDir defaultDbName = do
(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"
chatRelayServer <-
optional $
option
strParse
( long "relay-address-server"
<> metavar "SERVER"
<> help "SMP server to use for chat relay address link (requires --relay)"
)
headless <-
switch
( long "headless"
<> help "Run chat relay without interactive prompts, e.g. as a service (requires --relay; on first run also --user-display-name to create the profile)"
)
highlyAvailable <-
switch
( long "ha"
@@ -325,6 +342,12 @@ coreChatOptsP appDir defaultDbName = do
deviceName,
chatRelay,
webPreviewConfig,
chatRelayServer = case chatRelayServer of
Just _ | not chatRelay -> errorWithoutStackTrace "--relay-address-server option requires --relay option"
_ -> chatRelayServer,
headless = case headless of
True | not chatRelay -> errorWithoutStackTrace "--headless option requires --relay option"
_ -> headless,
highlyAvailable,
yesToUpMigrations,
migrationBackupPath,
@@ -438,6 +461,20 @@ chatOptsP appDir defaultDbName = do
( long "create-bot-client-service"
<> help "Flag for created bot to use client service certificate"
)
userDisplayName <-
optional $
strOption
( long "user-display-name"
<> metavar "NAME"
<> help "Use existing active user with this display name, or create one on the first start (incompatible with --create-bot-display-name)"
)
userImageFile <-
optional $
strOption
( long "user-image-file"
<> metavar "FILE"
<> help "Set user profile image from .png/.jpg/.jpeg file when the profile is created (requires --user-display-name); ignored if the user already exists (use \"/set profile image file <path>\" to change it)"
)
pure
ChatOpts
{ coreOptions,
@@ -453,11 +490,17 @@ chatOptsP appDir defaultDbName = do
muteNotifications,
markRead,
createBot = case createBotDisplayName of
Just botDisplayName -> Just CreateBotOpts {botDisplayName, allowFiles = createBotAllowFiles, clientService = createBotClientService}
Just botDisplayName
| isJust userDisplayName -> error "--user-display-name and --create-bot-display-name are mutually exclusive"
| otherwise -> Just CreateBotOpts {botDisplayName, allowFiles = createBotAllowFiles, clientService = createBotClientService}
Nothing
| createBotAllowFiles -> error "--create-bot-allow-files option requires --create-bot-name option"
| createBotClientService -> error "--create-bot-client-service option requires --create-bot-name option"
| otherwise -> Nothing
| otherwise -> Nothing,
userDisplayName,
userImageFile = case userImageFile of
Just _ | isNothing userDisplayName -> error "--user-image-file option requires --user-display-name option"
_ -> userImageFile
}
parseProtocolServers :: ProtocolTypeI p => ReadM [ProtoServerWithAuth p]
+5 -1
View File
@@ -121,7 +121,9 @@ testOpts =
autoAcceptFileSize = 0,
muteNotifications = True,
markRead = True,
createBot = Nothing
createBot = Nothing,
userDisplayName = Nothing,
userImageFile = Nothing
}
testCoreOpts :: CoreChatOpts
@@ -155,6 +157,8 @@ testCoreOpts =
deviceName = Nothing,
chatRelay = False,
webPreviewConfig = Nothing,
chatRelayServer = Nothing,
headless = False,
highlyAvailable = False,
yesToUpMigrations = False,
migrationBackupPath = Nothing,
+75
View File
@@ -47,6 +47,8 @@ chatProfileTests = do
describe "user profiles" $ do
it "update user profile and notify contacts" testUpdateProfile
it "update user profile with image" testUpdateProfileImage
it "reject profile image that is too large" testSetProfileImageTooLarge
it "set profile image from file" testSetProfileImageFromFile
it "use multiword profile names" testMultiWordProfileNames
it "present supporter badge to contacts" testUserBadgeBroadcast
it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect
@@ -57,6 +59,7 @@ chatProfileTests = do
it "supporter badge sent to contact connecting via address" testUserBadgeContactAddress
describe "user contact link" $ do
it "create and connect via contact link" testUserContactLink
it "create address on specified server" testCreateAddressOnServer
it "retry connecting via contact link" testRetryConnectingViaContactLink
it "add contact link to profile" testProfileLink
it "auto accept contact requests" testUserContactLinkAutoAccept
@@ -423,6 +426,52 @@ testUpdateProfileImage =
bob <## "use @alice2 <message> to send messages"
(bob </)
testSetProfileImageTooLarge :: HasCallStack => TestParams -> IO ()
testSetProfileImageTooLarge =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
-- image within the size limit is accepted
alice ##> "/set profile image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
alice <## "profile image updated"
-- image over the size limit is rejected;
-- the long command wraps in the virtual terminal, so drain echo lines until the error
alice `send` ("/set profile image data:image/png;base64," <> replicate 12500 'A')
let errPrefix = "bad chat command: Profile image is too large"
expectError = do
l <- getTermLine alice
unless (take (length errPrefix) l == errPrefix) expectError
expectError
(bob </)
testSetProfileImageFromFile :: HasCallStack => TestParams -> IO ()
testSetProfileImageFromFile ps = testChat aliceProfile test ps
where
tmp = tmpPath ps
pngPath = tmp <> "/avatar.png"
gifPath = tmp <> "/avatar.gif"
missingPath = tmp <> "/missing.png"
emptyPath = tmp <> "/empty.png"
test alice = do
B.writeFile pngPath "fake png bytes" -- content is not validated, only the extension
-- set profile image from a .png file
alice ##> ("/set profile image file " <> pngPath)
alice <## "profile image updated"
alice ##> "/show profile image"
alice <## "Profile image:"
alice <##. "data:image/png;base64,"
-- unsupported extension is rejected
B.writeFile gifPath "GIF89a"
alice ##> ("/set profile image file " <> gifPath)
alice <##. "bad chat command: unsupported image extension"
-- missing file is rejected
alice ##> ("/set profile image file " <> missingPath)
alice <##. "bad chat command: image file not found"
-- empty file is rejected
B.writeFile emptyPath ""
alice ##> ("/set profile image file " <> emptyPath)
alice <##. "bad chat command: image file is empty"
testMultiWordProfileNames :: HasCallStack => TestParams -> IO ()
testMultiWordProfileNames =
testChat3 aliceProfile' bobProfile' cathProfile' $
@@ -529,6 +578,32 @@ testUserContactLink =
alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")]
alice <##> cath
testCreateAddressOnServer :: HasCallStack => TestParams -> IO ()
testCreateAddressOnServer ps = testChat aliceProfile test ps
where
tmp = tmpPath ps
-- second SMP server, distinct from alice's configured server (localhost:7001)
altServer = "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"
altServerCfg =
smpServerCfg
{ transports = [("7003", transport @TLS, False)],
serverStoreCfg = persistentServerStoreCfg tmp
}
test alice = do
withSmpServer' altServerCfg $ do
-- without a server the address is created on the configured server (7001)
alice ##> "/_address 1"
(_, defaultLink) <- getContactLinks alice True
defaultLink `shouldContain` "localhost%3A7001" -- server is URL-encoded in the link
alice ##> "/_delete_address 1"
alice <## "Your chat address is deleted - accepted contacts will remain connected."
alice <## "To create a new chat address use /ad"
-- with a server the address is pinned to the requested server (7003)
alice ##> ("/_address 1 " <> altServer)
(_, pinnedLink) <- getContactLinks alice True
pinnedLink `shouldContain` "localhost%3A7003"
alice <## "disconnected 1 connections on server localhost"
testRetryConnectingViaContactLink :: HasCallStack => TestParams -> IO ()
testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps
where
+4 -1
View File
@@ -24,6 +24,7 @@ import Data.Maybe (fromMaybe)
import Data.String
import qualified Data.Text as T
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), mkStoreCxt)
import Simplex.Chat.Library.Commands (maxProfileImageSize)
import Simplex.Chat.Markdown (viewName)
import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText)
import Simplex.Chat.Protocol
@@ -241,7 +242,9 @@ genProfileImg = do
g <- C.newRandom
atomically $ B64.encode <$> C.randomBytes lrgLen g
where
lrgLen = maxEncodedInfoLength * 3 `div` 4 - 420
-- raw bytes that base64-encode to fit maxProfileImageSize when prefixed with "data:image/png;base64,"
lrgLen = (maxProfileImageSize - imagePrefixLen) * 3 `div` 4 - 1
imagePrefixLen = 22
-- PQ combinators /