mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-18 22:36:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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";
|
||||
|
||||
@@ -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
@@ -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 <> ")"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /
|
||||
|
||||
|
||||
Reference in New Issue
Block a user