Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2025-08-10 12:39:54 +01:00
11 changed files with 125 additions and 11 deletions
@@ -57,7 +57,7 @@ func openBrowserAlert(uri: String) {
cancelAlertAction,
UIAlertAction(
title: NSLocalizedString("Open full link", comment: "alert action"),
style: .destructive,
style: .default,
handler: { _ in UIApplication.shared.open(url.uri) }
),
UIAlertAction(
@@ -353,7 +353,7 @@ struct ComposeView: View {
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@State private var updatingCompose = false
var body: some View {
@@ -14,7 +14,7 @@ struct PrivacySettings: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
+1 -1
View File
@@ -98,7 +98,7 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: true,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT: true,
GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: true,
GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false,
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
@@ -105,7 +105,7 @@ class AppPreferences {
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true)
val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, true)
val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, false)
// TODO remove
val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } }
val simplexLinkMode: SharedPreference<SimplexLinkMode> = mkSafeEnumPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default)
@@ -43,6 +43,7 @@ import kotlinx.serialization.encoding.Encoder
import java.io.File
import java.net.URI
import java.nio.file.Files
import kotlin.math.min
const val MAX_NUMBER_OF_MENTIONS = 3
@@ -907,7 +908,8 @@ fun ComposeView(
if (sanitizedPos == null) {
composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage)
} else {
val message = if (sanitizedPos < s.selection.start) s.copy(text = updatedMsg) else ComposeMessage(updatedMsg, TextRange(sanitizedPos, sanitizedPos))
val pos = min(updatedMsg.count(), if (sanitizedPos < s.selection.start) s.selection.start else sanitizedPos)
val message = s.copy(text = updatedMsg, selection = TextRange(pos))
composeState.value = composeState.value.copy(message = message, parsedMessage = updatedParsedMsg)
parsedMessage = updatedParsedMsg
}
@@ -391,7 +391,7 @@ fun openBrowserAlert(uri: String, uriHandler: UriHandler) {
AlertManager.shared.hideAlert()
safeOpenUri(uri, uriHandler)
}) {
Text(generalGetString(MR.strings.privacy_chat_list_open_full_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(generalGetString(MR.strings.privacy_chat_list_open_full_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
+43
View File
@@ -0,0 +1,43 @@
# Channels
This supersedes the previous [super-peers RFC](./2025-02-13-super-peer-groups-mvp.md), by reducing its scope to channels.
## Problem
This doc proposes the scope for MVP for channels based on chat relays, as the first step to arbitrary large groups based on chat relays.
The technical difference of channels is that only owners can send messages and all members are "observers" who can send reactions.
From UX point of view, channels will be a separate thing, as one more action in the menu.
From threat model point of view, we want to protect from chat relays being able to act as owners - channel "identity" will be based on owners keys, as described in [this rfc](https://github.com/simplex-chat/simplexmq/blob/stable/rfcs/2025-04-04-short-links-for-groups.md).
## Solution
Minimal testable scope:
- protocol for managing owners changes and channel profile changes - probably based on some consensus protocol.
- message signing by owners - to authorize channel profile and ownership changes.
- make group link part of group profile, while delegating connection to channel to chat relays included in link data.
- messages sent from channel name.
- message forwarding by chat relays:
- all relays forward each message, client de-duplicates by message ID and highlights any differences between chat relays.
- forwarding operation should be persistent, resumable and batched, optimizing between latency and load.
Channels MVP implementation scope:
- split directory service from chat relay.
- message services to allow for fast subscriptions of chat relays to SMP servers using service certificates. It is in progress in [this PR](https://github.com/simplex-chat/simplexmq/pull/1565).
- fully migrate SMP servers to PostgreSQL.
- chat relay management (started in [this PR](https://github.com/simplex-chat/simplex-chat/pull/5653)), include chat relays into pre-configured operators.
- catch up with skipped messages (related to history navigation).
- manage chat relays in UI.
- member keys, for owners, profile signatures and for establishing e2e encryption in chat with admins. Possibly, profile keys as described in [this RFC](./2025-04-14-signing-messages.md) (option 2).
- reuploading files by relays for indefinite storage and maintaining channel quotas for files.
- operator content moderation.
- UI changes for channel/group creation.
Post MVP:
- pagination in member list.
- history "navigation" - request older messages.
- e2e encryption in chats with admins.
- message revocation from history by senders.
- message comments.
+2 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.4.3.0
version: 6.4.3.1
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -587,6 +587,7 @@ test-suite simplex-chat-test
, time ==1.12.*
, unicode-transforms ==0.4.*
, unliftio ==0.2.*
, uri-bytestring >=0.3.3.1 && <0.4
default-language: Haskell2010
if flag(client_postgres)
build-depends:
+46 -3
View File
@@ -20,6 +20,7 @@ import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace)
import Data.Either (fromRight)
@@ -328,7 +329,7 @@ markdownP = mconcat <$> A.many' fragmentP
strEncodeText :: StrEncoding a => a -> Text
strEncodeText = safeDecodeUtf8 . strEncode
parseUri :: B.ByteString -> Either Text U.URI
parseUri :: ByteString -> Either Text U.URI
parseUri s = case U.parseURI U.laxURIParserOptions s of
Left e -> Left $ "Invalid URI: " <> tshow e
Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority}
@@ -339,12 +340,54 @@ parseUri s = case U.parseURI U.laxURIParserOptions s of
| '.' `B.notElem` h -> Left $ "Invalid URI host: " <> safeDecodeUtf8 h
| otherwise -> Right uri
-- the heuristic for removing tracking parameters is the following:
-- 1) if the URI path looks like page name* rather than an identifier, allow the first parameter, as long as it is not blacklisted,
-- 2) also allow whitelisted parameters,
-- 3) remove all other parameters.
-- *page name: lowercase latin in snake-case or hyphen-case, allowing for sinlge leading or trailing hyphen or underscore.
sanitizeUri :: U.URI -> Maybe U.URI
sanitizeUri uri@U.URI {uriQuery = U.Query originalQS} =
let sanitizedQS = filter (\(p, _) -> p == "q" || p == "search") originalQS
sanitizeUri uri@U.URI {uriAuthority, uriPath, uriQuery = U.Query originalQS} =
let sanitizedQS
| isNamePath = case originalQS of
p : ps -> (if isBlacklisted (fst p) then id else (p :)) $ filter (isWhitelisted . fst) ps
[] -> []
| otherwise = filter (isWhitelisted . fst) originalQS
in if length sanitizedQS == length originalQS
then Nothing
else Just $ uri {U.uriQuery = U.Query sanitizedQS}
where
isBlacklisted p = any ($ p) qsBlacklist
isWhitelisted p = any (\(f, ps) -> f host && p `elem` ps) qsWhitelist
host = maybe "" (\U.Authority {authorityHost = U.Host h} -> h) uriAuthority
isNamePath = B.all (\c -> (c >= 'a' && c <= 'z') || c == '_' || c == '-' || c == '/') uriPath
qsWhitelist :: [(ByteString -> Bool, [ByteString])]
qsWhitelist =
[ (const True, ["q", "search"]),
(dom "youtube.com", ["v", "t"]),
(dom "youtu.be", ["t"])
]
dom d h = d == h || (('.' `B.cons` d) `B.isSuffixOf` h)
qsBlacklist :: [ByteString -> Bool]
qsBlacklist =
[ (B.any (== '_')),
("ad" `B.isPrefixOf`),
("af" `B.isPrefixOf`),
("dc" `B.isPrefixOf`),
("fb" `B.isPrefixOf`),
("gc" `B.isPrefixOf`),
("li" `B.isPrefixOf`),
("ref" `B.isPrefixOf`),
("si" `B.isPrefixOf`),
("tw" `B.isPrefixOf`),
("utm" `B.isPrefixOf`),
("camp" `B.isInfixOf`),
("cmp" `B.isInfixOf`),
("dev" `B.isInfixOf`),
("id" `B.isInfixOf`),
("prom" `B.isInfixOf`),
("source" `B.isInfixOf`),
("src" `B.isInfixOf`)
]
markdownText :: FormattedText -> Text
markdownText (FormattedText f_ t) = case f_ of
+25
View File
@@ -11,8 +11,10 @@ import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Markdown
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util ((<$$>))
import System.Console.ANSI.Types
import Test.Hspec
import qualified URI.ByteString as U
markdownTests :: Spec
markdownTests = do
@@ -26,6 +28,7 @@ markdownTests = do
textWithMentions
textWithCommands
multilineMarkdownList
testSanitizeUri
infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>>
@@ -359,3 +362,25 @@ multilineMarkdownList = describe "multiline markdown" do
it "command markdown" do
"/link 1" <<==>> [command' "link 1" "/link 1"]
" /link 1" <<==>> [command' "link 1" " /link 1"]
testSanitizeUri :: Spec
testSanitizeUri = describe "sanitizeUri" $ do
it "should allow the first parameter and whitelisted parameters on pages without IDs" $ do
"https://example.com/page?ref=123" `sanitized` Just "https://example.com/page"
"https://example.com/page?name" `sanitized` Nothing
"https://example.com/page?name=abc" `sanitized` Nothing
"https://example.com/page?name=abc&ref=123" `sanitized` Just "https://example.com/page?name=abc"
"https://example.com/page?search=query" `sanitized` Nothing
"https://example.com/page?q=query" `sanitized` Nothing
"https://example.com/page?ref=123&q=query" `sanitized` Just "https://example.com/page?q=query"
"https://youtube.com/watch?v=abc&t=123" `sanitized` Nothing
"https://www.youtube.com/watch?v=abc" `sanitized` Nothing
"https://www.youtube.com/watch?v=abc&t=123" `sanitized` Nothing
"https://www.youtube.com/watch?ref=456&v=abc&t=123" `sanitized` Just "https://www.youtube.com/watch?v=abc&t=123"
it "should only allow whitelisted parameters if path contains IDs" $ do
"https://example.com/page/a123?name=abc" `sanitized` Just "https://example.com/page/a123"
"https://youtu.be/a123?si=456" `sanitized` Just "https://youtu.be/a123"
"https://youtu.be/a123?t=456" `sanitized` Nothing
"https://youtu.be/a123?si=456&t=789" `sanitized` Just "https://youtu.be/a123?t=789"
where
s `sanitized` res = (U.serializeURIRef' <$$> (sanitizeUri <$> parseUri s)) `shouldBe` Right res