From 6e2995cab6ed828219e33e3bfbd602a3d6b02bf2 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:31:19 +0400 Subject: [PATCH] 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 " 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 --- .../src/Broadcast/Options.hs | 4 +- .../src/Directory/Options.hs | 4 +- bots/src/API/Docs/Commands.hs | 5 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Core.hs | 69 ++++++++++------ src/Simplex/Chat/Library/Commands.hs | 78 ++++++++++++++++--- src/Simplex/Chat/Library/Subscriber.hs | 2 +- src/Simplex/Chat/Mobile.hs | 6 +- src/Simplex/Chat/Options.hs | 51 +++++++++++- tests/ChatClient.hs | 6 +- tests/ChatTests/Profiles.hs | 75 ++++++++++++++++++ tests/ChatTests/Utils.hs | 5 +- 14 files changed, 265 insertions(+), 49 deletions(-) diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 268e4329cc..893e687d78 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -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 } diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 23115ec7c7..2c94152a1c 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -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 diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 3aa054af0c..52705fd24e 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -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", diff --git a/cabal.project b/cabal.project index c66e079518..90e997d3ea 100644 --- a/cabal.project +++ b/cabal.project @@ -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 diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 15e7bb2b59..9bd94403bc 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -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"; diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 693a467bbf..b0ae5d46ef 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index cc3e49beb6..51de91e588 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -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 \" 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 <> ")" diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3e1bf4d47a..0c15be8634 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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, diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 822253bd55..ef0ff9e345 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -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 diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 85074e93f4..4e3dc3ab34 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -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 diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index a936f58848..1846cb3582 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -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 \" 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] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c194b0c961..029c637980 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -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, diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 581aaec879..99cd5bf4dd 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -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 to send messages" (bob 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 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 diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index a38333c2a2..1f3426b81a 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -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 /