mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-22 01:29:49 +00:00
Merge branch 'stable' into stable-android
This commit is contained in:
@@ -637,7 +637,8 @@ jobs:
|
||||
toolchain:p
|
||||
cmake:p
|
||||
|
||||
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
|
||||
# rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing
|
||||
# (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows)
|
||||
- name: Build CLI
|
||||
id: windows_cli_build
|
||||
shell: msys2 {0}
|
||||
@@ -652,10 +653,10 @@ jobs:
|
||||
echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local
|
||||
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
|
||||
sed -i "s/, unix /--, unix /" simplex-chat.cabal
|
||||
cabal build -j --enable-tests
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
|
||||
path=$(cabal list-bin simplex-chat | tail -n 1)
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
@@ -679,7 +680,7 @@ jobs:
|
||||
scripts/desktop/build-lib-windows.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew -Psimplex.assets.dir=../../assets packageMsi
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
|
||||
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -191,9 +191,14 @@ private func handleTextTaps(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let index, let (uri, browser) = attributedStringLink(s, for: index) {
|
||||
if let index, let (uri, browser, simplex) = attributedStringLink(s, for: index) {
|
||||
if browser {
|
||||
openBrowserAlert(uri: uri)
|
||||
} else if simplex, let url = URL(string: uri) {
|
||||
// SimpleX links target this same app (simplex: scheme / simplex.chat universal link),
|
||||
// so UIApplication.shared.open is dropped by iOS while the app is in the foreground.
|
||||
// Route to the in-app connect flow instead (same sink onOpenURL feeds).
|
||||
ChatModel.shared.appOpenUrl = url
|
||||
} else if let url = URL(string: uri) {
|
||||
UIApplication.shared.open(url)
|
||||
} else {
|
||||
@@ -203,9 +208,10 @@ private func handleTextTaps(
|
||||
})
|
||||
}
|
||||
|
||||
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? {
|
||||
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool, Bool)? {
|
||||
var linkURL: String?
|
||||
var browser: Bool = false
|
||||
var simplex: Bool = false
|
||||
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
|
||||
if index >= range.location && index < range.location + range.length {
|
||||
if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo {
|
||||
@@ -213,6 +219,7 @@ private func handleTextTaps(
|
||||
} else if let url = attrs[linkAttrKey] as? String {
|
||||
linkURL = url
|
||||
browser = attrs[webLinkAttrKey] != nil
|
||||
simplex = attrs[simplexLinkAttrKey] != nil
|
||||
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
|
||||
if showSecrets.wrappedValue.contains(i) {
|
||||
showSecrets.wrappedValue.remove(i)
|
||||
@@ -225,7 +232,7 @@ private func handleTextTaps(
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return if let linkURL { (linkURL, browser) } else { nil }
|
||||
return if let linkURL { (linkURL, browser, simplex) } else { nil }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +257,8 @@ private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
|
||||
|
||||
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
|
||||
|
||||
private let simplexLinkAttrKey = NSAttributedString.Key("chat.simplex.app.simplexLink")
|
||||
|
||||
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
|
||||
|
||||
private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command")
|
||||
@@ -392,6 +401,7 @@ func messageText(
|
||||
attrs = linkAttrs()
|
||||
if !preview {
|
||||
attrs[linkAttrKey] = simplexUri
|
||||
attrs[simplexLinkAttrKey] = true
|
||||
handleTaps = true
|
||||
}
|
||||
if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) {
|
||||
|
||||
@@ -163,10 +163,13 @@ struct ContextProfilePickerView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
ProfileImage(imageStr: user.image, size: 38)
|
||||
Text(user.chatViewName)
|
||||
.fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.lineLimit(1)
|
||||
NameWithBadge(
|
||||
Text(user.chatViewName)
|
||||
.fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
|
||||
.foregroundColor(theme.colors.onBackground),
|
||||
user.profile.localBadge
|
||||
)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ struct ChatHelp: View {
|
||||
Button("connect to SimpleX Chat developers.") {
|
||||
dismissSettingsSheet()
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
// simplexTeamURL targets this same app; route to the in-app connect flow
|
||||
// (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
|
||||
ChatModel.shared.appOpenUrl = simplexTeamURL
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
|
||||
@@ -390,7 +390,9 @@ struct SettingsView: View {
|
||||
Button("Send questions and ideas") {
|
||||
dismiss()
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
// simplexTeamURL targets this same app; route to the in-app connect flow
|
||||
// (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
|
||||
ChatModel.shared.appOpenUrl = simplexTeamURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -146,9 +146,10 @@ fun ComposeContextProfilePickerView(
|
||||
) {
|
||||
ProfileImage(size = USER_ROW_AVATAR_SIZE, image = user.image)
|
||||
TextIconSpaced(false)
|
||||
Text(
|
||||
NameWithBadge(
|
||||
user.chatViewName,
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
user.profile.localBadge,
|
||||
Modifier.align(Alignment.CenterVertically),
|
||||
fontWeight = if (selectedUser.value.userId == user.userId && !incognitoDefault) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -321,7 +321,11 @@ private suspend fun downloadAsset(asset: GitHubAsset) {
|
||||
stream.copyTo(output)
|
||||
}
|
||||
val newFile = File(file.parentFile, asset.name)
|
||||
file.renameTo(newFile)
|
||||
// Moving instead of renameTo: a bare rename can silently fail (returns false, ignored),
|
||||
// and the enclosing createTmpFileAndDelete then deletes the only copy in its finally block,
|
||||
// leaving the user with an empty download dir. Files.move performs the same in-place rename
|
||||
// when possible, falls back to copy when it can't, and throws (handled below) on real failure.
|
||||
Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.app_check_for_updates_download_completed_title),
|
||||
|
||||
+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: 376d6a261a1074717aed65ad97bb6f2a9532011b
|
||||
tag: 92598c2ddb06cfc2c19797a2c900cffcb8af4d5c
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Fix: in-app updater deletes the downloaded file before the user can open/install it
|
||||
|
||||
## Symptom
|
||||
|
||||
Desktop in-app updater (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt`): after a successful download the "Download completed" dialog appears, but clicking **"Open file location"** opens an **empty** `/tmp/simplex` — the downloaded artifact is gone. Reported against a Linux AppImage build; verified from a terminal that the file was genuinely absent on disk (not a file-manager display glitch).
|
||||
|
||||
## Root cause
|
||||
|
||||
`downloadAsset` writes the download to a temporary UUID-named file created by `createTmpFileAndDelete`, whose contract is to delete that temp file in a `finally` block. To keep the bytes, the code renames the temp file to the asset name so the survivor sits at a *different* path than the one the `finally` deletes:
|
||||
|
||||
```kotlin
|
||||
createTmpFileAndDelete { file -> // file = /tmp/simplex/<UUID>
|
||||
file.outputStream().use { output -> stream.copyTo(output) }
|
||||
val newFile = File(file.parentFile, asset.name)
|
||||
file.renameTo(newFile) // return value IGNORED
|
||||
... show "Download completed" dialog ...
|
||||
} // finally { tmpFile.delete() }
|
||||
```
|
||||
|
||||
`File.renameTo` returns a boolean and **its result was ignored**. When the rename succeeds (the common case) the survivor is `newFile` and the `finally` deletes the now-absent UUID path (a no-op) — everything works. But if the rename returns `false`, the bytes stay at the UUID path, `newFile` is never created, and the `finally { tmpFile.delete() }` deletes the only copy. The dialog is still shown (the rename result was never checked), so the user sees "Download completed" over an empty directory.
|
||||
|
||||
The download path is **shared by every platform and asset type** (`.AppImage`, `.deb`, Windows `.msi`, macOS `.dmg`), so this affected all of them — most acutely `.deb`, where the in-app "Install" button is hidden and "Open file location" is the only way forward.
|
||||
|
||||
## Fix
|
||||
|
||||
Replace the unchecked `renameTo` with `Files.move(..., REPLACE_EXISTING)`:
|
||||
|
||||
```kotlin
|
||||
val newFile = File(file.parentFile, asset.name)
|
||||
Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **Same in-place rename in the normal case.** Verified that a same-directory `Files.move` preserves the inode — it executes the same `rename(2)` syscall as `renameTo`, with no copy. No behaviour or performance change on the happy path.
|
||||
- **Recovers when an in-place rename is not possible** (e.g. cross-filesystem): falls back to copy-then-delete, so `newFile` still ends up present.
|
||||
- **Surfaces genuine failures** by throwing, which the existing outer `catch (e: Exception)` in `downloadAsset` already handles (logs the error) — instead of silently deleting the download and showing a misleading "Download completed" dialog.
|
||||
|
||||
One line changed (plus a clarifying comment). `Files` / `StandardCopyOption` are already imported.
|
||||
|
||||
## Compatibility impact
|
||||
|
||||
Before this change the updater's download step worked only on *some* Linux systems — it failed on those where `File.renameTo` returns `false`. In particular it did **not** work on **Whonix**, where the "Download completed" dialog appeared over an empty folder and the update could not proceed. `Files.move` succeeds on those systems too (in-place rename when possible, copy+delete otherwise, throwing only on genuine failure), so this fix expands the set of platforms on which the in-app updater works — Whonix included — without changing behaviour where `renameTo` already succeeded.
|
||||
|
||||
## Scope / out of scope
|
||||
|
||||
- This change is limited to the shared download step; it fixes the reported symptom on every OS at once.
|
||||
- The per-OS *install* paths are untouched. A separate, related fragility remains in the macOS install branch (`File("/Applications/SimpleX.app").renameTo(...)` return value ignored at the app-replace/restore steps); it is a different symptom (botched/missing install, not a lost download) and is left for a follow-up, consistent with the out-of-scope list in `plans/2026-05-16-desktop-updater-fixes.md`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# iOS: open SimpleX links in chat messages via in-app connect flow
|
||||
|
||||
## Problem
|
||||
|
||||
On iOS, tapping a **SimpleX connection/invitation link inside message text** does nothing — it never reaches the connection flow. Reproduced on iPhone 17 (v6.5.2 and v6.5.5). On the same screens, tapping a web link (opens browser), a `mailto:`/`tel:` link, and the connection-link **card** all work. Notably it was **device-specific**: dead on an iPhone 17 but working on an iPhone 12 running the **same iOS version**, with only **one** SimpleX app installed.
|
||||
|
||||
## Root cause
|
||||
|
||||
Inline links are dispatched in `MsgContentView.handleTextTaps` (`apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift`):
|
||||
|
||||
- web links (`webLinkAttrKey`) → `openBrowserAlert` → `UIApplication.shared.open` (Safari)
|
||||
- everything else → `UIApplication.shared.open(url)`
|
||||
|
||||
SimpleX links fell into the second branch. Two facts make this the bug:
|
||||
|
||||
1. **The URI is always the `simplex:` custom scheme.** The core markdown parser normalizes every connection link to the `simplex:` scheme via `simplexConnReqUri` / `simplexShortLink` (`src/Simplex/Chat/Markdown.hs:344,353`), regardless of whether the message contained `https://simplex.chat/…` or `simplex:/…` (see `tests/MarkdownTests.hs`). So the tap always calls `UIApplication.shared.open("simplex:/contact#…")`.
|
||||
|
||||
2. **`simplex:` is registered to this app, and the app is in the foreground.** `UIApplication.shared.open` is an OS app-launch API: it asks iOS (LaunchServices) to resolve the scheme to its registered app and activate it. Here the registered app is SimpleX itself, already foregrounded. **Re-entering the same foreground app through `open()` is not a supported operation** — `open()` exists to hand a URL to a *different* app or the system. When the resolved target is the calling foreground app, the outcome is undefined: on some devices iOS still delivers the URL to `onOpenURL`, on others it is a silent no-op (`open` returns `false`, no error, no UI).
|
||||
|
||||
That undefined outcome is decided by device-local OS state (scheme resolution / launch services), which is why identical code + identical OS + identical single app behaved differently on the iPhone 12 (delivered → connected) and the iPhone 17 (no-op → dead). It is **not** an OS-version rule and **not** a multiple-handler conflict — both were ruled out (same OS; single install).
|
||||
|
||||
This also explains the full symptom matrix — only the path that re-enters the same app via `open()` is affected:
|
||||
|
||||
| Tapped | Dispatch | Target | Result |
|
||||
|---|---|---|---|
|
||||
| Web link | `openBrowserAlert` → `open()` | Safari (other app) | works |
|
||||
| `mailto:` / `tel:` | `open()` | Mail / Phone (other apps) | works |
|
||||
| Invite card | `planAndConnect` in-process | this app, no `open()` | works |
|
||||
| Inline SimpleX link | `open("simplex:…")` | this app (self), foreground | undefined → dead |
|
||||
|
||||
The underlying cause is using the **wrong mechanism**: an OS hand-off API to perform an **in-app** action. Every other connect path handles the connection in-process and never leaves the app:
|
||||
|
||||
- the card: `planAndConnect` directly (`FramedItemView.swift`)
|
||||
- the share extension: `ShareSheet.openExternalLink` sets `ChatModel.appOpenUrl`
|
||||
- multiplatform: `openVerifiedSimplexUri` → `connectIfOpenedViaUri` → `planAndConnect`
|
||||
|
||||
Inline links were the lone exception delegating to the OS, making them hostage to undefined self-open behavior.
|
||||
|
||||
## Fix
|
||||
|
||||
Restore the three-way dispatch the multiplatform clients use (`WEB_URL` / `OTHER_URL` / `SIMPLEX_URL`):
|
||||
|
||||
- web → `openBrowserAlert` (unchanged)
|
||||
- `mailto:` / `tel:` → `UIApplication.shared.open` (unchanged — these target other apps)
|
||||
- **SimpleX → `ChatModel.appOpenUrl`** — the same sink `onOpenURL` feeds, leading to `connectViaUrl` → `planAndConnect`, entirely **in-process** with no OS round-trip
|
||||
|
||||
SimpleX links are identified by a dedicated attribute key (`simplexLinkAttrKey`) set on the `.simplexLink` format, mirroring the multiplatform `SIMPLEX_URL` annotation tag, rather than sniffing the URL string — so all link types (contact, invitation, group, channel, relay) are covered.
|
||||
|
||||
This is correct regardless of the exact device-local trigger, because it removes the dependency on iOS re-delivering a self-owned URL. The invite card already proves the in-process path works on the affected device.
|
||||
|
||||
Also fixes the same issue for the **"Send questions and ideas"** (Settings) and **"connect to SimpleX Chat developers"** (chat help) buttons, which opened `simplexTeamURL` (a `simplex:` link) the same broken way.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift` — three-way tap dispatch + `simplexLinkAttrKey`
|
||||
- `apps/ios/Shared/Views/UserSettings/SettingsView.swift`, `apps/ios/Shared/Views/ChatList/ChatHelp.swift` — route `simplexTeamURL` in-process
|
||||
|
||||
No behavior change for web / `mailto:` / `tel:` links.
|
||||
|
||||
## Verification
|
||||
|
||||
- Tap an inline SimpleX invitation/contact link in a received message → the connection sheet opens (on iPhone 17, where it was previously dead).
|
||||
- The two developer-contact buttons open the connect flow.
|
||||
- Web links still open the browser; `mailto:`/`tel:` still open Mail/Phone.
|
||||
- Optional, to confirm the device-local nature: open a `simplex:/contact#…` link from another app (e.g. Notes) on the affected device — if that is also dead there but works on a second device, it confirms the difference is device-local scheme resolution rather than app code.
|
||||
@@ -38,8 +38,9 @@ scripts/desktop/prepare-openssl-windows.sh
|
||||
|
||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
||||
rm -rf $BUILD_DIR 2>/dev/null || true
|
||||
# Existence of this directory produces build error: cabal's bug
|
||||
rm -rf dist-newstyle/src/direct-sq* 2>/dev/null || true
|
||||
# Existence of these directories produces build error: cabal's bug
|
||||
# (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows)
|
||||
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* 2>/dev/null || true
|
||||
rm cabal.project.local 2>/dev/null || true
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."376d6a261a1074717aed65ad97bb6f2a9532011b" = "1j83kzjcgjr7ngbamby96r90yal80c6kv79l9shy05mppmp73f4y";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."92598c2ddb06cfc2c19797a2c900cffcb8af4d5c" = "0x98w50vcb2qwdxgkgfvygn1vzclb12zg1k6kcs9rrw54rhmvfmp";
|
||||
"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";
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 6.5.5.0
|
||||
version: 6.5.6.1
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
|
||||
@@ -3675,7 +3675,7 @@ processChatCommand cxt nm = \case
|
||||
profileToSend <-
|
||||
presentUserBadge user incognitoProfile $ case gInfo_ of
|
||||
Just gInfo_' ->
|
||||
let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_'
|
||||
let allowSimplexLinks = maybe True groupUserAllowSimplexLinks gInfo_'
|
||||
in userProfileInGroup' user allowSimplexLinks incognitoProfile
|
||||
Nothing -> userProfileDirect user incognitoProfile Nothing True
|
||||
chatEvent <- case gInfo_ of
|
||||
@@ -3988,10 +3988,10 @@ processChatCommand cxt nm = \case
|
||||
conn <- createRelayConnection db cxt user (groupMemberId' relayMember) connId ConnPrepared chatV subMode
|
||||
pure (relayMember, conn, groupRelay)
|
||||
let GroupMember {memberRole = userRole, memberId = userMemberId} = membership
|
||||
allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
|
||||
membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
|
||||
allowSimplexLinks = groupUserAllowSimplexLinks gInfo
|
||||
GroupMember {memberId = relayMemberId} = relayMember
|
||||
relayInv = GroupRelayInvitation {
|
||||
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
|
||||
let relayInv = GroupRelayInvitation {
|
||||
fromMember = MemberIdRole userMemberId userRole,
|
||||
fromMemberProfile = membershipProfile,
|
||||
relayMemberId,
|
||||
|
||||
@@ -367,7 +367,7 @@ prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole
|
||||
prohibitedSimplexLinks :: GroupInfo -> GroupMember -> MsgContent -> Maybe MarkdownList -> Bool
|
||||
prohibitedSimplexLinks gInfo m mc ft =
|
||||
not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo)
|
||||
&& (isChatLink mc || maybe False (any ftIsSimplexLink) ft)
|
||||
&& (isChatLink mc || maybe False (any ftIsSimplexLink) ft || hasObfuscatedSimplexLink (msgContentText mc))
|
||||
where
|
||||
isChatLink = \case
|
||||
MCChat {} -> True
|
||||
@@ -1177,7 +1177,7 @@ introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn
|
||||
sendGroupMemberMessages user gInfo conn introEvts'
|
||||
|
||||
userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile
|
||||
userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks
|
||||
userProfileInGroup user = userProfileInGroup' user . groupUserAllowSimplexLinks
|
||||
{-# INLINE userProfileInGroup #-}
|
||||
|
||||
userProfileInGroup' :: User -> Bool -> Maybe Profile -> Profile
|
||||
@@ -1195,7 +1195,7 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a
|
||||
memberKey = MemberKey <$> memberPubKey
|
||||
}
|
||||
where
|
||||
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g
|
||||
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g && groupFeatureMemberAllowed SGFDirectMessages m g
|
||||
|
||||
redactedMemberProfile :: Bool -> Profile -> Profile
|
||||
redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge} =
|
||||
@@ -1203,6 +1203,7 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes
|
||||
where
|
||||
removeSimplexLink s
|
||||
| allowSimplexLinks = Just s
|
||||
| hasObfuscatedSimplexLink s = Nothing
|
||||
| otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s
|
||||
|
||||
sendHistory :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
@@ -2129,7 +2130,7 @@ sendGroupMessages user gInfo scope asGroup members events = do
|
||||
_ -> False
|
||||
sendProfileUpdate = do
|
||||
let members' = filter (`supportsVersion` memberProfileUpdateVersion) members
|
||||
allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
|
||||
allowSimplexLinks = groupUserAllowSimplexLinks gInfo
|
||||
-- shouldSendProfileUpdate excludes incognito membership, so the badge is presented
|
||||
profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p
|
||||
void $ sendGroupMessage' user gInfo members' $ XInfo profileUpdate
|
||||
|
||||
@@ -731,8 +731,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
ct <- getContactViaMember db cxt user m
|
||||
liftIO $ setNewContactMemberConnRequest db user m cReq
|
||||
liftIO $ (ct,) <$> getGroupLinkId db user gInfo
|
||||
sendGrpInvitation ct m groupLinkId
|
||||
toView $ CEvtSentGroupInvitation user gInfo ct m
|
||||
if memberRole' membership >= GRAdmin
|
||||
then do
|
||||
sendGrpInvitation ct m groupLinkId
|
||||
toView $ CEvtSentGroupInvitation user gInfo ct m
|
||||
else messageError "processGroupMessage: group link host no longer has admin role"
|
||||
where
|
||||
sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> CM ()
|
||||
sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do
|
||||
@@ -813,7 +816,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
XGrpMemInfo memId _memProfile
|
||||
| sameMemberId memId m -> do
|
||||
let GroupMember {memberId = membershipMemId} = membership
|
||||
allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
|
||||
allowSimplexLinks = groupUserAllowSimplexLinks gInfo
|
||||
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
|
||||
-- TODO update member profile
|
||||
-- [async agent commands] no continuation needed, but command should be asynchronous for stability
|
||||
@@ -1535,9 +1538,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
|
||||
-- TODO [short links] deduplicate request by xContactId?
|
||||
gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId
|
||||
if useRelays' gInfo
|
||||
then messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)"
|
||||
else do
|
||||
if
|
||||
| useRelays' gInfo ->
|
||||
messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)"
|
||||
| memberRole' (membership gInfo) < GRAdmin ->
|
||||
messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored join request because host is no longer admin"
|
||||
| otherwise -> do
|
||||
acceptMember_ <- asks $ acceptMember . chatHooks . config
|
||||
maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case
|
||||
Right (acceptance, useRole)
|
||||
@@ -1566,20 +1572,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
createRelayRequestGroup db cxt user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited
|
||||
lift $ void $ getRelayRequestWorker True
|
||||
xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM ()
|
||||
xGrpRelayTest invId chatVRange challenge = do
|
||||
privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn)
|
||||
case privKey_ of
|
||||
Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address")
|
||||
Just privKey -> do
|
||||
let sig = C.signatureBytes $ C.sign' privKey challenge
|
||||
msg = XGrpRelayTest challenge (Just sig)
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
chatVR <- chatVersionRange
|
||||
let chatV = chatVR `peerConnChatVersion` chatVRange
|
||||
(cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode
|
||||
liftIO $ setCommandConnId db user cmdId testCId
|
||||
xGrpRelayTest invId chatVRange challenge
|
||||
| isTrue userChatRelay && isNothing ucGroupId_ =
|
||||
withAgent (`getConnLinkPrivKey` aConnId conn) >>= \case
|
||||
Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address")
|
||||
Just privKey -> do
|
||||
let sig = C.signatureBytes $ C.sign' privKey challenge
|
||||
msg = XGrpRelayTest challenge (Just sig)
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
chatVR <- chatVersionRange
|
||||
let chatV = chatVR `peerConnChatVersion` chatVRange
|
||||
(cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode
|
||||
liftIO $ setCommandConnId db user cmdId testCId
|
||||
| otherwise = messageError "relay test sent to non-relay link"
|
||||
where
|
||||
User {userChatRelay} = user
|
||||
-- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays
|
||||
-- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember)
|
||||
memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM ()
|
||||
@@ -2701,7 +2710,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
pure m
|
||||
where
|
||||
contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p'))
|
||||
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo
|
||||
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo && groupFeatureMemberAllowed SGFDirectMessages m gInfo
|
||||
updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of
|
||||
Just bc | isMainBusinessMember bc m -> do
|
||||
g' <- withStore $ \db -> updateGroupProfileFromMember db user g p'
|
||||
@@ -3090,7 +3099,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
pure toMember
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
-- [incognito] send membership incognito profile, create direct connection as incognito
|
||||
let allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
|
||||
let allowSimplexLinks = groupUserAllowSimplexLinks gInfo
|
||||
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
|
||||
dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile
|
||||
-- [async agent commands] no continuation needed, but commands should be asynchronous for stability
|
||||
@@ -3113,7 +3122,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
where
|
||||
GroupMember {memberId = membershipMemId} = membership
|
||||
changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent
|
||||
| senderRole < GRAdmin || senderRole < fromRole =
|
||||
| senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) =
|
||||
messageError "x.grp.mem.role with insufficient member permissions" $> Nothing
|
||||
| otherwise = do
|
||||
withStore' $ \db -> updateGroupMemberRole db user member memRole
|
||||
|
||||
@@ -18,6 +18,7 @@ import Control.Monad
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.TH as JQ
|
||||
import qualified Data.Attoparsec.ByteString.Char8 as AB
|
||||
import Data.Attoparsec.Text (Parser)
|
||||
import qualified Data.Attoparsec.Text as A
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
@@ -191,6 +192,16 @@ isLink = \case
|
||||
hasLinks :: MarkdownList -> Bool
|
||||
hasLinks = any $ \(FormattedText f _) -> maybe False isLink f
|
||||
|
||||
hasObfuscatedSimplexLink :: Text -> Bool
|
||||
hasObfuscatedSimplexLink t =
|
||||
fromRight False $ AB.parseOnly findLinkP $ encodeUtf8 $ T.filter (not . isSpace) t
|
||||
where
|
||||
findLinkP = do
|
||||
AB.skipWhile (\c -> c /= 's' && c /= 'h') -- links start only with "simplex:" or "https://"
|
||||
(True <$ (strP :: AB.Parser AConnectionLink))
|
||||
<|> (AB.anyChar *> findLinkP)
|
||||
<|> pure False
|
||||
|
||||
markdownP :: Parser Markdown
|
||||
markdownP = mconcat <$> A.many' fragmentP
|
||||
where
|
||||
|
||||
@@ -932,7 +932,7 @@ parseChatMessages msg = case B.head msg of
|
||||
Right (compressed :: L.NonEmpty Compressed) -> case traverse decompressedSize compressed of
|
||||
Nothing -> [Left "compressed size not specified"]
|
||||
Just sizes
|
||||
| sum sizes > maxDecompressedMsgLength -> [Left "decompressed size exceeds limit"]
|
||||
| any (maxDecompressedMsgLength <) sizes || maxDecompressedMsgLength < sum sizes -> [Left "decompressed size exceeds limit"]
|
||||
| otherwise -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1) compressed
|
||||
parseUncompressed' "" = [Left "empty string"]
|
||||
parseUncompressed' s = parseUncompressed (B.head s) s
|
||||
|
||||
@@ -355,7 +355,7 @@ getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cur
|
||||
map (toContactMember currentTs cxt user) <$>
|
||||
DB.query
|
||||
db
|
||||
(groupMemberQuery <> " WHERE m.group_member_id IN ?")
|
||||
(groupMemberQuery <> " WHERE m.group_member_id IN ? ORDER BY m.group_member_id ASC")
|
||||
(Only (In gmIds))
|
||||
#else
|
||||
rights <$> mapM (runExceptT . getGroupMemberById db cxt user) gmIds
|
||||
|
||||
@@ -1134,6 +1134,19 @@ Query:
|
||||
Plan:
|
||||
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
UPDATE group_members
|
||||
SET member_role = 'owner'
|
||||
WHERE member_category = 'user'
|
||||
AND group_id IN (
|
||||
SELECT group_id FROM groups WHERE local_display_name = 'team'
|
||||
)
|
||||
|
||||
Plan:
|
||||
SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?)
|
||||
LIST SUBQUERY 1
|
||||
SCAN groups USING COVERING INDEX sqlite_autoindex_groups_1
|
||||
|
||||
Query:
|
||||
DELETE FROM chat_item_reactions
|
||||
WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ?
|
||||
|
||||
@@ -638,6 +638,12 @@ groupFeatureUserAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupInfo -
|
||||
groupFeatureUserAllowed feature GroupInfo {membership = GroupMember {memberRole}, fullGroupPreferences} =
|
||||
groupFeatureMemberAllowed' feature memberRole fullGroupPreferences
|
||||
|
||||
-- A connection link in a profile description enables a direct connection, so a description
|
||||
-- keeps its links only when both SimpleX links and direct messages are allowed.
|
||||
groupUserAllowSimplexLinks :: GroupInfo -> Bool
|
||||
groupUserAllowSimplexLinks g =
|
||||
groupFeatureUserAllowed SGFSimplexLinks g && groupFeatureUserAllowed SGFDirectMessages g
|
||||
|
||||
mergeUserChatPrefs :: User -> Contact -> FullPreferences
|
||||
mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ module ChatTests.ChatRelays where
|
||||
import ChatClient
|
||||
import ChatTests.DBUtils
|
||||
import ChatTests.Groups (memberJoinChannel, memberJoinChannel', prepareChannel, prepareChannel', prepareChannel1Relay, setupRelay)
|
||||
import ChatTests.Profiles (addTestBadge, issueTestBadge, testBadgeKeys)
|
||||
import ChatTests.Utils
|
||||
import Control.Concurrent (threadDelay)
|
||||
import qualified Data.Aeson as J
|
||||
@@ -14,8 +15,10 @@ import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Maybe (fromMaybe)
|
||||
import qualified Data.Text as T
|
||||
import ProtocolTests (testGroupProfile)
|
||||
import Simplex.Chat.Controller (ChatConfig (..))
|
||||
import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..))
|
||||
import Simplex.Chat.Types (GroupProfile (..))
|
||||
import Simplex.Messaging.Crypto.BBS (bbsKeyGen)
|
||||
import Simplex.Messaging.Encoding.String (StrEncoding (..))
|
||||
import Simplex.Messaging.Util (decodeJSON)
|
||||
import Test.Hspec hiding (it)
|
||||
@@ -32,6 +35,40 @@ chatRelayTests = do
|
||||
it "share channel card in direct chat" testShareChannelDirect
|
||||
it "share channel card in group" testShareChannelGroup
|
||||
it "share channel card in channel" testShareChannelChannel
|
||||
describe "channel badges" $ do
|
||||
it "subscriber and owner see each other's badges forwarded by the relay" testChannelMemberBadges
|
||||
|
||||
-- A channel owner and a subscriber each hold a supporter badge; their member profiles only reach
|
||||
-- each other forwarded by the relay. Both sides should still see the other's active badge.
|
||||
testChannelMemberBadges :: HasCallStack => TestParams -> IO ()
|
||||
testChannelMemberBadges ps = do
|
||||
Right (pk, sk) <- bbsKeyGen
|
||||
let cfg = testCfg {badgePublicKeys = testBadgeKeys pk}
|
||||
withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChatCfgOpts ps cfg testOpts "cath" cathProfile $ \cath -> do
|
||||
addTestBadge alice =<< issueTestBadge sk Nothing
|
||||
addTestBadge cath =<< issueTestBadge sk Nothing
|
||||
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
|
||||
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
|
||||
-- a channel message lets the relay-forwarded member profiles settle on both sides
|
||||
alice #> "#team hi"
|
||||
bob <# "#team> hi"
|
||||
cath <# "#team> hi [>>]"
|
||||
threadDelay 1000000
|
||||
-- owner and subscriber are connected only via the relay, so /i shows the badge then "member not connected" for both
|
||||
alice ##> "/i #team cath"
|
||||
alice <## "group ID: 1"
|
||||
alice <##. "member ID: "
|
||||
alice <## "supporter badge - active"
|
||||
alice <## "no expiry"
|
||||
alice <## "member not connected"
|
||||
cath ##> "/i #team alice"
|
||||
cath <## "group ID: 1"
|
||||
cath <##. "member ID: "
|
||||
cath <## "supporter badge - active"
|
||||
cath <## "no expiry"
|
||||
cath <## "member not connected"
|
||||
|
||||
testGetSetChatRelays :: HasCallStack => TestParams -> IO ()
|
||||
testGetSetChatRelays ps =
|
||||
|
||||
@@ -81,6 +81,7 @@ chatGroupTests = do
|
||||
it "group live message" testGroupLiveMessage
|
||||
it "update group profile" testUpdateGroupProfile
|
||||
it "update member role" testUpdateMemberRole
|
||||
it "check owner role change" testOwnerRoleChange
|
||||
it "group description is shown as the first message to new members" testGroupDescription
|
||||
it "moderate message of another group member" testGroupModerate
|
||||
it "moderate own message (should process as deletion)" testGroupModerateOwn
|
||||
@@ -108,6 +109,7 @@ chatGroupTests = do
|
||||
it "invitee incognito" testGroupLinkInviteeIncognito
|
||||
it "incognito - join/invite" testGroupLinkIncognitoJoinInvite
|
||||
it "group link member role" testGroupLinkMemberRole
|
||||
it "demotion does not remove group link" testGroupLinkDemotedAdmin
|
||||
it "host profile received" testGroupLinkHostProfileReceived
|
||||
it "existing contact merged" testGroupLinkExistingContactMerged
|
||||
describe "group links - member screening" $ do
|
||||
@@ -1608,6 +1610,37 @@ testUpdateMemberRole =
|
||||
alice ##> "/mr team alice admin"
|
||||
alice <## "bad chat command: can't change role for self"
|
||||
|
||||
testOwnerRoleChange :: HasCallStack => TestParams -> IO ()
|
||||
testOwnerRoleChange =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
createGroup3 "team" alice bob cath
|
||||
void $ withCCTransaction cath $ \db ->
|
||||
DB.execute_
|
||||
db
|
||||
[sql|
|
||||
UPDATE group_members
|
||||
SET member_role = 'owner'
|
||||
WHERE member_category = 'user'
|
||||
AND group_id IN (
|
||||
SELECT group_id FROM groups WHERE local_display_name = 'team'
|
||||
)
|
||||
|]
|
||||
|
||||
cath ##> "/mr #team bob owner"
|
||||
cath <## "#team: you changed the role of bob to owner"
|
||||
concurrentlyN_
|
||||
[ alice <## "error: x.grp.mem.role with insufficient member permissions",
|
||||
bob <## "error: x.grp.mem.role with insufficient member permissions"
|
||||
]
|
||||
|
||||
bob ##> "/ms team"
|
||||
bob
|
||||
<### [ "alice (Alice): owner, host, connected",
|
||||
"bob (Bob): admin, you, connected",
|
||||
"cath (Catherine): admin, connected"
|
||||
]
|
||||
|
||||
testGroupDescription :: HasCallStack => TestParams -> IO ()
|
||||
testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do
|
||||
connectUsers alice bob
|
||||
@@ -2929,6 +2962,25 @@ testGroupLinkMemberRole =
|
||||
bob <## "#team: cath changed your role from member to admin"
|
||||
alice <## "#team: cath changed the role of bob from member to admin"
|
||||
|
||||
testGroupLinkDemotedAdmin :: HasCallStack => TestParams -> IO ()
|
||||
testGroupLinkDemotedAdmin =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob _cath -> do
|
||||
createGroup2' "team" alice (bob, GRAdmin) True
|
||||
|
||||
bob ##> "/create link #team member"
|
||||
_gLink <- getGroupLink bob "team" GRMember True
|
||||
|
||||
alice ##> "/mr #team bob member"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you changed the role of bob to member",
|
||||
bob <## "#team: alice changed your role from admin to member"
|
||||
]
|
||||
|
||||
-- demotion does not remove bob's group link (it is preserved, usable again on re-promotion)
|
||||
bob ##> "/show link #team"
|
||||
void $ getGroupLink bob "team" GRMember False
|
||||
|
||||
testGroupLinkHostIncognito :: HasCallStack => TestParams -> IO ()
|
||||
testGroupLinkHostIncognito =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
@@ -2903,6 +2903,12 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil
|
||||
bob <## "bad chat command: feature not allowed SimpleX links"
|
||||
bob ##> ("/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"" <> inv <> "\\ntest\"}}]")
|
||||
bob <## "bad chat command: feature not allowed SimpleX links"
|
||||
-- a link split with a space or a newline is still blocked
|
||||
let (lnk1, lnk2) = splitAt 12 inv
|
||||
bob ##> ("#team \"" <> lnk1 <> " " <> lnk2 <> "\"")
|
||||
bob <## "bad chat command: feature not allowed SimpleX links"
|
||||
bob ##> ("#team \"" <> lnk1 <> "\\n" <> lnk2 <> "\"")
|
||||
bob <## "bad chat command: feature not allowed SimpleX links"
|
||||
(alice </)
|
||||
(cath </)
|
||||
bob `send` ("@alice \"" <> inv <> "\\ntest\"")
|
||||
|
||||
@@ -25,6 +25,7 @@ markdownTests = do
|
||||
textColor
|
||||
textWithUri
|
||||
textWithHyperlink
|
||||
obfuscatedSimplexLinks
|
||||
textWithEmail
|
||||
textWithPhone
|
||||
textWithMentions
|
||||
@@ -284,6 +285,24 @@ textWithHyperlink = describe "text with HyperLink without link text" do
|
||||
"[click here](example.com)" <==> "[click here](example.com)"
|
||||
"[click here](https://example.com )" <==> "[click here](https://example.com )"
|
||||
|
||||
obfuscatedSimplexLinks :: Spec
|
||||
obfuscatedSimplexLinks = describe "SimpleX links obfuscated with whitespace" do
|
||||
let addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"
|
||||
inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D"
|
||||
let spaced s = T.replace "://" ":// " s -- insert a space right after the scheme
|
||||
it "detects links split with spaces or newlines" do
|
||||
hasObfuscatedSimplexLink addr `shouldBe` True
|
||||
hasObfuscatedSimplexLink (spaced addr) `shouldBe` True
|
||||
hasObfuscatedSimplexLink (T.intercalate "\n" $ T.chunksOf 8 addr) `shouldBe` True
|
||||
hasObfuscatedSimplexLink ("connect with me: " <> spaced addr) `shouldBe` True
|
||||
hasObfuscatedSimplexLink (T.intercalate " " $ T.chunksOf 8 $ "https://simplex.chat" <> inv) `shouldBe` True
|
||||
it "detects a split link followed by other text" do
|
||||
hasObfuscatedSimplexLink (spaced addr <> "\nplease connect") `shouldBe` True
|
||||
it "ignores text without a SimpleX link" do
|
||||
hasObfuscatedSimplexLink "" `shouldBe` False
|
||||
hasObfuscatedSimplexLink "hello there, this is a normal message" `shouldBe` False
|
||||
hasObfuscatedSimplexLink "see https://example.com/page?ref=123 for details" `shouldBe` False
|
||||
|
||||
email :: Text -> Markdown
|
||||
email = Markdown $ Just Email
|
||||
|
||||
|
||||
Reference in New Issue
Block a user