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
+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