diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b89f6ccce0..4e5050fe8f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 7bd3d04e59..035d24c6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,10 @@ website/translations.json website/src/img/images/ website/src/images/ website/src/js/lottie.min.js -website/src/js/ethers* +website/src/js/ethers.* +website/src/js/directory.js +website/src/js/channel-preview.js +website/src/js/simplex-lib.js website/src/file-assets/ website/src/link-images/ website/src/privacy.md diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json new file mode 100644 index 0000000000..9d066d386e --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-investor.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json new file mode 100644 index 0000000000..b8b9a000d6 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-legend.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json new file mode 100644 index 0000000000..443575f1c7 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-supporter.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index e158b9374f..00c8d7070b 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -47,7 +47,7 @@ struct ChatInfoToolbar: View { } .padding(.trailing, 4) let t = Text(cInfo.displayName).font(.headline) - (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) + NameWithBadge((cInfo.contact?.verified == true ? contactVerifiedShield + t : t), cInfo.nameBadge, .headline) .lineLimit(1) .if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in VStack(spacing: 0) { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 29c87ff7e0..fdd1dc8a6a 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -374,25 +374,17 @@ struct ChatInfoView: View { // show actual display name, alias can be edited in this view let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if contact.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = cInfo.nameBadge + // the shield is smaller (.title2) than the name (.largeTitle), so on the shared baseline it + // sits low; raise it by half the cap-height difference to center it with the capitals + let shieldRaise = (UIFont.preferredFont(forTextStyle: .largeTitle).capHeight - UIFont.preferredFont(forTextStyle: .title2).capHeight) / 2 + let nameText = contact.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2).baselineOffset(shieldRaise) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(cInfo.fullName) .font(.title2) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 639de1dbc9..75a5baafee 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -16,6 +16,7 @@ struct CIFileView: View { @EnvironmentObject var theme: AppTheme let file: CIFile? let edited: Bool + let senderProfile: LocalProfile? var smallViewSize: CGFloat? var body: some View { @@ -85,7 +86,7 @@ struct CIFileView: View { if let file = file { switch (file.fileStatus) { case .rcvInvitation, .rcvAborted: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { @@ -93,7 +94,7 @@ struct CIFileView: View { } } } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary) + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) AlertManager.shared.showAlertMsg( title: "Large file!", message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." @@ -165,7 +166,7 @@ struct CIFileView: View { case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .rcvInvitation: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { fileIcon("arrow.down.doc.fill", color: theme.colors.primary) } else { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) @@ -227,9 +228,9 @@ struct CIFileView: View { } } -func fileSizeValid(_ file: CIFile?) -> Bool { +func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool { if let file = file { - return file.fileSize <= getMaxFileSize(file.fileProtocol) + return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) } return false } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b56f1f9f2a..972e9c4ec6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -14,6 +14,7 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem + let senderProfile: LocalProfile? var scrollToItem: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat @@ -51,10 +52,18 @@ struct CIImageView: View { if let file = file { switch file.fileStatus { case .rcvInvitation, .rcvAborted: - Task { - if let user = m.currentUser { - await receiveFile(user: user, fileId: file.fileId) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user: user, fileId: file.fileId) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } case .rcvAccepted: switch file.fileProtocol { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index e1172dab92..912fde4043 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -16,6 +16,7 @@ import Combine struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem + private let senderProfile: LocalProfile? private let preview: UIImage? @State private var duration: Int @State private var progress: Int = 0 @@ -35,8 +36,9 @@ struct CIVideoView: View { private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 } @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 - init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { + init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { self.chatItem = chatItem + self.senderProfile = senderProfile self.preview = preview self._duration = State(initialValue: duration) self.maxWidth = maxWidth @@ -421,10 +423,18 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { - Task { - if let user = m.currentUser { - await receiveFile(user, file.fileId, false, false) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user, file.fileId, false, false) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d09289c1d5..372c7df8a3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -127,7 +127,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -142,7 +142,7 @@ struct FramedItemView: View { ciMsgContentView(chatItem) } case let .video(text, _, duration): - CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -349,7 +349,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { - CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) + CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo)) .overlay(DetermineWidth()) if text != "" || ci.meta.isLive { ciMsgContentView (chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 9aaff57cc5..11c3c4c3f4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -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) { diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 3858d15252..bd0e549d38 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -387,23 +387,31 @@ struct ChatItemInfoView: View { Text("you") .italic() .foregroundColor(theme.colors.onBackground) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir { VStack(alignment: .leading) { - Text(groupMember.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(groupMember.chatViewName).foregroundColor(theme.colors.onBackground), + groupMember.nameBadge + ) + .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else { - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } } @@ -451,7 +459,7 @@ struct ChatItemInfoView: View { HStack{ MemberProfileImage(member, size: 30) .padding(.trailing, 2) - Text(member.chatViewName) + NameWithBadge(Text(member.chatViewName), member.nameBadge) .lineLimit(1) Spacer() if sentViaProxy == true { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 66148034df..ded0019692 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -895,8 +895,9 @@ struct ChatView: View { } } else { let voiceNoFrame = voiceWithoutFrame(ci) + let channelReceived = !ci.chatDir.sent && cInfo.isChannel let maxWidth = cInfo.chatType == .group - ? voiceNoFrame + ? voiceNoFrame || channelReceived ? (g.size.width - 28) - 42 : (g.size.width - 28) * 0.84 - 42 : voiceNoFrame @@ -981,8 +982,8 @@ struct ChatView: View { let v = VStack(spacing: 8) { ChatInfoImage(chat: chat, size: alertProfileImageSize) - Text(chat.chatInfo.displayName) - .font(.title3) + let badge = chat.chatInfo.nameBadge + NameWithBadge(Text(chat.chatInfo.displayName).font(.title3), badge, .title3) { if let badge { showBadgeInfoAlert(chat.chatInfo.displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -1998,12 +1999,12 @@ struct ChatView: View { let (name, role) = if ci.meta.showGroupAsSender { (groupInfo.chatViewName, NSLocalizedString("group", comment: "shown on group welcome message")) } else { - (member.chatViewName, member.memberRole.text) + (member.chatViewName, member.memberRole.text(isChannel: groupInfo.isChannel)) } Group { if #available(iOS 16.0, *) { MemberLayout(spacing: 16, msgWidth: msgWidth) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2012,7 +2013,7 @@ struct ChatView: View { } } else { HStack(spacing: 16) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2026,7 +2027,7 @@ struct ChatView: View { alignment: chatItem.chatDir.sent ? .trailing : .leading ) } else { - Text(memberNames(member, prevMember, memCount)) + NameWithBadge(Text(memberNames(member, prevMember, memCount)), memCount == 1 ? member.nameBadge : nil, .caption1) .lineLimit(2) } } @@ -2311,7 +2312,7 @@ struct ChatView: View { } else { saveButton(file: fileSource) } - } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) { + } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) { downloadButton(file: file) } if ci.meta.editable && !mc.isVoice && !live { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift index 1ec46816f5..4b9169c72a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift @@ -23,6 +23,7 @@ struct ComposeFileView: View { .foregroundColor(Color(uiColor: .tertiaryLabel)) .padding(.leading, 4) Text(fileName) + .lineLimit(1) Spacer() if cancelEnabled { Button { cancelFile() } label: { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5242923258..e308a145b9 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1247,7 +1247,9 @@ struct ComposeView: View { } private var maxFileSize: Int64 { - getMaxFileSize(.xftp) + // the user's active badge raises the limit, but not in incognito chats where no badge is presented + let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault + return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile) } // Spec: spec/client/compose.md#sendLiveMessage diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 427a600627..9047eaf84b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -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() diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 6b18c0c5ef..336b4adfd1 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -183,7 +183,7 @@ struct AddGroupMembersViewCommon: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in - Text(role.text) + Text(role.text(isChannel: groupInfo.isChannel)) } } .frame(height: 36) @@ -220,9 +220,12 @@ struct AddGroupMembersViewCommon: View { HStack{ ProfileImage(imageStr: contact.image, size: 30) .padding(.trailing, 2) - Text(ChatInfo.direct(contact: contact).chatViewName) - .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(ChatInfo.direct(contact: contact).chatViewName) + .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground), + contact.active ? contact.profile.localBadge : nil + ) + .lineLimit(1) Spacer() Image(systemName: icon) .foregroundColor(iconColor) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index abcadc6c3f..44fc302aff 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -56,7 +56,7 @@ struct ChannelMembersView: View { MemberProfileImage(member, size: 38) .padding(.trailing, 2) VStack(alignment: .leading) { - displayName + NameWithBadge(displayName, member.nameBadge) .lineLimit(1) if user { Text("you") @@ -66,7 +66,7 @@ struct ChannelMembersView: View { } Spacer() if showRole { - Text(member.memberRole.text) + Text(member.memberRole.text(isChannel: groupInfo.isChannel)) .foregroundColor(theme.colors.secondary) } } diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index 27935768e3..aa94f5b346 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -24,26 +24,24 @@ struct ChannelRelaysView: View { var body: some View { List { relaysList() - // TODO [relays] re-enable when relay management ships - // if groupInfo.isOwner { - // Section { - // Button { - // showAddRelay = true - // } label: { - // Label("Add relay", systemImage: "plus") - // } - // } - // } + if groupInfo.isOwner { + Section { + Button { + showAddRelay = true + } label: { + Label("Add relay", systemImage: "plus") + } + } + } + } + .sheet(isPresented: $showAddRelay) { + // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // regardless of relayStatus, so all current rows must be excluded from the add list. + let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) + AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { + Task { await chatModel.loadGroupMembers(groupInfo) } + } } - // TODO [relays] re-enable when relay management ships - // .sheet(isPresented: $showAddRelay) { - // // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays - // // regardless of relayStatus, so all current rows must be excluded from the add list. - // let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) - // AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { - // Task { await chatModel.loadGroupMembers(groupInfo) } - // } - // } .onAppear { Task { await chatModel.loadGroupMembers(groupInfo) @@ -82,20 +80,18 @@ struct ChannelRelaysView: View { : subscriberRelayStatusText(member.wrapped) relayMemberRow(member.wrapped, statusText: statusText) } - // TODO [relays] re-enable when relay management ships - // if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) { - // link.swipeActions(edge: .trailing) { - // Button { - // showRemoveMemberAlert(groupInfo, member.wrapped) - // } label: { - // Label("Remove relay", systemImage: "trash") - // } - // .tint(.red) - // } - // } else { - // link - // } - link + if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) { + link.swipeActions(edge: .trailing) { + Button { + showRemoveMemberAlert(groupInfo, member.wrapped) + } label: { + Label("Remove relay", systemImage: "trash") + } + .tint(.red) + } + } else { + link + } } } footer: { Text("Chat relays forward messages to channel subscribers.") diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift new file mode 100644 index 0000000000..dd46b7a117 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift @@ -0,0 +1,169 @@ +// +// ChannelWebAccessView.swift +// SimpleX (iOS) +// +// Created by simplex.chat on 31/05/2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelWebAccessView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) var dismiss: DismissAction + @Binding var groupInfo: GroupInfo + @State private var webPage: String + @State private var allowEmbedding: Bool + @State private var saving = false + @State private var groupRelays: [GroupRelay] = [] + + init(groupInfo: Binding) { + _groupInfo = groupInfo + let access = groupInfo.wrappedValue.groupProfile.publicGroup?.publicGroupAccess + _webPage = State(initialValue: access?.groupWebPage ?? "") + _allowEmbedding = State(initialValue: access?.allowEmbedding ?? false) + } + + var body: some View { + List { + if let code = embedCode { + webpageInfo("Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.") + + Section { + ScrollView { + Text(code) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + .frame(maxHeight: 88) + Button { + UIPasteboard.general.string = code + } label: { + Label("Copy code", systemImage: "doc.on.doc") + } + } header: { + Text("Webpage code") + } footer: { + Text("Add this code to your webpage. It will display the preview of your channel / group.") + } + } else { + webpageInfo("Used chat relays do not support webpages.") + } + + Section { + TextField("https://", text: $webPage) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + } header: { + Text("Enter webpage URL") + } footer: { + Text("It will be shown to subscribers and used to allow loading the preview.") + } + + Section { + Toggle("Allow anyone to embed", isOn: $allowEmbedding) + } footer: { + Text(allowEmbedding ? "Any webpage can show the preview." : "Only your page above can show the preview.") + } + + Section { + Button { + saveAccess() + } label: { + HStack { + Text(groupInfo.isChannel ? "Save and notify subscribers" : "Save and notify members") + if saving { Spacer(); ProgressView() } + } + } + .disabled(!hasChanges || saving) + } + } + .modifier(ThemedBackground(grouped: true)) + .onAppear { + Task { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { groupRelays = relays } + } + } + .onDisappear { + if hasChanges { + showAlert( + title: NSLocalizedString("Save webpage settings?", comment: "alert title"), + message: NSLocalizedString("Webpage settings were changed. If you save, the updated settings will be sent to subscribers.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: saveAccess, + cancelButton: true + ) + } + } + } + + private func webpageInfo(_ text: LocalizedStringKey) -> some View { + Section { + Text(text).foregroundColor(theme.colors.secondary) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 0, trailing: 16)) + } + + private var hasChanges: Bool { + let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + let currentWebPage = access?.groupWebPage ?? "" + let currentEmbedding = access?.allowEmbedding ?? false + return webPage != currentWebPage || allowEmbedding != currentEmbedding + } + + private var relayDomains: [String] { + groupRelays.compactMap { $0.relayCap.webDomain } + } + + private var embedCode: String? { + if let pg = groupInfo.groupProfile.publicGroup, + !relayDomains.isEmpty { + """ +
+ + """ + } else { + nil + } + } + + private func saveAccess() { + saving = true + Task { + do { + var gp = groupInfo.groupProfile + if var pg = gp.publicGroup { + let trimmedPage = webPage.trimmingCharacters(in: .whitespacesAndNewlines) + let existingAccess = pg.publicGroupAccess + pg.publicGroupAccess = PublicGroupAccess( + groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage, + groupDomain: existingAccess?.groupDomain, + domainWebPage: existingAccess?.domainWebPage ?? false, + allowEmbedding: allowEmbedding + ) + gp.publicGroup = pg + } + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + saving = false + } + } catch { + logger.error("ChannelWebAccessView apiUpdateGroup error: \(responseError(error))") + await MainActor.run { saving = false } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 1353f590fa..5563d79e61 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -244,6 +244,12 @@ struct GroupChatInfoView: View { } } + if groupInfo.useRelays && groupInfo.isOwner { + Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) { + channelWebAccessButton() + } + } + if developerTools { Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { infoRow("Local name", chat.chatInfo.localDisplayName) @@ -502,7 +508,7 @@ struct GroupChatInfoView: View { // TODO server connection status VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) (user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member))) .lineLimit(1) @@ -575,7 +581,7 @@ struct GroupChatInfoView: View { } else { let role = member.memberRole if [.owner, .admin, .moderator, .observer].contains(role) { - Text(member.memberRole.text) + Text(member.memberRole.text(isChannel: groupInfo.isChannel)) .foregroundColor(theme.colors.secondary) } } @@ -657,6 +663,17 @@ struct GroupChatInfoView: View { } } + private func channelWebAccessButton() -> some View { + let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage" + return NavigationLink { + ChannelWebAccessView(groupInfo: $groupInfo) + .navigationBarTitle(title) + .navigationBarTitleDisplayMode(.large) + } label: { + Label(title, systemImage: "globe") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 22253c4808..43d23878f5 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -84,7 +84,7 @@ struct GroupLinkView: View { if !isChannel { Picker("Initial role", selection: $groupLinkMemberRole) { ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in - Text(role.text) + Text(role.text(isChannel: isChannel)) } } .frame(height: 36) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index dc14c7520b..c87f97089c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -178,15 +178,15 @@ struct GroupMemberInfoView: View { let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat" infoRow(label, groupInfo.displayName) - if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) { + if let roles = member.canChangeRoleTo(groupInfo: groupInfo) { Picker("Change role", selection: $newRole) { ForEach(roles) { role in - Text(role.text) + Text(role.text(isChannel: groupInfo.isChannel)) } } .frame(height: 36) } else { - infoRow("Role", member.memberRole.text) + infoRow("Role", member.memberRole.text(isChannel: groupInfo.isChannel)) } if let link = member.relayLink { infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link))) @@ -522,25 +522,14 @@ struct GroupMemberInfoView: View { // show alias if set, alias cannot be edited in this view let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if mem.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = mem.nameBadge + let nameText = mem.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(mem.fullName) .font(.title2) @@ -644,8 +633,7 @@ struct GroupMemberInfoView: View { blockForAllButton(mem) } } - // TODO [relays] re-enable when relay management ships - if canRemove && mem.memberRole != .relay { + if canRemove { if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) { removeMemberButton(mem) } else if mem.memberRole != .relay { @@ -739,15 +727,17 @@ struct GroupMemberInfoView: View { private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert { Alert( - title: Text("Change member role?"), + title: Text("Change role?"), message: ( mem.memberCurrent ? ( - groupInfo.businessChat == nil - ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") - : Text("Member role will be changed to \"\(newRole.text)\". All chat members will be notified.") + groupInfo.isChannel + ? Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All subscribers will be notified.") + : groupInfo.businessChat == nil + ? Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All group members will be notified.") + : Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All chat members will be notified.") ) - : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation.") + : Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". The member will receive a new invitation.") ), primaryButton: .default(Text("Change")) { Task { diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift index 23001e64bf..74cb702d21 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -20,7 +20,7 @@ struct MemberSupportChatToolbar: View { MemberProfileImage(groupMember, size: imageSize) .padding(.trailing, 4) let t = Text(groupMember.chatViewName).font(.headline) - (groupMember.verified ? memberVerifiedShield + t : t) + NameWithBadge((groupMember.verified ? memberVerifiedShield + t : t), groupMember.nameBadge, .headline) .lineLimit(1) } .foregroundColor(theme.colors.onBackground) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 880933985c..da9d56a699 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -172,7 +172,7 @@ struct MemberSupportView: View { .padding(.trailing, 2) VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) Text(memberStatus(member)) .lineLimit(1) @@ -205,7 +205,7 @@ struct MemberSupportView: View { } else if member.memberPending { return member.memberStatus.text } else { - return LocalizedStringKey(member.memberRole.text) + return LocalizedStringKey(member.memberRole.text(isChannel: groupInfo.isChannel)) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 3047572236..5844fd3ff9 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -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) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 243d804685..a6e7fc5870 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -173,7 +173,9 @@ struct ChatPreviewView: View { : !contact.sndReady ? theme.colors.secondary : nil - previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color) + NameWithBadge((contact.verified == true ? verifiedIcon + t : t).foregroundColor(color), chat.chatInfo.nameBadge, .title3) + .lineLimit(1) + .frame(alignment: .topLeading) case let .group(groupInfo, _): let color = if deleting { theme.colors.secondary @@ -424,11 +426,11 @@ struct ChatPreviewView: View { } case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { - CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { - CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) } case let .voice(_, duration): smallContentPreviewVoice(size: dynamicMediaSize) { @@ -436,7 +438,7 @@ struct ChatPreviewView: View { } case .file: smallContentPreviewFile(size: dynamicMediaSize) { - CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize) + CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize) } case let .chat(_, chatLink, ownerSig): smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 9276bbfc78..341bc10655 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -22,12 +22,16 @@ struct ContactRequestView: View { .padding(.leading, 4) VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { - Text(contactRequest.chatViewName) - .font(.title3) - .fontWeight(.bold) - .foregroundColor(theme.colors.primary) - .padding(.leading, 8) - .frame(alignment: .topLeading) + NameWithBadge( + Text(contactRequest.chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(theme.colors.primary), + chat.chatInfo.nameBadge, + .title3 + ) + .padding(.leading, 8) + .frame(alignment: .topLeading) Spacer() formatTimestampText(contactRequest.updatedAt) .font(.subheadline) diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 63d28e3624..8c230dc56a 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -129,7 +129,8 @@ struct UserPicker: View { } } .padding(.trailing, 6) - Text(u.user.displayName).font(.title2).lineLimit(1) + NameWithBadge(Text(u.user.displayName).font(.title2), u.user.profile.localBadge, .title2) + .lineLimit(1) } .padding(rowPadding) .modifier(ListRow { diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index fcfcde2c07..9214e3ecde 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -200,10 +200,9 @@ struct ContactListNavLink: View { private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) - return ( - contact.verified == true - ? verifiedIcon + t - : t + return NameWithBadge( + contact.verified == true ? verifiedIcon + t : t, + chat.chatInfo.nameBadge ) .lineLimit(1) } @@ -318,8 +317,7 @@ struct ContactListNavLink: View { HStack{ ProfileImage(imageStr: chat.chatInfo.image, size: 30) - Text(chat.chatInfo.chatViewName) - .foregroundColor(color) + NameWithBadge(Text(chat.chatInfo.chatViewName).foregroundColor(color), chat.chatInfo.nameBadge) .lineLimit(1) Spacer() diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift new file mode 100644 index 0000000000..67f6d6d6b2 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -0,0 +1,174 @@ +// +// NameBadge.swift +// SimpleX +// +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// The badge is sized to a fraction of the font size (em), NOT the font's cap-height metric: the metric +// underestimates the rendered capital letters, so a cap-height-tall badge looks too small. These ratios +// are calibrated visually to match caps - the same constants as the Compose (Android/desktop) app. +private let fontCapHeightRatio: CGFloat = 0.85 +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private let badgeBaselineOffsetRatio: CGFloat = 0.05 + +// A contact/member name with the supporter badge right after it. The name keeps its own styling +// (font, weight, color, even a verification shield concatenated into the Text); the badge is sized to +// the given text style and sits on the name's baseline. Use this everywhere a name may carry a badge. +// Pass onTap to make the badge open the info alert. The badge hides itself for a nil/long-expired badge. +struct NameWithBadge: View { + let name: Text + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ name: Text, _ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.name = name + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + name + NameBadge(badge, textStyle, onTap: onTap) + } + } +} + +// The badge glyph alone, sized to the given text style and sitting on the text baseline in an +// HStack(alignment: .firstTextBaseline). Renders nothing for a nil badge or a long-expired one +// (ExpiredOld); a failed or unknown-key badge shows a warning glyph. Prefer NameWithBadge; use this +// directly only where the name is not a single Text. Pass onTap to open the badge info alert. +struct NameBadge: View { + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + if let badge, badge.status != .expiredOld { + // the leading padding is the gap to the name; it lives here so an absent badge adds no gap. + // the alignment guide pushes the badge bottom slightly below the baseline (round-letter undershoot) + let v = glyph(badge) + .frame(height: badgeHeight) + .alignmentGuide(.firstTextBaseline) { $0.height * (1 - badgeBaselineOffsetRatio) } + .padding(.leading, badgeGap) + if let onTap { + v.onTapGesture(perform: onTap) + } else { + v + } + } + } + + private var badgeHeight: CGFloat { + UIFont.preferredFont(forTextStyle: textStyle).pointSize * fontCapHeightRatio + } + + // the gap to the name, matching the verification shield's gap (textSpace - one space in the name's font) + private var badgeGap: CGFloat { + let font = UIFont.preferredFont(forTextStyle: textStyle) + return (" " as NSString).size(withAttributes: [.font: font]).width + } + + @ViewBuilder private func glyph(_ badge: LocalBadge) -> some View { + switch badge.status { + case .failed, .unknownKey: + Image(systemName: "exclamationmark.triangle.fill") + .resizable().scaledToFit() + .foregroundColor(.orange) + default: + Image(badgeImageName(badge.badge.badgeType)) + .resizable().scaledToFit() + .opacity(badge.status == .expired ? 0.4 : 1) + } + } +} + +private func badgeImageName(_ t: BadgeType) -> String { + switch t { + case .legend: "badge-legend" + case .investor: "badge-investor" + default: "badge-supporter" // supporter + unknown + } +} + +// The badge as an inline attachment for a UIKit label, for the custom alert where the name is a UILabel +// and the SwiftUI NameBadge can't be used. Sized to the font's cap height with its bottom on the baseline, +// preceded by a space for the gap to the name. Returns nil for a nil/long-expired badge. Mirrors NameBadge's glyph. +func nameBadgeAttachment(_ badge: LocalBadge?, font: UIFont) -> NSAttributedString? { + guard let badge, badge.status != .expiredOld else { return nil } + var image: UIImage? + switch badge.status { + case .failed, .unknownKey: + image = UIImage(systemName: "exclamationmark.triangle.fill")? + .withTintColor(.systemOrange, renderingMode: .alwaysOriginal) + default: + image = UIImage(named: badgeImageName(badge.badge.badgeType)) + if badge.status == .expired, let img = image { + // a recently expired badge is dimmed, matching NameBadge's 0.4 opacity + image = UIGraphicsImageRenderer(size: img.size).image { _ in + img.draw(at: .zero, blendMode: .normal, alpha: 0.4) + } + } + } + guard let image else { return nil } + let attachment = NSTextAttachment() + attachment.image = image + let h = font.pointSize * fontCapHeightRatio + // text coordinates: a negative y drops the image below the baseline by badgeBaselineOffsetRatio of its height + attachment.bounds = CGRect(x: 0, y: -h * badgeBaselineOffsetRatio, width: h * image.size.width / image.size.height, height: h) + let s = NSMutableAttributedString(string: " ") // the gap to the name + s.append(NSAttributedString(attachment: attachment)) + return s +} + +func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { + switch badge.status { + case .failed: + showAlert( + NSLocalizedString("Unverified badge", comment: "badge alert title"), + message: NSLocalizedString("This badge could not be verified and may not be genuine.", comment: "badge alert") + ) + case .unknownKey: + showAlert( + NSLocalizedString("Badge cannot be verified", comment: "badge alert title"), + message: NSLocalizedString("The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.", comment: "badge alert") + ) + default: + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + let t = badge.badge.badgeType.text + let title = t.prefix(1).uppercased() + t.dropFirst() + if case .investor = badge.badge.badgeType { + let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name) + showAlert(title, message: message) { + [ UIAlertAction(title: NSLocalizedString("Learn more", comment: "badge alert button"), style: .default) { _ in + if let url = URL(string: "https://simplex.chat/crowdfunding") { + UIApplication.shared.open(url) + } + }, + okAlertAction ] + } + } else { + // supporter, legend and unknown types use the supporter wording + let supports = + if badge.status == .expired, let expiry = badge.badge.badgeExpiry { + String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, expiry.formatted(date: .abbreviated, time: .omitted)) + } else { + String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name) + } + let v7 = NSLocalizedString("You can support SimpleX starting from v7 of the app.", comment: "badge alert") + showAlert(title, message: supports + "\n\n" + v7) + } + } +} diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 9f2fc833ba..82d17cd2b1 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat func getTopViewController() -> UIViewController? { let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene @@ -134,6 +135,7 @@ class OpenChatAlertViewController: UIViewController { private let profileName: String private let profileFullName: String private let profileImage: UIView + private let profileBadge: LocalBadge? private let subtitle: String? private let information: String? private let cancelTitle: String @@ -145,6 +147,7 @@ class OpenChatAlertViewController: UIViewController { profileName: String, profileFullName: String, profileImage: UIView, + profileBadge: LocalBadge? = nil, subtitle: String? = nil, information: String? = nil, cancelTitle: String = "Cancel", @@ -155,6 +158,7 @@ class OpenChatAlertViewController: UIViewController { self.profileName = profileName self.profileFullName = profileFullName self.profileImage = profileImage + self.profileBadge = profileBadge self.subtitle = subtitle self.information = information self.cancelTitle = cancelTitle @@ -190,12 +194,18 @@ class OpenChatAlertViewController: UIViewController { // Name label let nameLabel = UILabel() - nameLabel.text = profileName nameLabel.font = UIFont.preferredFont(forTextStyle: .headline) nameLabel.textColor = .label nameLabel.numberOfLines = 2 nameLabel.textAlignment = .center nameLabel.translatesAutoresizingMaskIntoConstraints = false + if let badge = nameBadgeAttachment(profileBadge, font: nameLabel.font) { + let s = NSMutableAttributedString(string: profileName) + s.append(badge) + nameLabel.attributedText = s + } else { + nameLabel.text = profileName + } var profileViews = [profileImage, nameLabel] @@ -365,6 +375,7 @@ func showOpenChatAlert( profileName: String, profileFullName: String, profileImage: Content, + profileBadge: LocalBadge? = nil, theme: AppTheme, subtitle: String? = nil, information: String? = nil, @@ -383,6 +394,7 @@ func showOpenChatAlert( profileName: profileName, profileFullName: profileFullName, profileImage: hostedView, + profileBadge: profileBadge, subtitle: subtitle, information: information, cancelTitle: cancelTitle, diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 4a7e50d7d2..67fd353ebc 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -560,9 +560,11 @@ private struct ActiveProfilePicker: View { HStack { ProfileImage(imageStr: user.image, size: 30) .padding(.trailing, 2) - Text(user.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(user.chatViewName).foregroundColor(theme.colors.onBackground), + user.profile.localBadge + ) + .lineLimit(1) Spacer() if selectedProfile == user, !incognitoEnabled { Image(systemName: "checkmark") @@ -1160,6 +1162,7 @@ private func showPrepareContactAlert( : "person.crop.circle.fill", size: alertProfileImageSize ), + profileBadge: contactShortLinkData.localBadge, theme: theme, information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), @@ -1253,6 +1256,7 @@ private func showOpenKnownContactAlert( iconName: contact.chatIconName, size: alertProfileImageSize ), + profileBadge: contact.active ? contact.profile.localBadge : nil, theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 93f32a53a6..135a90c65e 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -420,7 +420,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 } } } @@ -535,12 +537,14 @@ func settingsRow(_ icon: String, color: Color/* = .secondary*/, struct ProfilePreview: View { var profileOf: NamedChat var color = Color(uiColor: .tertiarySystemGroupedBackground) + var badge: LocalBadge? = nil var body: some View { HStack { ProfileImage(imageStr: profileOf.image, size: 44, color: color) .padding(.trailing, 6) - profileName(profileOf).lineLimit(1) + NameWithBadge(profileName(profileOf), badge, .title2) + .lineLimit(1) } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index daaf7da954..0b07f89096 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; + CE11BADE0000000000000002 /* NameBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE11BADE0000000000000001 /* NameBadge.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; @@ -169,6 +170,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */; }; 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -183,8 +185,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -413,6 +415,7 @@ 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; + CE11BADE0000000000000001 /* NameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBadge.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -547,6 +550,7 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelWebAccessView.swift; sourceTree = ""; }; 6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -561,8 +565,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -731,8 +735,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +822,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a */, ); path = Libraries; sourceTree = ""; @@ -880,6 +884,7 @@ CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */, CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */, CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */, + CE11BADE0000000000000001 /* NameBadge.swift */, ); path = Helpers; sourceTree = ""; @@ -1175,6 +1180,7 @@ 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */, 6495D7072F48D0000060512B /* AddGroupRelayView.swift */, ); path = Group; @@ -1559,6 +1565,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + CE11BADE0000000000000002 /* NameBadge.swift in Sources */, B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, @@ -1636,6 +1643,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */, 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, @@ -2073,7 +2081,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2098,7 +2106,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2123,7 +2131,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2148,7 +2156,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2165,11 +2173,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2185,11 +2193,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2210,7 +2218,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2225,7 +2233,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2247,7 +2255,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2262,7 +2270,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2284,7 +2292,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2310,7 +2318,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2335,7 +2343,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2362,7 +2370,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2389,7 +2397,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2404,7 +2412,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2423,7 +2431,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 336; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2438,7 +2446,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 7.0; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 978e1d9630..0000000000 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,66 +0,0 @@ -{ - "originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9", - "pins" : [ - { - "identity" : "codescanner", - "kind" : "remoteSourceControl", - "location" : "https://github.com/twostraws/CodeScanner", - "state" : { - "revision" : "34da57fb63b47add20de8a85da58191523ccce57", - "version" : "2.5.0" - } - }, - { - "identity" : "elegant-emoji-picker", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Finalet/Elegant-Emoji-Picker", - "state" : { - "branch" : "main", - "revision" : "71d2d46092b4d550cc593614efc06438f845f6e6" - } - }, - { - "identity" : "ink", - "kind" : "remoteSourceControl", - "location" : "https://github.com/johnsundell/ink", - "state" : { - "revision" : "bcc9f219900a62c4210e6db726035d7f03ae757b", - "version" : "0.6.0" - } - }, - { - "identity" : "lzstring-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Ibrahimhass/lzstring-swift", - "state" : { - "revision" : "7f62f21de5b18582a950e1753b775cc614722407" - } - }, - { - "identity" : "swiftygif", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kirualex/SwiftyGif", - "state" : { - "revision" : "5e8619335d394901379c9add5c4c1c2f420b3800" - } - }, - { - "identity" : "webrtc", - "kind" : "remoteSourceControl", - "location" : "https://github.com/simplex-chat/WebRTC.git", - "state" : { - "revision" : "34bedc50f9c58dccf4967ea59c7e6a47d620803b" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams", - "state" : { - "revision" : "9234124cff5e22e178988c18d8b95a8ae8007f76", - "version" : "5.1.2" - } - } - ], - "version" : 3 -} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3302850b64..8b186f168d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -136,6 +136,8 @@ public struct Profile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + // the badge proof from the wire profile - opaque to the UI, round-tripped to the core (apiPrepareContact) + public var badge: BadgeProof? public var localAlias: String { get { "" } } var profileViewName: String { @@ -158,6 +160,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { contactLink: String? = nil, preferences: Preferences? = nil, peerType: ChatPeerType? = nil, + localBadge: LocalBadge? = nil, localAlias: String ) { self.profileId = profileId @@ -168,6 +171,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.contactLink = contactLink self.preferences = preferences self.peerType = peerType + self.localBadge = localBadge self.localAlias = localAlias } @@ -179,6 +183,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + public var localBadge: LocalBadge? public var localAlias: String var profileViewName: String { @@ -201,6 +206,70 @@ public enum ChatPeerType: String, Codable { case bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +public enum BadgeType: Hashable { + case supporter + case legend + case investor + case unknown(String) + + // the disclosed (signed) type name, shown to the user for verified badges + public var text: String { + switch self { + case .supporter: "supporter" + case .legend: "legend" + case .investor: "investor" + case let .unknown(s): s + } + } +} + +extension BadgeType: Codable { + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "supporter": self = .supporter + case "legend": self = .legend + case "investor": self = .investor + case let s: self = .unknown(s) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(text) + } +} + +public enum BadgeStatus: String, Codable { + case active + case expired + // expired over a month ago - the badge is not shown at all + case expiredOld + case failed + // signed with a key index this app version does not know - shown as a warning + case unknownKey +} + +public struct BadgeInfo: Codable, Hashable { + public var badgeType: BadgeType + public var badgeExpiry: Date? + public var badgeExtra: String +} + +public struct LocalBadge: Codable, Hashable { + public var badge: BadgeInfo + public var status: BadgeStatus +} + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +public struct BadgeProof: Codable, Hashable { + public var badgeKeyIdx: Int + public var presHeader: String + public var proof: String + public var badgeInfo: BadgeInfo +} + public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile { LocalProfile( profileId: profileId, @@ -1459,6 +1528,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none) + public var nameBadge: LocalBadge? { + get { + switch self { + case let .direct(contact): return contact.active ? contact.profile.localBadge : nil + case let .contactRequest(contactRequest): return contactRequest.profile.localBadge + default: return nil + } + } + } + public var displayName: String { get { switch self { @@ -2265,7 +2345,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public var userContactLinkId_: Int64? public var cReqChatVRange: VersionRange var localDisplayName: ContactName - var profile: Profile + public var profile: LocalProfile var createdAt: Date public var updatedAt: Date @@ -2283,7 +2363,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { userContactLinkId_: 1, cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", - profile: Profile.sampleData, + profile: LocalProfile.sampleData, createdAt: .now, updatedAt: .now ) @@ -2534,6 +2614,13 @@ public enum GroupType: Codable, Hashable { } public struct PublicGroupAccess: Codable, Hashable { + public init(groupWebPage: String? = nil, groupDomain: String? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) { + self.groupWebPage = groupWebPage + self.groupDomain = groupDomain + self.domainWebPage = domainWebPage + self.allowEmbedding = allowEmbedding + } + public var groupWebPage: String? public var groupDomain: String? public var domainWebPage: Bool = false @@ -2627,6 +2714,8 @@ public struct ContactShortLinkData: Codable, Hashable { public var profile: Profile public var message: MsgContent? public var business: Bool + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + public var localBadge: LocalBadge? } public struct GroupSummary: Decodable, Hashable { @@ -2652,6 +2741,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable { case new case invited case accepted + case acknowledgedRoster case active case inactive case rejected @@ -2727,6 +2817,7 @@ extension RelayStatus { case .new: "new" case .invited: "invited" case .accepted: "accepted" + case .acknowledgedRoster: "acknowledged roster" case .active: "active" case .inactive: "inactive" case .rejected: "rejected" @@ -2783,6 +2874,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var fullName: String { get { memberProfile.fullName } } public var image: String? { get { memberProfile.image } } public var contactLink: String? { get { memberProfile.contactLink } } + public var nameBadge: LocalBadge? { memberProfile.localBadge } public var verified: Bool { activeConn?.connectionCode != nil } public var blocked: Bool { blockedByAdmin || !memberSettings.showMessages } @@ -2889,8 +2981,16 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if memberRole == .relay || !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil } + if groupInfo.useRelays && !groupInfo.isOwner { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.supportedRoles.filter { $0 <= userRole } + if groupInfo.useRelays { + // TODO [relays]: for now owners can only set observer/member in channels. + // Restore the full Owner-excluded picker when moderator/admin promotion is supported: + // return GroupMemberRole.supportedRoles.filter { $0 <= userRole && $0 != .owner } + return [.observer, .member] + } else { + return GroupMemberRole.supportedRoles.filter { $0 <= userRole } + } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { @@ -2978,12 +3078,16 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .moderator, .admin, .owner] - public var text: String { + public func text(isChannel: Bool) -> String { switch self { case .relay: return NSLocalizedString("relay", comment: "member role") - case .observer: return NSLocalizedString("observer", comment: "member role") + case .observer: return isChannel + ? NSLocalizedString("subscriber", comment: "member role") + : NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") - case .member: return NSLocalizedString("member", comment: "member role") + case .member: return isChannel + ? NSLocalizedString("contributor", comment: "member role") + : NSLocalizedString("member", comment: "member role") case .moderator: return NSLocalizedString("moderator", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") @@ -5504,7 +5608,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case .userAccepted: return NSLocalizedString("accepted you", comment: "rcv group event chat item") case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item") case let .memberRole(_, profile, role): - return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text) + return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text(isChannel: isChannel)) case let .memberBlocked(_, profile, blocked): if blocked { return String.localizedStringWithFormat(NSLocalizedString("blocked %@", comment: "rcv group event chat item"), profile.profileViewName) @@ -5512,7 +5616,7 @@ public enum RcvGroupEvent: Decodable, Hashable { return String.localizedStringWithFormat(NSLocalizedString("unblocked %@", comment: "rcv group event chat item"), profile.profileViewName) } case let .userRole(role): - return String.localizedStringWithFormat(NSLocalizedString("changed your role to %@", comment: "rcv group event chat item"), role.text) + return String.localizedStringWithFormat(NSLocalizedString("changed your role to %@", comment: "rcv group event chat item"), role.text(isChannel: isChannel)) case let .memberDeleted(_, profile): return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName) case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item") @@ -5558,9 +5662,9 @@ public enum SndGroupEvent: Decodable, Hashable { func text(isChannel: Bool) -> String { switch self { case let .memberRole(_, profile, role): - return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text) + return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text(isChannel: isChannel)) case let .userRole(role): - return String.localizedStringWithFormat(NSLocalizedString("you changed role for yourself to %@", comment: "snd group event chat item"), role.text) + return String.localizedStringWithFormat(NSLocalizedString("you changed role for yourself to %@", comment: "snd group event chat item"), role.text(isChannel: isChannel)) case let .memberBlocked(_, profile, blocked): if blocked { return String.localizedStringWithFormat(NSLocalizedString("you blocked %@", comment: "snd group event chat item"), profile.profileViewName) diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 3d0dd663c1..c44bcee5bd 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -29,6 +29,10 @@ public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB // Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +public let MAX_FILE_SIZE_XFTP_SUPPORTER: Int64 = 2_147_483_648 // 2GB +public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB + public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max public let MAX_FILE_SIZE_SMP: Int64 = 8000000 @@ -273,11 +277,26 @@ public func cleanupFile(_ aChatItem: AChatItem) { } } -public func getMaxFileSize(_ fileProtocol: FileProtocol) -> Int64 { +public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 { switch fileProtocol { - case .xftp: return MAX_FILE_SIZE_XFTP - case .smp: return MAX_FILE_SIZE_SMP - case .local: return MAX_FILE_SIZE_LOCAL + case .smp: MAX_FILE_SIZE_SMP + case .local: MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + case .xftp: + if let badge = senderProfile?.localBadge, badge.status == .active { + badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER + } else { + MAX_FILE_SIZE_XFTP + } + } +} + +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +public func ciSenderProfile(_ ci: ChatItem, _ chatInfo: ChatInfo) -> LocalProfile? { + switch (ci.chatDir, chatInfo) { + case let (.groupRcv(groupMember), _): return groupMember.memberProfile + case let (.directRcv, .direct(contact)): return contact.profile + default: return nil } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt index a09ca2792b..9649e37c0f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt @@ -95,8 +95,9 @@ fun UserPickerUserBox( } } val user = userInfo.user - Text( + NameWithBadge( user.displayName, + user.profile.localBadge, fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index f3024d7ca1..c5dd81fec7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1461,6 +1461,17 @@ data class Chat( @Serializable sealed class ChatInfo: SomeChat, NamedChat { + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none). + // a badge that expired over a month ago (ExpiredOld) is not shown at all. + val nameBadge: LocalBadge? get() { + val badge = when { + this is Direct && contact.active -> contact.profile.localBadge + this is ContactRequest -> contactRequest.profile.localBadge + else -> null + } + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } + @Serializable @SerialName("direct") data class Direct(val contact: Contact): ChatInfo() { override val chatType get() = ChatType.Direct @@ -2025,7 +2036,10 @@ data class Profile( override val localAlias : String = "", val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + // the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge), + // but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof + val badge: BadgeProof? = null ): NamedChat { val profileViewName: String get() { @@ -2053,7 +2067,8 @@ data class LocalProfile( override val localAlias: String, val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + val localBadge: LocalBadge? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } @@ -2077,6 +2092,70 @@ enum class ChatPeerType { @SerialName("bot") Bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +@Serializable(with = BadgeTypeSerializer::class) +sealed class BadgeType { + @Serializable @SerialName("supporter") object Supporter: BadgeType() + @Serializable @SerialName("legend") object Legend: BadgeType() + @Serializable @SerialName("investor") object Investor: BadgeType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType() + + // the disclosed (signed) type name, shown to the user for verified badges + val text: String + get() = when (this) { + is Supporter -> "supporter" + is Legend -> "legend" + is Investor -> "investor" + is Unknown -> type + } +} + +object BadgeTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeType = + when (val v = decoder.decodeString()) { + "supporter" -> BadgeType.Supporter + "legend" -> BadgeType.Legend + "investor" -> BadgeType.Investor + else -> BadgeType.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text) +} + +@Serializable +enum class BadgeStatus { + @SerialName("active") Active, + @SerialName("expired") Expired, + // expired over a month ago - the badge is not shown at all + @SerialName("expiredOld") ExpiredOld, + @SerialName("failed") Failed, + // signed with a key index this app version does not know - shown as a warning + @SerialName("unknownKey") UnknownKey +} + +@Serializable +data class BadgeInfo( + val badgeType: BadgeType, + val badgeExpiry: Instant? = null, + val badgeExtra: String = "" +) + +@Serializable +data class LocalBadge( + val badge: BadgeInfo, + val status: BadgeStatus +) + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +@Serializable +data class BadgeProof( + val badgeKeyIdx: Int, + val presHeader: String, + val proof: String, + val badgeInfo: BadgeInfo +) + @Serializable data class UserProfileUpdateSummary( val updateSuccesses: Int, @@ -2309,7 +2388,9 @@ enum class MemberCriteria { data class ContactShortLinkData ( val profile: Profile, val message: MsgContent?, - val business: Boolean + val business: Boolean, + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + val localBadge: LocalBadge? = null ) @Serializable @@ -2334,6 +2415,7 @@ enum class RelayStatus { @SerialName("new") New, @SerialName("invited") Invited, @SerialName("accepted") Accepted, + @SerialName("acknowledgedRoster") AcknowledgedRoster, @SerialName("active") Active, @SerialName("inactive") Inactive, @SerialName("rejected") Rejected; @@ -2342,6 +2424,7 @@ enum class RelayStatus { New -> generalGetString(MR.strings.relay_status_new) Invited -> generalGetString(MR.strings.relay_status_invited) Accepted -> generalGetString(MR.strings.relay_status_accepted) + AcknowledgedRoster -> generalGetString(MR.strings.relay_status_acknowledged_roster) Active -> generalGetString(MR.strings.relay_status_active) Inactive -> generalGetString(MR.strings.relay_status_inactive) Rejected -> generalGetString(MR.strings.relay_status_rejected) @@ -2440,6 +2523,11 @@ data class GroupMember ( override val image: String? get() = memberProfile.image val contactLink: String? = memberProfile.contactLink val verified get() = activeConn?.connectionCode != null + // the badge shown for a member's name; a badge that expired over a month ago (ExpiredOld) is not shown + val nameBadge: LocalBadge? get() { + val badge = memberProfile.localBadge + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } val blocked get() = blockedByAdmin || !memberSettings.showMessages override val localAlias: String = memberProfile.localAlias @@ -2529,8 +2617,15 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (memberRole == GroupMemberRole.Relay || !canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null + else if (groupInfo.useRelays && !groupInfo.isOwner) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.selectableRoles.filter { it <= userRole } + if (groupInfo.useRelays) + // TODO [relays]: for now owners can only set observer/member in channels. + // Restore the full Owner-excluded picker when moderator/admin promotion is supported: + // GroupMemberRole.selectableRoles.filter { it <= userRole && it != GroupMemberRole.Owner } + listOf(GroupMemberRole.Observer, GroupMemberRole.Member) + else + GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { @@ -2608,11 +2703,11 @@ enum class GroupMemberRole(val memberRole: String) { val selectableRoles: List = listOf(Observer, Member, Moderator, Admin, Owner) } - val text: String get() = when (this) { + fun text(isChannel: Boolean): String = when (this) { Relay -> generalGetString(MR.strings.group_member_role_relay) - Observer -> generalGetString(MR.strings.group_member_role_observer) + Observer -> generalGetString(if (isChannel) MR.strings.group_member_role_observer_channel else MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) - Member -> generalGetString(MR.strings.group_member_role_member) + Member -> generalGetString(if (isChannel) MR.strings.group_member_role_member_channel else MR.strings.group_member_role_member) Moderator -> generalGetString(MR.strings.group_member_role_moderator) Admin -> generalGetString(MR.strings.group_member_role_admin) Owner -> generalGetString(MR.strings.group_member_role_owner) @@ -2758,7 +2853,7 @@ class UserContactRequest ( val contactRequestId: Long, val cReqChatVRange: VersionRange, override val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -2784,7 +2879,7 @@ class UserContactRequest ( contactRequestId = 1, cReqChatVRange = VersionRange(1, 1), localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -5006,13 +5101,13 @@ sealed class RcvGroupEvent() { is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName) is UserAccepted -> generalGetString(MR.strings.rcv_group_event_user_accepted) is MemberLeft -> generalGetString(MR.strings.rcv_group_event_member_left) - is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text) + is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text(isChannel = isChannel)) is MemberBlocked -> if (blocked) { String.format(generalGetString(MR.strings.rcv_group_event_member_blocked), profile.profileViewName) } else { String.format(generalGetString(MR.strings.rcv_group_event_member_unblocked), profile.profileViewName) } - is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text) + is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text(isChannel = isChannel)) is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName) is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted) is GroupDeleted -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_channel_deleted else MR.strings.rcv_group_event_group_deleted) @@ -5051,8 +5146,8 @@ sealed class SndGroupEvent() { val text: String get() = text(isChannel = false) fun text(isChannel: Boolean): String = when (this) { - is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text) - is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text) + is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text(isChannel = isChannel)) + is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text(isChannel = isChannel)) is MemberBlocked -> if (blocked) { String.format(generalGetString(MR.strings.snd_group_event_member_blocked), profile.profileViewName) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 35e92e2cd2..54986e1815 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2289,7 +2289,7 @@ object ChatController { return when { r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup r is API.Error -> { - AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "$r.err") + AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}") null } else -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 13be8b1d71..a099bf333b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -715,19 +715,32 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val displayName = contact.profile.displayName.trim() + val badge = cInfo.nameBadge val text = buildAnnotatedString { if (contact.verified) { appendInlineContent(id = "shieldIcon") } append(displayName) - } - val inlineContent: Map = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun (name: String) { clipboard.setText(AnnotatedString(name)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 9c36f4896b..affe5ce326 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -170,9 +170,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools @Composable fun ForwardedFromSender(forwardedFromItem: AChatItem) { @Composable - fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) { - Text( + fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground, badge: LocalBadge? = null) { + NameWithBadge( text, + badge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.body1, @@ -191,13 +192,13 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (forwardedFromItem.chatItem.chatDir.sent) { ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) { - ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName) + ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, badge = forwardedFromItem.chatItem.chatDir.groupMember.nameBadge) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else { - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground, badge = forwardedFromItem.chatInfo.nameBadge) } } } @@ -344,9 +345,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools ) { MemberProfileImage(size = 36.dp, member) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - Text( + NameWithBadge( member.chatViewName, - modifier = Modifier.weight(10f, fill = true), + member.nameBadge, + Modifier.weight(10f, fill = true), maxLines = 1, overflow = TextOverflow.Ellipsis ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 5299a5e686..6f7c746691 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow @@ -1564,8 +1565,8 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) { ContactVerifiedShield() } - Text( - cInfo.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + cInfo.displayName, cInfo.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -1999,7 +2000,7 @@ fun BoxScope.ChatItemsList( Column( Modifier .padding(top = 8.dp) - .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .padding(start = 8.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) .fillMaxWidth() .then(swipeableModifier), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -2016,8 +2017,10 @@ fun BoxScope.ChatItemsList( } else { null to 1 } - Text( + // the name and the badge are one element, so SpaceBetween separates them from the role, not from each other + NameWithBadge( memberNames(member, prevMember, memCount), + if (prevMember == null && memCount == 1) member.nameBadge else null, Modifier .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) .weight(1f, false), @@ -2032,7 +2035,7 @@ fun BoxScope.ChatItemsList( val tailRendered = style is ShapeStyle.Bubble && style.tailVisible Text( - member.memberRole.text, + member.memberRole.text(isChannel = chatInfo.isChannel), Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), fontSize = 13.5.sp, fontWeight = FontWeight.Medium, @@ -2076,7 +2079,7 @@ fun BoxScope.ChatItemsList( } Row( Modifier - .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(swipeableOrSelectionModifier) ) { @@ -2089,7 +2092,7 @@ fun BoxScope.ChatItemsList( Column( Modifier .padding(top = 8.dp) - .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .padding(start = 8.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) .fillMaxWidth() .then(swipeableModifier), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -2159,7 +2162,7 @@ fun BoxScope.ChatItemsList( } Row( Modifier - .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(swipeableOrSelectionModifier) ) { @@ -2284,8 +2287,18 @@ fun BoxScope.ChatItemsList( .background(MaterialTheme.appColors.receivedMessage) ) { ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) + val bannerBadge = chatInfo.nameBadge + val uriHandler = LocalUriHandler.current Text( - chatInfo.displayName, + buildAnnotatedString { + append(chatInfo.displayName) + if (bannerBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (bannerBadge != null) mapOf("nameBadge" to nameBadgeInline(bannerBadge, MaterialTheme.typography.h3.fontSize) { showBadgeInfoAlert(chatInfo.displayName, bannerBadge, uriHandler) }) else emptyMap(), style = MaterialTheme.typography.h3, color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index ce023e83c9..b7be49055e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -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 ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt index 7ab7963547..26e069c739 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt @@ -33,8 +33,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo .size(36.dp), tint = if (isInDarkTheme()) FileDark else FileLight ) - Text(fileName) - Spacer(Modifier.weight(1f)) + Text(fileName, maxLines = 1, modifier = Modifier.weight(1f)) if (cancelEnabled) { IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) { Icon( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 26824cdd49..21290b99b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -113,7 +113,9 @@ data class ComposeState( val inProgress: Boolean = false, val progressByTimeout: Boolean = false, val useLinkPreviews: Boolean, - val mentions: MentionedMembers = emptyMap() + val mentions: MentionedMembers = emptyMap(), + // the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch + val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP) ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( ComposeMessage( @@ -251,8 +253,6 @@ data class ComposeState( } } -private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) - sealed class RecordingState { object NotStarted: RecordingState() class Started(val filePath: String, val progressMs: Int = 0): RecordingState() @@ -306,6 +306,7 @@ private fun isVideoUri(uri: URI): Boolean { fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { + val maxFileSize = value.maxFileSize val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) @@ -324,11 +325,12 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { } suspend fun MutableState.processPickedMedia(uris: List, text: String?) { + val maxFileSize = value.maxFileSize val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> var bitmap: ImageBitmap? - when { + val uploadContent: UploadContent? = when { isImage(uri) -> { // Image val drawable = getDrawableFromUri(uri) @@ -338,16 +340,19 @@ suspend fun MutableState.processPickedMedia(uris: List, text: // It's a gif or webp val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { - content.add(UploadContent.AnimatedImage(uri)) + UploadContent.AnimatedImage(uri) } else { bitmap = null AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) ) + null } } else if (bitmap != null) { - content.add(UploadContent.SimpleImage(uri)) + UploadContent.SimpleImage(uri) + } else { + null } } else -> { @@ -355,11 +360,22 @@ suspend fun MutableState.processPickedMedia(uris: List, text: val res = getBitmapFromVideo(uri, withAlertOnException = true) bitmap = res.preview val durationMs = res.duration - content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) + UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0) } } - if (bitmap != null) { + // content and imagesPreview must stay index-aligned and equal-length: both consumers + // (ComposeImageView and sendMessageAsync) cross-index one list by the other's index. + // Only pair them when a preview bitmap exists; otherwise skip the media entirely. + if (bitmap != null && uploadContent != null) { + content.add(uploadContent) imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) + } else if (uploadContent is UploadContent.Video && !AlertManager.shared.hasAlertsShown()) { + // A corrupted/undecodable video can yield a null preview frame without throwing, so + // getBitmapFromVideo shows no alert. Skip it (other picked media still send) and tell + // the user instead of dropping it silently. hasAlertsShown guards against stacking the + // alert across multiple bad items and against duplicating the one already shown on the + // exception path. Image decode failures are already surfaced by getBitmapFromUri above. + showVideoDecodingException() } } if (imagesPreview.isNotEmpty()) { @@ -493,7 +509,7 @@ fun ComposeView( if (live) { composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false) } else { - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews, maxFileSize = composeState.value.maxFileSize) resetLinkPreview() } recState.value = RecordingState.NotStarted @@ -1100,7 +1116,7 @@ fun ComposeView( if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { - composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews).copy(maxFileSize = composeState.value.maxFileSize) } } @@ -1328,6 +1344,11 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + // keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented + LaunchedEffect(chat.chatInfo) { + val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() + composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile)) + } if (appPlatform.isDesktop) { // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` DisposableEffect(Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 0749df7775..7223f69f98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -228,7 +228,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState Divider() @@ -55,7 +55,7 @@ fun ChannelMembersView( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner) + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner, isChannel = groupInfo.isChannel) } } } @@ -71,7 +71,7 @@ fun ChannelMembersView( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - ChannelMemberRow(member, user = false, showRole = false) + ChannelMemberRow(member, user = false, showRole = false, isChannel = groupInfo.isChannel) } } } @@ -81,7 +81,7 @@ fun ChannelMembersView( } @Composable -private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean) { +private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean, isChannel: Boolean) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -94,8 +94,9 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole if (member.verified) { MemberVerifiedShield() } - Text( + NameWithBadge( member.chatViewName, + member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified @@ -111,7 +112,7 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole } if (showRole) { Text( - member.memberRole.text, + member.memberRole.text(isChannel = isChannel), color = MaterialTheme.colors.secondary ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index d99e16d15f..d7ffde71c8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -87,8 +87,6 @@ private fun ChannelRelaysLayout( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - // TODO [relays] re-enable when relay management ships - /* if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) { DefaultDropdownMenu(showMenu) { ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { @@ -97,7 +95,6 @@ private fun ChannelRelaysLayout( }) } } - */ val statusText = if (groupInfo.isOwner) { ownerRelayStatusText(member, groupRelays) } else { @@ -109,8 +106,6 @@ private fun ChannelRelaysLayout( } SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages)) } - // TODO [relays] re-enable when relay management ships - /* if (groupInfo.isOwner) { SectionView { SectionItemView(click = { @@ -139,7 +134,6 @@ private fun ChannelRelaysLayout( } } } - */ SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt new file mode 100644 index 0000000000..98067e49dd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -0,0 +1,186 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* + +@Composable +fun ChannelWebPageView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit +) { + val isChannel = groupInfo.isChannel + val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + val webPage = rememberSaveable { mutableStateOf(access?.groupWebPage ?: "") } + val allowEmbedding = rememberSaveable { mutableStateOf(access?.allowEmbedding ?: false) } + val groupRelays = remember { mutableStateListOf() } + + val dataUnchanged = webPage.value.trim() == (access?.groupWebPage ?: "") && + allowEmbedding.value == (access?.allowEmbedding ?: false) + + val save: () -> Unit = { + withBGApi { + val trimmedPage = webPage.value.trim() + val newAccess = PublicGroupAccess( + groupWebPage = trimmedPage.ifEmpty { null }, + groupDomain = access?.groupDomain, + domainWebPage = access?.domainWebPage ?: false, + allowEmbedding = allowEmbedding.value + ) + val gp = groupInfo.groupProfile.copy( + publicGroup = groupInfo.groupProfile.publicGroup?.copy(publicGroupAccess = newAccess) + ) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, gp, isChannel) + if (gInfo != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, gInfo) + } + close() + } + } + } + + val closeWithAlert = { + if (dataUnchanged) { + close() + } else { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.save_preferences_question), + confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members), + dismissText = generalGetString(MR.strings.exit_without_saving), + onConfirm = save, + onDismiss = close, + ) + } + } + + LaunchedEffect(Unit) { + val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId) + groupRelays.clear() + groupRelays.addAll(relays) + } + + BackHandler(onBack = closeWithAlert) + ModalView(close = closeWithAlert, cardScreen = true) { + ChannelWebPageLayout( + isChannel = isChannel, + webPage = webPage, + allowEmbedding = allowEmbedding, + groupRelays = groupRelays, + groupInfo = groupInfo, + dataUnchanged = dataUnchanged, + save = save + ) + } +} + +@Composable +private fun ChannelWebPageLayout( + isChannel: Boolean, + webPage: MutableState, + allowEmbedding: MutableState, + groupRelays: List, + groupInfo: GroupInfo, + dataUnchanged: Boolean, + save: () -> Unit +) { + val clipboard = LocalClipboardManager.current + ColumnWithScrollBar { + AppBarTitle(stringResource(if (isChannel) MR.strings.channel_webpage else MR.strings.group_webpage)) + + val embedCode = embedCode(groupRelays, groupInfo) + if (embedCode != null) { + SectionTextFooter(stringResource(MR.strings.webpage_info)) + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.webpage_code)) { + SectionItemView { + Text( + embedCode, + style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + SectionItemView({ + clipboard.setText(AnnotatedString(embedCode)) + showToast(generalGetString(MR.strings.copied)) + }) { + Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.copy_code), color = MaterialTheme.colors.primary) + } + } + SectionTextFooter(stringResource(MR.strings.webpage_code_footer)) + } else { + SectionTextFooter(stringResource(MR.strings.relays_no_web_support)) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.enter_webpage_url)) { + PlainTextEditor(webPage, placeholder = stringResource(MR.strings.web_page_url_placeholder)) + } + SectionTextFooter(stringResource(MR.strings.webpage_url_footer)) + SectionDividerSpaced() + + SectionView { + PreferenceToggle(stringResource(MR.strings.allow_anyone_to_embed), checked = allowEmbedding.value) { + allowEmbedding.value = it + } + } + SectionTextFooter(stringResource(if (allowEmbedding.value) MR.strings.embed_any_webpage_can_show else MR.strings.embed_only_your_page)) + SectionDividerSpaced() + + SectionView { + SectionItemView(save, disabled = dataUnchanged) { + Text( + stringResource(MR.strings.save_verb), + color = if (dataUnchanged) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + + SectionBottomSpacer() + } +} + +private fun embedCode(groupRelays: List, groupInfo: GroupInfo): String? { + val pg = groupInfo.groupProfile.publicGroup ?: return null + val relayDomains = groupRelays.mapNotNull { it.relayCap.webDomain } + if (relayDomains.isEmpty()) return null + val domains = relayDomains.joinToString(",") + return """
+""" +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index c88e3b2bff..2b1c7bcd09 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -175,6 +175,9 @@ fun ModalData.GroupChatInfoView( manageGroupLink = { ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } }, + manageWebPage = { + ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) } + }, onSearchClicked = onSearchClicked, deletingItems = deletingItems ) @@ -506,6 +509,7 @@ fun ModalData.GroupChatInfoLayout( clearChat: () -> Unit, leaveGroup: () -> Unit, manageGroupLink: () -> Unit, + manageWebPage: () -> Unit, close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, onSearchClicked: () -> Unit, deletingItems: State @@ -727,7 +731,7 @@ fun ModalData.GroupChatInfoLayout( } } SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { - MemberRow(groupInfo.membership, user = true) + MemberRow(groupInfo.membership, user = true, isChannel = groupInfo.isChannel) } } } @@ -761,7 +765,7 @@ fun ModalData.GroupChatInfoLayout( val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp) DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu) Box(Modifier.padding(start = selectionOffset)) { - MemberRow(member) + MemberRow(member, isChannel = groupInfo.isChannel) } } } @@ -796,6 +800,13 @@ fun ModalData.GroupChatInfoLayout( } } + if (groupInfo.useRelays && groupInfo.isOwner) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.advanced_options)) { + ChannelWebPageButton(groupInfo, manageWebPage) + } + } + if (developerTools) { SectionDividerSpaced() SectionView(title = stringResource(MR.strings.section_title_for_console)) { @@ -1046,7 +1057,7 @@ private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialThem } @Composable -fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false) { +fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false, isChannel: Boolean = false) { @Composable fun MemberInfo() { if (member.blocked) { @@ -1054,7 +1065,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr } else { val role = member.memberRole if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Moderator, GroupMemberRole.Observer)) { - Text(role.text, color = MaterialTheme.colors.secondary) + Text(role.text(isChannel = isChannel), color = MaterialTheme.colors.secondary) } } } @@ -1088,8 +1099,10 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr if (member.verified) { MemberVerifiedShield() } - Text( - if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, + member.nameBadge, + maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } @@ -1207,6 +1220,16 @@ private fun ChannelLinkButton(onClick: () -> Unit) { ) } +@Composable +private fun ChannelWebPageButton(groupInfo: GroupInfo, onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_travel_explore), + stringResource(if (groupInfo.isChannel) MR.strings.channel_webpage else MR.strings.group_webpage), + onClick, + iconColor = MaterialTheme.colors.secondary + ) +} + @Composable private fun ChannelLinkQRCodeSection(groupLink: String) { val clipboard = LocalClipboardManager.current @@ -1411,6 +1434,7 @@ fun PreviewGroupChatInfoLayout() { clearChat = {}, leaveGroup = {}, manageGroupLink = {}, + manageWebPage = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index b68f0efaf5..f6f19e9d72 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -308,7 +308,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun(name: String) { clipboard.setText(AnnotatedString(name)) @@ -875,14 +884,15 @@ fun ConnectViaAddressButton(onClick: () -> Unit) { private fun RoleSelectionRow( roles: List, selectedRole: MutableState, - onSelected: (GroupMemberRole) -> Unit + onSelected: (GroupMemberRole) -> Unit, + isChannel: Boolean ) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - val values = remember { roles.map { it to it.text } } + val values = remember { roles.map { it to it.text(isChannel = isChannel) } } ExposedDropDownSettingRow( generalGetString(MR.strings.change_role), values, @@ -943,12 +953,14 @@ fun updateMemberRoleDialog( AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.change_member_role_question), text = if (memberCurrent) { - if (groupInfo.businessChat == null) - String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text) + if (groupInfo.isChannel) + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_channel), newRole.text(isChannel = groupInfo.isChannel)) + else if (groupInfo.businessChat == null) + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text(isChannel = groupInfo.isChannel)) else - String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text) + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text(isChannel = groupInfo.isChannel)) } else - String.format(generalGetString(MR.strings.member_role_will_be_changed_with_invitation), newRole.text), + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_invitation), newRole.text(isChannel = groupInfo.isChannel)), confirmText = generalGetString(MR.strings.change_verb), onDismiss = onDismiss, onConfirm = onConfirm, @@ -964,9 +976,9 @@ fun updateMembersRoleDialog( AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.change_member_role_question), text = if (groupInfo.businessChat == null) - String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text) + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text(isChannel = groupInfo.isChannel)) else - String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text), + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text(isChannel = groupInfo.isChannel)), confirmText = generalGetString(MR.strings.change_verb), onConfirm = onConfirm, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 180c9f9d23..c35f3a37e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -137,8 +137,8 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic if (member.verified) { MemberVerifiedShield() } - Text( - member.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + member.displayName, member.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 3d76c845ad..b685d20fb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -150,7 +150,7 @@ private fun ModalData.MemberSupportViewLayout( ) { Box(contentAlignment = Alignment.CenterStart) { DropDownMenuForSupportChat(chat.remoteHostId, member, groupInfo, showMenu) - SupportChatRow(member) + SupportChatRow(member, isChannel = groupInfo.isChannel) } } } @@ -163,7 +163,7 @@ private fun ModalData.MemberSupportViewLayout( } @Composable -fun SupportChatRow(member: GroupMember) { +fun SupportChatRow(member: GroupMember, isChannel: Boolean) { fun memberStatus(): String { return if (member.activeConn?.connStatus is ConnStatus.Failed) { generalGetString(MR.strings.member_info_member_failed) @@ -174,7 +174,7 @@ fun SupportChatRow(member: GroupMember) { } else if (member.memberPending) { member.memberStatus.text } else { - member.memberRole.text + member.memberRole.text(isChannel = isChannel) } } @@ -234,8 +234,8 @@ fun SupportChatRow(member: GroupMember) { if (member.verified) { MemberVerifiedShield() } - Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + member.chatViewName, member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index afd55ed928..02bee37c24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -33,6 +33,7 @@ fun CIFileView( edited: Boolean, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -71,12 +72,12 @@ fun CIFileView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> { - if (fileSizeValid(file)) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } @@ -151,7 +152,7 @@ fun CIFileView( is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled)) is CIFileStatus.RcvInvitation -> - if (fileSizeValid(file)) + if (fileSizeValid(file, senderProfile)) fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) else fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) @@ -225,7 +226,9 @@ fun CIFileView( } } -fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +// whether a received file is within the size we accept from its sender +fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean = + file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 8bfbea9fa6..ed9a0e6007 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -35,6 +35,7 @@ fun CIImageView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -160,13 +161,6 @@ fun CIImageView( } } - fun fileSizeValid(): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { @@ -213,12 +207,12 @@ fun CIImageView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> - if (fileSizeValid()) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 8289149ad9..f8dfba4c6c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -34,6 +34,7 @@ fun CIVideoView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -84,7 +85,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, senderProfile, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -114,7 +115,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) } } } } @@ -546,20 +547,13 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun fileSizeValid(file: CIFile?): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false -} - -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { - if (fileSizeValid(file)) { +private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index b4abbe70a2..4f6cde9a4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -449,7 +449,7 @@ fun ChatItemView( } if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) { ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { withBGApi { Log.d(TAG, "ChatItemView downloadFileAction") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index f55c49fdd1..5c07fe3abf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -201,7 +201,7 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) + CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (text != "" || ci.meta.isLive) { CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -312,7 +312,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -320,7 +320,7 @@ fun FramedItemView( } } is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) + CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 7dfb52063e..ca1528a3ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -254,9 +254,9 @@ suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentT suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { // groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) - val currentMembers = chatModel.groupMembers.value + val currentMembersById = chatModel.groupMembers.value.associateBy { it.id } val newMembers = groupMembers.map { newMember -> - val currentMember = currentMembers.find { it.id == newMember.id } + val currentMember = currentMembersById[newMember.id] val currentMemberStats = currentMember?.activeConn?.connectionStats val newMemberConn = newMember.activeConn if (currentMemberStats != null && newMemberConn != null && newMemberConn.connectionStats == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index d749865e10..2c7e443b4d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -152,7 +152,15 @@ fun ChatPreviewView( } else { Color.Unspecified } - chatPreviewTitleText(color = color) + NameWithBadge( + cInfo.chatViewName, + cInfo.nameBadge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = color + ) } } is ChatInfo.Group -> { @@ -316,13 +324,13 @@ fun ChatPreviewView( } } is MsgContent.MCImage -> SmallContentPreview { - CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIImageView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } is MsgContent.MCVideo -> SmallContentPreview { - CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIVideoView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } @@ -334,7 +342,7 @@ fun ChatPreviewView( } } is MsgContent.MCFile -> SmallContentPreviewFile { - CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) { + CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIFileView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 901761f65c..96e7fbacd0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -27,8 +27,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { .padding(start = 8.dp, end = 8.sp.toDp()) .weight(1F) ) { - Text( + NameWithBadge( contactRequest.chatViewName, + contactRequest.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 47668c4fb3..07058b5787 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -104,8 +104,8 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) { } else { ProfileImage(size = 42.dp, chat.chatInfo.image) } - Text( - chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + chat.chatInfo.chatViewName, chat.chatInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (disabled) MaterialTheme.colors.secondary else if (chat.chatInfo.incognito) Indigo else Color.Unspecified ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 5cf7d9325f..81a6b31323 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -210,7 +210,7 @@ fun UserPicker( } } else if (currentUser != null) { SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped) + ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge) } } } @@ -468,10 +468,11 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning image = u.image, size = 54.dp * fontSizeSqrtMultiplier ) - Text( + // the end padding is on the row, not the name, so the badge stays right after the name + NameWithBadge( u.displayName, - modifier = Modifier - .padding(start = 10.dp, end = 8.dp), + u.profile.localBadge, + Modifier.padding(start = 10.dp, end = 8.dp), color = if (enabled) MenuTextColor else MaterialTheme.colors.secondary, fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 636887275c..524fbedb1c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -54,8 +54,9 @@ fun ContactPreviewView( if (cInfo.contact.verified) { VerifiedIcon() } - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor @@ -63,8 +64,9 @@ fun ContactPreviewView( } is ChatInfo.ContactRequest -> Row(verticalAlignment = Alignment.CenterVertically) { - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 1774cf4394..b4680a4259 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.* @@ -15,10 +16,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR @@ -287,6 +290,7 @@ class AlertManager { profileName: String, profileFullName: String, profileImage: @Composable () -> Unit, + profileBadge: LocalBadge? = null, subtitle: String? = null, information: String? = null, confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat), @@ -314,8 +318,17 @@ class AlertManager { ) { profileImage() Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + val nameFontSize = MaterialTheme.typography.h4.fontSize Text( - profileName, + buildAnnotatedString { + append(profileName) + if (profileBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (profileBadge != null) mapOf("nameBadge" to nameBadgeInline(profileBadge, nameFontSize)) else emptyMap(), textAlign = TextAlign.Center, style = MaterialTheme.typography.h4, lineHeight = 20.sp, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 5f3a73e7ea..d2ee1db09c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -3,10 +3,14 @@ package chat.simplex.common.views.helpers import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.shape.* import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -14,15 +18,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.BadgeType import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.LocalBadge +import chat.simplex.common.model.localDate import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlin.math.max +import kotlin.math.roundToInt @Composable fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) { @@ -103,6 +120,137 @@ fun ProfileImage( } } +// badge height in em: calibrated visually so the badge top matches capital letters and digits +// (Inter's declared cap height is 2048/2816 = 0.727em, but the rendered text is taller than the metrics predict) +private const val fontCapHeightRatio = 0.95f + +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private const val badgeBaselineOffsetRatio = 0.05f + +// the badge glyph's width / height (the SVGs are cropped to the glyph: 300 x 399) +private const val badgeAspectRatio = 300f / 399f + +// A contact/member name with the badge right after it: the badge is baseline-aligned with the name +// and sized to its font (fontSize if given, otherwise style.fontSize), and a truncated name keeps it visible. +@Composable +fun NameWithBadge( + name: String, + badge: LocalBadge?, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current +) { + Row(modifier) { + Text( + name, + Modifier.alignByBaseline().weight(1f, fill = false), + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + overflow = overflow, + maxLines = maxLines, + style = style + ) + NameBadge(badge, if (fontSize.isSpecified) fontSize else style.fontSize) + } +} + +// Badge next to the contact name in a Row: top aligned with capital letters, bottom just below the text baseline. +// Use NameWithBadge unless the row needs special arrangement; then the name Text must use Modifier.alignByBaseline(). +@Composable +fun RowScope.NameBadge(badge: LocalBadge?, fontSize: TextUnit = LocalTextStyle.current.fontSize) { + // a badge that expired over a month ago (ExpiredOld) is not shown + if (badge == null || badge.status == BadgeStatus.ExpiredOld) return + val height = with(LocalDensity.current) { (if (fontSize.isSpecified) fontSize else 14.sp).toDp() } * fontCapHeightRatio + BadgeGlyph( + badge, + // the alignment line sits badgeBaselineOffsetRatio above the badge's bottom edge, + // so the Row places the badge that much below the text baseline; + // 6.dp matches the visible gap between the name and the verification shield: + // the shield has 3.dp end padding plus ~17% internal glyph margin, the badge artwork has none + Modifier.alignBy { (it.measuredHeight * (1 - badgeBaselineOffsetRatio)).roundToInt() }.padding(start = 6.dp).height(height).aspectRatio(badgeAspectRatio) + ) +} + +// badge inside a Text via appendInlineContent(id): bottom on the baseline, cap-height tall. +// precede with append(" ") for the space between the name and the badge. +fun nameBadgeInline(badge: LocalBadge, fontSize: TextUnit, onBadgeClick: (() -> Unit)? = null): InlineTextContent { + val height = fontSize * fontCapHeightRatio + return InlineTextContent( + Placeholder(height * badgeAspectRatio, height, PlaceholderVerticalAlign.AboveBaseline) + ) { + // the placeholder bottom sits on the baseline and can't extend below it, + // so the badge is drawn shifted down by badgeBaselineOffsetRatio instead + BadgeGlyph(badge, Modifier.fillMaxSize().graphicsLayer { translationY = size.height * badgeBaselineOffsetRatio }, onBadgeClick) + } +} + +@Composable +private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() -> Unit)? = null) { + val mod = modifier.let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it } + if (badge.status == BadgeStatus.Failed || badge.status == BadgeStatus.UnknownKey) { + Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod) + } else { + Image( + painterResource(badgeImage(badge.badge.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f, + modifier = mod + ) + } +} + +fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) { + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() } + when { + badge.status == BadgeStatus.Failed -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unverified_title), + text = generalGetString(MR.strings.badge_unverified_desc) + ) + badge.status == BadgeStatus.UnknownKey -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unknown_key_title), + text = generalGetString(MR.strings.badge_unknown_key_desc) + ) + badge.badge.badgeType is BadgeType.Investor -> + AlertManager.shared.showAlertDialog( + title = title, + text = String.format(generalGetString(MR.strings.badge_invested), name), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.learn_more), + onDismiss = { uriHandler.openUriCatching("https://simplex.chat/crowdfunding") } + ) + else -> { + // Supporter, Legend and unknown types use the supporter wording + val expiry = badge.badge.badgeExpiry + val supports = + if (badge.status == BadgeStatus.Expired && expiry != null) + String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry)) + else + String.format(generalGetString(MR.strings.badge_supports_simplex), name) + AlertManager.shared.showAlertMsg( + title = title, + text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7) + ) + } + } +} + +private fun badgeImage(t: BadgeType): ImageResource = when (t) { + is BadgeType.Legend -> MR.images.badge_legend + is BadgeType.Investor -> MR.images.badge_investor + else -> MR.images.badge_supporter // Supporter + Unknown +} + @Composable fun ProfileImage(size: Dp, image: ImageResource) { Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index e8070b5c76..cd40585cad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -102,6 +102,28 @@ fun TextEditor( } } +@Composable +fun PlainTextEditor( + value: MutableState, + placeholder: String? = null, + singleLine: Boolean = true +) { + BasicTextField( + value = value.value, + onValueChange = { value.value = it }, + modifier = Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = 12.dp), + textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), + singleLine = singleLine, + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = { innerTextField -> + if (value.value.isEmpty() && placeholder != null) { + Text(placeholder, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) + } + innerTextField() + } + ) +} + @Serializable data class ParsedFormattedText( val formattedText: List? = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 23c622bc34..86f2f13313 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000 const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB +const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB + const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI @@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair { // count, size in return fileCount to bytes } -fun getMaxFileSize(fileProtocol: FileProtocol): Long { - return when (fileProtocol) { - FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP - FileProtocol.SMP -> MAX_FILE_SIZE_SMP - FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL +fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) { + FileProtocol.SMP -> MAX_FILE_SIZE_SMP + FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + FileProtocol.XFTP -> { + val badge = senderProfile?.localBadge + if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP + else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND + else MAX_FILE_SIZE_XFTP_SUPPORTER } } +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) { + is CIDirection.GroupRcv -> dir.groupMember.memberProfile + is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile + else -> null +} + expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration fun showWrongUriAlert() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 39c4cb0b7f..fd6b27482d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -413,12 +413,12 @@ private fun MutableState.FinishedView(chatDeletion: Boolean) } ) {} } - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) if (chatDeletion) { ProgressView() } } + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 9fd5dd5b4a..e5dbe01d68 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -474,6 +474,8 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: icon = contact.chatIconName ) }, + // the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too + profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null, confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat), onConfirm = { openKnownContact(chatModel, rhId, close, contact) @@ -633,6 +635,7 @@ fun showPrepareContactAlert( else MR.images.ic_account_circle_filled ) }, + profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge, information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), onConfirm = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index a7aa1f400b..6799fa1300 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -230,7 +230,8 @@ private fun ProfilePickerOption( disabled: Boolean, onSelected: () -> Unit, image: @Composable () -> Unit, - onInfo: (() -> Unit)? = null + onInfo: (() -> Unit)? = null, + badge: LocalBadge? = null ) { Row( Modifier @@ -243,7 +244,7 @@ private fun ProfilePickerOption( ) { image() TextIconSpaced(false) - Text(title, modifier = Modifier.align(Alignment.CenterVertically)) + NameWithBadge(title, badge, Modifier.align(Alignment.CenterVertically)) if (onInfo != null) { Spacer(Modifier.padding(6.dp)) Column(Modifier @@ -365,7 +366,8 @@ fun ActiveProfilePicker( } } }, - image = { ProfileImage(size = 42.dp, image = user.image) } + image = { ProfileImage(size = 42.dp, image = user.image) }, + badge = user.profile.localBadge ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c0b822dbb4..c8e040c592 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -326,12 +326,13 @@ fun AppVersionItem(showVersion: () -> Unit) { Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else "")) } -@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) { +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false, badge: LocalBadge? = null) { ProfileImage(size = size, image = profileOf.image, color = iconColor) Spacer(Modifier.padding(horizontal = 8.dp)) Column(Modifier.height(size), verticalArrangement = Arrangement.Center) { - Text( + NameWithBadge( profileOf.displayName, + badge, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = if (stopped) MaterialTheme.colors.secondary else textColor, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 5bca9402a8..71c0bf98a4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1850,8 +1850,10 @@ observer + subscriber author member + contributor moderator admin owner @@ -1925,6 +1927,20 @@ Welcome message Group link Channel link + Channel webpage + Group webpage + Advanced options + https:// + Allow anyone to embed + Enter webpage URL + It will be shown to subscribers and used to allow loading the preview. + Webpage code + Add this code to your webpage. It will display the preview of your channel / group. + Copy code + Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting. + Used chat relays do not support webpages. + Any webpage can show the preview. + Only your page above can show the preview. Create group link Create link Delete link? @@ -2029,9 +2045,10 @@ Change role Change Switch - Change group role? + Change role? The role will be changed to "%s". Everyone in the group will be notified. The role will be changed to "%s". Everyone in the chat will be notified. + The role will be changed to "%s". Everyone in the channel will be notified. The role will be changed to "%s". The member will receive a new invitation. Connect directly? Сonnection request will be sent to this group member. @@ -3008,6 +3025,7 @@ new invited accepted + acknowledged roster active inactive rejected @@ -3112,4 +3130,12 @@ SimpleX — %d unread Close to tray Runs in background to receive messages + %s supports SimpleX Chat. + %1$s supported SimpleX Chat. The badge expired on %2$s. + You can support SimpleX starting from v7 of the app. + %s invested in SimpleX Chat crowdfunding. + Unverified badge + This badge could not be verified and may not be genuine. + Badge cannot be verified + The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js index 7c0836960c..e6828817ee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -3,7 +3,7 @@ useWorker = typeof window.Worker !== "undefined"; isDesktop = true; // Create WebSocket connection. -const socket = new WebSocket(`ws://${location.host}`); +const socket = new WebSocket(`ws://${location.host}${location.search}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); sendMessageToNative = (msg) => { @@ -192,4 +192,4 @@ function updateCallInfoView(state, description) { document.getElementById("state").innerText = state; document.getElementById("description").innerText = description; } -//# sourceMappingURL=ui.js.map \ No newline at end of file +//# sourceMappingURL=ui.js.map diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 20fe6a48a3..75782d75d7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -18,10 +18,12 @@ import org.nanohttpd.protocols.http.response.Status import org.nanohttpd.protocols.websockets.* import java.io.IOException import java.net.BindException -import java.net.URI +import java.security.SecureRandom +import java.util.Base64 private const val SERVER_HOST = "localhost" private const val SERVER_PORT = 50395 +private const val CALL_SERVER_TOKEN_BYTES = 32 val connections = ArrayList() // Spec: spec/services/calls.md#ActiveCallView @@ -153,14 +155,15 @@ private fun SendStateUpdates() { @Composable fun WebRTCController(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val uriHandler = LocalUriHandler.current + val token = remember { newCallServerToken() } val endCall = { val call = chatModel.activeCall.value if (call != null) withBGApi { chatModel.callManager.endCall(call) } } val server = remember { - startServer(onResponse).apply { + startServer(onResponse, token = token).apply { try { - uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") + uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/?token=$token") } catch (e: Exception) { Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") AlertManager.shared.showAlertMsg( @@ -208,7 +211,11 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } -fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { +fun startServer( + onResponse: (WVAPIMessage) -> Unit, + port: Int = SERVER_PORT, + token: String = newCallServerToken(), +): NanoWSD { val server = object: NanoWSD(SERVER_HOST, port) { override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) @@ -227,8 +234,18 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na override fun handle(session: IHTTPSession): Response { return when { - session.headers["upgrade"] == "websocket" -> super.handle(session) - session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") + session.headers["upgrade"] == "websocket" -> + if (hasValidCallServerToken(session.parameters, token)) { + super.handle(session) + } else { + unauthorizedResponse() + } + session.uri.contains("/simplex/call/") -> + if (hasValidCallServerToken(session.parameters, token)) { + resourcesToResponse("/desktop/call.html") + } else { + unauthorizedResponse() + } else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL")) } } @@ -239,11 +256,23 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na if (port == 0) throw e Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") server.stop() - return startServer(onResponse, port = 0) + return startServer(onResponse, port = 0, token = token) } return server } +internal fun newCallServerToken(): String { + val bytes = ByteArray(CALL_SERVER_TOKEN_BYTES) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +} + +internal fun hasValidCallServerToken(parameters: Map>, token: String): Boolean = + token.isNotEmpty() && parameters["token"]?.any { it == token } == true + +private fun unauthorizedResponse(): Response = + newFixedLengthResponse(Status.UNAUTHORIZED, "text/plain", "Unauthorized") + class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) { override fun onOpen() { connections.add(this) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt index 52e845b422..43428bab72 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -67,15 +66,17 @@ actual fun UserPickerUsersSection( } } - Text( - user.displayName, - fontSize = 12.sp, - fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(65.dp), - textAlign = TextAlign.Center - ) + Row(Modifier.width(65.dp), horizontalArrangement = Arrangement.Center) { + Text( + user.displayName, + fontSize = 12.sp, + fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alignByBaseline().weight(1f, fill = false) + ) + NameBadge(user.profile.localBadge, 12.sp) + } } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index f6a6023d47..c4c34a1db9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -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), diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt new file mode 100644 index 0000000000..800c69f617 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt @@ -0,0 +1,70 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.startServer +import java.net.Socket +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +// Integration test for the desktop call server's token gate (the handle() enforcement), +// which the unit-level CallServerTokenTest does not exercise. +class CallServerAuthTest { + private val token = "integration-test-token" + // port = 0 binds a random free port, avoiding a clash with a real call server on SERVER_PORT + private val server = startServer(onResponse = {}, port = 0, token = token) + private val port get() = server.listeningPort + + @AfterTest + fun tearDown() = server.stop() + + @Test + fun testWebSocketUpgradeRejectedWithoutToken() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/"))) + } + + @Test + fun testWebSocketUpgradeRejectedWithWrongToken() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong"))) + } + + @Test + fun testWebSocketUpgradeAcceptedWithToken() { + assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token"))) + } + + @Test + fun testCallPageRejectedWithoutToken() { + assertEquals(401, requestStatus(get(path = "/simplex/call/"))) + } + + @Test + fun testCallPagePassesAuthGateWithToken() { + // Resource serving may differ in the test classpath, so assert only that the auth gate was passed (not 401) + assertNotEquals(401, requestStatus(get(path = "/simplex/call/?token=$token"))) + } + + private fun get(path: String): List = listOf("GET $path HTTP/1.1", "Host: localhost:$port") + + private fun webSocketUpgrade(path: String): List = + listOf( + "GET $path HTTP/1.1", + "Host: localhost:$port", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + ) + + // Sends a raw HTTP request and returns the response status code from the status line. + private fun requestStatus(requestLines: List): Int = + Socket("localhost", port).use { socket -> + socket.soTimeout = 5000 + socket.getOutputStream().apply { + write((requestLines.joinToString("\r\n") + "\r\n\r\n").toByteArray()) + flush() + } + val statusLine = socket.getInputStream().bufferedReader().readLine() ?: error("no response from call server") + statusLine.split(" ")[1].toInt() + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt new file mode 100644 index 0000000000..dc729b1ec2 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt @@ -0,0 +1,29 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.hasValidCallServerToken +import chat.simplex.common.views.call.newCallServerToken +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CallServerTokenTest { + @Test + fun testCallServerTokenRequiresExactTokenParameter() { + val token = "secret" + + assertTrue(hasValidCallServerToken(mapOf("token" to listOf(token)), token)) + assertFalse(hasValidCallServerToken(mapOf("token" to listOf("wrong")), token)) + assertFalse(hasValidCallServerToken(mapOf("x-token" to listOf(token)), token)) + assertFalse(hasValidCallServerToken(mapOf("token" to listOf(token)), "")) + } + + @Test + fun testCallServerTokenIsUrlSafe() { + val token = newCallServerToken() + + assertTrue(token.length >= 40) + assertFalse(token.contains("+")) + assertFalse(token.contains("/")) + assertFalse(token.contains("=")) + } +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index a2b63a5810..5cdd339440 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5.4 -android.version_code=353 +android.version_name=7.0-beta.0 +android.version_code=356 android.bundle=false -desktop.version_name=6.5.4 -desktop.version_code=145 +desktop.version_name=7.0-beta.0 +desktop.version_code=147 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 41321edc68..f0501fef4f 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -1,8 +1,14 @@ module Main where import Server (simplexChatServer) +import Simplex.Chat.Badges.CLI (runBadgeCommand) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI) +import System.Environment (getArgs) main :: IO () -main = simplexChatCLI terminalChatConfig (Just simplexChatServer) +main = do + args <- getArgs + case args of + ("badge" : _) -> runBadgeCommand args + _ -> simplexChatCLI terminalChatConfig (Just simplexChatServer) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 4036bd8cf1..89c5178f7d 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -313,43 +313,48 @@ getGroupReg_ db gId = getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) getGroupAndReg cc user@User {userId, userContactId} gId = - withDB "getGroupAndReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ - DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + withDB "getGroupAndReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = - withDB "getUserGroupReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ + withDB "getUserGroupReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) getUserGroupRegs cc user@User {userId, userContactId} ctId = - withDB' "getUserGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] -getAllListedGroups_ db cxt user@User {userId, userContactId} = +getAllListedGroups_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg cxt user) + >>= mapM (withGroupLink . toGroupInfoReg currentTs cxt user) where withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = - withDB' "searchListedGroups" cc $ \db -> + withDB' "searchListedGroups" cc $ \db -> do + currentTs <- getCurrentTime case searchType of STAll -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -357,11 +362,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " STRecent -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -369,11 +374,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC " STSearch search -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s) pure (gs, n) where @@ -381,7 +386,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - groups = (map (toGroupInfoReg (storeCxt cc) user) <$>) + groups currentTs = (map (toGroupInfoReg currentTs (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -395,21 +400,24 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa |] getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)] -getAllGroupRegs_ db cxt user@User {userId, userContactId} = - map (toGroupInfoReg cxt user) +getAllGroupRegs_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs cxt user) <$> DB.query db groupReqQuery (userId, userContactId) getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = - withDB' "getDuplicateGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getDuplicateGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listLastGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" pure (gs, n) @@ -417,15 +425,16 @@ listLastGroups cc user@User {userId, userContactId} count = listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listPendingGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg cxt User {userContactId} (groupRow :. grRow) = - (toGroupInfo cxt userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg currentTs cxt User {userContactId} (groupRow :. grRow) = + (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 9b64fddca7..2f1332852e 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -10,6 +10,10 @@ This file is generated automatically. - [AgentCryptoError](#agentcryptoerror) - [AgentErrorType](#agenterrortype) - [AutoAccept](#autoaccept) +- [BadgeInfo](#badgeinfo) +- [BadgeProof](#badgeproof) +- [BadgeStatus](#badgestatus) +- [BadgeType](#badgetype) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -84,6 +88,7 @@ This file is generated automatically. - [FileProtocol](#fileprotocol) - [FileStatus](#filestatus) - [FileTransferMeta](#filetransfermeta) +- [FileType](#filetype) - [Format](#format) - [FormattedText](#formattedtext) - [FullGroupPreferences](#fullgrouppreferences) @@ -123,6 +128,7 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [LocalBadge](#localbadge) - [LocalProfile](#localprofile) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) @@ -359,6 +365,49 @@ INACTIVE: - acceptIncognito: bool +--- + +## BadgeInfo + +**Record type**: +- badgeType: [BadgeType](#badgetype) +- badgeExpiry: UTCTime? +- badgeExtra: string + + +--- + +## BadgeProof + +**Record type**: +- badgeKeyIdx: int +- presHeader: string +- proof: string +- badgeInfo: [BadgeInfo](#badgeinfo) + + +--- + +## BadgeStatus + +**Enum type**: +- "active" +- "expired" +- "expiredOld" +- "failed" +- "unknownKey" + + +--- + +## BadgeType + +**Enum type**: +- "supporter" +- "legend" +- "investor" + + --- ## BlockingInfo @@ -1797,6 +1846,7 @@ ContactViaAddress: - profile: [Profile](#profile) - message: [MsgContent](#msgcontent)? - business: bool +- localBadge: [LocalBadge](#localbadge)? --- @@ -2089,6 +2139,15 @@ NO_FILE: - cancelled: bool +--- + +## FileType + +**Enum type**: +- "normal" +- "roster" + + --- ## Format @@ -2305,6 +2364,7 @@ MemberSupport: - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? - customData: JSONObject? - groupSummary: [GroupSummary](#groupsummary) +- rosterVersion: int64? - membersRequireAttention: int - viaGroupLinkUri: string? - groupKeys: [GroupKeys](#groupkeys)? @@ -2710,6 +2770,15 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## LocalBadge + +**Record type**: +- badge: [BadgeInfo](#badgeinfo) +- status: [BadgeStatus](#badgestatus) + + --- ## LocalProfile @@ -2724,6 +2793,7 @@ Unknown: - simplexName: [SimplexNameInfo](#simplexnameinfo)? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- localBadge: [LocalBadge](#localbadge)? - localAlias: string @@ -3090,6 +3160,7 @@ count= - simplexName: [SimplexNameInfo](#simplexnameinfo)? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- badge: [BadgeProof](#badgeproof)? --- @@ -3320,6 +3391,7 @@ Cancelled: - xftpRcvFile: [XFTPRcvFile](#xftprcvfile)? - fileInvitation: [FileInvitation](#fileinvitation) - fileStatus: [RcvFileStatus](#rcvfilestatus) +- fileType: [FileType](#filetype) - rcvFileInline: [InlineFileMode](#inlinefilemode)? - senderDisplayName: string - chunkSize: int64 @@ -3444,6 +3516,7 @@ ParseError: - "new" - "invited" - "accepted" +- "acknowledgedRoster" - "active" - "inactive" - "rejected" @@ -4275,7 +4348,7 @@ Handshake: - cReqChatVRange: [VersionRange](#versionrange) - localDisplayName: string - profileId: int64 -- profile: [Profile](#profile) +- profile: [LocalProfile](#localprofile) - createdAt: UTCTime - updatedAt: UTCTime - xContactId: string? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 22eeb45c03..7e364cadf6 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -203,6 +203,7 @@ cliCommands = "AbortSwitchGroupMember", "AcceptContact", "AcceptMember", + "AddBadge", "AddContact", "AddMember", "AllowRelayGroup", @@ -281,6 +282,7 @@ cliCommands = "SetGroupTimedMessages", "SetLocalDeviceName", "SetProfileAddress", + "SetPublicGroupAccess", "SetSendReceipts", "SetShowMemberMessages", "SetShowMessages", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index bb6843f379..3f9ccac830 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -34,6 +34,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), BadgeType (..), JSONBadge (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -183,6 +184,7 @@ ciQuoteType = chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] chatTypesDocsData = [ ((sti @(Chat 'CTDirect)) {typeName = "AChat"}, STRecord, "", [], "", ""), + ((sti @JSONBadge) {typeName = "LocalBadge"}, STRecord, "", [], "", ""), ((sti @JSONChatInfo) {typeName = "ChatInfo"}, STUnion, "JCInfo", ["JCInfoInvalidJSON"], "", ""), ((sti @JSONCIContent) {typeName = "CIContent"}, STUnion, "JCI", ["JCIInvalidJSON"], "", ""), ((sti @JSONCIDeleted) {typeName = "CIDeleted"}, STUnion, "JCID", [], "", ""), @@ -207,6 +209,7 @@ chatTypesDocsData = (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), (sti @AutoAccept, STRecord, "", [], "", ""), + (sti @BadgeProof, STRecord, "", [], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), @@ -216,6 +219,8 @@ chatTypesDocsData = (sti @ChatDeleteMode, STUnion, "CDM", [], Param "type" <> Choice "self" [("messages", "")] (OnOffParam "notify" "notify" (Just True)), ""), (sti @ChatError, STUnion, "Chat", ["ChatErrorDatabase", "ChatErrorRemoteHost", "ChatErrorRemoteCtrl"], "", ""), (sti @ChatErrorType, STUnion, "CE", ["CEContactNotFound", "CEServerProtocol", "CECallState", "CEInvalidChatMessage"], "", ""), + (sti @BadgeStatus, STEnum, "BS", [], "", ""), + (sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""), (sti @ChatFeature, STEnum, "CF", [], "", ""), (sti @ChatItemDeletion, STRecord, "", [], "", "Message deletion result."), (sti @ChatPeerType, STEnum, "CPT", [], "", ""), @@ -266,6 +271,7 @@ chatTypesDocsData = (sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""), (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), + (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""), (sti @Format, STUnion, "", ["Unknown"], "", ""), (sti @FormattedText, STRecord, "", [], "", ""), (sti @FullGroupPreferences, STRecord, "", [], "", ""), @@ -304,6 +310,7 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), @@ -424,11 +431,14 @@ deriving instance Generic AddressSettings deriving instance Generic AgentCryptoError deriving instance Generic AgentErrorType deriving instance Generic AutoAccept +deriving instance Generic BadgeProof deriving instance Generic BlockingInfo deriving instance Generic BlockingReason deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType +deriving instance Generic BadgeStatus +deriving instance Generic BadgeType deriving instance Generic ChatBotCommand deriving instance Generic ChatDeleteMode deriving instance Generic ChatError @@ -483,6 +493,7 @@ deriving instance Generic FileInvitation deriving instance Generic FileProtocol deriving instance Generic FileStatus deriving instance Generic FileTransferMeta +deriving instance Generic FileType deriving instance Generic Format deriving instance Generic FormattedText deriving instance Generic FullGroupPreferences @@ -518,6 +529,7 @@ deriving instance Generic HandshakeError deriving instance Generic InlineFileMode deriving instance Generic InvitationLinkPlan deriving instance Generic InvitedBy +deriving instance Generic JSONBadge deriving instance Generic JSONChatInfo deriving instance Generic JSONCIContent deriving instance Generic JSONCIDeleted @@ -527,6 +539,7 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic BadgeInfo deriving instance Generic LocalProfile deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 36e87db62d..c5a3b11953 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -170,6 +170,7 @@ toTypeInfo tr = "DBEntityId'" -> ST TInt64 [] "Integer" -> ST TInt64 [] "Version" -> ST TInt [] + "VersionRoster" -> ST TInt64 [] "BoolDef" -> ST TBool [] "PQEncryption" -> ST TBool [] "PQSupport" -> ST TBool [] @@ -198,7 +199,11 @@ toTypeInfo tr = "AgentInvId", "AgentRcvFileId", "AgentSndFileId", + "BadgeMasterKey", "B64UrlByteString", + "BBSProof", + "BBSPresHeader", + "BBSSignature", "CbNonce", "ConnectionLink", "ConnShortLink", diff --git a/docs/guide/README.md b/docs/guide/README.md index 04e7538968..ad3849d1d3 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -10,6 +10,7 @@ The first messaging platform that has no user identifiers of any kind — 100% p - [Quick start](#quick-start) - scroll down this page - [Sending messages](./send-messages.md) - [Secret groups](./secret-groups.md) +- [Channel webpage](./channel-webpage.md) - [Chat profiles](./chat-profiles.md) - [Managing data](./managing-data.md) - [Audio & video calls](./audio-video-calls.md) diff --git a/docs/guide/channel-webpage.md b/docs/guide/channel-webpage.md new file mode 100644 index 0000000000..8cb0f4fbe5 --- /dev/null +++ b/docs/guide/channel-webpage.md @@ -0,0 +1,127 @@ +--- +title: Channel webpage +--- +# Channel webpage + +A channel webpage shows a preview of your channel on the web: its name, description, recent messages and subscriber count. Visitors can see what the channel is about before they subscribe, and the page gives them a "Join" button along with links to download the app. + +You don't have to build the preview yourself. The chat relays that host your channel publish its content as a small file, and a ready-made script renders it on your page. All it takes is to copy the code the app generates and paste it into a web page you host. + +## What you need + +A channel webpage can be set up by the **owner** of the channel, as long as the channel is hosted on chat relays that support webpages. If they don't, the app shows "Used chat relays do not support webpages." and no code is generated. + +You'll also need somewhere to publish an HTML page. Your own site works, but any static hosting will do. + +## Step 1. Open the channel webpage settings + +Open the channel and tap its name at the top to open the channel information. Scroll down to **Advanced options** and tap **Channel webpage**. This button is only shown to channel owners. + +## Step 2. Allow embedding while you build the page + +Turn on **Allow anyone to embed** and tap **Save**. + +With this on, the relay serves your channel preview to any page, so you can build and test from wherever the page lives without it being tied to one domain yet. Leave the webpage URL empty for now; nothing is shown to your subscribers until you set it. + +## Step 3. Copy the code + +Under **Webpage code** you'll see a snippet like the one below. Tap **Copy code**. + +```html +
+ +``` + +Everything specific to your channel is already in the code: its link, its ID, and the relay domains that serve the preview. There's no need to edit those values. + +## Step 4. Add the code to your page and test it + +Paste the snippet into the page you're going to publish. Here's a complete minimal page: + +```html + + + + + + My Channel + + + +
+ + + +``` + +Publish the page and open it in a browser. The channel preview shows up in place of the `
`. Because embedding is still open, it loads no matter which address you test from, so you can adjust the page until it looks right. + +## Step 5. Set the webpage URL and lock it down + +Once the page works, go back to **Channel webpage** in the app: + +- Under **Enter webpage URL**, type the address where the page is published, for example `https://example.com/my-channel`. +- Turn **Allow anyone to embed** off if you don't want other sites to be able to show your channel preview. Leave it on if you're happy for anyone to embed it. +- Tap **Save**. + +The URL now appears as a link in your channel info that every subscriber can see. If you turned embedding off, the relay also restricts the preview to your own domain. + +## Customizing the preview + +You can add optional `data-*` attributes to the `
` to change how it looks. Only `data-channel-id` and `data-relay-domains` are required, and both are already in the generated code. + +| Attribute | Values | Default | What it does | +| --- | --- | --- | --- | +| `data-channel-id` | channel ID (from the app) | none | Required. Identifies the channel to load. | +| `data-relay-domains` | comma-separated domains | none | Required. Relay domains that serve the preview, tried in order. | +| `data-channel-link` | channel link | none | Enables the "Join" button and QR code. Recommended. | +| `data-app-download-buttons` | `on`, `off` | `on` | Shows or hides the app download buttons. | +| `data-color-scheme` | `light`, `dark`, `site` | `light` | Color theme. `site` follows your page's theme (it uses dark styling when a parent element has the `dark` CSS class). | +| `data-light-background` | CSS color | `#ffffff` | Background color in light mode. | +| `data-dark-background` | CSS color | `#000832` | Background color in dark mode. | +| `data-relay-scheme` | `https`, `http` | `https` | Protocol used to load the preview from the relays. Leave it as `https`. | + +For example, here's a dark theme with a custom background and the download buttons hidden: + +```html +
+ +``` + +## Good to know + +The preview updates on its own. The relays republish channel content periodically, so the page picks up new messages without any change on your side. It's a read-only snapshot, so visitors can't post to it. + +Only what the channel already shows publicly is included: recent messages, member display names and avatars, reactions, and the subscriber count. Deleted and disappearing messages are never published. + +If your channel is served by more than one relay, all of them are listed in `data-relay-domains`. The script tries them in order, so the preview still loads when one relay is unavailable. + +## If something doesn't work + +If the preview area stays empty, check that the page is hosted on the same domain as the URL you set in Step 5, or turn **Allow anyone to embed** back on while you sort it out. The relay only lets that domain load the preview. + +If the app says "Used chat relays do not support webpages.", the relays hosting your channel don't support this feature yet, so no code can be generated. + +If there's no **Channel webpage** button, remember that it only appears for channel owners on channels hosted on relays. diff --git a/docs/protocol/channels-overview.md b/docs/protocol/channels-overview.md index d4cd2d2965..7a55f8b6e5 100644 --- a/docs/protocol/channels-overview.md +++ b/docs/protocol/channels-overview.md @@ -182,7 +182,7 @@ The low-level protocol supports multiple owners from the initial release. The ap - **Subscribers** connect to relays and receive content. They cannot send messages by default, but can be given posting rights. -Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. +Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. The owner-signed roster tracks the promoted set - members, moderators, and admins; subscribers are observers until an owner promotes them. For protocol-level detail - wire formats, message types, signing and verification mechanics, delivery pipeline - see [SimpleX Channels Protocol](./channels-protocol.md). @@ -242,6 +242,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/ - Undetectably substitute content - subscribers on honest relays receive the original. - Alter the channel's authoritative state on the owner's device. - Substitute the channel profile or impersonate an owner - these require valid signatures. +- Replay an old roster or role change to re-elevate a removed or demoted member for existing subscribers - they reject anything older than the roster version they applied (a new joiner with no prior roster can still be served an old one, until it syncs from another relay). - Redirect subscribers to a different channel - the entity ID is validated across link and profile. - Determine subscriber identity or network address - inherited from SMP transport. - Correlate subscriber participation across channels - each connection uses independent SMP queues. The subscriber chooses their SMP router independently, so collusion between a relay and the relay's SMP router does not compromise connections through a different router. diff --git a/flake.nix b/flake.nix index 43f4e8912a..fdd041bd88 100644 --- a/flake.nix +++ b/flake.nix @@ -406,6 +406,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -525,6 +527,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -591,6 +595,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -607,6 +612,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -626,6 +632,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -641,6 +648,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 76e6f9f3ee..ec4125193f 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -16,6 +16,8 @@ EXPORTS chat_password_hash chat_valid_name chat_json_length + chat_badge_keygen + chat_badge_issue chat_encrypt_media chat_decrypt_media chat_write_file diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index ad7ab04462..ec3dbd5b07 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.8.0", + "version": "0.10.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 181b79f57c..c06b3e96fa 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -193,6 +193,33 @@ export interface AutoAccept { acceptIncognito: boolean } +export interface BadgeInfo { + badgeType: BadgeType + badgeExpiry?: string // ISO-8601 timestamp + badgeExtra: string +} + +export interface BadgeProof { + badgeKeyIdx: number // int + presHeader: string + proof: string + badgeInfo: BadgeInfo +} + +export enum BadgeStatus { + Active = "active", + Expired = "expired", + ExpiredOld = "expiredOld", + Failed = "failed", + UnknownKey = "unknownKey", +} + +export enum BadgeType { + Supporter = "supporter", + Legend = "legend", + Investor = "investor", +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -2083,6 +2110,7 @@ export interface ContactShortLinkData { profile: Profile message?: MsgContent business: boolean + localBadge?: LocalBadge } export enum ContactStatus { @@ -2393,6 +2421,11 @@ export interface FileTransferMeta { cancelled: boolean } +export enum FileType { + Normal = "normal", + Roster = "roster", +} + export type Format = | Format.Bold | Format.Italic @@ -2623,6 +2656,7 @@ export interface GroupInfo { uiThemes?: UIThemeEntityOverrides customData?: object groupSummary: GroupSummary + rosterVersion?: number // int64 membersRequireAttention: number // int viaGroupLinkUri?: string groupKeys?: GroupKeys @@ -2989,6 +3023,11 @@ export interface LinkPreview { content?: LinkContent } +export interface LocalBadge { + badge: BadgeInfo + status: BadgeStatus +} + export interface LocalProfile { profileId: number // int64 displayName: string @@ -2999,6 +3038,7 @@ export interface LocalProfile { simplexName?: SimplexNameInfo preferences?: Preferences peerType?: ChatPeerType + localBadge?: LocalBadge localAlias: string } @@ -3382,6 +3422,7 @@ export interface Profile { simplexName?: SimplexNameInfo preferences?: Preferences peerType?: ChatPeerType + badge?: BadgeProof } export type ProxyClientError = @@ -3688,6 +3729,7 @@ export interface RcvFileTransfer { xftpRcvFile?: XFTPRcvFile fileInvitation: FileInvitation fileStatus: RcvFileStatus + fileType: FileType rcvFileInline?: InlineFileMode senderDisplayName: string chunkSize: number // int64 @@ -3859,6 +3901,7 @@ export enum RelayStatus { New = "new", Invited = "invited", Accepted = "accepted", + AcknowledgedRoster = "acknowledgedRoster", Active = "active", Inactive = "inactive", Rejected = "rejected", @@ -4966,7 +5009,7 @@ export interface UserContactRequest { cReqChatVRange: VersionRange localDisplayName: string profileId: number // int64 - profile: Profile + profile: LocalProfile createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp xContactId?: string diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 0dff1f7f1d..430bafc066 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "6.5.4", + "version": "7.0.0-beta.0", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.8.0", + "@simplex-chat/types": "^0.10.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 72761a1ac5..15d8a9f2b0 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v6.5.4'; +const RELEASE_TAG = 'v7.0.0-beta.0'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 2ae4ce941e..9dfcf5a88e 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "6.5.4" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "6.5.4" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.0.0b0" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.0.0-beta.0" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 95a4eea2bc..953dcf75fc 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -143,6 +143,21 @@ AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FI class AutoAccept(TypedDict): acceptIncognito: bool +class BadgeInfo(TypedDict): + badgeType: "BadgeType" + badgeExpiry: NotRequired[str] # ISO-8601 timestamp + badgeExtra: str + +class BadgeProof(TypedDict): + badgeKeyIdx: int # int + presHeader: str + proof: str + badgeInfo: "BadgeInfo" + +BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] + +BadgeType = Literal["supporter", "legend", "investor"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -1469,6 +1484,7 @@ class ContactShortLinkData(TypedDict): profile: "Profile" message: NotRequired["MsgContent"] business: bool + localBadge: NotRequired["LocalBadge"] ContactStatus = Literal["active", "deleted", "deletedByUser"] @@ -1683,6 +1699,8 @@ class FileTransferMeta(TypedDict): chunkSize: int # int64 cancelled: bool +FileType = Literal["normal", "roster"] + class Format_bold(TypedDict): type: Literal["bold"] @@ -1843,6 +1861,7 @@ class GroupInfo(TypedDict): uiThemes: NotRequired["UIThemeEntityOverrides"] customData: NotRequired[dict[str, object]] groupSummary: "GroupSummary" + rosterVersion: NotRequired[int] # int64 membersRequireAttention: int # int viaGroupLinkUri: NotRequired[str] groupKeys: NotRequired["GroupKeys"] @@ -2095,6 +2114,10 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class LocalBadge(TypedDict): + badge: "BadgeInfo" + status: "BadgeStatus" + class LocalProfile(TypedDict): profileId: int # int64 displayName: str @@ -2105,6 +2128,7 @@ class LocalProfile(TypedDict): simplexName: NotRequired["SimplexNameInfo"] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + localBadge: NotRequired["LocalBadge"] localAlias: str MemberCriteria = Literal["all"] @@ -2379,6 +2403,7 @@ class Profile(TypedDict): simplexName: NotRequired["SimplexNameInfo"] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + badge: NotRequired["BadgeProof"] class ProxyClientError_protocolError(TypedDict): type: Literal["protocolError"] @@ -2592,6 +2617,7 @@ class RcvFileTransfer(TypedDict): xftpRcvFile: NotRequired["XFTPRcvFile"] fileInvitation: "FileInvitation" fileStatus: "RcvFileStatus" + fileType: "FileType" rcvFileInline: NotRequired["InlineFileMode"] senderDisplayName: str chunkSize: int # int64 @@ -2709,7 +2735,7 @@ class RelayProfile(TypedDict): shortDescr: NotRequired[str] image: NotRequired[str] -RelayStatus = Literal["new", "invited", "accepted", "active", "inactive", "rejected"] +RelayStatus = Literal["new", "invited", "accepted", "acknowledgedRoster", "active", "inactive", "rejected"] ReportReason = Literal["spam", "content", "community", "profile", "other"] @@ -3493,7 +3519,7 @@ class UserContactRequest(TypedDict): cReqChatVRange: "VersionRange" localDisplayName: str profileId: int # int64 - profile: "Profile" + profile: "LocalProfile" createdAt: str # ISO-8601 timestamp updatedAt: str # ISO-8601 timestamp xContactId: NotRequired[str] diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 5f3d2bf332..8441560013 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -583,6 +583,8 @@ const processCommand = (function () { case "capabilities": console.log("starting outgoing call - capabilities") if (activeCall) endCall() + // Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here) + stopNotConnectedCall() let localStream: MediaStream | null = null try { @@ -623,7 +625,8 @@ const processCommand = (function () { if (activeCall) endCall() // It can be already defined on Android when switching calls (if the previous call was outgoing) - notConnectedCall = undefined + // Stop its preview tracks before clearing, otherwise camera/mic stay live + stopNotConnectedCall() inactiveCallMediaSources.mic = true inactiveCallMediaSources.camera = command.media == CallMediaType.Video inactiveCallMediaSourcesChanged(inactiveCallMediaSources) @@ -1444,6 +1447,14 @@ const processCommand = (function () { } } + // Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live. + function stopNotConnectedCall() { + if (notConnectedCall) { + notConnectedCall.localStream.getTracks().forEach((track) => track.stop()) + notConnectedCall = undefined + } + } + function resetVideoElements() { const videos = getVideoElements() if (!videos) return diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts index eac659a17a..862c727bd5 100644 --- a/packages/simplex-chat-webrtc/src/desktop/ui.ts +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -2,8 +2,8 @@ useWorker = typeof window.Worker !== "undefined" isDesktop = true -// Create WebSocket connection. -const socket = new WebSocket(`ws://${location.host}`) +// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server. +const socket = new WebSocket(`ws://${location.host}${location.search}`) socket.addEventListener("open", (_event) => { console.log("Opened socket") diff --git a/plans/2026-05-26-public-groups-via-relays-unified.md b/plans/2026-05-26-public-groups-via-relays-unified.md new file mode 100644 index 0000000000..91f7c3a6ce --- /dev/null +++ b/plans/2026-05-26-public-groups-via-relays-unified.md @@ -0,0 +1,227 @@ +# Plan: Public groups via relays (unified) + +Date: 2026-05-26 + +This plan is self-contained. It supersedes `2026-05-08-public-groups-via-relays.md` and folds in the privileged-roster mechanism understood since. Implementers should work from this document alone. File:line anchors are current as of this date — confirm before editing. + +## Overview + +Channels (shipped) are relay-mediated groups where the relay forwards content from owners only; subscribers are pinned to `GRObserver` and cannot post. Public groups are the second value of the same two-axis design: same wire, same transport, but every member can post, and there are moderators/admins who can act. + +| `useRelays` | `groupType` | Name | Posting | Notes | +|---|---|---|---|---| +| `false` | (none) | Secret group | all members | today's P2P full-mesh group | +| `true` | `GTChannel` | Channel | owners only | shipped; subscribers anonymous to each other | +| `true` | `GTGroup` | **Public group** | every member | **new**; member-to-member DMs deferred | +| `true` | `GTUnknown _` | (refuse) | — | newer-client link seen by older client → refuse to join | + +Three concepts, kept distinct: **transport** = `useRelays` (topology, batch, signatures, delivery); **governance** = `groupType` (who may post, member affordances); **joiner role** = the default role new joiners get, set by the owner on the signed profile. + +Two things make public groups work and neither exists today: + +1. **The joiner role must come from the owner-signed profile**, not a relay-side global config — otherwise relays disagree on the default role. (Section 2.) +2. **Members must learn who the moderators/admins are — their identity, signing key, and role — in a way a relay cannot forge.** Today a relay can fabricate a moderator (Section 1, Problem). This is the load-bearing piece and ships first. + +`GTGroup`, `PublicGroupProfile`, `useRelays'`, and the relay/signing/forwarding machinery already exist (anchors below). The work is additive. + +--- + +# Section 1 — Privileged roster (`XGrpRoster`) + +This is a **general relay-group mechanism**: public groups use it now; channels inherit it for their multi-owner/moderator future. It is the first, self-contained task. + +## 1.1 Problem and trust model + +Owners are trusted because their keys come from the **link**, never the relay: on join, `createLinkOwnerMember` (`Store/Groups.hs:3072`) writes each owner's `member_pub_key` from the link's `OwnerAuth` chain, validated against `publicGroupId == sha256(rootKey)`. `xGrpMemIntro` even nulls the key when `mRole == GROwner` (`Subscriber.hs:3029`): *"owner key must only come from link data, not from relay intro."* + +Non-owner privileged members have no such anchor. Today `xGrpMemIntro` **keeps** the relay-asserted key for `GRModerator`/`GRAdmin`, and `introduceInChannel` (`Internal.hs:1165`) introduces all of `getGroupModerators` (which returns mod+admin+owner, `Store/Groups.hs:1190`). So a malicious relay can assert "X is a moderator, here is X's key," and the subscriber will then trust the relay-chosen key to verify X's signed administrative actions (`XGrpMemDel`, `XGrpMemRestrict`, `XGrpMemRole`). The `when (memRole > GRMember)` gate in `xGrpMemNew` (`Subscriber.hs:2957`) blocks the *dissemination* path but not the *join-time intro* path — the protection is half-applied. Dormant for channels (single-owner broadcast), activated by public groups. + +**Conclusion:** a non-owner privileged member's `(memberId, name, key, role)` must be **owner-signed**, exactly like owners are link-signed. That is the roster. + +## 1.2 Wire event and signing + +New event `XGrpRoster` (add to `ChatMsgEvent`, `Protocol.hs:422`), JSON-encoded, carrying: + +- `version :: Word32` — monotonic, from 0. +- `roster :: [{ memberId, name, key, role }]` — the complete current privileged set, `role ∈ {GRModerator, GRAdmin}`. `name` is a display name only (to avoid ugly "unknown member" records, as `XGrpMsgForward` already carries one). Owners are **not** in the roster. + +Add `XGrpRoster_` to `requiresSignature` (`Protocol.hs:1231`) ⇒ `True`. This makes the owner sign it via the existing `groupMsgSigning` (`Internal.hs:1962`, binding `CBGroup <> (publicGroupId, ownerMemberId)`, key `groups.member_priv_key`) and makes recipients require a valid owner signature via `withVerifiedMsg` (`Subscriber.hs:3461`). No new crypto. + +**The handler MUST assert the resolved author is an owner** (`memberRole' author == GROwner`). `withVerifiedMsg` verifies the signature against the *author's* key, and the relay chooses `fwdSender` — so without this assertion a relay could route a roster as a member whose key it controls and the signature would verify. Owners exist on recipients only via the link `OwnerAuth` chain, so a relay can neither fabricate an owner nor sign as one. This assertion is the crux of the roster's integrity. + +## 1.3 Authoritative model — versioned snapshot, latest-wins, TOFU keys + +Each `XGrpRoster` is the complete current privileged set. Recipients treat the highest-version valid roster as authoritative for *who is privileged and their keys*; absence from the newest accepted roster means *not privileged* (reverts to the joiner default unless an accompanying `XGrpMemRole` sets a specific role — see 1.6). This is self-healing: a member who missed one change gets the full current state on the next roster. + +**Key handling is trust-on-first-use, pinned per `memberId`** (per entry): + +- `memberId` unknown, or known without a key → store the key (first sight, from the owner). Set name/role. +- `memberId` already has a key: + - same key → fine; update name/role. + - **different key → error.** Never overwrite; keep the old key; surface a suspicious-roster event. + +There is **no in-place key rotation**: a genuine re-key is modeled as a *new member* — the owner removes the old `memberId` (`XGrpMemDel` + roster drop) and adds a new `memberId` with the new key. Consistent with SimpleX's no-mutable-identity stance, and with the `xGrpMemNew` rule in 1.5. + +**Anti-replay / rollback.** The relay cannot forge a signed roster but can replay an older one. + +- The current roster `version` is anchored in the owner-controlled link mutable data (which already holds `OwnerAuth`, profile, subscriber count). The relay cannot forge it. A roster change that bumps the version also updates link data. **Status:** the write side is implemented; the join-time **read/detect** is deferred — comparing the anchor against the relay-served roster at join is racy (the forwarded roster may not have arrived yet → false positives), so correct staleness detection must be triggered by roster arrival, not at join. The residual new-joiner rollback gap below stands; the hard anti-replay (member + relay) is in place. +- **Existing members** reject any roster with `version` below the highest already accepted — full anti-replay for them. +- **New joiners** process the latest version the relay actually serves, even if it lags the link anchor, so honest relay propagation lag never blocks a join. The anchor is used for staleness *detection*, not a hard gate, in v1. +- **Documented residual gap:** a stale/malicious relay can serve an old-but-valid roster to a brand-new joiner. Documented in `channels-overview.md` with future mitigations: escalate verification to an owner, or have the client compare the relay's version to the link anchor and refuse/retry above a staleness threshold. + +## 1.4 Cap on the privileged set + +Bound the privileged set so the signed roster always fits one message — never paginate. A hard **cap on moderators + admins** (owners are on the link, not counted), enforced **at promotion time** on the owner: refuse to elevate beyond the cap with a clear error, so the roster is always constructible as one signed message. + +Derive the number from the single encoded-message budget (verify the exact constant — the encoded-message-length limit minus signature + JSON overhead) divided by worst-case entry size. With `{memberId, name, key, role}` entries this is comfortably in the tens-to-~100 range; pick the final value from the measured worst-case entry. + +## 1.5 Remove the dissemination gates; gate on the roster instead + +Because the relay forwards the roster on join **before anything else**, a privileged member's key/role is owner-established before any relay-asserted introduction arrives. So the relay may now disseminate privileged members' full profiles like any other member, and the gates come out: + +- **Remove** the `when (memRole > GRMember)` throw in `xGrpMemNew` (`Subscriber.hs:2957`). +- **Remove** the forward-side `memberRole' s <= GRMember` filter in `sendBodyToMembers` (confirm exact site in `Subscriber.hs`). +- **Replace** with a roster check in `xGrpMemNew`: for an announcement of a privileged role, require that a member record with that `memberId` already exists **with that privileged role** (roster-established). If found → accept the **profile** update only; **never overwrite `key`, `memberId`, or `role`** (roster-authoritative). If not found → reject (a relay conjuring a privileged member not in the roster). + +`introduceInChannel` forwards the cached roster to the new member first, then proceeds; it may still announce the newcomer to moderators and maintain the relations vector. The per-mod `XGrpMemIntro` carrying keys is no longer the trust path for privileged members. + +## 1.6 Delivery — what is sent, when, to whom + +Two orthogonal axes, and the roster owns only one: + +- **Axis A — privileged set + keys** (who is mod/admin, their key/name/role): owned by the roster. +- **Axis B — group-membership lifecycle** (removed / restricted / left): owned by `XGrpMemDel` / `XGrpMemRestrict` / `XGrpLeave`, unchanged, applies to everyone. + +Dispatch by whether an operation touches the {moderator, admin} set (the *roster roles*). Owner is not a roster role — promotion to/from owner uses `XGrpMemRole` (+ link `OwnerAuth`), never the roster. + +**`APIMembersRole` (role change, possibly batched):** + +- Emit `XGrpMemRole(M, target)` for each affected member exactly as today — this conveys the exact target role for any role, including owner and specific ≤member roles. +- **Additionally** build and **broadcast** the full signed roster (version++) **iff the {mod, admin} set changed** (any member entered, left, or moved within mod/admin). +- A mixed batch fires both. Example: target=member over `[moderator M, observer O]` → `XGrpMemRole` for both (exact roles) **and** a roster (M left the set). The promotion case `XGrpMemRole(M, mod)` + roster is mildly redundant on the role field and harmless; the key comes only from the roster. + +The broadcast reuses the existing owner-admin-event forwarding (`shouldForward = isUserGrpFwdRelay gInfo && not forwarded`, `Subscriber.hs:3191`). Privileged-set changes are rare administrative events, so this is on the order of an `XGrpInfo`/`XGrpPrefs` broadcast — not per-message. The broadcast roster is the **self-healing** mechanism: a member who missed a prior `XGrpMemRole` is corrected by the snapshot. + +**`XGrpMemDel` (removal):** broadcast `XGrpMemDel` as today (it neutralizes the member for existing members). If the removed member was privileged, the owner sends a refreshed roster (version++), which the relay broadcasts like any other version bump (see below). + +**Relay broadcast rule — always broadcast on a strict version bump.** A newer-version roster is applied, cached, and broadcast to current members, uniformly — promotion, key/role change, demotion, or privileged removal. We do **not** try to make removal cache-only: a demotion (member stays in the group) is indistinguishable from a deletion at the roster-diff level, so suppressing the broadcast would silently drop the self-healing the spec requires for role changes. The only cost is one redundant roster broadcast alongside `XGrpMemDel` on the rare deletion of a privileged member — and even there the broadcast is not waste, since it self-heals the privileged-set side if the `XGrpMemDel` was lost. (This supersedes an earlier "cache-only on deletion" idea, which could not be implemented without either a wire flag or a fragile demotion-vs-deletion diff.) + +**On relay add:** the owner sends the current roster to the new relay so it can serve joiners. + +**Joiners:** the relay forwards the cached roster at join (1.5). + +**Short offline gaps** are covered by ordinary queued delivery: the role-change roster broadcast sits in the member's SMP queue (FIFO) ahead of any later moderator events, so it is processed first on reconnect. + +**Quota-blocked catch-up.** A member offline long enough to fill its queue causes the relay to be quota-blocked — the broadcast may never have been enqueued, so naive queueing would leave the member without the current roster, rejecting moderator events indefinitely. Fix: when the queue drains, the relay **sends the current cached roster ahead of the resumed backlog**, so the member holds the current privileged set before processing the events it couldn't verify. + +The hook is confirmed: QCONT is delivered to the **sender** when the recipient drains (`simplexmq Agent.hs:3402`), and the relay receives it per subscriber in the group-member connection handler (`Subscriber.hs:1215` — `continueSending` + `sendPendingGroupMessages user gInfo m conn`, with `gInfo`/`m`/`conn` in scope; a relay→subscriber connection is a group-member connection). Implementation must ensure **roster-first ordering** relative to both the agent-level `continueSending` flush and the re-driven delivery tasks, and gate the extra send on a per-member "delivered roster version" so it fires only when the member is behind. + +The roster is **never** delivered through the profile-dissemination prepend. That path (`member_relations_vector` → `XGrpMemNew`) carries **profiles** only; with the gate removed (1.5), a privileged member's profile disseminates through it like any other member's, but only after the roster has established their key/role. Profile via prepend, key/role via roster — orthogonal, no double-prepend. + +## 1.7 Relay-side cache (the one new storage pattern) + +Relays already forward signed bytes verbatim (`encodeFwdElement` `Batch.hs:106`, `verifiedMsgParts` `Protocol.hs:1445`; `messages.msg_chat_binding` + `msg_signatures`; reconstructed in `toTask` `Delivery.hs:154`). What does **not** exist is "store the latest roster and re-emit to joiners" — `sendHistory` (`Internal.hs:1207`) reconstructs content and does *not* preserve signatures, so it is not a template. + +Add a small per-group cache holding the latest signed roster message bytes, plus the roster `version` as a **separate column** alongside them (so the relay compares versions without re-parsing the blob). On receiving `XGrpRoster` from an owner the relay: verifies the owner signature; **checks `version` strictly greater than the cached version** (lower → reject as rollback; equal → idempotent no-op); then updates its own member-role records, overwrites the cache + stored version, and (for a role-change-origin roster) creates a delivery task to all current members. On join, it forwards the cached bytes verbatim. + +The relay-side version check protects an **honest** relay's cache from being rolled back by a replayed signed roster — which in turn protects every joiner that relay serves. It does not constrain a **malicious** relay (it controls its own cache); that remains the documented new-joiner residual gap (1.3), bounded by the member-side check (1.3) and the link version anchor. + +## 1.8 Races and tests + +- **Promotion vs. action ordering.** A newly-promoted mod could act before its roster reaches a recipient ⇒ `RGEMsgBadSignature`. Covered for MVP by causal ordering (the roster is broadcast at promotion, before the mod learns of and acts on it), QCONT catch-up (item 7), and recipient tolerance (a rejected first action is re-sent; the next roster repairs trust). The fuller fix — the **roster-specific prepend** (prepend the cached signed roster ahead of a privileged member's forwarded action for recipients below the current version, reusing the item-7 delivered-version tracker, distinct from the `XGrpMemNew` profile prepend) — touches the hot per-recipient delivery loop that carries every forwarded message, so it is **deferred to a focused, separately-tested pass** rather than shipped untested. +- **Multi-owner roster signed by an unknown owner** (owner added after the recipient fetched the link): recipient cannot verify ⇒ buffer/refetch link. Cannot occur for single-owner MVP; flag for v7. +- **Roster vs. profile-update concurrency:** benign (different fields); verify the roster's relations-vector handling does not clobber the profile `MRIntroduced` semantics they share. + +Tests: relay-fabricated moderator key is rejected (forgery); promotion delivers a verifiable key; demotion via roster + `XGrpMemRole` reconciles; removed privileged member does not reappear for a new joiner; replayed older roster rejected by existing members; TOFU key-change rejected; batch `APIMembersRole` emits roster + `XGrpMemRole` correctly; self-healing after a dropped role event. + +## 1.9 Key anchors for Section 1 + +`ChatMsgEvent` `Protocol.hs:422`; `requiresSignature` `Protocol.hs:1231`; `groupMsgSigning` `Internal.hs:1962`; `withVerifiedMsg` `Subscriber.hs:3461`; `xGrpMemNew` `Subscriber.hs:2957`; `xGrpMemIntro` `Subscriber.hs:3015`; `introduceInChannel` `Internal.hs:1165`; `getGroupModerators` `Store/Groups.hs:1190`; `memberInfo` `Internal.hs:1187`; `createLinkOwnerMember` `Store/Groups.hs:3072`; `GroupKeys` `Types.hs:462`; `member_relations_vector` machinery in `Types/MemberRelations.hs` (`MemberRelation`, `MRIntroduced`, `IDSubjectIntroduced`, `setNewRelations`); forwarding `Batch.hs:106` / `Protocol.hs:1445` / `Delivery.hs:154`. New columns go in a **new tail migration** (`M20260222_chat_relays` is the *pattern* for relay group columns but is not the tail — never edit an applied migration; `M20260525_member_removed_at` is the current tail). + +--- + +# Section 2 — Joiner role on the signed profile + +Today the relay derives a joiner's role from `channelSubscriberRole` (`Controller.hs:161`, default `GRObserver` `Chat.hs:119`), a global config — so relays can disagree and the owner cannot set it per group. Move it onto the owner-signed profile. + +## 2.1 Types and helpers + +- Add `joinerRole :: Maybe GroupMemberRole` to `PublicGroupProfile` (`Types.hs:798`). No migration: JSON derives via `deriveJSON defaultJSON` with `omitNothingFields = True`, so `Nothing` is omitted on encode and a missing field decodes to `Nothing`. +- Add a `groupType` accessor and `isChannel` on `GroupInfo`/`GroupProfile`, and a resolver `joinerRoleFor :: GroupInfo -> GroupMemberRole` = `joinerRole` if set, else type-keyed default (`GTChannel → GRObserver`, `GTGroup → GRMember`, `GTUnknown _ → GRObserver`). Reuse the existing `publicGroupEditor`/`memberRole'` (`Types.hs:499`/`506`); do **not** introduce a profile-side `memberRole'` (name collision). + +## 2.2 Replace the global config + +Switch every `channelSubscriberRole` reader to `joinerRoleFor gInfo` and delete the config: `Controller.hs:161`, `Chat.hs:119`, `Commands.hs:2053`, `Commands.hs:2546`, `Subscriber.hs:3248`, `Subscriber.hs:4019`. Verify no out-of-tree consumer reads it. + +## 2.3 Command, preferences, defensive refusal + +- `APINewPublicGroup` (`Controller.hs:526`, handler `Commands.hs:2495`) gains `groupType` (default `GTChannel`) and optional `joinerRole` (default `joinerRoleFor` of the type); both written onto the constructed profile (today hardcodes `groupType = GTChannel` at `Commands.hs:2538`). +- Parameterize the channel-prefs parser by `GroupType`: Channel keeps its override (`support = OFF`); Public group and `GTUnknown` use secret-group defaults (member-to-moderator escalation is expected). Do not duplicate the parser — parameterize it. +- `directMessages` stays ON by inheritance but is dormant in any relay-mediated group; hide its toggle when `useRelays` and refuse `xGrpDirectInv` defensively when `useRelays'` (`Subscriber.hs:3321`, currently ungated): emit `messageError`, create no contact. + +## 2.4 Compatibility + +Existing channels: no `joinerRole` ⇒ falls back to `GRObserver` for `GTChannel`. No data migration. Older relays without this change resolve the joiner role from their global config — warn the owner at create time if a selected relay's chat version is below the public-groups version (soft warning, not a block). + +--- + +# Section 3 — Backend tests + +Public-group helpers paralleling the channel helpers, plus: + +1. Member posts; all members receive it (no "unknown member" lines). 2. Multi-author session: no "unknown member" anywhere. 3. Member edit/delete/react forwarded to all. 4. `xGrpDirectInv` refused under `useRelays` (no contact created); repeat for Channel. 5. Blocked member's messages not forwarded. 6. Multi-relay delivery with cross-relay dedup. 7. History on join. 8. `asGroup=true` from a non-owner rejected. 9. Receipts disabled above the member limit. 10. Older client refuses `groupType = "group"` (needs-newer-version). 11. Incognito member posting attributes the incognito profile. 12. `joinerRole` propagates and defaults correctly (Channel→observer, Public group→member; absent→type default). Plus the roster tests in 1.8. + +--- + +# Section 4 — Clients (iOS, then Kotlin) + +## 4.1 Audit `useRelays` vs `isChannel` (structural commit, on its own) + +~70–75 sites per platform branch on `useRelays` as a proxy for "is a channel." Split per a mechanical rule and land as a pure structural commit (no behavior change in the same diff): + +- **Transport** (keep `useRelays`): link/relay management, owner-can't-leave-own-relay-group, relay-status indicator, incognito flag, typing-state gating, member-DM-affordance suppression. +- **Governance** (switch to `isChannel`): titles, "subscribers" vs "members" framing, "Channel preferences" labels, channel-style vs group-style member display. + +## 4.2 Model and behavior + +- Model: add `group` arm to `GroupType` (with serializers); `joinerRole`, `groupType`, `isChannel` accessors. Authoritative role resolution stays in Haskell; clients use it for display. +- **Narrow the existing refusal:** PR #7009 (merged to `stable`) added `GLPUpdateRequired` for `groupType /= GTChannel` (`Controller.hs:1051`, `Commands.hs` `unsupportedGroupType`). Change it to refuse only `GTUnknown _`; `GTGroup` proceeds to a public-group join. +- Create flow: one view with a Channel / Public-group segmented control (default Channel) driving the title, link-step label, success screen, and two API params (`groupType`, `joinerRole` = observer for Channel, member for Public group — no role picker in MVP). Hide `directMessages` in create prefs when `useRelays`. Render the threat-model note below the title for Public groups (text in Section 5). +- Suppress the member-tap "send direct message" affordance in any relay-mediated group. +- Members view shows the relay-known roster; header "subscribers" (channel) vs "members" (public group). No filtered view in MVP. +- Strings/icons: ~5–10 `_public_group` string keys mirroring channel forms; reuse `group_members_*` for "members" framing; a distinct Public-group icon (pending design). Kotlin-only: chat-list filter chips place Public groups in the "groups" bucket. + +Platforms ship independently (API defaults are backward compatible). + +--- + +# Section 5 — Threat model, docs, release + +Fold into `channels-overview.md` (public groups inherit the entire channel threat model; deltas only): + +- **A relay can fabricate content as any member** (channels: only as owners). Content (`XMsgNew`/`Update`/`Del`/`React`) is unsigned by design for deniability (`requiresSignature` lists roster/admin events only); broader blast radius in public groups. Detectable via cross-relay consistency. Mitigation is the future opt-in content signing on the channel roadmap; the create-flow note states the trade-off ("a malicious relay could change or fabricate messages from any member — pick relays you trust, or use a secret group for peer-to-peer integrity"). +- **Roster rollback for new joiners** (1.3): documented bounded delta + future mitigations. +- Unchanged: relay cannot impersonate an owner or substitute the profile (signed events, validated entity ID); `joinerRole` and the privileged roster are owner-signed, so the relay cannot unilaterally change a joiner's default role or fabricate a moderator. + +Document `XGrpRoster` (event, signing, versioning, TOFU, delivery) in `channels-protocol.md`. Bump the chat protocol version (the public-groups version that gates `GTGroup` and `joinerRole`). Release notes include the relay-fabrication line. + +--- + +# Sequencing + +1. **Section 1 — privileged roster** (backend). The core; gates the rest of the value. Land the gate-removal/roster-check and the event together so no half-applied trust window exists. +2. **Section 2 — joiner role on profile** (backend). Independent of Section 1. +3. **Section 3 — backend tests** (alongside 1–2). +4. **Section 4 — clients**: audit (structural) first, then iOS, then Kotlin. +5. **Section 5 — docs/version/release** with the backend release. + +Backend (1–3) gates the clients. iOS and Kotlin are independent of each other. + +--- + +# Out of scope (deferred) + +- **Member-to-member DMs in relay-mediated groups.** Prohibited here (client affordance suppressed, receive-path refusal, relay does not forward `XGrpDirectInv` — so no relay-visible DM graph). A future plan must re-derive the threat model: relay-forwarded DMs would expose (sender, target, time) metadata; relay-blind rendezvous via per-member queues is the privacy-preserving alternative. +- **`memberAdmission` on relay-mediated join** (hardcoded `GAAccepted` bypasses review/captcha — generic relay-groups gap). +- **Roster filter/pagination in the members view** for very large groups. +- **Multi-owner** roster signing/verification and owner promotion via link `OwnerAuth` (v7); **opt-in content signing** (v7 roadmap); **full anti-rollback for new joiners** (link-version hard gate). diff --git a/plans/2026-06-01-roster-members-multipart.md b/plans/2026-06-01-roster-members-multipart.md new file mode 100644 index 0000000000..aca98c4698 --- /dev/null +++ b/plans/2026-06-01-roster-members-multipart.md @@ -0,0 +1,220 @@ +# Roster: regular members + larger rosters via inline file + +Date: 2026-06-01 (revised). Extends Section 1 of `2026-05-26-public-groups-via-relays-unified.md`. + +> Anchors below were re-verified against `f/public-groups-members-in-roster` **after** PR #7036 (`core: signed XMember in public group`, commit `0773ccd05`) merged in. Most line numbers shifted; the header-fits check uses `maxEncodedMsgLength = 15602` (now `Protocol.hs:905`). Confirm before editing. + +## Reconciliation with PR #7036 (merged into this branch) + +PR #7036 landed things this plan predates. Read this section first — it changes the relay flow the plan builds on. + +**Renames (the plan's old names no longer exist — grep will miss them):** + +| Was | Now | Location | +|---|---|---| +| `forwardCachedRoster` | `forwardGroupRoster` | `Internal.hs:1172` | +| `setCachedGroupRoster` | `setGroupRoster` | `Store/Groups.hs:1415` | +| `getCachedGroupRoster` | `getGroupRoster` | `Store/Groups.hs:1428` | +| `setRelayLinkAccepted` | `setRelayKey` (no longer sets relay status) | `Store/Groups.hs:1543` | + +"Cached roster" is now "saved roster" throughout; the `roster_msg_*` columns and the `roster_blob` this plan adds are unchanged in intent. + +**Roster version baseline is now `Just 0`, not NULL, for relay groups.** `createNewGroup` initializes `roster_version = Just (VersionRoster 0)` for `useRelays` groups (`Store/Groups.hs:365, 427`), and an old channel materializes `0` the first time a relay connects (`Subscriber.hs:905-910`). Consequences: the first promotion bumps `0 -> 1` (not NULL -> 0); the owner and already-onboarded members/relays compare against a real `0`, **but a relay's own `roster_version` is still NULL the first time it receives v0** — applying v0 from NULL is exactly what lets it ack and become publishable (verified: today's `maybe False (newVer <=) (rosterVersion gInfo)` at `Subscriber.hs:3207` applies v0 only because the relay is at `Nothing`; `Just 0` would reject it and the relay would hang `RSInvited`). So the multipart version guards MUST be `Maybe`-comparisons that treat `Nothing` as below `0` (spelled out under *Header handler* and *Completion*); and the **empty roster (v0)** must round-trip through the header+blob path (a 2-byte blob: `Word16` count `0`, one chunk, `chunkSize >= fileSize` -> `RcvChunkFinal` on chunk 1). The empty/small blob is the *common* case for relay onboarding, not an edge case. + +**NEW relay roster-ack handshake (`XGrpRosterAck`) — this plan MUST integrate it.** PR #7036 added `XGrpRosterAck :: VersionRoster -> Maybe Text` (`Protocol.hs:499`; tag `x.grp.roster.ack`; NOT in `requiresSignature` — it rides the relay's authenticated connection). Flow: + +- On relay connect (`GCInviteeMember` + `isRelay`) the owner **always** sends the current roster via `sendGroupRosterToRelay`, and the relay stays `RSInvited` (**unpublishable**) until it acks (`Subscriber.hs:900-910`). +- The relay applies the roster in `relayApplyRoster` and, **only while its own status is `RSAccepted`**, sends `XGrpRosterAck author newVer Nothing` (or an error string) — `Subscriber.hs:3210-3221`, `sendRosterAck` at `3276`. +- The owner's `xGrpRosterAck` handler (`Subscriber.hs:3279-3297`) transitions the relay `RSInvited -> RSAccepted` (and publishes via `setGroupLinkDataAsync`) on a version-matching success ack, or `RSInvited -> RSRejected` on error. + +Impact on the multipart design (a REQUIRED change, not just a rename): under this plan the header only *starts* a transfer, so on a relay the **apply, `setGroupRoster`, the ack, AND the broadcast all move to blob completion**, not header receipt. The relay becoming publishable now **gates on the blob transfer completing**: header -> chunks -> verify digest -> `processRoster` + `setGroupRoster` -> (`relayOwnStatus == RSAccepted`) `sendRosterAck` -> broadcast. This is the desired fail-safe (an unpublishable relay can't serve a half-applied roster), but it puts owner->relay blob delivery on the relay-onboarding critical path, not just self-healing. The error branch MUST still ack-with-error (digest mismatch / parse failure -> `sendRosterAck author newVer (Just "...")`) so the owner marks the relay `RSRejected` instead of leaving it hung at `RSInvited`. The current `relayApplyRoster` to fork is `tryAllErrors (setRoster sm)`, where `setRoster` = `processRoster` + `setGroupRoster` (`Subscriber.hs:3205-3228`); the multipart version splits this across header (write `roster_pending_*`) and completion (run `setRoster` + ack + broadcast). + +## Goal + +Let owners promote channel subscribers to **regular members** who can post, and carry more named members than fit one message. + +The JSON roster already exists (event, signing, relay cache, TOFU apply, broadcast, join forward, QCONT). This plan **widens the roster set to include plain members and changes the delivery** to a binary blob over the inline file transfer; the apply logic (`processRoster`) is reused. + +The member list moves out of the `XGrpRoster` message into a binary blob sent over the existing inline file transfer. `XGrpRoster` becomes a small signed header (version + the blob's size and digest). + +## Roster set: the promoted set {member, mod, admin} + +Owners stay on the link, never in the roster. Two edits, then every gate follows. + +**1. Widen `isRosterRole`** (`Internal.hs:1237`) to `{GRMember, GRModerator, GRAdmin}`. Every call site wants the promoted set, so this single edit covers: + +- `validateGroupRoster` filter (`Internal.hs:1243`) — fixes the bug where member entries are dropped. +- `buildGroupRoster` filter (`Internal.hs:1255`). +- promotion gates / cap / trigger / counts (`Commands.hs:2737, 2739, 2746, 2762, 2763, 2768`); update the cap error text at `2740`. +- owner-remove roster refresh (`Commands.hs:2888`, guarded by `anyPrivilegedRemoved` computed from `isRosterRole` at `2899`) — so removing a plain member, not just a mod/admin, refreshes the roster. +- receive gates: `xGrpMemNew` (`Subscriber.hs:3011/3026/3045`) and `xGrpMemRole` owner-only (`3185`). +- **join key-proof gate (NEW in PR #7036, `Subscriber.hs:1620`, `memberJoinRequestViaRelay`)**: a join claiming a `memberId` already roster-established as `isRosterRole` must prove possession of the pinned key (signature + `memberPubKey` match + `viaRelay == this relay's memberId`). Widening to members **extends this proof to promoted members** — a promoted member re-connecting through a relay must sign its `XMember` with the roster-pinned key. This is correct and desirable (it is the receive-side counterpart of the promote-time key invariant in *Known limitations*), and it composes with PR #7036's `acceptGroupJoinRequestAsync existingMem_` path that attaches the connection to the existing roster record. Confirm the promoted member signs its join `XMember` (it does — `encodeXMemberConnInfo`, `Internal.hs`). + +**2. Split the role query.** `getGroupRosterMembers` (`Store/Groups.hs:1215`) currently serves two now-diverging needs: + +- **Build / revert** wants the promoted set. Redefine `getGroupRosterMembers` to `member_role IN (GRMember, GRModerator, GRAdmin)` (current members). Callers: `bumpAndBroadcastRoster` (`Internal.hs:2175`), `sendGroupRosterToRelay` (`2188`), and the `processRoster` revert set `currentPriv` (`Subscriber.hs:3245`). Build and revert MUST be the same query, or a dropped member is never reverted. +- **`introduceInChannel`** (`Internal.hs:1188`) wants only the moderation set (mod+admin). Widening it would announce every joiner to every member and introduce every member to every joiner (traffic + anonymity blowup). Reuse the existing `getGroupModerators` (`Store/Groups.hs:1204-1209`, returns mod+admin+owner) rather than adding a function: keep `getGroupOwners` for the owner-first intro, and take mod+admin as `getGroupModerators` minus owners. **Re-apply `filter memberCurrent`** — `getGroupModerators` does NOT filter current members (unlike the old `getGroupRosterMembers`), so without it a removed or left moderator would be introduced to joiners. Members are learned from the roster blob, not introductions. + +**Owner-only (confirmed decision).** Only the owner changes any roster role. The alternatives were considered and rejected for v1 — letting a mod/admin set member roles would need either the owner co-signing rosters from a mod/admin (owner round-trip + load) or a separate roster-signing key trusted from mod/admin (broader trust surface) — so owner-only keeps the single owner-key trust anchor. + +**Leave and owner-remove differ.** This plan **removes** the `xGrpLeave` roster-bump block (`Subscriber.hs:3439`): since `isRosterRole` is widened, it would otherwise fire `bumpAndBroadcastRoster` on every plain-member leave. So a member **leave** does NOT bump the roster — the leave is the membership axis (`XGrpLeave` neutralizes the member on the relay). An owner **remove** (`APIRemoveMembers`) DOES still bump via `bumpAndBroadcastRoster` (`Commands.hs:2888`, widened to cover plain members). `bumpAndBroadcastRoster` thus stays only for promotion (`APIMembersRole`) and owner-remove. + +## Wire: signed header + unsigned blob + +**Authoritative metadata is in the signed header.** `version`, blob `fileSize`, and `fileDigest` all live in the owner-signed `XGrpRoster`; the unsigned `BFileChunk`s carry no authoritative metadata. (This is why "total parts in the unsigned part", an earlier review question, is a non-issue here.) + +- **Header**: `XGrpRoster { version :: VersionRoster, fileInv :: InlineFileInvitation }`, JSON, signed, forwarded. `InlineFileInvitation { fileSize, fileDigest :: FD.FileDigest }` is a lean `FileInvitation` (no name/connReq/inline/descr; always inline). Tiny; fits `maxEncodedMsgLength` (15602, `Protocol.hs:905`). +- **Blob**: the binary member list. `RosterMember { memberId, key, role, privileges :: Word16 }` — drop `name`, add `privileges` (reserved: always `0`, parsed and ignored in v1). ~60 B/entry. Members get a placeholder name from `nameFromMemberId`; real profiles arrive on first post. +- **Serializer/parser**: a binary codec for the blob (a `Word16`-count-prefixed `[RosterMember]`); `RosterMember` becomes binary-only. Full code in *Blob format* below. Owner serializes → digest → chunks; receiver concatenates chunk bytes → verifies the digest → parses. +- **Cap** `maxGroupRosterSize` → **256** (tunable). Enforce at promotion over the promoted set (`Commands.hs:2739`, via the widened predicate); the receive-side entry-count bound is the parser alone (`rosterBlobP`'s `n > maxGroupRosterSize`); reject a signed `fileSize > cap × max-entry-size` before creating a file. Roster files are exempt from the inline `offer/receiveChunks` ceiling (at 256 ≈ 15 KB the blob is about one `fileChunkSize` chunk; the multipart path handles two if role words push it over). + +**Type changes.** + +- `GroupRoster` (`Protocol.hs:372-376`): `{version, roster :: [RosterMember]}` → `{version, fileInv :: InlineFileInvitation}`. It stays JSON (the signed header), so `InlineFileInvitation` needs a JSON instance; update its stale doc comment ("Owner-signed snapshot of the privileged (moderator/admin) set"). +- `RosterMember` (`Protocol.hs:378`): drop `name`, add `privileges :: Word16`; remove `deriveJSON` (`Protocol.hs:812`) — binary-only now — and add the `Encoding` below. `buildGroupRoster`'s constructor (`Internal.hs:1255`, currently `name = memberShortenedName m`) drops the `name` field; the consumer side already maps to `nameFromMemberId`. +- `validateGroupRoster` (`Internal.hs:1241-1242`): was `GroupRoster -> GroupRoster` over `.roster`; now `[RosterMember] -> [RosterMember]`, run on the parsed blob. + +### Blob format (serializer / parser) + +`RosterMember` is **binary-only** (carried in the blob, never in a JSON message) and gets the `Encoding` below. `MemberKey` (`Types.hs:972`, only `StrEncoding`) and `GroupMemberRole` (`Types/Shared.hs:33`, only `TextEncoding`) lack a binary `Encoding`: `MemberKey` delegates to the underlying `PublicKey` (`Crypto.hs:568`), and the role delegates to its canonical `TextEncoding` (the same `"member"/"moderator"/"admin"` form JSON and the DB use — single source of truth; `GRUnknown` round-trips). + +```haskell +-- MemberKey gains a binary Encoding (it only had StrEncoding); delegate to the Ed25519 key. +instance Encoding MemberKey where + smpEncode (MemberKey k) = smpEncode k + smpP = MemberKey <$> smpP + +-- General instance (belongs beside GroupMemberRole's TextEncoding in Types/Shared.hs, not here). +instance Encoding GroupMemberRole where + smpEncode = smpEncode . textEncode + smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP + +-- Tuple encoding (Encoding (a,b,c,d), Encoding.hs:192), as GrpMsgForward / FwdSender do. +instance Encoding RosterMember where + smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges) + smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP + +-- Blob = Word16 count (NOT smpEncodeList: its 1-byte count overflows at the 256 cap) followed +-- by that many entries. This is the byte sequence the digest is computed over and verified +-- against before parsing. +encodeRosterBlob :: [RosterMember] -> ByteString +encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms) + +rosterBlobP :: Parser [RosterMember] +rosterBlobP = do + n <- fromIntegral <$> smpP @Word16 + when (n > maxGroupRosterSize) $ fail "roster: too many entries" + A.count n smpP +``` + +- **Owner**: `encodeRosterBlob` over the promoted set → `FileDigest` (SHA-512, as the file machinery computes it, `LC.sha512Hash`) → chunk; the digest goes in the signed `XGrpRoster` header. +- **Receiver**: concatenate chunk bytes → verify the digest (S1, over plaintext) → `parseAll rosterBlobP` (consume all input; reject trailing bytes). Parsing runs only after the digest matches, so the bytes are owner-attested; the `n > maxGroupRosterSize` guard and `parseAll` are defensive against a buggy/garbled blob. +- **Per-entry layout**: `memberId` (1-byte len + id) + `key` (1-byte len + Ed25519 pubkey) + role (1-byte len + role word, e.g. `member` = 7 B) + `privileges` (2 bytes) ≈ ~60 B/entry. The file-transferred blob has no tight size budget, so canonical text is fine. +- `privileges` is reserved: serialized as `0`, parsed and ignored in v1. + +## Delivery: send → header → chunks → completion + +### Owner send + +`bumpAndBroadcastRoster` and `sendGroupRosterToRelay` build the blob (`buildGroupRoster` over the widened query), compute its `FileDigest`, send the `XGrpRoster` header, then send the blob as `BFileChunk`s against that message's `shared_msg_id`. + +`sendFileInline_` reads from a file, so add a send-from-bytes variant (shared with the relay re-serve). The owner's own version bump stays as today (in `bumpAndBroadcastRoster`, `Internal.hs:2176`) — the owner is the source of truth; "bump only at completion" is a receive-side rule. + +### Header handler (`xGrpRoster`, member and relay) + +The header no longer applies anything — it starts a transfer. It only writes `roster_pending_*`; it never writes `roster_version` or the live `roster_msg_*`. + +- **Short-circuit** unless `version > max(roster_version, roster_pending_version)` — strictly greater than both applied and pending — before creating a file. These are **`Maybe` comparisons: `Nothing` (un-materialized version) counts as below `0`** (mirror today's `maybe False (newVer <=) …`, `Subscriber.hs:3207`), so a relay's first receipt at NULL **applies** v0 while a re-receive at `Just 0` short-circuits — the v0 onboarding depends on this. + - Why both: the QCONT re-serve is unconditional, so the relay may re-forward a still-cached v5 while a member is mid-receiving v6 (applied 4). Compared only to applied, v5 > 4 would supersede v6, then the arriving v6 chunks fail the v5 digest → stuck. + - Why never bump here: a header-time bump makes the genuine blob complete as an equal-version no-op, leaving the receiver at `vN` with `v(N-1)` data. +- **Create the rcv-file** with `cryptoArgs = Nothing` (see Security), `file_type = roster`, `chat_item_id` NULL, `shared_msg_id` = the header's id. Accept it via `startRcvInlineFT` (chat-item-free), not `acceptRcvInlineFT`, so chunk 1 isn't rejected on `RFSNew`. +- **One in-flight per group is automatic**: the single `groups` row makes `roster_pending_*` single-valued, and there is one `(group_id, file_type = roster)` file. A duplicate header is idempotent. A version greater than both applied and pending supersedes: `UPDATE roster_pending_*` and delete the existing roster file (cleanup below), then create the new. + +### Chunks + +The header is enqueued before chunk 1 (per-connection FIFO). + +**Reset-on-chunk-1** (decision 4): if chunk 1 arrives with partial chunks, discard and restart so relay restart / re-subscribe / QCONT can re-drive from the start. Discarding MUST (GAP 3): + +- delete the `rcv_file_chunks` rows, +- truncate/remove the on-disk file, and +- evict its handle from the `rcvFiles` map (`closeFileHandle`). + +`appendFileChunk` opens in AppendMode and caches the handle (`Internal.hs:1781`, handle at `1794`), so clearing only the rows would append after the stale bytes and corrupt the blob (digest fails — the stuck state decision 4 avoids). + +**Orphaned chunk**: a roster `BFileChunk` matching no in-flight `(group_id, shared_msg_id, file_type = roster)` file is **ACKed and ignored**, never errored (the version is already applied or superseded). This is how an up-to-date member tolerates the unconditional re-serve: the re-served header short-circuits (no file), then its chunks arrive with no transfer in flight. Distinct from reset-on-chunk-1, which fires only when partial chunks exist. + +### Completion (on `RcvChunkFinal`) + +1. Verify the assembled file's digest against `roster_pending_digest`. On mismatch, discard (delete the file, clear `roster_pending_*`); do not apply or bump. +2. **Version guard**: apply only if `roster_pending_version > roster_version` (same `Maybe` semantics — a `Nothing` applied version counts as below `0`, so a first v0 completion from NULL applies). A stale/out-of-order completion is rejected, not applied as a downgrade. +3. Parse → `validateGroupRoster` → `processRoster` (TOFU keys, role updates, revert absent promoted members, role-change items; pass `nameFromMemberId` where it used the entry name). + +In **one transaction**: `processRoster` → set `roster_version = roster_pending_version` → set `roster_blob` → clear `roster_pending_*` → delete the file. A **relay** also promotes the pending signed-header columns into the live `roster_msg_*` (this is what `setGroupRoster` writes today at header time — it moves here) and applies to its own records, then **sends the roster ack and broadcasts** (below). So a joiner never sees a live header at `vN` paired with a blob at `vN-1`. + +**Relay ack at completion (PR #7036 integration).** The relay's `XGrpRosterAck` (previously sent in `relayApplyRoster` at header receipt) moves to completion, gated exactly as today on `relayOwnStatus gInfo == Just RSAccepted`: on a successful completion send `sendRosterAck author roster_pending_version Nothing`; on digest-mismatch or parse failure send `sendRosterAck author roster_pending_version (Just "...")` so the owner marks the relay `RSRejected` rather than leaving it `RSInvited` forever. A relay therefore becomes publishable only after the full blob arrives and applies — the desired fail-safe, but it makes owner→relay blob delivery part of the relay-onboarding path, so it MUST be reliably driven (see *Owner send* and *Relay re-serve*; for a freshly-connecting relay the owner drives it via `sendGroupRosterToRelay`, including the empty v0 blob). The `relayOwnStatus == Just RSAccepted` gate is ported unchanged but now evaluated at completion rather than header receipt — confirm `relayOwnStatus` cannot change across the header→completion window (it shouldn't: a relay can't reach `RSActive` before acking, since the ack is what publishes it). + +The version guard plus the per-version `shared_msg_id` keying are what make the design correct; the short-circuit and one-in-flight-per-group are optimizations. + +### Relay re-serve (broadcast / join / QCONT) + +Per recipient, forward the signed header (as `forwardGroupRoster` does today, `Internal.hs:1172`) AND re-send the blob as `BFileChunk`s from `groups.roster_blob` (the send-from-bytes variant). An incoming `BFileChunk` returns no delivery task (`Subscriber.hs:1089`), so the blob send is driven here. + +**No per-member version gate in v1 (GAP 2).** QCONT/SENT re-forwards the saved roster unconditionally today (`Subscriber.hs:1143`, `1237`), and no per-member delivered-version tracker exists in the tree — this plan adds none. So a re-serve re-sends the whole blob on every drain; at cap 256 that is ~15 KB — one (occasionally two) `BFileChunk`s per drain — acceptable. + +It is safe because: an up-to-date member short-circuits the header and ACK-ignores the orphaned chunks; a stale (≤ pending) re-forward mid-transfer is a no-op via the short-circuit; and the completion version guard rejects any stale completion. + +If the cap is later raised so the blob spans many chunks, add a per-member `delivered_roster_version` column (read on QCONT/join/broadcast, written on confirmed delivery) and re-serve only when behind — future work. + +### Supersede / cancel cleanup + +Cleanup spans ALL of these — miss none: + +- `files`, `rcv_files`, `rcv_file_chunks`, +- the on-disk file and its `rcvFiles` handle (`closeFileHandle`), +- the `roster_pending_*` columns on `groups` (set NULL). + +## File-machinery changes (only these) + +- **Lookup**: add `files.shared_msg_id`; resolve roster chunks by `(group_id, shared_msg_id, file_type = roster)`. Leave `getGroupFileIdBySharedMsgId` (`Store/Files.hs:310`, chat-item JOIN) for normal files; branch on `file_type` / `chat_item_id IS NULL`. +- **Fork the three receive sites that call `getChatItemByFileId`** (they throw with no chat item): + - `startReceivingFile` (`Internal.hs:827`, reached on chunk 1) — skip the chat item + `CEvtRcvFileStart`. + - `receiveFileChunk` `RcvChunkFinal` (`Subscriber.hs:1329`) — replace with the completion path above. + - `FileChunkCancel` (`Subscriber.hs:1313`) — delete file + drop in-flight state, no chat item. +- **Cleanup keyed on `group_id`** (not chat items): `getGroupFileInfo` INNER-JOINs `chat_items`, so group delete (`Commands.hs:1270`) and clear (`1305`) skip roster files; the DB row cascades on group delete but the on-disk file leaks. Add a roster-file cleanup for delete/clear, cancel, and supersede. + +## Storage / migration + +In-flight state lives on `groups` (mirroring the live saved roster) and `files` (located by `shared_msg_id`) — no join table. These columns go into the in-progress **`M20260602_group_roster`** migration (already part of this work, not yet merged — so it's editable, not an applied migration), SQLite + Postgres; tests regenerate the schema files. + +| `groups` column(s) | Holds | Lifecycle | +|---|---|---| +| `roster_version` *(kept)* | applied version | bumped at completion | +| `roster_msg_*` *(kept)* | live signed header (was full JSON) | relay forwards verbatim; promoted from pending at completion | +| `roster_blob` *(new)* | durable completed blob | written at completion; relay re-serves it | +| `roster_pending_version`, `roster_pending_digest` *(new)* | in-flight version + digest | set on header receipt; cleared at completion | +| `roster_pending_msg_*` *(new, relay-only)* | in-flight signed header | set on header receipt; promoted to live at completion (NULL on members) | + +The kept `roster_msg_*` columns stay the relay's verbatim-forward source and trust anchor: `forwardGroupRoster` re-forwards them so the joiner verifies the owner signature, and the digest inside authenticates the unsigned blob. + +`files` adds `shared_msg_id` and `file_type`. The in-flight transfer is the `files` / `rcv_files` / `rcv_file_chunks` rows with `(group_id, file_type = roster)`. + +## Security + +- **Owner-signed header**: assert `memberRole' author == GROwner` (`Subscriber.hs:3198`); keep `XGrpRoster_` in `requiresSignature`. +- **Integrity is entirely the digest** (S1): verify the assembled **plaintext** blob against the owner-signed `fileDigest` at completion. Hence `cryptoArgs = Nothing` — a set cryptoArgs makes `appendFileChunk` re-encrypt the file in place (`Internal.hs:1801`), so the on-disk bytes would be ciphertext and the check would fail. A corrupted chunk fails the digest and the roster is rejected. +- **TOFU** key pinning per `memberId` unchanged (different key for a known id → keep the trusted key). +- **Rollback (S2)**: the signature binds `publicGroupId + version` and the digest binds the blob to that header, so cross-group/version substitution stays blocked. But the blob now carries plain members, so a same-group replay of an old `(header, blob)` to a **new joiner** can re-introduce a removed poster or mask a demotion (existing members are protected by the version check). Update `channels-overview.md`. + +## Known limitations / out of scope + +- A malicious relay can withhold/corrupt chunks → the member stays on its last-applied roster (it can drop any message anyway); new-joiner rollback now covers plain members. +- A just-promoted member's first posts may show "unknown member" until the file arrives — self-healing. +- A member who **leaves** lingers in the roster blob until the next bump (this plan drops the leave-triggered refresh). Harmless: they have no relay connection and cannot post, so a new joiner sees only a ghost row; the owner's explicit remove (`APIRemoveMembers`) drops them. +- Out of scope: granting/enforcing `privileges`; member content signing; joiner-role-on-profile; clients. Do not couple the roster set to the joiner-role mechanism (decision 2) — it is the absolute `{member, mod, admin}`. + +## Tests (`tests/ChatTests/Groups.hs`) + +The roster tests now live under the `describe "promoted members roster"` block (PR #7036 moved them and added `testChannelAddRelayWithRoster`, which onboards a 2nd relay through the roster-ack handshake). Update those to header+file delivery — `testChannelAddRelayWithRoster` in particular now exercises the v0/empty-roster blob transfer feeding the relay ack, so it must drive the header+chunk(s) to completion before the relay acks. + +Then add: digest-mismatch blob rejected (no apply, no version bump) **and the relay acks-with-error → owner marks it `RSRejected`** (PR #7036 path); a relay does **not** ack / become publishable until the blob completes (ack moved to completion); member promotion enters the broadcast roster and can post; reset-on-chunk-1 recovery; superseding version cleans up the in-flight older file; version not bumped on header receipt or on a failed blob; `introduceInChannel` still mod+admin only (no member introductions); on-disk roster file cleaned on group delete/clear mid-transfer; non-owner promotion refused; a promoted member re-connecting through a relay is accepted only with a valid signed `XMember` over the roster-pinned key (the widened `memberJoinRequestViaRelay` gate). Existing mod/admin tests must still pass. diff --git a/plans/2026-06-01-supporter-badges-v1.md b/plans/2026-06-01-supporter-badges-v1.md new file mode 100644 index 0000000000..29a47a103e --- /dev/null +++ b/plans/2026-06-01-supporter-badges-v1.md @@ -0,0 +1,80 @@ +# Supporter Badges v1 - Verification + +Badge verification in stable so that v6.5 users can see and verify badges from v7 users. Badge purchase and issuance is v2. + +## Why BBS+ + +BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures) allow a holder of a signed credential to generate zero-knowledge proofs that selectively disclose some signed attributes while hiding others. Each proof uses a random nonce, making different proofs from the same credential computationally unlinkable - a verifier seeing two proofs cannot determine they came from the same credential. This means a supporter badge shown to different contacts cannot be correlated, preserving SimpleX's unlinkable identity model. + +The server that signs the credential sees the master secret during signing but cannot link any received proof back to any signing session - this is the core zero-knowledge property. + +## References + +- IETF draft: https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/ +- libbbs: https://github.com/Fraunhofer-AISEC/libbbs (Apache-2.0, Fraunhofer-AISEC) +- blst: https://github.com/supranational/blst (Apache-2.0, audited by NCC Group) - internal dependency of libbbs for BLS12-381 curve operations + +Both are vendored verbatim into simplexmq so that users and maintainers can verify the source matches upstream. Only libbbs API is called directly. + +## Crypto + +3 signed messages: `[ms, expiry, level]`. `ms` undisclosed (index 0), `expiry` and `level` disclosed (indexes 1, 2). Proof size: 304 bytes (272 base + 32 per undisclosed). + +Server public key (`srvPK`, 96 bytes) hardcoded in app. + +## libbbs integration + +Vendor libbbs + blst C sources into simplexmq. Haskell FFI bindings following the SNTRUP761 pattern (`Simplex.Messaging.Crypto.BBS.Bindings`). + +Full FFI surface for testing the complete flow: + +- `bbs_keygen_full` - generate keypair +- `bbs_sign` - sign messages +- `bbs_proof_gen` - generate ZK proof with selective disclosure +- `bbs_proof_verify` - verify proof +- `bbs_sha256_ciphersuite` - ciphersuite constant + +Unit tests: keygen, sign, proof gen, proof verify roundtrip. Verify proof size. Verify rejection of tampered proofs. Verify two proofs from same credential don't correlate (different presentation headers produce different proofs that both verify). + +Use blst portable C fallback for now (avoids per-arch assembly). + +## Profile type + +Add optional `badge` field to `Profile`. The `SupporterBadge` type uses base64-encoded newtypes for binary fields, following the `KEMPublicKey`/`KEMCiphertext` pattern from SNTRUP761 bindings: + +```haskell +data SupporterBadge = SupporterBadge + { proof :: BBSProof + , proofNonce :: ByteString + , badgeExpiry :: UTCTime + , badgeType :: Text + } +``` + +`badgeType` is a string: `"supporter"`, `"business"`, `"legend"`, `"cf_investor"`. Displayed in UI as Supporter, Business, Legend, Crowdfunding Investor. `BBSProof` is a newtype over `ByteString` with `StrEncoding` instances for base64url JSON encoding. + +Backward compatible: `omitNothingFields` means older clients ignore it, newer clients without badge send `Nothing`. + +## DB + +- `badge` fields on `contact_profiles` and `group_member_profiles` to store received badge data +- `badge_status` column on `contacts` and `group_members` to store verification result +- `badge` fields on user profile (`users` or `contact_profiles` for own profile) for when badge issuance is added in v2 + +## Verification + +On receiving profile with `badge` (in Subscriber.hs, `XInfo`/`XGrpMemInfo`/`XContact` handlers): + +1. `bbs_proof_verify(srvPK, proof, "", proofNonce, disclosed=[1,2], [expiry, level])` +2. Check `expiry >= now` +3. Store badge + verification status on contact/member + +## UI + +Badge icon next to display name for verified contacts/members. Different icons per level string. Expired badges shown differently or hidden. + +## Not in v1 + +- Badge purchase, issuance, credential storage, proof generation - v2 +- Service framework - v2 +- Payment platform integration - v2 diff --git a/plans/2026-06-04-channel-message-signing.md b/plans/2026-06-04-channel-message-signing.md new file mode 100644 index 0000000000..f3828018b9 --- /dev/null +++ b/plans/2026-06-04-channel-message-signing.md @@ -0,0 +1,233 @@ +# Plan: optional signing of channel content messages (`XMsgNew` / `XMsgUpdate`) + +## Goal / user problem + +In relay-based channels, content (`XMsgNew`) is forwarded by relays and is **not** signed today (only group-state events are — `requiresSignature`, `Protocol.hs:1251`), so a relay can forge or alter content attributed to a member. This feature lets a member *optionally* attach their member signature, so recipients holding the (signed) roster can verify authorship + integrity. + +Decisions: +- **UI: both** — device-stored default ("sign my channel messages", off) + per-send long-press override (mirrors custom disappearing-message TTL). +- **Default: off**, with an in-UI tradeoff explanation (signing = non-repudiable, transferable proof of authorship). +- **Recipient indicator: in scope** (iOS + Kotlin) — signing is useless if invisible to readers. +- **Event scope: `XMsgNew` + `XMsgUpdate` only**; edits reuse the original's setting. `XMsgReact`/`XMsgDel` stay unsigned in v1. + +## Prerequisites / sequencing + +Lands after #7017 (signed roster) and #7048 (roster over inline files; `GRMember` role). Neither merged yet (branch `f/allow-sign-new-msg`; `git log` tops at #7043). Dependency is specific: *verification* needs the sender's member public key, distributed via the roster; without it a signed message degrades to `MSSSignedNoKey` rather than `MSSVerified`. Integration tests must use the roster/channel setup from those PRs. + +**Line numbers are pre-rebase** (grounded against #7043); #7017/#7048 shift every anchor, so **re-locate by symbol**. The dependency PRs add no 6th `updateGroupChatItem` caller, but other branches are queued (`f/channel-comments`, `f/public-groups-members-in-roster`) — hence the caller re-check gate below. + +## What already exists (so the change stays small) + +Wire format, signing, verification, DB persistence, and CLI display are present and reused unchanged: +- **Send signing:** `groupMsgSigning` (`Internal.hs:1963`) → `createSndMessages` threads `Maybe MsgSigning` (`:1950`) → `createNewSndMessage` Ed25519-signs `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, storing `SignedMsg` in `SndMessage.signedMsg_` (`Store/Messages.hs:234`; `Messages.hs:1156`). +- **Wire:** `batchMessages` prepends the signature via `encodeBatchElement` (`Batch.hs:46,65`); relay groups always batch (`memberSendAction` → only `MSASendBatched` under `useRelays'`, `Internal.hs:2222,2228`). +- **Receive verify:** `withVerifiedMsg` (`Subscriber.hs:3469`) runs for all group messages (`:1004`, forwarded `:3431`); `XMsgNew_`/`XMsgUpdate_` ∉ `requiresSignature` ⇒ `signatureOptional` (`:3491`), so signed → `MSSVerified`/`MSSSignedNoKey`, unsigned → accepted. **No protocol-version bump.** +- **Sent-item persistence:** `createNewSndChatItem` sets `msgSigned = MSSVerified <$ signedMsg_` (`Store/Messages.hs:550`) — own item auto-marked, readable by the edit path. +- **Received-item persistence:** `createNewRcvChatItem` records `RcvMessage.msgSigned` (`Store/Messages.hs:565,567`); `CIMeta.msgSigned :: Maybe MsgSigStatus` (`Messages.hs:520`). +- **CLI:** `sigStatusStr` (`View.hs:388`) appends `" (signed)"` / `" (signed, no key to verify)"`. + +Missing: (1) the *decision* to sign content (`groupMsgSigning` returns `Nothing` for content today); (2) per-send plumbing from the API; (3) reuse on edit; (4) the §7 stale-badge fix; (5) the §5 anonymity gate (HIGH); (6) the apps. + +## Threat model + +Actors: member (sender), recipients, and **chat relays** that forward content + roster. Relays are untrusted for content authenticity. + +- **Forgery of member content.** Signing closes it for signed messages: relay lacks the Ed25519 key; signature binds `(publicGroupId, memberId, body)` — no forgery, cross-bind, or alteration. +- **Downgrade / stripping (residual, by design).** Optional signing lets a relay strip a signature and deliver unsigned. Absence of a badge is **not** proof of forgery — only *presence* of a verified badge is a guarantee. A future "required signing" group setting would close it; out of scope. +- **Stale-badge spoof on edits (fixed — §7).** An in-place edit must not keep a `verified` badge over content from an unsigned, relay-forged `XMsgUpdate`. +- **Publish-as-channel de-anonymization (structurally prevented — §5).** Channels allow "publish as the channel" (`showGroupAsSender`/`asGroup`): subscribers see a post as *from the channel*, not the specific owner (Design Objective 6, `docs/protocol/channels-overview.md:214`); today a relay revealing the owner is only a *deniable* leak (`channels-overview.md:~237`). `groupMsgSigning` (`Internal.hs:1963-1967`) is blind to `showGroupAsSender`, so it would sign with binding `(publicGroupId, ownerMemberId)`, broadcast on the wire even for `FwdChannel` (`encodeFwdElement` → `encodeBatchElement signedMsg_`, `Batch.hs:108`). A malicious relay sets the live-forward `fwdSender` freely (it is derived from stored `sentAsGroup`, `Store/Delivery.hs:158`), so every subscriber verifies it as `MSSVerified` — turning the deniable leak into **non-repudiable proof** of which owner authored an intentionally anonymous post; the device-default toggle would trigger this silently. For an anonymity property this must be structurally impossible: signing is never applied to as-channel content (§5), the app option is hidden for as-channel sends (§C), and a defense-in-depth guard keeps `encodeFwdElement` signature-free for `FwdChannel` (Edge cases). (`processContentItem:1302` is the *history* path and rebuilds content unsigned — not the vector.) +- **Non-repudiation (tradeoff, by design).** A verified signature is transferable proof of authorship — a deniability loss; hence opt-in/off-by-default with UI explanation. For *as-channel* posts the loss is unacceptable, not a tradeoff — hence the §5 exclusion. +- **What "verified" means.** Signed input is `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, with `msgBody` embedding `sharedMsgId`, `MsgScope`, content (`Store/Messages.hs:242`). It proves **authorship + integrity + group/member/scope/message binding** — and nothing else: not `fwdBrokerTs` (relay-controlled, `Protocol.hs:382-387`), ordering, or completeness. Surface this in UI/help. +- **Signed content is still relay-suppressible.** `XMsgDel_` ∉ `requiresSignature` (`Protocol.hs:1252-1262`), so an unsigned relay-forged owner-attributed delete is accepted (role-based check vs. the relay-chosen author, `Subscriber.hs:~2269`). Pre-existing, within the relay's drop power; bounds signing's value (proves *what was said*, not that all is delivered). +- **Replay.** Binding covers `sharedMsgId` + `MsgScope`; cross-scope/group replay is blocked, same-message replay is a dedup duplicate. +- **Bad-signature spam (fail-closed, pre-existing).** Failed verification drops content with an `RGEMsgBadSignature` item per occurrence (`Subscriber.hs:3473-3475,3483`); a tampering relay can spam these. Inherited from state-event behavior. + +## Core changes (Haskell) + +### 1. Signable-content predicate + +`Protocol.hs`, next to `requiresSignature` (`:1251`): +```haskell +-- | Content events whose authorship a member may optionally prove by signing. +signableContent :: CMEventTag e -> Bool +signableContent = \case + XMsgNew_ -> True + XMsgUpdate_ -> True + _ -> False +``` + +### 2. Signing decision carries the opt-in + +Named type near `MsgSigning` (`Protocol.hs:426`) — not a bare `Bool`: +```haskell +-- | Whether opt-in content signing applies to this group send. +-- Independent of mandatory state-event signing (requiresSignature), +-- which always applies in relay groups regardless of this value. +data ContentSig = SignContent | DontSignContent + deriving (Eq, Show) +``` +Extend `groupMsgSigning` (`Internal.hs:1963`): +```haskell +groupMsgSigning :: ContentSig -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning csig gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt + | useRelays' gInfo && shouldSign = + Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey + where + tag = toCMEventTag evt + shouldSign = requiresSignature tag || (csig == SignContent && signableContent tag) +groupMsgSigning _ _ _ = Nothing +``` +- `useRelays'`/`groupKeys = Just` guards unchanged: in non-relay groups or keyless members, `SignContent` is a no-op (`Nothing`). +- Mandatory state-event signing unaffected (`requiresSignature` branch preserved). + +### 3. Thread `ContentSig` through the send functions + +`groupMsgSigning` is called only in `sendGroupMessages_` (`Internal.hs:2134`) and `sendGroupMemberMessages` (`:1972`). Add a `ContentSig` param to `sendGroupMessages_` (`:2132`, used in `idsEvts`), `sendGroupMessages` (`:2100`, pass-through), `sendGroupMessage` (`:2088`, pass-through). Keep `sendGroupMessage'` (`:2094`) and `sendGroupMemberMessages` (`:1969`) unchanged by hardcoding `DontSignContent` internally. + +Behavior-preserving (all existing callers pass `DontSignContent`) ⇒ its own commit. Call sites to pass `DontSignContent` (grep-verified): +- `sendGroupMessages`: `Subscriber.hs:1370`; `Commands.hs:793,800,2778,2909`. +- `sendGroupMessage`: `Commands.hs:889,2690,3272,3812,3815,3819`. +- `sendGroupMessages_` direct: `Commands.hs:2826,3849`. + +The two variable-`ContentSig` sites are the feature (next commit): content send (`Commands.hs:4405`) and group edit (`Commands.hs:732`). + +### 4. API: per-send `sign` flag + +Add a field to `APISendMessages` (`Controller.hs:332`): +```haskell +| APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, signMessages :: Bool, composedMessages :: NonEmpty ComposedMessage} +``` +Parser (`Commands.hs:5006`), mirroring `liveMessageP`/`sendMessageTTLP`, defaulting off so old command strings still parse: +```haskell +"/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> signMessagesP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)) +-- with: signMessagesP = " sign=" *> onOffP <|> pure False (place after sendMessageTTLP, before " json ") +``` +Wire: `/_send live=.. ttl=.. sign=on|off json ...`. Per-send granularity (like `ttl`), not per-`ComposedMessage`. API boundary (app↔core, same bundle) ⇒ not a protocol-compat concern. + +### 5. Content send path + +`sendGroupContentMessages` (`Commands.hs:4366`) and `sendGroupContentMessages_` (`:4375`) gain a `ContentSig` param. `showGroupAsSender` is in scope at the send site (`:4405`); **as-channel posts are never signed** (anonymity gate — see threat model): +```haskell +let csig' = if showGroupAsSender then DontSignContent else csig +(msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients csig' chatMsgEvents +``` +This gate is structural (must live here, not only in UI); it also keeps the sender's own as-channel item unsigned and keeps §6 edit-reuse consistent. + +- `APISendMessages` handler (`:637-650`): `signMessages` → `SignContent`/`DontSignContent`, passed down (both `SRGroup` and `SRDirect`; direct ignores it — `sendContactContentMessages` doesn't sign). The `:4405` gate then forces `DontSignContent` for as-channel sends regardless of the flag. +- `APIReportMessage` (`:679`): `DontSignContent` (reports unsigned in v1). + +### 6. Edit / restore reuse (the `XMsgUpdate` requirement) + +Group edit, `Commands.hs:710-742`. Own sent item loaded with `CIMeta` at `:720`; add `msgSigned` to the pattern and reuse it: +```haskell +... meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender, msgSigned} +... +let reuseSig = if isJust msgSigned then SignContent else DontSignContent +SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSig event +``` +`msgSigned` is loaded via `mkCIMeta`/`toGroupChatItem` (`Store/Messages.hs:2412`); for own items it is `Just MSSVerified` iff signed (`createNewSndChatItem` stores only `MSSVerified <$ signedMsg_`, `:550`), so `isJust` is the right test. This makes an edit (including the recipient-deleted-restore case) signed exactly when the original was; it is automatically consistent with §5 (as-channel originals are never signed ⇒ edits stay unsigned). + +Direct edit (`:697-704`) and local edit (`:745`) need no change (never signed). + +### 7. Security fix: refresh `msg_signed` on in-place content update + +**Finding:** `updateGroupChatItem_` (`Store/Messages.hs:2755`) updates content/status/timed fields but **not `msg_signed`** (`UPDATE` at `:2760-2767`); `updatedChatItem` (`:2749`) carries the original `meta.msgSigned`. Today invisible (content never signed); once content is signed and badged, an in-place edit from an **unsigned, relay-forged `XMsgUpdate`** would keep a stale `MSSVerified` badge over attacker content. + +**Why pass it in:** the `MSSVerified` vs `MSSSignedNoKey` outcome is computed at receive by `withVerifiedMsg` and lives only on the chat item; the stored `messages` row holds signature bytes but not the verification *outcome*. So the status must come from receive-time `RcvMessage.msgSigned`, not be re-derived. + +**Fix (contained to the group helper):** add a `Maybe MsgSigStatus` param to `updateGroupChatItem` (`:2746`); after `let ci' = updatedChatItem …` (`:2749`) override `ci'`'s `meta.msgSigned`, and add `msg_signed = ?` to `updateGroupChatItem_`'s `UPDATE` (`:2755`/`:2760-2767`). `updateGroupChatItem_` is called *only* from `updateGroupChatItem` (grep), so this is self-contained. **Leave `updatedChatItem` (`:2544`) unchanged** — it serves the unsigned direct/local paths (`:2540`, `:3210`). + +All **five** callers pass an explicit value (no implicit "preserve"): +- `Commands.hs:738` (sender edit): `MSSVerified <$ signedMsg_` from the returned `SndMessage` (mirrors `:550`; equals the reused setting). +- `Subscriber.hs:2212` (recipient in-place edit — *the spoof path*): `msgSigned` from the handler's `RcvMessage msg`. Unsigned forged edit ⇒ `Nothing` ⇒ badge removed; verified ⇒ kept. +- `Subscriber.hs:2172` (recipient restore in-place, after `saveRcvChatItem'`): same `msgSigned` from `msg`. +- `Subscriber.hs:1152` (`mdeUpdatedCI` decryption-error marker): `Nothing` — local marker, badge correctly cleared. +- `Subscriber.hs:1509` (`upsertBusinessRequestItem` business-chat welcome): `Nothing` — never a relay channel, safely preserves `Nothing`. (Sibling direct path `:1480` uses `updateDirectChatItem'`, unaffected.) + +Net: signed status is set explicitly from the source of current content in every group create/update path, so a stale badge cannot exist. + +### 8. Paths deliberately left unsigned + +- Auto-reply welcome content (`Subscriber.hs:1267` `XMsgUpdate`, `:1269` `XMsgNew`) via `sendGroupMessage'` ⇒ `DontSignContent`. +- `XMsgReact` (`Commands.hs:889`), `XMsgDel` (`Commands.hs:792-799`): unsigned in v1. Asymmetry: a post is verifiable, its reactions/deletes are not — and a signed post is still relay-suppressible (threat model). Later, extending `signableContent` could let recipients reject unsigned deletes of signed posts. + +## App changes (iOS + Kotlin) + +### A. Decode the signature status +- **JSON tags:** core uses `enumJSON (dropPrefix "MSS")` ⇒ `MSSVerified → "verified"`, `MSSSignedNoKey → "signedNoKey"` (lower-cases first letter). **Not** the DB/text strings (`"verified"`/`"no_key"`). +- iOS: `enum MsgSigStatus: String, Decodable { case verified, signedNoKey }`; add `public var msgSigned: MsgSigStatus?` to `CIMeta` (`apps/ios/SimpleXChat/ChatTypes.swift:3721-3737`). +- Kotlin: `@Serializable enum class MsgSigStatus { @SerialName("verified") Verified, @SerialName("signedNoKey") SignedNoKey }`; add `val msgSigned: MsgSigStatus? = null` to `CIMeta` (`apps/multiplatform/.../model/ChatModel.kt:3434-3450`). +- Optional field ⇒ backward-safe decode of old core JSON. + +### B. Device preference (default off) +- iOS: `@AppStorage(DEFAULT_PRIVACY_SIGN_CHANNEL_MESSAGES) private var signChannelMessages = false` + toggle in `PrivacySettings.swift` (pattern: `protectScreen`, `:68-70`) with a non-repudiation footer. +- Kotlin: `val privacySignChannelMessages = mkBoolPreference(SHARED_PREFS_PRIVACY_SIGN_CHANNEL_MESSAGES, false)` (`SimpleXAPI.kt:314`; declarations near `:122-125`) + `SettingsPreferenceItem` in `PrivacySettings.kt` with explanation. +- App-side only (like `customDisappearingMessageTime`), not core `AppSettings`. + +### C. Composer option (per-send override) + thread `sign` to the API +- Change the send closure to `(_ ttl: Int?, _ sign: Bool?)` (iOS `SendMessageView.swift:21`; Kotlin `SendMsgView.kt:54`), `sign == nil` ⇒ use device default; composer passes effective `sign = override ?? default`. +- Long-press item next to "Disappearing message" (iOS `SendMessageView.swift:224-247`; Kotlin `SendMsgView.kt:198-209`): "Sign message" (default off) / "Send without signing" (default on). +- **Gate visibility** on relay channel + membership has a signing key + **not as-channel** (the UI half of §5 — never offer it for as-channel publication). If app `GroupInfo` lacks relay/key state, add a derived `memberSigningAvailable` boolean to its JSON; AND it with the composer's as-channel state. Mirror `timedMessageAllowed`. +- `apiSendMessages`: add `sign: Bool`, append `sign=on|off` — iOS `ChatCommand.apiSendMessages` (`AppAPITypes.swift:48`, encode `:239`) + `SimpleXAPI.swift:545`; Kotlin `CC.ApiSendMessages` (`SimpleXAPI.kt:3676`, encode `:3867`) + `SimpleXAPI.kt:1097`. + +### D. Recipient indicator +- Show a "signed by author" indicator when `meta.msgSigned == .verified` in the meta row: iOS `CIMetaView.swift` `ciMetaText` (`:93-160`); Kotlin `CIMetaView.kt` `CIMetaText` (`:67-115`) + update `reserveSpaceForMeta` (`:118-175`) for icon width. +- `signedNoKey`: show muted or nothing so it isn't read as `verified` (design). Surface the "verified ≠ timestamp/ordering/completeness" caveat (threat model) in help. +- Own signed items use the same indicator (core sets `MSSVerified` on signed sends). + +## Compatibility analysis +- **Protocol wire format:** unchanged; existing batch-element signature prefix. No `chatVRange` bump; pre-feature relay-capable peers verify/accept correctly. +- **API command:** `sign=` additive with default; app+core ship together. +- **DB:** no migration. `chat_items.msg_signed` exists (added `M20260222_chat_relays`; in both schema files; written by `createNewChatItem_:603`). +- **App JSON:** new optional `msgSigned` decodes as absent on older cores. + +## Edge cases, races, correctness +- **Member without keys** (`groupKeys = Nothing`): `groupMsgSigning` returns `Nothing` even with `SignContent` ⇒ silent unsigned send. UI gate should prevent offering it; document the silent degrade. +- **Non-relay groups:** `useRelays'` guard ⇒ never signed; UI must not offer it. +- **Live messages:** initial `XMsgNew` then repeated `XMsgUpdate`, each reusing the item's `msgSigned` ⇒ every increment signed. Extra cost per keystroke-batch; acceptable. +- **Separate (non-batched) path drops signatures** (`sndMessageMBR` uses raw `msgBody`, `Internal.hs:2199`, vs the batched path's `encodeBatchElement`). Never reached in relay groups (`memberSendAction` → `MSASendBatched`). Add a test-asserted invariant; optionally make `sndMessageMBR` use `encodeBatchElement signedMsg_` too, so routing changes can't silently drop channel signatures. +- **Defense-in-depth: no signature on `FwdChannel`.** `encodeFwdElement` (`Batch.hs:108`) includes `signedMsg_` unconditionally; §5 makes it `Nothing` for `FwdChannel` in normal flow. Add a guard/assertion that `encodeFwdElement` carries no signature when `fwdSender = FwdChannel`, so no future upstream path can reintroduce the de-anonymization. +- **History re-send strips signatures (badge non-determinism, by design).** Relay history catch-up rebuilds content via `prepareGroupMsg` into plain `XGrpMsgForward` events (`processContentItem`, `Internal.hs:1279-1305`) and lacks the private key ⇒ unsigned. So for the same message, a live-forward recipient sees a badge while a history-catch-up recipient does not. Graceful (absence ≠ forgery); document in UI/help and test. +- **Concurrency:** signing/verification are pure given keys; no new shared state. Send holds `withGroupLock`; receive update runs under existing receive-loop serialization. No new races. + +## Tests + +Protocol (`tests/ProtocolTests.hs`, extending `:112-312`): +- Round-trip signed `XMsgNew`/`XMsgUpdate` through `SignedMsg`; assert binding `CBGroup <> (publicGroupId, memberId)`; `verify` accepts the right key, rejects wrong key / altered body / altered binding. + +Integration (`tests/ChatTests/`, using `setupRelay`/`prepareChannel1Relay`/`createChannel1Relay`/`memberJoinChannel`, `Groups.hs:8621-8750`): +- **Sign + verify:** `sign=on` ⇒ recipient and sender items are `(signed)` (`sigStatusStr`). +- **Off / opt-out:** `sign=off`/default ⇒ no `(signed)`. +- **No key:** missing roster key ⇒ `(signed, no key to verify)` (`MSSSignedNoKey`). +- **Edit reuse:** signed message edit stays `(signed)`; unsigned stays unsigned. +- **Edit downgrade (security):** unsigned `XMsgUpdate` for a previously-signed item (forging-relay, cf. `ChatRelays.hs:220-230`) ⇒ badge **removed** (§7). +- **As-channel never signed (anonymity):** owner posts `as_group=on sign=on` ⇒ no item is `(signed)` and no signature on the wire/stored message (guards §5). +- **History downgrade:** live-forward recipient sees `(signed)`; later history-catch-up recipient sees the same message without it (Edge cases). +- **Forgery rejection:** mismatched-binding replay/fabrication ⇒ signature stripped / `RGEMsgBadSignature`. + +App: minimal decode test that `"verified"`/`"signedNoKey"` parse to the right enum on both platforms (guards the §A tag mismatch). + +## Commit / diff plan + +1. **Structural (behavior-preserving):** add `ContentSig`, `signableContent`, parameterize `groupMsgSigning` + the three send functions, update all callers with `DontSignContent`. Reviewable as "no behavior change". +2. **Security fix (independent, behavioral no-op today):** add `Maybe MsgSigStatus` to `updateGroupChatItem`, override `meta.msgSigned` after `updatedChatItem`, add `msg_signed` to `updateGroupChatItem_`'s `UPDATE`, update all five callers (§7). Until commit 3 every call passes `Nothing`/unchanged, so no observable change yet — but correct on its own, with a regression test that bites once signing exists. +3. **Feature behavior (core):** `APISendMessages` field + parser; content send and edit pass the real `ContentSig` (with the §5 as-channel gate); report path `DontSignContent`. +4. **App — decode + recipient indicator.** +5. **App — device preference + composer option + `apiSendMessages` wiring.** +6. **Tests** (protocol + integration) — may accompany commits 2/3. + +Each commit builds and passes tests independently (bisect/rollback). + +### Pre-implementation gates (after rebasing onto #7017 + #7048) +- **MUST:** the as-channel gate (`showGroupAsSender ⇒ DontSignContent`, §5) lives in the *core* send path, and the app option is hidden for as-channel sends (§C) — not UI-only. +- **MUST:** re-run `grep -rn 'updateGroupChatItem\b'` and confirm **every** caller passes an explicit `Maybe MsgSigStatus` — a missed caller silently re-introduces the §7 spoof. (Pre-rebase set: `Commands.hs:738`; `Subscriber.hs:1152,1509,2172,2212`.) +- **SHOULD:** re-run the `sendGroupMessages`/`sendGroupMessage`/`sendGroupMessages_` caller greps; only content-send and edit pass a variable `ContentSig`, all others `DontSignContent`. +- **SHOULD:** the three "verified"-meaning caveats (no timestamp/ordering; history downgrade; relay-suppressible) are surfaced in UI/help, and the history-downgrade test exists. + +## Out of scope / future +- Group-level "expected/required signing" owner setting (closes the optional-downgrade gap). +- Signing reactions/deletes; signing auto-reply content; verifiable reports (signed `MCReport`). + +## Open assumptions to confirm during implementation +- App `GroupInfo` exposes relay+key state for the UI gate, or a derived boolean is added to its JSON. +- Visual treatment of `signedNoKey` vs `verified`, and how to surface the "verified ≠ timestamp/ordering/completeness" caveat (threat model) in help. diff --git a/plans/2026-06-04-fix-corrupted-video-upload-error.md b/plans/2026-06-04-fix-corrupted-video-upload-error.md new file mode 100644 index 0000000000..e88e781a5d --- /dev/null +++ b/plans/2026-06-04-fix-corrupted-video-upload-error.md @@ -0,0 +1,100 @@ +# Fix: IndexOutOfBoundsException when uploading media with an undecodable preview + +## Symptom + +``` +java.lang.IndexOutOfBoundsException: Index 6 out of bounds for length 6 + at java.util.ArrayList.get(ArrayList.java:434) + at chat.simplex.common.views.chat.ComposeViewKt.ComposeView$sendMessageAsync(ComposeView.kt:827) + ... +``` + +The crash fires when sending a batch of picked media (e.g. 7 items) in which at least +one item produces no preview bitmap — most commonly a corrupted or unusual video whose +first frame cannot be extracted. + +## Root cause + +`ComposePreview.MediaPreview` carries two parallel lists that are assumed to be +**equal-length and index-aligned**: + +```kotlin +class MediaPreview(val images: List, val content: List) +``` + +Both consumers cross-index one list by the other's index, so the invariant is load-bearing: + +- `ComposeImageView` (preview row) iterates `media.images` and reads `media.content[index]`. +- `ComposeView.sendMessageAsync` iterates `preview.content` and reads `preview.images[index]` + (the `MCImage` / `MCVideo` preview string). This is the crash site. + +`processPickedMedia` built the two lists out of step: + +- `imagesPreview` was appended **only when `bitmap != null`**. +- `content` was appended **unconditionally** for videos, and for animated images that + passed the size check — regardless of whether a preview bitmap was produced. + +`getBitmapFromVideo` returns `PreviewAndDuration(null, …)` whenever Android's +`MediaMetadataRetriever` cannot extract a frame, **even when no exception is thrown** +(`Utils.android.kt:351`). So a single undecodable video appends to `content` but not to +`images`, leaving `content.size == images.size + 1`. Iterating `content` then indexes +`images[lastIndex+1]` → `Index N out of bounds for length N`. + +This is a pre-existing bug in the shared media picker; it is unrelated to any in-flight +feature work and reproduces on both Android and Desktop (both use the `commonMain` +`processPickedMedia`). + +## Fix + +Keep `content` and `imagesPreview` strictly paired at the source. The `when` now yields an +`UploadContent?` instead of mutating `content` inside its branches, and both lists are +appended together, gated on a non-null preview bitmap: + +```kotlin +if (bitmap != null && uploadContent != null) { + content.add(uploadContent) + imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) +} +``` + +Each iteration now adds exactly zero or one entry to **both** lists, so the +equal-length / index-aligned invariant holds by construction. + +### Behavior change + +Media that yields no decodable preview frame is now **skipped** rather than enqueued. +Previously such a video crashed the send; now only the bad item is dropped and the rest of +the picked batch sends normally (the loop evaluates each URI independently). + +The skip is **not silent**. A skipped video shows `showVideoDecodingException()`, gated on +`AlertManager.hasAlertsShown()` so the alert neither stacks across several bad items in one +batch nor duplicates the one `getBitmapFromVideo` already shows on its exception path. The +genuinely silent gap this closes is the video path that returns a null frame **without** +throwing (Android `getFrameAtTime` returns null; Desktop snapshot times out) — that path +previously produced no alert and then crashed on send. + +Image decode failures need no new alert here: `getBitmapFromUri` is already called for every +image (animated or not) with `withAlertOnException = !hasAlertsShown()`, so a null image +bitmap is surfaced before this point. Only the video null-frame case lacked any notice. + +## Why this approach + +- **Fixes the invariant at its origin** rather than papering over it at the two read + sites. Guarding `images[index]` in `sendMessageAsync` would stop the crash but leave the + preview row (`ComposeImageView`) silently mismatched and the actual media set ambiguous. +- **Minimal, surgical diff** confined to `processPickedMedia`; no API/type changes, no new + placeholder assets, no touch to the read sites. +- **Cross-platform by construction**: the change lives in `commonMain`, so Android and + Desktop are both covered. iOS has a separate Swift compose implementation and is out of + scope for this fix. + +## Other `MediaPreview` construction sites (verified aligned) + +- `cs.preview` → single-element `listOf(mc.image)` / `listOf(content)` (edit path): aligned. +- `constructFailedMessage` takes `last()` of each list: aligned if the input was aligned. + +## Test notes + +Manual repro: pick a multi-item batch including a corrupted/zero-frame video and send. +- Before: `IndexOutOfBoundsException` on send. +- After: the undecodable item is dropped; remaining media sends normally. diff --git a/plans/2026-06-09-perf-group-members-merge-on2.md b/plans/2026-06-09-perf-group-members-merge-on2.md new file mode 100644 index 0000000000..3000103b05 --- /dev/null +++ b/plans/2026-06-09-perf-group-members-merge-on2.md @@ -0,0 +1,95 @@ +# Perf — index member merge in `setGroupMembers` to O(n) + +**PR:** #7061 (`nd/group-members-merge-on2`) +**Scope:** client-only (multiplatform: android + desktop). One-line change, no behavioral change. +**File:** `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt:254` + +## Root cause (verified) + +`setGroupMembers` is the shared loader for the in-memory member list. After fetching +members from core (`apiListMembers`) it merges the freshly-loaded list with the +connection stats already held in memory, so an in-flight `connectionStats` isn't lost +when the list is reloaded — `ChatListNavLinkView.kt:257-267`: + +```kotlin +val currentMembers = chatModel.groupMembers.value +val newMembers = groupMembers.map { newMember -> + val currentMember = currentMembers.find { it.id == newMember.id } // O(n) scan, inside an O(n) map → O(n²) + ... +} +``` + +Two compounding costs: + +1. **O(n²) merge.** `currentMembers.find { it.id == newMember.id }` is a linear scan + run once per new member — `n` lookups × `n` scan = O(n²). +2. **~n² String allocations + GC pressure.** `GroupMember.id` is a *computed* property, + not a stored field — `ChatModel.kt:2424`: + + ```kotlin + val id: String get() = "#$groupId @$groupMemberId" + ``` + + Every `it.id` and `newMember.id` access allocates a fresh `String`. The nested + `find` evaluates `it.id` for (worst case) every current member on every iteration, + so the merge allocates on the order of n² short-lived strings, each compared by + value. In groups with thousands of members this is a visible main-thread lag spike. + +## Worst case observed + +`setGroupMembers` reloads `chatModel.groupMembers` (and runs the merge) whenever the +member list is (re)loaded while members are already in memory. The most noticeable +case: the **Chats with members** support-chat modal (`MemberSupportView`) reloads the +whole list via `LaunchedEffect(Unit) { setGroupMembers(...) }` every time it (re)enters +composition — e.g. after reading and closing a member's support chat — so it pays the +full O(n²) merge and produces a lag spike on close in large groups +(`MemberSupportView.kt:44`). + +## The fix (minimal — one change) + +Index the current members by id **once**, then look up in O(1): + +```kotlin +val currentMembersById = chatModel.groupMembers.value.associateBy { it.id } +val newMembers = groupMembers.map { newMember -> + val currentMember = currentMembersById[newMember.id] + ... +} +``` + +- `associateBy { it.id }` builds the index in a single O(n) pass; each subsequent + lookup is O(1). Total merge cost drops from O(n²) to O(n). +- String allocations drop from ~n² to ~2n (one `it.id` per current member while + building the map, one `newMember.id` per lookup). + +## Why it's safe (no behavioral change) + +- **Member ids are unique** — `id = "#$groupId @$groupMemberId"` is unique per member + within a group, and `setGroupMembers` always works within a single `groupInfo`. So + `associateBy { it.id }` cannot collide; `map[id]` returns exactly what + `find { it.id == id }` returned. +- Same result set, same merged `GroupMember` objects, same order of `newMembers` + (the `map` over `groupMembers` is unchanged). Only the lookup strategy changes. +- Everything downstream of the merge is untouched — `groupMembersIndexes`, + `groupMembers.value`, `membersLoaded`, `populateGroupMembersIndexes()` + (`ChatListNavLinkView.kt:268-271`). + +## Also sped up (same shared loader) + +`setGroupMembers` is the common in-memory member loader, called from several screens; +all of them get the same speedup in large groups (identical results, just O(n)): + +- Group member list / member management — `GroupChatInfoView` (`:121, :1250, :1267`) +- @-mention autocomplete — `GroupMentions` (`:119, :134`) +- Channel relays — `ChannelRelaysView` (`:38, :124`) +- Add members — `AddGroupView` (`:52`) +- The group chat's member load on open — `ChatView` (multiple sites) +- Chats with members — `MemberSupportView` (`:45, :67`) + +## Verification + +- Reasoned: ids unique within a group → `associateBy`/lookup is semantically identical + to the linear `find`. No caller observes a difference. +- Manual: open **Chats with members** in a large group, read and close a member's + support chat repeatedly — the lag spike on close should be gone. Member list, + @-mentions, relays, and add-members screens should remain identical, just faster. diff --git a/plans/2026-06-12-fix-migrate-text-overlap.md b/plans/2026-06-12-fix-migrate-text-overlap.md new file mode 100644 index 0000000000..1bb1d3a5c6 --- /dev/null +++ b/plans/2026-06-12-fix-migrate-text-overlap.md @@ -0,0 +1,71 @@ +# Fix overlapping warning texts after finalizing migration + +Branch: `nd/fix-migrate-text` · regression from PR [#6777](https://github.com/simplex-chat/simplex-chat/pull/6777) (`df5ea3d46`, new settings section design). + +## 1. Problem statement + +On the "Migrate device" screen (Android and desktop), after tapping **Finalize migration** the finished state renders broken: the two warning texts — "You **must not** use the same database on two devices." and "**Please note**: using the same database on two devices will break the decryption of messages…" — are painted on top of each other and on top of the "Migration complete" section card, directly under the section header. + +Reproduced on desktop with default settings. The screen immediately before (`LinkShownView`, with the QR code) renders correctly. + +## 2. Solution summary + +Move the two `SectionTextFooter` calls in `FinishedView` out of the `Box` and place them after it, so they render as sequential children of the screen's scroll `Column` — the same placement `LinkShownView` already uses for its footers. + +```diff + } +- SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) +- SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) + if (chatDeletion) { + ProgressView() + } + } ++ SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) ++ SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) + } +``` + +Total diff: 1 file, 2 lines moved (+2 / −2 at different indentation). + +## 3. Root cause + +PR #6777 added card chrome to `SectionView` and, in a sub-commit ("Migrate views: move all SectionTextFooter / SectionSpacer out of SectionView lambdas"), moved footers out of the card lambdas so they read as captions below the cards. In `FinishedView` (`MigrateFromDevice.kt`) the footers were moved out of the `SectionView` — but left **inside the wrapping `Box`**: + +```kotlin +Box { + SectionView(stringResource(MR.strings.migrate_from_device_migration_complete)) { + // "Start chat" / "Delete database" buttons + } + SectionTextFooter(…you_must_not_start_database_on_two_device…) // Box child 2 + SectionTextFooter(…using_on_two_device_breaks_encryption…) // Box child 3 + if (chatDeletion) { + ProgressView() // Box child 4 (overlay) + } +} +``` + +That `Box` exists for exactly one reason: to overlay `ProgressView` (a fullscreen-centered spinner) over the section while the chat database is being deleted. `Box` stacks its children at `TopStart`, so both footers render at the Box's top-left corner — over the card's top edge and over each other. This is the only migration sub-view where the refactor produced this shape: the other `Box`-wrapped states keep all flow content inside one child (a `SectionView` or an inner `Column`), and `LinkShownView` has no `Box` at all. + +`FinishedView` is composed inside `ColumnWithScrollBar` (via `SectionByState`), so composables emitted at the function's top level land in the scroll `Column` and stack vertically — which is where the footers belong. + +## 4. The fix in detail, and why this shape + +Three candidate fixes were compared: + +- **Move the 2 footer lines after the `Box`** (chosen). Smallest possible diff, zero re-indentation. Footers become siblings of the Box in the scroll `Column`, identical to the working `LinkShownView` pattern in the same file. `ProgressView` keeps its overlay semantics unchanged (same as `DatabaseInitView`, `ArchivingView`, `LinkCreationView`). Only behavioural delta beyond the bug fix: during the transient `chatDeletion` spinner, the overlay centers over the card rather than card + footers — matching every other migration sub-view. +- **Wrap card + footers in a `Column` inside the Box.** Behaviorally near-identical, but ~40 lines of indentation churn and a layout shape no sibling view uses. Rejected: larger diff, no benefit. +- **Also hoist `ProgressView` out of the Box.** Changes overlay semantics (spinner would flow below content instead of over it). Rejected: touches behavior the bug report doesn't concern. + +Regression risk: the change is placement-only — no logic, no state, no measurement changes. The new arrangement is the proven pattern of the adjacent view. + +## 5. Scope verification — no other instances of the bug class + +The class ("flow content as direct children of an overlay `Box`") was searched for across all Kotlin source sets (`commonMain`, `androidMain`, `desktopMain`, `android`, `desktop`) with three complementary structural scans: + +1. Every `Box` block with ≥2 stacking flow children (section views, footers, spacers, settings items): **only** `FinishedView`. +2. All 384 footer/spacer call sites classified by nearest enclosing block: the only ones directly inside a `Box` are the two fixed lines. +3. All ~25 composable functions that emit footers at function top level (placement decided by caller): no caller invokes them inside a `Box`. + +iOS is structurally immune: SwiftUI footers are part of `Section { } footer: { }` inside a `List`; `MigrateFromDevice.swift`'s `finishedView` was verified correct. + +Related but distinct (not fixed here): 10 `SectionTextFooter` calls app-wide still sit *inside* `SectionView` card lambdas (6 in migration views, plus `LinkAMobileView`, `ConnectMobileView`, 2 in `NetworkAndServers`), rendering inside the white card instead of as captions below it. Cosmetic placement inconsistency with #6777's stated pattern, no overlap — left for a separate change if desired. diff --git a/plans/2026-06-15-fix-cli-outdated-help.md b/plans/2026-06-15-fix-cli-outdated-help.md new file mode 100644 index 0000000000..e0105ef5bb --- /dev/null +++ b/plans/2026-06-15-fix-cli-outdated-help.md @@ -0,0 +1,42 @@ +# Remove CLI help entries for long-removed commands + +Branch: `nd/fix-cli-outdated-help` · file `src/Simplex/Chat/Help.hs`. + +## 1. Problem statement + +Typing `/get stats` in the terminal CLI does nothing useful — it is documented in `/help` but no parser accepts it, so it fails to parse. Investigation found this is not isolated: four documented commands no longer exist in the parser. + +## 2. Solution summary + +Remove the four stale entries (five lines, including one continuation note) from `Help.hs`: + +- `/pq @ on/off` + its "(both have to enable…)" note — `contactsHelpInfo` +- `/pq on/off` — `settingsInfo` +- `/get stats` — `settingsInfo` +- `/reset stats` — `settingsInfo` + +The stats pair were the tail of `settingsInfo`, so the now-orphaned trailing comma on the preceding `/(un)mute #` element is also dropped to keep the list literal valid. + +No replacement text is added: PQ has no command (it is automatic), and the stats functionality has no argument-compatible successor (see §4). + +## 3. Root cause + +Both removals were core changes that deleted parser, handler, and command constructor but left `Help.hs` untouched: + +- **`/pq` (both forms)** — commit `756779186` "core: enable PQ encryption for contacts (#4049)", 2024-04-22. It removed the parsers `"/pq @" *> (SetContactPQ …)` and `"/pq " *> (APISetPQEncryption …)`; post-quantum encryption for contacts became automatic, so the manual toggle was obsolete. `SetContactPQ` and `APISetPQEncryption` no longer exist in `src/`. +- **`/get stats` / `/reset stats`** — commit `5907d8bd0` "core: remove legacy agent stats (#4375)", 2024-07-01. It removed the parsers `"/get stats" $> GetAgentStats` and `"/reset stats" $> ResetAgentStats`, their handlers, the `GetAgentStats`/`ResetAgentStats` constructors in `Controller.hs`, and the `View.hs` rendering — but its diff touched `Chat.hs`, `Controller.hs`, `View.hs`, `cabal.project`, `sha256map.nix`, not `Help.hs`. + +In both cases the help text became a promise the binary could no longer keep. + +## 4. Scope verification — no other stale entries, no replacements documented + +All 120 commands documented across every section of `Help.hs` were extracted and matched against the parser string literals in `Library/Commands.hs` (`chatCommandP`). Every entry resolves to a live parser except the four above. ~10 entries that a naive prefix match flagged were manually confirmed valid: incognito-suffix forms parsed by `incognitoP` (`/accept incognito`, `/connect incognito`, `/simplex incognito`), usage examples (`/file bob ./photo.jpg`, `/group team`), and inline sub-alternatives (`/start remote host new`, `/stop remote host new`, `/switch remote host local`, `/chats all`). + +Why no replacement text: + +- **PQ** — there is no command; encryption is negotiated automatically. Documenting nothing is correct. +- **Stats** — the nearest live commands are `/get servers summary ` and `/reset servers stats`, but they require a `userId` argument and return the agent servers summary, not the old argument-less usage statistics. They were never in CLI help; adding them is a separate documentation enhancement, deliberately out of scope for a "remove what no longer exists" fix. + +## 5. Why this shape + +Pure deletion of dead documentation — no behavioral change, smallest diff that makes `/help` truthful. Comma handling is the only subtlety: the `/pq @` and `/pq on/off` removals sit before comma-bearing neighbors (a `""` separator and `/network` respectively) and need no adjustment; the `/get stats` + `/reset stats` removal makes `/(un)mute #` the last `settingsInfo` element, so its trailing comma is removed to avoid a dangling-comma parse error before `]`. diff --git a/plans/2026-06-15-fix-file-upload-long-name.md b/plans/2026-06-15-fix-file-upload-long-name.md new file mode 100644 index 0000000000..f0917783bd --- /dev/null +++ b/plans/2026-06-15-fix-file-upload-long-name.md @@ -0,0 +1,77 @@ +# Fix: long file name hides the close icon in the compose file preview + +Date: 2026-06-15 +Branch: `nd/fix-file-upload-with-long-name` +Platforms affected: Android, Desktop, iOS + +## Problem + +When a file is attached for sending, the compose area shows a preview row with the +file icon, the file name, and a close (X) icon to cancel/remove the file before +sending. If the file name is long, the close icon is not shown, so the user cannot +dismiss the attachment. + +## Cause + +The bug is the same layout defect on both codebases: the file-name text is +unconstrained, so a long name consumes all horizontal space and squeezes the +trailing close button to zero width. + +### Android / Desktop — `ComposeFileView.kt` + +The row was laid out as: + +``` +Icon(fixed) | Text(fileName) | Spacer(weight 1f) | IconButton(close) + ^ unweighted, no maxLines +``` + +In a Compose `Row`, unweighted children are measured first and take the remaining +width before weighted children get anything. The unweighted `Text` therefore grabbed +the whole remaining width on a long name, leaving the weighted `Spacer` — and the +`IconButton` after it — with ~0 width. The flexible element was the `Spacer`, but a +`Spacer` can only distribute the space the rigid `Text` did not already eat. + +### iOS — `ComposeFileView.swift` + +``` +Image(fixed) | Text(fileName) | Spacer() | Button(close) + ^ no lineLimit +``` + +A `Text` with no `lineLimit` reports its full single-line ideal width and refuses to +truncate, so a long name collapses the `Spacer` and pushes the `Button` past the +`.frame(maxWidth: .infinity)` edge, off-screen. + +## Fix + +Make the file name the element that yields space and let it truncate, so the +fixed-size close control's space is always reserved. + +- **Kotlin:** give the `Text` the `weight(1f)` (instead of the `Spacer`) and + `maxLines = 1`, and drop the now-redundant `Spacer`. This matches the existing + idiom — `ComposeImageView` puts `weight(1f)` on its content, and `CIFileView` + caps file-name text with `maxLines = 1`. +- **Swift:** add `.lineLimit(1)` to the `Text`, so it truncates instead of + overflowing, matching how file names are shown elsewhere on iOS. + +## Why this is the right fix (not a workaround) + +`ComposeFileView` was the only compose preview that gave the weight to a `Spacer` +rather than to its content; every sibling preview (`ComposeImageView`, +`ContextItemView`) reserves space for the trailing close control by weighting the +content. The change brings the file preview in line with the established pattern +rather than adding a special case. It is purely structural — no behavior changes +beyond layout. + +## Scope / risk + +- One-spot edit per file; no API or behavior change. +- Android and Desktop share the Kotlin file, so both are fixed together; iOS is the + separate Swift file. +- No string/translation keys touched. + +## Verification + +- Visual: attach a file with a very long name on Android, Desktop, and iOS; confirm + the name truncates and the close (X) icon stays visible and tappable. diff --git a/plans/2026-06-17-fix-group-garbled-error.md b/plans/2026-06-17-fix-group-garbled-error.md new file mode 100644 index 0000000000..c660d13a48 --- /dev/null +++ b/plans/2026-06-17-fix-group-garbled-error.md @@ -0,0 +1,27 @@ +# Fix garbled error when saving group profile (member admission) + +## Problem + +Saving a group profile change — e.g. enabling member admission (Review = "All") from Group preferences → Member admission — can fail with an unreadable alert: + +``` +chat.simplex.common.model.API$Error@3ea295c.err +``` + +The user sees an object reference instead of the actual error, so there is no way to tell what went wrong. + +## Cause + +In `apiUpdateGroup` the `API.Error` branch builds the alert message with `"$r.err"` (`SimpleXAPI.kt:2292`). In a Kotlin string template `"$r.err"` interpolates `r.toString()` — and `API.Error` has no custom `toString`, so it yields `chat.simplex.common.model.API$Error@` — then appends the literal text `.err`. The meaningful message (`r.err.string`) is never read. + +This surfaces whenever the core rejects the update. A concrete trigger is a **desynced member role**: the client shows the Save controls because `groupInfo.isOwner` is true, but the core's `assertUserGroupRole gInfo GROwner` (`Commands.hs:3840`) disagrees and returns `CEGroupUserRole`. The display bug then hides which error it was. + +## Fix + +Render the error message instead of the object reference: + +```kotlin +AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}") +``` + +One-line change in `SimpleXAPI.kt`. This is the only occurrence of the `"$r.err"` pattern in the codebase. The underlying core rejection is unchanged — but it is now shown clearly to the user. diff --git a/plans/2026-06-19-channel-received-remove-right-gap.md b/plans/2026-06-19-channel-received-remove-right-gap.md new file mode 100644 index 0000000000..5ada84b2b0 --- /dev/null +++ b/plans/2026-06-19-channel-received-remove-right-gap.md @@ -0,0 +1,95 @@ +# Remove the right gap on received messages in channels + +## Problem + +In groups, received messages are laid out as left-aligned chat bubbles whose +maximum width is capped well short of the right edge, leaving a large empty gap +on the right so long content wraps early. In channels this wastes horizontal +space — channel posts are broadcast/feed-style content that reads better using +nearly the full row width. + +## Change + +For channels only, received messages drop the right-side gap so content can use +nearly the full row width (a small edge margin remains). This only changes the +maximum available width: long text uses more of the row, short messages still +size to content, and media stays within its existing cap. Sent messages keep +their existing layout. + +### Android / desktop (`apps/multiplatform`) + +The `end` padding becomes `12.dp` (the same edge margin sent messages use) +instead of `adjustTailPaddingOffset(66.dp, …)`, at the four received-message +layout sites in `ChatItemsList` (`ChatView.kt`): the `GroupRcv` +(member-attributed) and `ChannelRcv` (unattributed) branches, each with and +without an avatar. + +```kotlin +end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp + else adjustTailPaddingOffset(66.dp, start = false) +``` + +### iOS (`apps/ios`) + +iOS computes one per-message `maxWidth` in `ChatView.swift` and applies it to +every bubble; the `* 0.84` factor is the gap. For a received message in a +channel that factor is dropped (full width minus the avatar inset) — the same +geometry the voice-message case already uses: + +```swift +let channelReceived = !ci.chatDir.sent && cInfo.isChannel +let maxWidth = cInfo.chatType == .group +? voiceNoFrame || channelReceived +? (g.size.width - 28) - 42 +: (g.size.width - 28) * 0.84 - 42 +: ... +``` + +The received check (`!ci.chatDir.sent`) is explicit here because, unlike the +Kotlin layout (which has a separate received branch), iOS shares one `maxWidth` +between sent and received. + +## Why gate on `ChatInfo.isChannel` (`useRelays`) + +The change is gated per chat on `ChatInfo.isChannel`, which is +`groupInfo?.useRelays == true` — `chatInfo.isChannel` on both Android/desktop and +iOS (`cInfo.isChannel`). + +This is the robust signal. The whole channel feature on both platforms keys on +`useRelays` (channel preferences, member management, info view, broadcast +compose, etc.); `useRelays` is a non-optional `Bool` that is always present on a +group. + +- **Not on the group-type `isChannel`** (`publicGroup?.groupType == channel`). + This was the first attempt and it left the gap in place on iOS. The likely + mechanism: `publicGroup` is an optional reconstructed from nullable DB columns + (`src/Simplex/Chat/Store/Groups.hs` `toGroupProfile`, plus a creation path that + sets `publicGroup = Nothing`), so when it is not populated for a chat the + optional chain silently evaluates to `false` and the gap is never removed. + `useRelays` cannot fail this way — it is a required `Bool` set at group + creation (`useRelays = not direct`, `Commands.hs:2080`). Independent of the + exact mechanism, `useRelays` is the safer signal. It is also as precise: the + only group type ever constructed is `GTChannel` (`GTGroup` is defined but never + instantiated), and `useRelays == true` is set on exactly that same + public-group/channel path, so `useRelays == true` ⟺ "is a channel" for every + chat today — regular groups, business chats and direct chats all have + `useRelays` false/absent (verified: no non-channel path sets it true). +- **Not on the item direction.** The unattributed `ChannelRcv` direction is + produced for any group message without an attributed member, not only in + channels, and channels also contain member-attributed (`GroupRcv`) posts. + Gating on direction would both over- and under-match, so the gate is the + per-chat `isChannel`. + +## Scope + +Regular groups, business chats, and direct chats are unchanged (`isChannel` is +false for them). Sent messages are untouched. + +## Verification + +- Android/desktop: `:common:compileKotlinDesktop` compiles clean. +- iOS: change is a small, type-safe Swift expression; build/verify on macOS + (Xcode) — not compilable on the Linux build host used here. +- Visual (both platforms): in a channel, long received messages widen toward the + right edge; in a regular group and in direct chats the right gap is unchanged; + sent messages are unchanged everywhere. diff --git a/plans/2026-06-19-fix-updater-open-file-location.md b/plans/2026-06-19-fix-updater-open-file-location.md new file mode 100644 index 0000000000..7e4d7e0af1 --- /dev/null +++ b/plans/2026-06-19-fix-updater-open-file-location.md @@ -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/ + 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`. diff --git a/plans/2026-06-19-ios-open-simplex-links-in-messages.md b/plans/2026-06-19-ios-open-simplex-links-in-messages.md new file mode 100644 index 0000000000..f7c89b303a --- /dev/null +++ b/plans/2026-06-19-ios-open-simplex-links-in-messages.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. diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index af408d4054..fdf7154491 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -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 diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 3f35d652fe..b527720e5b 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,50 @@ + + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.6:

+

Public channels - speak freely!

+
    +
  • Reliability: many relays per channel.
  • +
  • Ownership: you can run your own relays.
  • +
  • Security: owners hold channel keys.
  • +
  • Privacy: for owners and subscribers.
  • +
+

Easier to invite your friends: we made connecting simpler for new users.

+

Safe web links:

+
    +
  • opt-in to send link previews.
  • +
  • use SOCKS proxy for previews (if enabled).
  • +
  • prevent hyperlink phishing.
  • +
  • remove link tracking.
  • +
+

Non-profit governance: to make SimpleX Network last.

+
+
+ + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.5:

+

Public channels - speak freely!

+
    +
  • Reliability: many relays per channel.
  • +
  • Ownership: you can run your own relays.
  • +
  • Security: owners hold channel keys.
  • +
  • Privacy: for owners and subscribers.
  • +
+

Easier to invite your friends: we made connecting simpler for new users.

+

Safe web links:

+
    +
  • opt-in to send link previews.
  • +
  • use SOCKS proxy for previews (if enabled).
  • +
  • prevent hyperlink phishing.
  • +
  • remove link tracking.
  • +
+

Non-profit governance: to make SimpleX Network last.

+
+
https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 407c8198dc..7c6f6b8bae 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.4.1 +version: 7.0.0.4 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -38,6 +38,8 @@ library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings + Simplex.Chat.Badges + Simplex.Chat.Badges.CLI Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Delivery @@ -51,6 +53,7 @@ library Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Mobile + Simplex.Chat.Mobile.Badges Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC @@ -90,6 +93,7 @@ library Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Util + Simplex.Chat.Web if !flag(client_library) exposed-modules: Simplex.Chat.Bot @@ -134,9 +138,12 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access + Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain + Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name Simplex.Chat.Store.Postgres.Migrations.M20260604_simplex_name_profiles Simplex.Chat.Store.Postgres.Migrations.M20260606_simplex_name_verified @@ -297,9 +304,12 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access + Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain + Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name Simplex.Chat.Store.SQLite.Migrations.M20260604_simplex_name_profiles Simplex.Chat.Store.SQLite.Migrations.M20260606_simplex_name_verified @@ -564,6 +574,7 @@ test-suite simplex-chat-test main-is: Test.hs other-modules: APIDocs + BadgeTests Bots.BroadcastTests Bots.DirectoryTests ChatClient diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ec17614db3..b795ba9b9c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,6 +29,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Time.Clock (getCurrentTime, nominalDay) import Simplex.Chat.Controller +import Simplex.Chat.Badges (BBSPublicKeyStr (..)) import Simplex.Chat.Library.Commands import Simplex.Chat.Operators import Simplex.Chat.Operators.Presets @@ -65,6 +66,17 @@ defaultChatConfig = tbqSize = 1024 }, chatVRange = supportedChatVRange, + badgePublicKeys = + M.fromList + [ (1, toBBSPublicKey "mW_5Zp1wHnXDF56wOZwFcRjGrf0GLLsfyymIQDqYoWfjfvS7oQWSfi7hH65N8JhuE9x8wbKXHidnQLO4GnOSMP_bRKUMH1qIzv5SQKFHNM8G4PaWcTcri8iZLc-3xhSI"), + (2, toBBSPublicKey "odGCB7uVDXTURsHgSvSciByV4Q3-3ZvEB8myDsDJqm-PwOYc5-At36uc7n_pyUDxEQEHr9i4RJgFih2FSArPW-EQBXNPNf4wTtA0znn74qLEGc4fh9pVYPEIm_ZGbnsJ"), + (3, toBBSPublicKey "txkT2003WMjc43KvYvPKEcR970NLmw5UZY51eUqgk91sgp53idt1HTlKYvnrEttJDFMlctYf1-bpri0e9DhBQ-xk1J4WoLN2uif_1OcA1pGCobpk9lwtsq1Idek4biy0"), + (4, toBBSPublicKey "q_YzegihaLYrEm9z3cAghsfDGNZfXuEpQGMJERJQS4M0Szl4gvSC_fV_muKc3NIMA_8iYuBN8qyvb5U55RctCRn3kleFQ4sqf-WBgoydX6UVo7BsYcUbXWWEFZXlOGIH"), + (5, toBBSPublicKey "oqymHASH_okefShrnz4HnTooUNlE1WoDRnSrgd0bTCpOacgJWBsMpwZpdmYlX-vQAKAC_zmI4VdKoOznnhW-sdUXZw6bthCi5JYjGxCR1Co27i1tix5UXCTbR5Jp901-"), + (6, toBBSPublicKey "kDqaB6zKSRp_97QPFj5JPDlo0vzfSTLSp9goFx1qajv4q4H6dR6BbkmWZ4xx_9Q2AxmcpqcV0ethz1OH-Jk_Sz2J1mIz1PUVM9LkdLhi_PNtqhezzO5dbVs-HJ1fNqe6"), + (7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"), + (8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd") + ], confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options @@ -116,6 +128,7 @@ defaultChatConfig = highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, + webPreviewConfig = Nothing, channelSubscriberRole = GRObserver, relayChecksInterval = 15 * 60, -- 15 minutes relayInactiveTTL = nominalDay, @@ -140,11 +153,11 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'} randomPresetServers <- chooseRandomServers presetServers' let rndSrvs = L.toList randomPresetServers operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op @@ -182,6 +195,7 @@ newChatController deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO relayGroupLinkChecksAsync <- newTVarIO Nothing + webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState chatRelayTests <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO @@ -226,6 +240,7 @@ newChatController deliveryJobWorkers, relayRequestWorkers, relayGroupLinkChecksAsync, + webPreviewState, chatRelayTests, expireCIThreads, expireCIFlags, diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs new file mode 100644 index 0000000000..e861d27f11 --- /dev/null +++ b/src/Simplex/Chat/Badges.hs @@ -0,0 +1,414 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE ExistentialQuantification #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Badges + ( BadgeType (..), + BadgeStatus (..), + BadgeInfo (..), + BadgeCredential (..), + BadgeProof (..), + LocalBadge (..), + JSONBadge (..), + BBSPublicKeyStr (..), + localBadgeInfo, + localBadgeStatus, + maxXFTPFileSize, + maxFileSizeSupporter, + maxFileSizeLegend, + BadgePresHeaderTag (..), + BadgePresHeader (..), + BadgePurchase (..), + BadgeMasterKey (..), + BadgeRequest (..), + VerifiedBadgeRequest (..), + bbsBadgeHeader, + generateMasterKey, + verifyPayment, + issueBadge, + verifyCredential, + generateBadgeProof, + badgeProof, + verifyBadge, + verifyBadge_, + mkBadgeStatus, + BadgeRow, + badgeToRow, + localBadgeToRow, + rowToBadge, + ) where + +import Control.Concurrent.STM +import Crypto.Random (ChaChaDRG) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Either (fromRight) +import Data.Int (Int64) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.String +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, nominalDay) +import Simplex.FileTransfer.Description (gb, maxFileSize) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif + +-- Badge type + +data BadgeType + = BTSupporter + | BTLegend + | BTInvestor + | BTUnknown Text + deriving (Eq, Show) + +instance TextEncoding BadgeType where + textEncode = \case + BTSupporter -> "supporter" + BTLegend -> "legend" + BTInvestor -> "investor" + BTUnknown tag -> tag + textDecode s = Just $ case s of + "supporter" -> BTSupporter + "legend" -> BTLegend + "investor" -> BTInvestor + tag -> BTUnknown tag + +instance ToJSON BadgeType where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeType where + parseJSON = textParseJSON "BadgeType" + +-- Badge status + +data BadgeStatus = BSActive | BSExpired | BSExpiredOld | BSFailed | BSUnknownKey + deriving (Eq, Show) + +-- Disclosed badge content (BBS messages 1, 2, 3) + +data BadgeInfo = BadgeInfo + { badgeType :: BadgeType, + badgeExpiry :: Maybe UTCTime, + badgeExtra :: Text + } + deriving (Eq, Show) + +-- a badge expired longer than this ago is BSExpiredOld and is not shown in the UI +badgeOldInterval :: NominalDiffTime +badgeOldInterval = 31 * nominalDay + +-- the verification outcome of a received proof: Just True = verified, Just False = failed, +-- Nothing = the proof's key index is not among this app version's configured keys (BSUnknownKey). +mkBadgeStatus :: UTCTime -> Maybe Bool -> BadgeInfo -> BadgeStatus +mkBadgeStatus now verified BadgeInfo {badgeExpiry} = case verified of + Nothing -> BSUnknownKey + Just False -> BSFailed + Just True -> case badgeExpiry of + Just e + | addUTCTime badgeOldInterval e < now -> BSExpiredOld + | e < now -> BSExpired + _ -> BSActive + +-- A badge credential (own, secret) and a proof (a presentation) are independent records. +-- badgeKeyIdx is the issuer key index: it tells verifiers which configured key to use. +-- Only proofs ride the wire (in a profile); credentials come from the badge service. Neither is +-- ever serialized as a sum - each travels as its own record, so the JSON carries no credential/proof tag. + +data BadgeCredential = BadgeCredential + { badgeKeyIdx :: Int, + masterKey :: BadgeMasterKey, + signature :: BBSSignature, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +data BadgeProof = BadgeProof + { badgeKeyIdx :: Int, + presHeader :: BBSPresHeader, + proof :: BBSProof, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +-- Local badge: a stored badge plus its display status (the in-memory sum; never serialized as a sum). +-- OwnBadge - the user's own credential (loaded from the DB). +-- PeerBadge - a verified peer proof (from the DB, or received over the wire). +-- ShownBadge - decoded from a crypto-free profile JSON for display only: no crypto, so it cannot be sent. +data LocalBadge + = OwnBadge BadgeCredential BadgeStatus + | PeerBadge BadgeProof BadgeStatus + | ShownBadge BadgeInfo BadgeStatus + deriving (Eq, Show) + +localBadgeInfo :: LocalBadge -> BadgeInfo +localBadgeInfo = \case + OwnBadge BadgeCredential {badgeInfo} _ -> badgeInfo + PeerBadge BadgeProof {badgeInfo} _ -> badgeInfo + ShownBadge i _ -> i + +localBadgeStatus :: LocalBadge -> BadgeStatus +localBadgeStatus = \case + OwnBadge _ st -> st + PeerBadge _ st -> st + ShownBadge _ st -> st + +-- XFTP file size limit raised by an active badge: a legend badge to 5GB, any other to 2GB, otherwise the default. +maxFileSizeSupporter :: Int64 +maxFileSizeSupporter = gb 2 + +maxFileSizeLegend :: Int64 +maxFileSizeLegend = gb 5 + +maxXFTPFileSize :: Maybe LocalBadge -> Int64 +maxXFTPFileSize = \case + Just b | localBadgeStatus b == BSActive -> case badgeType (localBadgeInfo b) of + BTLegend -> maxFileSizeLegend + _ -> maxFileSizeSupporter + _ -> maxFileSize + +-- Presentation header: a tag char + payload. PHTest is unbound - a fresh random nonce per +-- presentation, not bound to any context; the 'T' tag marks it so master rejects it. +-- PHUnknown is the forward-compat catch-all for tags this version does not interpret. + +data BadgePresHeaderTag = PHTestTag | PHUnknownTag Char + +instance StrEncoding BadgePresHeaderTag where + strEncode = B.singleton . \case + PHTestTag -> 'T' + PHUnknownTag c -> c + strP = tag <$> A.anyChar + where + tag = \case + 'T' -> PHTestTag + c -> PHUnknownTag c + +data BadgePresHeader + = PHTest ByteString + | PHUnknown Char ByteString + +instance StrEncoding BadgePresHeader where + strEncode = \case + PHTest nonce -> strEncode PHTestTag <> nonce + PHUnknown c b -> strEncode (PHUnknownTag c) <> b + strP = + strP >>= \case + PHTestTag -> PHTest <$> A.takeByteString + PHUnknownTag c -> PHUnknown c <$> A.takeByteString + +-- v6.5.x accepts both; v7 will reject PHTest/PHUnknown +badgePresHeaderAccepted :: BadgePresHeader -> Bool +badgePresHeaderAccepted = \case + PHTest _ -> True + PHUnknown _ _ -> True + +-- Payment proof + +data BadgePurchase + = BPAppleReceipt Text + | BPGoogleReceipt Text + | BPStripeSession + | BPRedeemCode Text + deriving (Eq, Show) + +-- Master key + +newtype BadgeMasterKey = BadgeMasterKey ByteString + deriving newtype (Eq, Show, StrEncoding) + +instance ToJSON BadgeMasterKey where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON BadgeMasterKey where + parseJSON = strParseJSON "BadgeMasterKey" + +generateMasterKey :: TVar ChaChaDRG -> IO BadgeMasterKey +generateMasterKey drg = BadgeMasterKey <$> atomically (C.randomBytes 32 drg) + +-- Workflow types + +data BadgeRequest = BadgeRequest + { masterKey :: BadgeMasterKey, + badgeInfo :: BadgeInfo + } + deriving (Show) + +newtype VerifiedBadgeRequest = VerifiedBadgeRequest BadgeRequest + deriving (Show) + +-- Constants + +bbsBadgeHeader :: BBSHeader +bbsBadgeHeader = BBSHeader "SimpleX badges v1" + +bbsBadgeMessageCount :: Int +bbsBadgeMessageCount = 4 + +bbsBadgeDisclosedIndexes :: [Int] +bbsBadgeDisclosedIndexes = [1, 2, 3] + +-- Message encoding + +encodeExpiry :: Maybe UTCTime -> ByteString +encodeExpiry = maybe "lifetime" strEncode + +badgeMessages :: BadgeMasterKey -> BadgeInfo -> [ByteString] +badgeMessages (BadgeMasterKey ms) info = ms : badgeInfoMessages info + +badgeInfoMessages :: BadgeInfo -> [ByteString] +badgeInfoMessages BadgeInfo {badgeType, badgeExpiry, badgeExtra} = + [encodeExpiry badgeExpiry, encodeUtf8 (textEncode badgeType), encodeUtf8 badgeExtra] + +-- Payment verification (stub - always passes) + +verifyPayment :: BadgePurchase -> BadgeRequest -> IO (Maybe VerifiedBadgeRequest) +verifyPayment _payment req = pure $ Just (VerifiedBadgeRequest req) + +-- Server-side: issue a badge credential, recording which issuer key signed it + +issueBadge :: Int -> BBSSecretKey -> VerifiedBadgeRequest -> IO (Either String BadgeCredential) +issueBadge keyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) + | badgeExtra badgeInfo /= "" = pure $ Left "badgeExtra must be empty (reserved)" + | otherwise = fmap (\sig -> BadgeCredential keyIdx masterKey sig badgeInfo) <$> bbsSign sk bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: verify the credential received from server + +verifyCredential :: BBSPublicKey -> BadgeCredential -> IO Bool +verifyCredential pk (BadgeCredential _ masterKey signature badgeInfo) = + bbsVerify pk signature bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: generate a proof for a contact/group; the proof carries the credential's key index + +generateBadgeProof :: BBSPublicKey -> BadgeCredential -> BBSPresHeader -> IO (Either String BadgeProof) +generateBadgeProof pk (BadgeCredential keyIdx masterKey signature badgeInfo) ph = + fmap (\p -> BadgeProof keyIdx ph p badgeInfo) <$> bbsProofGen pk signature bbsBadgeHeader ph bbsBadgeDisclosedIndexes (badgeMessages masterKey badgeInfo) + +-- application-level proof generation with a semantic presentation header +badgeProof :: BBSPublicKey -> BadgeCredential -> BadgePresHeader -> IO (Either String BadgeProof) +badgeProof pk cred ph = generateBadgeProof pk cred (BBSPresHeader $ strEncode ph) + +-- Recipient-side: verify a badge proof with the configured key its index points to. +-- Nothing means the key index is not in the configured keys (this app version can't verify it). + +verifyBadge :: Map Int BBSPublicKey -> BadgeProof -> IO (Maybe Bool) +verifyBadge keys b@(BadgeProof keyIdx _ _ _) = case M.lookup keyIdx keys of + Nothing -> pure Nothing + Just pk -> Just <$> verifyBadgeWith pk b + +verifyBadgeWith :: BBSPublicKey -> BadgeProof -> IO Bool +verifyBadgeWith pk (BadgeProof _ ph@(BBSPresHeader phBytes) proof badgeInfo) + | either (const False) badgePresHeaderAccepted (strDecode phBytes) = + bbsProofVerify pk proof bbsBadgeHeader ph bbsBadgeDisclosedIndexes bbsBadgeMessageCount (badgeInfoMessages badgeInfo) + | otherwise = pure False + +verifyBadge_ :: Map Int BBSPublicKey -> Maybe BadgeProof -> IO (Maybe Bool) +verifyBadge_ keys = maybe (pure (Just False)) (verifyBadge keys) + +-- DB + +instance FromField BadgeType where fromField = fromTextField_ textDecode + +instance ToField BadgeType where toField = toField . textEncode + +-- (proof, pres_header, expiry, type, verified, extra, master_key, signature, key_idx) - binary columns wrapped in Binary (BLOB/bytea) +type BadgeRow = (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe UTCTime, Maybe Text, Maybe BoolInt, Maybe Text, Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int) + +-- receive/store sites have a wire proof + a computed verification outcome; +-- the status here only drives the stored verified flag, the display status is recomputed on load +badgeToRow :: Maybe BadgeProof -> Maybe Bool -> BadgeRow +badgeToRow badge verified = localBadgeToRow $ (`PeerBadge` st) <$> badge + where + st = case verified of + Just True -> BSActive + Just False -> BSFailed + Nothing -> BSUnknownKey + +localBadgeToRow :: Maybe LocalBadge -> BadgeRow +localBadgeToRow (Just lb) = case lb of + OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Just (Binary mk), Just (Binary sg), Just idx) + PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Just (Binary p), Just (Binary ph), badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Just idx) + ShownBadge BadgeInfo {badgeType, badgeExpiry, badgeExtra} st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Nothing) + where + verifiedField st = case st of + BSFailed -> Just (BI False) + BSUnknownKey -> Nothing + _ -> Just (BI True) +localBadgeToRow Nothing = (Nothing, Nothing, Nothing, Nothing, Just (BI False), Nothing, Nothing, Nothing, Nothing) + +rowToBadge :: UTCTime -> BadgeRow -> Maybe LocalBadge +rowToBadge now (p_, ph_, badgeExpiry, type_, verified_, extra_, mk_, sg_, idx_) = do + btText <- type_ + bt <- textDecode btText + let info = BadgeInfo {badgeType = bt, badgeExpiry, badgeExtra = maybe "" id extra_} + -- NULL badge_verified means the key index was unknown when stored (Nothing) + st = mkBadgeStatus now (unBI <$> verified_) info + case (mk_, sg_, p_, ph_, idx_) of + (Just (Binary mk), Just (Binary sg), _, _, Just idx) -> Just $ OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) info) st + (_, _, Just (Binary p), Just (Binary ph), Just idx) -> Just $ PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) info) st + _ -> Just $ ShownBadge info st + +-- JSON + +$(JQ.deriveJSON (enumJSON $ dropPrefix "BS") ''BadgeStatus) + +$(JQ.deriveJSON defaultJSON ''BadgeInfo) + +$(JQ.deriveJSON defaultJSON ''BadgeRequest) + +-- Each record is a plain JSON object (defaultJSON), platform-independent and with no credential/proof +-- tag - the context (a proof in a profile, a credential from the service) determines which it is. + +$(JQ.deriveJSON defaultJSON ''BadgeCredential) + +$(JQ.deriveJSON defaultJSON ''BadgeProof) + +-- LocalBadge is sent to the UI/clients WITHOUT crypto - only disclosed info + status. The credential/proof +-- bytes stay core-side. FromJSON reconstructs a display-only badge (empty proof) for read-only consumers +-- (remote host, UI echoes); the authoritative badge is loaded from the DB (rowToBadge), never from this JSON. +data JSONBadge = JSONBadge {badge :: BadgeInfo, status :: BadgeStatus} + +$(JQ.deriveJSON defaultJSON ''JSONBadge) + +instance ToJSON LocalBadge where + toJSON lb = toJSON $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + toEncoding lb = toEncoding $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + +instance FromJSON LocalBadge where + parseJSON v = do + JSONBadge info st <- parseJSON v + pure $ ShownBadge info st + +newtype BBSPublicKeyStr = BBSPublicKeyStr {toBBSPublicKey :: BBSPublicKey} + +instance IsString BBSPublicKeyStr where + fromString = BBSPublicKeyStr . fromRight (error "bad base64 in BBSPublicKey") . strDecode . B.pack diff --git a/src/Simplex/Chat/Badges/CLI.hs b/src/Simplex/Chat/Badges/CLI.hs new file mode 100644 index 0000000000..8a7cd84b61 --- /dev/null +++ b/src/Simplex/Chat/Badges/CLI.hs @@ -0,0 +1,87 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Offline operator tooling for supporter badges, invoked as `simplex-chat badge ...`. +-- keygen - the issuer keypair: the "secret" signs, the "public" goes into the app config. +-- master-key - the user's master secret (their unlinkability secret; generated client-side in the real flow). +-- sign - bind a user master secret to a badge with the issuer secret, printed as one-line JSON for `/badge add`. +module Simplex.Chat.Badges.CLI (runBadgeCommand) where + +import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.Text as T +import Data.Time.Clock (UTCTime) +import Data.Time.Format (defaultTimeLocale, parseTimeM) +import Options.Applicative +import Simplex.Chat.Badges +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey (..), BBSSecretKey (..), bbsKeyGen) +import Simplex.Messaging.Encoding.String (strDecode, strEncode, textDecode) +import System.Exit (die) + +bbsSecretLen :: Int +bbsSecretLen = 32 + +data BadgeCommand + = Keygen + | MasterKey + | Sign Int BBSSecretKey BadgeMasterKey BadgeType (Maybe UTCTime) + +runBadgeCommand :: [String] -> IO () +runBadgeCommand args = + handleParseResult (execParserPure defaultPrefs badgeInfo args) >>= \case + Keygen -> keygen + MasterKey -> genMasterKey + Sign keyIdx sk ms badgeType badgeExpiry -> sign keyIdx sk ms badgeType badgeExpiry + where + badgeInfo = info (helper <*> hsubparser badgeCmd) fullDesc + badgeCmd = command "badge" (info (helper <*> badgeCommandP) (progDesc "SimpleX supporter badge tooling")) + +badgeCommandP :: Parser BadgeCommand +badgeCommandP = + hsubparser $ + command "keygen" (info (pure Keygen) (progDesc "generate an issuer keypair (issuer secret + public, base64url)")) + <> command "master-key" (info (pure MasterKey) (progDesc "generate a user master secret (base64url)")) + <> command "sign" (info signP (progDesc "sign a badge for a user master secret, printed as one-line JSON")) + where + signP = + Sign + <$> option auto (long "key-idx" <> metavar "KEY_IDX" <> help "index of the issuer key in the app config") + <*> option (eitherReader secretR) (long "secret" <> metavar "ISSUER_SECRET" <> help "issuer secret from keygen (base64url)") + <*> option (eitherReader (strDecode . B.pack)) (long "master" <> metavar "MASTER" <> help "user master secret from master-key (base64url)") + <*> option (eitherReader badgeTypeR) (long "type" <> metavar "TYPE" <> help "badge type (supporter, legend, investor)") + <*> option (eitherReader expireR) (long "expire" <> metavar "lifetime|YYYY-MM-DD" <> help "expiry date, or 'lifetime'") + secretR s = do + sk@(BBSSecretKey b) <- strDecode (B.pack s) + if B.length b == bbsSecretLen + then Right sk + else Left "bad issuer secret - use the 'secret' value from keygen" + badgeTypeR = maybe (Left "invalid badge type") Right . textDecode . T.pack + expireR = \case + "lifetime" -> Right Nothing + s -> maybe (Left "use 'lifetime' or YYYY-MM-DD") (Right . Just) $ parseTimeM True defaultTimeLocale "%Y-%m-%d" s + +keygen :: IO () +keygen = + bbsKeyGen >>= \case + Left e -> die $ "keygen failed: " <> e + Right (BBSPublicKey pk, BBSSecretKey sk) -> do + B.putStrLn $ "secret " <> strEncode sk + B.putStrLn $ "public " <> strEncode pk + +genMasterKey :: IO () +genMasterKey = do + drg <- C.newRandom + mk <- generateMasterKey drg + B.putStrLn $ strEncode mk + +sign :: Int -> BBSSecretKey -> BadgeMasterKey -> BadgeType -> Maybe UTCTime -> IO () +sign keyIdx secretKey masterKey badgeType badgeExpiry = do + let req = VerifiedBadgeRequest (BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}} :: BadgeRequest) + issueBadge keyIdx secretKey req >>= \case + Left e -> die $ "sign failed: " <> e + -- single-line JSON (master secret + signature + info), pasted into the app via `/badge add` + Right cred -> LB.putStrLn $ J.encode cred diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bb2dde9701..d76035bcc5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -39,6 +39,7 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import Data.Set (Set) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.String @@ -81,6 +82,8 @@ import Simplex.Messaging.Agent.Store.DB (SQLError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges (BadgeCredential) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption) @@ -137,6 +140,8 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, + -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation + badgePublicKeys :: Map Int BBSPublicKey, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -158,6 +163,7 @@ data ChatConfig = ChatConfig ciExpirationInterval :: Int64, -- microseconds deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, + webPreviewConfig :: Maybe WebPreviewConfig, channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays relayChecksInterval :: NominalDiffTime, relayInactiveTTL :: NominalDiffTime, @@ -169,10 +175,47 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +data WebPreviewConfig = WebPreviewConfig + { webDomain :: Text, + webJsonDir :: FilePath, + webCorsFile :: Maybe FilePath, + webUpdateInterval :: Int, -- seconds + webPreviewItemCount :: Int + } + +data PublishableGroup = PublishableGroup + { pgFileName :: FilePath, + pgCorsEntry :: Maybe (Text, CorsOrigin) + } + +data CorsOrigin = CorsAny | CorsOrigins [Text] + deriving (Show) + +data WebPreviewState = WebPreviewState + { publishableGroupIds :: TVar (Map Int64 PublishableGroup), + priorityRender :: TQueue Int64, + filesToRemove :: TQueue FilePath, + corsNeeded :: TVar Bool, + routinePending :: TVar (Set Int64), + wakeSignal :: TMVar (), + webPreviewWorkerAsync :: TVar (Maybe (Async ())) + } + +newWebPreviewState :: IO WebPreviewState +newWebPreviewState = do + publishableGroupIds <- newTVarIO mempty + priorityRender <- newTQueueIO + filesToRemove <- newTQueueIO + corsNeeded <- newTVarIO False + routinePending <- newTVarIO mempty + wakeSignal <- newEmptyTMVarIO + webPreviewWorkerAsync <- newTVarIO Nothing + pure WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal, webPreviewWorkerAsync} + -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. mkStoreCxt :: ChatConfig -> StoreCxt -mkStoreCxt ChatConfig {chatVRange} = StoreCxt chatVRange +mkStoreCxt ChatConfig {chatVRange, badgePublicKeys} = StoreCxt chatVRange badgePublicKeys {-# INLINE mkStoreCxt #-} data RandomAgentServers = RandomAgentServers @@ -262,6 +305,7 @@ data ChatController = ChatController deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), + webPreviewState :: Maybe WebPreviewState, chatRelayTests :: TMap ConnId RelayTest, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, @@ -556,6 +600,7 @@ data ChatCommand | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) | ShowGroupDescription GroupName + | SetPublicGroupAccess GroupName PublicGroupAccess | CreateGroupLink GroupName GroupMemberRole | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName @@ -581,6 +626,7 @@ data ChatCommand | SetBotCommands [ChatBotCommand] | UpdateProfile ContactName (Maybe Text) -- UserId (not used in UI) | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) + | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index d90f1964de..957812f6e9 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -140,7 +140,7 @@ createActiveUser cc CoreChatOpts {chatRelay} = \case displayName <- T.pack <$> withPrompt "display name" getLine createUser loop False $ mkProfile displayName where - mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Nothing, preferences = Nothing} + mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, simplexName = 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 diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 59e7a2c941..56ed65fb4b 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -187,8 +187,6 @@ contactsHelpInfo = indent <> highlight "/verify @ " <> " - clear security code verification", indent <> highlight "/info @ " <> " - info about contact connection", indent <> highlight "/switch @ " <> " - switch receiving messages to another SMP relay", - indent <> highlight "/pq @ on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for a contact", - indent <> " " <> " (both have to enable for quantum resistance)", "", green "Contact chat preferences:", indent <> highlight "/set voice @ yes/no/always " <> " - allow/prohibit voice messages with the contact", @@ -324,16 +322,13 @@ settingsInfo = map styleMarkdown [ green "Chat settings:", - indent <> highlight "/pq on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for the new contacts", indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/smp " <> " - show / set configured SMP servers", indent <> highlight "/xftp " <> " - show / set configured XFTP servers", indent <> highlight "/info " <> " - information about contact connection", indent <> highlight "/info # " <> " - information about member connection", indent <> highlight "/(un)mute " <> " - (un)mute contact, the last messages can be printed with /tail command", - indent <> highlight "/(un)mute # " <> " - (un)mute group", - indent <> highlight "/get stats " <> " - get usage statistics", - indent <> highlight "/reset stats " <> " - reset usage statistics" + indent <> highlight "/(un)mute # " <> " - (un)mute group" ] databaseHelpInfo :: [StyledString] diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b3b0a76c4d..af2f6a593f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -55,6 +55,7 @@ import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber +import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -89,6 +90,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U +import Simplex.Chat.Web (webPreviewWorker) import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -200,6 +202,7 @@ startChatController mainApp enableSndFiles = do startCleanupManager void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users + startWebPreview users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -231,6 +234,20 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT $ runRelayGroupLinkChecks relayUser) atomically $ writeTVar relayAsync a _ -> pure () + startWebPreview users = do + let relayUsers = filter (\User {userChatRelay} -> isTrue userChatRelay) users + ChatConfig {webPreviewConfig = cfg_} <- asks config + case (relayUsers, cfg_) of + (_ : _, Just cfg) -> do + wps_ <- asks webPreviewState + forM_ wps_ $ \WebPreviewState {webPreviewWorkerAsync} -> + readTVarIO webPreviewWorkerAsync >>= \case + Nothing -> do + cc <- ask + a <- Just <$> async (liftIO $ webPreviewWorker cfg cc relayUsers) + atomically $ writeTVar webPreviewWorkerAsync a + _ -> pure () + _ -> pure () startExpireCIs user = whenM shouldExpireChats $ do startExpireCIThread user setExpireCIFlag user True @@ -364,16 +381,16 @@ processChatCommand cxt nm = \case user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) (isTrue userChatRelay) service p True ts mapM_ (setUserServers db user ts) uss - createPresetContactCards db user `catchAllErrors` \_ -> pure () + createPresetContactCards db cxt user `catchAllErrors` \_ -> pure () createNoteFolder db user pure user atomically . writeTVar u $ Just user pure $ CRActiveUser user where - createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () - createPresetContactCards db user = do - createContact db user simplexStatusContactProfile - createContact db user simplexTeamContactProfile + createPresetContactCards :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO () + createPresetContactCards db cxt user = do + createContact db cxt user simplexStatusContactProfile + createContact db cxt user simplexTeamContactProfile chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) chooseServers user_ = do as <- asks randomAgentServers @@ -652,12 +669,14 @@ processChatCommand cxt nm = \case _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user APISetChatTags (ChatRef cType chatId scope) tagIds -> withUser $ \user -> case cType of - CTDirect -> withFastStore' $ \db -> do - updateDirectChatTags db chatId (maybe [] L.toList tagIds) - CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId - CTGroup | isNothing scope -> withFastStore' $ \db -> do - updateGroupChatTags db chatId (maybe [] L.toList tagIds) - CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId + CTDirect -> withFastStore $ \db -> do + Contact {contactId} <- getContact db cxt user chatId + liftIO $ updateDirectChatTags db contactId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getDirectChatTags db contactId) + CTGroup | isNothing scope -> withFastStore $ \db -> do + GroupInfo {groupId} <- getGroupInfo db cxt user chatId + liftIO $ updateGroupChatTags db groupId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getGroupChatTags db groupId) _ -> throwCmdError "not supported" APIDeleteChatTag tagId -> withUser $ \user -> do withFastStore' $ \db -> deleteChatTag db user tagId @@ -1270,6 +1289,8 @@ processChatCommand cxt nm = \case filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId $ do deleteCIFiles user filesInfo + -- the roster blob file has no chat item, so it is missed by getGroupFileInfo above + cleanupGroupRosterFile user gInfo (members, recipients) <- getRecipients gInfo let doSendDel = memberActive membership && isOwner msgSigned <- @@ -1692,8 +1713,11 @@ processChatCommand cxt nm = \case CRServerOperatorConditions <$> getServerOperators db APISetChatTTL userId (ChatRef cType chatId scope) newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do - (oldTTL_, globalTTL, ttlCount) <- withStore' $ \db -> - (,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user + (oldTTL_, globalTTL, ttlCount) <- withStore $ \db -> do + oldTTL <- getSetChatTTL db user + globalTTL <- liftIO $ getChatItemTTL db user + ttlCount <- liftIO $ getChatTTLCount db user + pure (oldTTL, globalTTL, ttlCount) let newTTL = fromMaybe globalTTL newTTL_ oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do @@ -1702,9 +1726,13 @@ processChatCommand cxt nm = \case lift $ setChatItemsExpiration user globalTTL ttlCount ok user where - getSetChatTTL db = case cType of - CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ - CTGroup | isNothing scope -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + getSetChatTTL db currentUser = case cType of + CTDirect -> do + Contact {contactId} <- getContact db cxt currentUser chatId + liftIO $ getDirectChatTTL db contactId <* setDirectChatTTL db contactId newTTL_ + CTGroup | isNothing scope -> do + GroupInfo {groupId} <- getGroupInfo db cxt currentUser chatId + liftIO $ getGroupChatTTL db groupId <* setGroupChatTTL db groupId newTTL_ _ -> pure Nothing expireChat user globalTTL = do currentTs <- liftIO getCurrentTime @@ -1955,7 +1983,8 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - let userData = contactShortLinkData (userProfileDirect user incognitoProfile Nothing True) Nothing + linkProfile <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True + let userData = contactShortLinkData linkProfile Nothing userLinkData = UserInvLinkData userData (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink @@ -1976,7 +2005,7 @@ processChatCommand cxt nm = \case updatePCCIncognito db user conn (Just pId) sLnk pure $ CRConnectionIncognitoUpdated user conn' (Just incognitoProfile) (ConnNew, Just pId, False) -> do - sLnk <- updatePCCShortLinkData conn $ userProfileDirect user Nothing Nothing True + sLnk <- updatePCCShortLinkData conn =<< presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) conn' <- withFastStore' $ \db -> do deletePCCIncognitoProfile db user pId updatePCCIncognito db user conn Nothing sLnk @@ -1995,9 +2024,10 @@ processChatCommand cxt nm = \case recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode let short = isJust $ connShortLink' =<< connLinkInv - userLinkData_ - | short = Just $ UserInvLinkData $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing - | otherwise = Nothing + userLinkData_ <- + if short + then Just . UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser Nothing (userProfileDirect newUser Nothing Nothing True) + else pure Nothing (agConnId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink conn' <- withFastStore' $ \db -> do @@ -2022,9 +2052,9 @@ processChatCommand cxt nm = \case gVar <- asks random (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing Nothing hostMember <- maybe (throwCmdError "no host member") pure hostMember_ - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember - createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing + createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing Nothing cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent) message @@ -2034,9 +2064,9 @@ processChatCommand cxt nm = \case pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId Nothing - void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDDirectRcv ct - createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing + createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing cInfo = DirectChat ct void $ createItem Nothing $ CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ connRequestPQEncryption cReq void $ createFeatureEnabledItems_ user ct @@ -2053,11 +2083,11 @@ processChatCommand cxt nm = \case subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ Nothing - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo - aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing + aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing Nothing let chat = case aci of Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats @@ -2125,7 +2155,7 @@ processChatCommand cxt nm = \case -- create changed feature items (connecting incognito sends default preferences, instead of user preferences) lift . when incognito $ createContactChangedFeatureItems user ct ct' forM_ msg_ $ \(sharedMsgId, mc) -> do - ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing + ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing toView $ CEvtNewChatItems user [ci] pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' @@ -2218,7 +2248,7 @@ processChatCommand cxt nm = \case liftIO $ setPreparedGroupStartedConnection db groupId getGroupInfo db cxt user groupId forM_ msg_ $ \(sharedMsgId, mc) -> do - ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing + ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing toView $ CEvtNewChatItems user [ci] pure $ CRStartedConnectionToGroup user gInfo' customUserProfile [] CVRConnectedContact _ct -> throwChatError $ CEException "contact already exists when connecting to group" @@ -2274,10 +2304,11 @@ processChatCommand cxt nm = \case Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode -- TODO [relays] relay: add identity, key to link data? - let userData - | isTrue userChatRelay = relayShortLinkData (userProfileDirect user Nothing Nothing True) - | otherwise = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing - userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} + userData <- + if isTrue userChatRelay + then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) + else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) + let userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink let ccLink'' = if isTrue userChatRelay then setShortLinkType CCTRelay ccLink' else ccLink' @@ -2730,34 +2761,45 @@ processChatCommand cxt nm = \case -- TODO [relays] possible optimization is to read only required members + relays g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" - let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members + let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, finalPrivilegedCount) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ throwCmdError "can't change role of multiple members when admins selected, or new role is admin" when anyPending $ throwCmdError "can't change role of members pending approval" assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) + -- in relay groups the roster has a single signer, so only the owner may change moderator/admin roles + when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $ + throwCmdError "only the group owner can change moderator and admin roles" + when (useRelays' gInfo && isRosterRole newRole && finalPrivilegedCount > maxGroupRosterSize) $ + throwCmdError $ "the number of members, moderators and admins would exceed the limit of " <> show maxGroupRosterSize (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems - (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g currentMems + let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && (isRosterRole newRole || anyPrivilegedTarget) + rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing + (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g rosterVer currentMems + forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView unless (null acis) $ toView $ CEvtNewChatItems user acis let errs = errs1 <> errs2 unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole, msgSigned} -- same order is not guaranteed where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds - selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) - selectMembers = foldr' addMember ([], [], [], GRObserver, False, False) + -- anyPrivilegedTarget: a target currently moderator/admin; finalPrivilegedCount: + -- moderators + admins after the change (targets take newRole, others keep their role). + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool, Bool, Int) + selectMembers = foldr' addMember ([], [], [], GRObserver, False, False, False, 0) where - addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending) + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, privCount) | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin anyPending' = anyPending || memberPending m - in - if - | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending') - | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending') - | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending') - | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending) + anyPrivTarget' = anyPrivTarget || isRosterRole memberRole + privCount' = if isRosterRole newRole then privCount + 1 else privCount + in if + | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount') + | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount') + | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount') + | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, if isRosterRole memberRole then privCount + 1 else privCount) changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) changeRoleInvitedMems user gInfo memsToChange = do -- not batched, as we need to send different invitations to different connections anyway @@ -2772,19 +2814,20 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> updateGroupMemberRole db user m newRole pure (m :: GroupMember) {memberRole = newRole} _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of + changeRoleCurrentMems :: User -> Group -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + changeRoleCurrentMems user (Group gInfo members) rosterVer memsToChange = case L.nonEmpty memsToChange of Nothing -> pure ([], [], [], False) Just memsToChange' -> do - let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' + let mKey m = if isJust rosterVer then MemberKey <$> memberPubKey m else Nothing + events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange' recipients = filter memberCurrent members (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients events let signed = any (either (const False) (isJust . signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" - (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ + (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) pure (errs, changed, acis, signed) where sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c @@ -2848,20 +2891,25 @@ processChatCommand cxt nm = \case withGroupLock "removeMembers" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId - let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members + let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin, anyPrivilegedRemoved) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds memCount = length groupMemberIds when (count /= memCount) $ throwChatError CEGroupMemberNotFound when (memCount > 1 && anyAdmin) $ throwCmdError "can't remove multiple members when admins selected" assertUserGroupRole gInfo $ max GRAdmin maxRole + when (useRelays' gInfo && anyPrivilegedRemoved && memberRole' (membership gInfo) /= GROwner) $ + throwCmdError "only the group owner can remove members, moderators and admins" (errs1, deleted1) <- deleteInvitedMems user invitedMems let recipients = filter memberCurrent members - (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing recipients currentMems + let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyPrivilegedRemoved + rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing + (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing rosterVer recipients currentMems (errs3, deleted3, acis3, signed3) <- foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], [], False) pendingApprvMems let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members (errs4, deleted4, acis4, signed4) <- foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], [], False) pendingRvwMems + forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView let acis = acis2 <> acis3 <> acis4 errs = errs1 <> errs2 <> errs3 <> errs4 deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 @@ -2876,19 +2924,20 @@ processChatCommand cxt nm = \case unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRUserDeletedMembers user gInfo' deleted withMessages msgSigned -- same order is not guaranteed where - selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) - selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False) + selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) + selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False, False) where - addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole} + addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin, anyPrivRemoved) m@GroupMember {groupMemberId, memberStatus, memberRole} | groupMemberId `S.member` gmIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin + anyPrivRemoved' = anyPrivRemoved || isRosterRole memberRole n' = n + 1 in case memberStatus of - GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin') - GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin') - GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin') - _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin') + GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved') + GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved') + GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved') + _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin', anyPrivRemoved') | otherwise = acc deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) deleteInvitedMems user memsToDelete = do @@ -2901,14 +2950,14 @@ processChatCommand cxt nm = \case deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool) deletePendingMember (accErrs, accDeleted, accACIs, accSigned) user gInfo recipients m = do (m', scopeInfo) <- mkMemberSupportChatInfo m - (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] + (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) Nothing recipients [m'] pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs, accSigned || signed) - deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - deleteMemsSend user gInfo chatScopeInfo recipients memsToDelete = case L.nonEmpty memsToDelete of + deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + deleteMemsSend user gInfo chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of Nothing -> pure ([], [], [], False) Just memsToDelete' -> do let chatScope = toChatScope <$> chatScopeInfo - events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' + events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete' (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients events let signed = any (either (const False) (isJust . signedMsg_)) msgs_ itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) @@ -3050,6 +3099,12 @@ processChatCommand cxt nm = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) + SetPublicGroupAccess gName access -> withUser $ \user -> do + gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfo db cxt user + case publicGroup of + Just pg -> runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} + Nothing -> throwChatError $ CECommandError "not a public group" APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId assertUserGroupRole gInfo GRAdmin @@ -3102,7 +3157,7 @@ processChatCommand cxt nm = \case (connId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode - void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) -- TODO not sure it is correct to set connections status here? pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive @@ -3158,7 +3213,7 @@ processChatCommand cxt nm = \case joinPreparedConn subMode conn joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile - let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile gInfo) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True dm <- encodeConnInfo $ XInfo p sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3308,6 +3363,7 @@ processChatCommand cxt nm = \case fileStatus <- withFastStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) + AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -3535,7 +3591,7 @@ processChatCommand cxt nm = \case conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' joinPreparedConn conn incognitoProfile chatV joinPreparedConn conn incognitoProfile chatV = do - let profileToSend = userProfileDirect user incognitoProfile Nothing True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3580,13 +3636,18 @@ processChatCommand cxt nm = \case where cReqHash1 = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} cReqHash2 = contactCReqHash $ CRContactUri crData {crScheme = simplexChat} + -- relay-group joins (only via connectToRelay) carry the target relay member in preparedEntity_; + -- its memberId binds the join signature so a sibling relay can't replay it + relayMemberId_ = case preparedEntity_ of + Just (PCEGroup gInfo m) | useRelays' gInfo -> Just (memberId' m) + _ -> Nothing joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" -- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile connect' groupLinkId xContactId_ gInfo_ = do let inGroup = isJust groupLinkId @@ -3601,7 +3662,7 @@ processChatCommand cxt nm = \case subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile_ groupLinkId subMode chatV pqSup - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3616,7 +3677,7 @@ processChatCommand cxt nm = \case subMode <- chatReadVar subscriptionMode let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup - void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup + void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of @@ -3625,7 +3686,7 @@ processChatCommand cxt nm = \case xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn + void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing Nothing PQSupportOn ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" @@ -3637,13 +3698,14 @@ processChatCommand cxt nm = \case r <- tryAllErrors $ do (fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink relayLinkData_ <- liftIO $ decodeLinkUserData cData - case (relayLinkData_, linkEntityId) of - (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p + relayMemberId <- case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do + withFastStore $ \db -> updateRelayMemberData db cxt user relayMember (MemberId entityId) (MemberKey relayKey) p + pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) - void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing + void $ connectViaContact user (Just $ PCEGroup gInfo (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing relayMember' <- withFastStore $ \db -> getGroupMember db cxt user (groupId' gInfo) (groupMemberId' relayMember) pure (relayLink, relayMember', r) syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () @@ -3679,23 +3741,20 @@ processChatCommand cxt nm = \case pure (connId, chatV) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure - joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection - joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> Maybe MemberId -> PQSupport -> CM Connection + joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile - let profileToSend = case gInfo_ of - Just gInfo_' -> - let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' - in userProfileInGroup' user allowSimplexLinks incognitoProfile - Nothing -> userProfileDirect user incognitoProfile Nothing True - chatEvent <- case gInfo_ of - Just (Just gInfo) | useRelays' gInfo -> do - let GroupInfo {membership = GroupMember {memberId}} = gInfo - memberPubKey <- case groupKeys gInfo of - Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" - pure $ XMember profileToSend memberId (MemberKey memberPubKey) - _ -> pure $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ - dm <- encodeConnInfoPQ pqSup chatV chatEvent + profileToSend <- + presentUserBadge user incognitoProfile $ case gInfo_ of + Just gInfo_' -> + let allowSimplexLinks = maybe True groupUserAllowSimplexLinks gInfo_' + in userProfileInGroup' user allowSimplexLinks incognitoProfile + Nothing -> userProfileDirect user incognitoProfile Nothing True + dm <- case gInfo_ of + Just (Just gInfo) | useRelays' gInfo -> case relayMemberId_ of + Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend + Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId" + _ -> encodeConnInfoPQ pqSup chatV $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined @@ -3703,12 +3762,12 @@ processChatCommand cxt nm = \case contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRejected && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: CryptoFile -> CM Integer - checkSndFile (CryptoFile f cfArgs) = do + checkSndFile :: Maybe LocalBadge -> CryptoFile -> CM Integer + checkSndFile sndBadge (CryptoFile f cfArgs) = do fsFilePath <- lift $ toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs - when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f + when (fromInteger fileSize > maxXFTPFileSize sndBadge) $ throwChatError $ CEFileSize f pure fileSize updateProfile :: User -> Profile -> CM ChatResponse updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' @@ -3738,7 +3797,7 @@ processChatCommand cxt nm = \case case changedCts_ of Nothing -> pure $ UserProfileUpdateSummary 0 0 [] Just changedCts -> do - let idsEvts = L.map ctSndEvent changedCts + idsEvts <- mapM ctSndEvent changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ unless (null errs) $ toView $ CEvtChatErrors errs @@ -3762,8 +3821,11 @@ processChatCommand cxt nm = \case mergedProfile = userProfileDirect user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileDirect user' Nothing (Just ct') False - ctSndEvent :: ChangedProfileContact -> (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) - ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, Nothing, XInfo mergedProfile') + -- non-incognito (filtered above), so the user's badge is presented; a profile update keeps the badge instead of clearing it + ctSndEvent :: ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) + ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = do + p <- presentUserBadge user' Nothing mergedProfile' + pure (ConnectionId connId, Nothing, XInfo p) ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> @@ -3771,9 +3833,9 @@ processChatCommand cxt nm = \case setMyAddressData :: User -> UserContactLink -> CM UserContactLink setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user - let shortLinkProfile = userProfileDirect user Nothing Nothing True - -- TODO [short links] do not save address to server if data did not change, spinners, error handling - userData + shortLinkProfile <- presentUserBadge user Nothing $ userProfileDirect user Nothing Nothing True + -- TODO [short links] do not save address to server if data did not change, spinners, error handling + let userData | isTrue userChatRelay = relayShortLinkData shortLinkProfile | otherwise = contactShortLinkData shortLinkProfile $ Just addressSettings userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} @@ -3794,7 +3856,8 @@ processChatCommand cxt nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchAllErrors` eToView + p <- presentUserBadge user incognitoProfile mergedProfile' + void (sendDirectContactMessage user ct' $ XInfo p) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> CM ChatResponse @@ -3993,10 +4056,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, @@ -4084,7 +4147,7 @@ processChatCommand cxt nm = \case Just r -> pure r Nothing -> do (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ov = verifyLinkOwner rootKey [] l sig_ invitationReqAndPlan cReq (Just l') contactSLinkData_ ov where @@ -4111,7 +4174,7 @@ processChatCommand cxt nm = \case withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ContactLinkData _ UserContactData {owners} = cData ov = verifyLinkOwner rootKey owners l' sig_ plan <- contactRequestPlan user cReq contactSLinkData_ ov @@ -4313,7 +4376,7 @@ processChatCommand cxt nm = \case contactShortLinkData p settings = let msg = autoReply =<< settings business = maybe False businessAddress settings - contactData = ContactShortLinkData p msg business + contactData = ContactShortLinkData p msg business Nothing in encodeShortLinkData contactData relayShortLinkData :: Profile -> UserLinkData relayShortLinkData Profile {displayName, fullName, shortDescr, image} = @@ -4377,7 +4440,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if contactConnIncognito ct then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4458,7 +4522,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers n = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if incognitoMembership gInfo then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4830,6 +4895,28 @@ createContactsSndFeatureItems user cts = CUPContact {preference} -> preference CUPUser {preference} -> preference +-- attach an issued badge credential to the user's own profile and present it to all current contacts. +-- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). +addUserBadge :: User -> BadgeCredential -> CM () +addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do + keys <- asks $ badgePublicKeys . config + key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys + verified <- liftIO $ verifyCredential key cred + unless verified $ throwCmdError "badge credential does not verify against configured key" + now <- liftIO getCurrentTime + user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + asks currentUser >>= atomically . (`writeTVar` Just user') + cxt <- asks $ mkStoreCxt . config + contacts <- withFastStore' $ \db -> getUserContacts db cxt user' + withChatLock "addUserBadge" $ forM_ contacts $ \ct -> + case contactSendConn_ ct of + Right conn + | not (connIncognito conn) -> do + let ct' = updateMergedPreferences user' ct + p <- presentUserBadge user' Nothing $ userProfileDirect user' Nothing (Just ct') False + void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView + _ -> pure () + assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -5026,10 +5113,11 @@ runRelayGroupLinkChecks user = do then do -- TODO [relays] emit event to UI when relay own status promoted to RSActive -- CEvtGroupRelayUpdated requires GroupRelay (owner-side), not available on relay side - void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSAccepted RSActive + void $ withStore' $ \db -> updateRelayOwnStatus_ db gInfo RSActive else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () + sendRelayCapIfNeeded user gInfo checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) @@ -5353,6 +5441,7 @@ chatCommandP = "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), + "/public group access " *> char_ '#' *> (SetPublicGroupAccess <$> displayNameP <*> publicGroupAccessP), "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)), "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), @@ -5441,6 +5530,7 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNameDescr), ("/profile" <|> "/p") $> ShowProfile, + "/badge add " *> (AddBadge <$> jsonP), "/set bot commands " *> (SetBotCommands <$> botCommandsP), "/delete bot commands" $> SetBotCommands [], "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), @@ -5559,6 +5649,12 @@ chatCommandP = clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False pure UserMsgReceiptSettings {enable, clearOverrides} onOffP = ("on" $> True) <|> ("off" $> False) + publicGroupAccessP = do + groupWebPage <- optional (" web=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + groupDomain <- optional (" domain=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + domainWebPage <- (" domain_page=" *> onOffP) <|> pure False + allowEmbedding <- (" embed=" *> onOffP) <|> pure False + pure PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} profileNameDescr = (,) <$> displayNameP <*> shortDescrP -- 'Help with bot':'link ','Menu of commands':[...] botCommandsP :: Parser [ChatBotCommand] @@ -5579,7 +5675,7 @@ chatCommandP = newUserP relay = do (cName, shortDescr) <- profileNameDescr service <- (" service=" *> onOffP) <|> pure False - let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Nothing, preferences = Nothing} + let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, simplexName = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef relay, clientService = BoolDef service} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space @@ -5588,7 +5684,7 @@ chatCommandP = let preferences = case files_ of Just True -> Nothing _ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}} - profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Just CPTBot, preferences} + profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing, simplexName = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef False, clientService = BoolDef service} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ae75a493e2..95724c29b5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -53,12 +53,13 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds, secondsToDiffTime) +import Simplex.Chat.Badges (BadgeCredential (..), BadgePresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBinaryBatch, encodeFwdElement) +import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBatchElement, encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators @@ -79,6 +80,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Util (encryptFile, shuffle) import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD +import qualified Simplex.Messaging.Crypto.Lazy as LC import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent @@ -89,7 +91,7 @@ import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..)) -import Simplex.Messaging.Compression (compressionLevel) +import Simplex.Messaging.Compression (compressionLevel, limitDecompress') import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -366,7 +368,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 @@ -699,7 +701,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI ci <- xftpAcceptRcvFT db cxt user fileId filePath userApproved rfd <- getRcvFileDescrByRcvFileId db fileId pure (ci, rfd) - receiveViaCompleteFD user fileId rfd userApproved cryptoArgs + receiveViaCompleteFD user fileId rfd fileSize userApproved cryptoArgs pure ci (Nothing, Just _fileConnReq) -> throwChatError $ CEException "accepting file via a separate connection is deprecated" -- group & direct file protocol @@ -741,10 +743,17 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI || (rcvInline_ == Just True && fileSize <= fileChunkSize * offerChunks) ) -receiveViaCompleteFD :: User -> FileTransferId -> RcvFileDescr -> Bool -> Maybe CryptoFileArgs -> CM () -receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} userApprovedRelays cfArgs = +receiveViaCompleteFD :: User -> FileTransferId -> RcvFileDescr -> Integer -> Bool -> Maybe CryptoFileArgs -> CM () +receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} expectedFileSize userApprovedRelays cfArgs = when fileDescrComplete $ do rd <- parseFileDescription fileDescrText + let FD.ValidFileDescription FD.FileDescription {size = FD.FileSize encSize, redirect} = rd + redirectSize = maybe 0 (\FD.RedirectFileInfo {size = FD.FileSize s} -> toInteger s) redirect + -- for a redirect, encSize is the description blob and redirectSize the final file; take the larger + rcvSize = max (toInteger encSize) redirectSize + -- 10 MB margin: encryption and chunk-size rounding make the transfer larger than the advertised size + maxRcvSize = min expectedFileSize (toInteger FD.maxFileSizeHard) + toInteger (FD.mb 10 :: Int64) + when (rcvSize > maxRcvSize) $ throwChatError $ CEFileRcvChunk "declared file size exceeds the file invitation size" if userApprovedRelays then receive' rd True else do @@ -904,7 +913,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId Just conn@Connection {customUserProfileId} -> do incognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId pure (ct, conn, ExistingIncognito <$> incognitoProfile) - let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend (ct,conn,) <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode) @@ -916,7 +925,7 @@ acceptContactRequestAsync UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId, pqSupport = cReqPQSup} incognitoProfile = do subMode <- chatReadVar subscriptionMode - let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV @@ -927,9 +936,9 @@ acceptContactRequestAsync liftIO $ setCommandConnId db user cmdId connId getContact db cxt user contactId -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember acceptGroupJoinRequestAsync - user + user@User {userId} uclId gInfo@GroupInfo {groupProfile, membership, businessChat} cReqInvId @@ -941,11 +950,22 @@ acceptGroupJoinRequestAsync gAccepted gLinkMemRole incognitoProfile - memberKey_ = do + memberKey_ + existingMem_ = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted - (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ + -- a roster-established privileged member attaches a connection to its existing record (keeping + -- owner-authoritative role + key); everyone else is created fresh with the group-link role + cxt <- chatStoreCxt + (groupMemberId, memberId) <- case existingMem_ of + Just m -> do + -- refresh the hash placeholder name from the authenticated join profile; role + key stay roster-authoritative + withStore $ \db -> do + liftIO $ updateGroupMemberStatus db userId m initialStatus + void $ updateMemberProfile db cxt user m cReqProfile + pure (groupMemberId' m, memberId' m) + Nothing -> withStore $ \db -> + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -961,7 +981,6 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -979,8 +998,9 @@ acceptGroupJoinSendRejectAsync cReqXContactId_ rejectionReason = do gVar <- asks random + cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing let GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkReject $ @@ -991,7 +1011,6 @@ acceptGroupJoinSendRejectAsync rejectionReason } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -1045,8 +1064,9 @@ acceptRelayJoinRequestAsync cReqInvId cReqChatVRange relayLink = do - -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) - let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities + ChatConfig {webPreviewConfig} <- asks config + let webDomain_ = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + msg = XGrpRelayAcpt relayLink RelayCapabilities {webDomain = webDomain_} subMode <- chatReadVar subscriptionMode cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange @@ -1160,24 +1180,50 @@ memberIntroEvt gInfo reMember = mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions +-- Forward the saved owner-signed roster verbatim (reusing its signed shared_msg_id), then the +-- blob chunks, so the recipient verifies the owner signature. +serveRoster :: User -> GroupInfo -> GroupMember -> CM () +serveRoster user gInfo member = + when (member `supportsVersion` groupRosterVersion) $ do + cxt <- chatStoreCxt + withStore' (\db -> getGroupRoster db gInfo) >>= \case + Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}, blob_) -> + case J.eitherDecodeStrict' signedBody :: Either String (ChatMessage 'Json) of + Left e -> logError $ "serveRoster: cannot decode saved roster message: " <> tshow e + Right chatMsg@ChatMessage {msgId} -> + withStore' (\db -> runExceptT $ getGroupMemberById db cxt user ownerGMId) >>= \case + Right owner -> do + let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' owner) (memberShortenedName owner), fwdBrokerTs = brokerTs} + sendFwdMemberMessage member fwd (VMSigned MSSVerified sm chatMsg) + forM_ ((,) <$> msgId <*> blob_) $ \(sid, blob) -> + sendInlineBlobChunks user gInfo [member] sid blob + Left e -> logError $ "serveRoster: roster owner not found: " <> tshow e + Nothing -> pure () + -- Used in groups with relays to introduce moderators and above to a new member, -- and to announce the new member to moderators and above. -- This doesn't create introduction records in db, compared to above methods. introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do - modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo + (owners, adminsMods) <- withStore' $ \db -> + (,) <$> getGroupOwners db cxt user gInfo <*> getGroupAdminsMods db cxt user gInfo + let modMs = owners <> adminsMods void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing withStore' $ \db -> setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] - let introEvts = map (memberIntroEvt gInfo) modMs - forM_ (L.nonEmpty introEvts) $ \introEvts' -> - sendGroupMemberMessages user gInfo conn introEvts' + -- owner intros first so the joiner has the owner profile loaded before applying the saved roster (signed by the owner) + sendIntros owners + serveRoster user gInfo subscriber + sendIntros adminsMods withStore' $ \db -> setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced + where + sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts -> + sendGroupMemberMessages user gInfo conn evts 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,16 +1241,40 @@ 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, simplexName, peerType} = - Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, simplexName, preferences = Nothing, peerType} +redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge, simplexName} = + Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType, badge, simplexName} 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 +-- Roles carried by the roster; owners are on the link, not the roster. +isRosterRole :: GroupMemberRole -> Bool +isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin + +-- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. +-- Runs on the parsed roster blob. +validateGroupRoster :: [RosterMember] -> [RosterMember] +validateGroupRoster entries = + dedup S.empty $ filter (\RosterMember {role} -> isRosterRole role) entries + where + dedup _ [] = [] + dedup seen (rm@RosterMember {memberId} : rms) + | memberId `S.member` seen = dedup seen rms + | otherwise = rm : dedup (S.insert memberId seen) rms + +-- Privileged members without a known key are skipped (recipients can't verify them). +buildGroupRoster :: [GroupMember] -> [RosterMember] +buildGroupRoster mods = take maxGroupRosterSize $ mapMaybe rosterMember mods + where + rosterMember GroupMember {memberId, memberPubKey, memberRole} + | isRosterRole memberRole = (\k -> RosterMember {memberId, key = MemberKey k, role = memberRole, privileges = 0}) <$> memberPubKey + | otherwise = Nothing + sendHistory :: User -> GroupInfo -> GroupMember -> CM () sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just conn} = @@ -1331,7 +1401,7 @@ setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM G setGroupLinkData nm user gInfo gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withFastStore $ \db -> - (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays linkType = if useRelays' gInfo then CCTChannel else CCTGroup sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData)) @@ -1341,7 +1411,7 @@ setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () setGroupLinkDataAsync user gInfo gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withStore $ \db -> - (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) @@ -1367,6 +1437,9 @@ updatePublicGroupData user gInfo pure (gInfo', gLink) setGroupLinkDataAsync user gInfo' gLink pure gInfo' + | useRelays' gInfo && isRelay (membership gInfo) = do + cxt <- chatStoreCxt + withStore $ \db -> updatePublicMemberCount db cxt user gInfo | otherwise = pure gInfo updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> CM (GroupInfo, Bool) @@ -1437,10 +1510,9 @@ encodeShortLinkData d = decodeLinkUserData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) decodeLinkUserData cData | B.null s = pure Nothing - | B.head s == 'X' = case Z1.decompress $ B.drop 1 s of - Z1.Error e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) - Z1.Skip -> pure Nothing - Z1.Decompress s' -> decode s' + | B.head s == 'X' = case limitDecompress' maxDecompressedMsgLength $ B.drop 1 s of + Left e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) + Right s' -> decode s' | otherwise = decode s where decode s' = case J.eitherDecodeStrict s' of @@ -1616,13 +1688,16 @@ sendFileInline_ FileTransferMeta {filePath, chunkSize} sharedMsgId sendMsg = chSize = fromIntegral chunkSize parseChatMessage :: Connection -> ByteString -> CM (ChatMessage 'Json) -parseChatMessage conn s = do +parseChatMessage conn s = snd <$> parseChatMessage' conn s +{-# INLINE parseChatMessage #-} + +parseChatMessage' :: Connection -> ByteString -> CM (Maybe SignedMsg, ChatMessage 'Json) +parseChatMessage' conn s = case parseChatMessages s of - [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ _ m)) -> checkEncoding m) =<< msg + [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ sm m)) -> (sm,) <$> checkEncoding m) =<< msg _ -> throwChatError $ CEException "parseChatMessage: single message is expected" where errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) -{-# INLINE parseChatMessage #-} getChatScopeInfo :: StoreCxt -> User -> GroupChatScope -> CM GroupChatScopeInfo getChatScopeInfo cxt user = \case @@ -1819,6 +1894,51 @@ closeFileHandle fileId files = do h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m) liftIO $ mapM_ hClose h_ `catchAll_` pure () +-- The roster file has no chat item, so chat-item file enumeration misses it; clean it up by group. +cleanupGroupRosterFile :: User -> GroupInfo -> CM () +cleanupGroupRosterFile User {userId} GroupInfo {groupId} = do + infos <- withStore' $ \db -> getGroupRosterFileInfo db userId groupId + forM_ infos $ \(fileId, filePath_) -> do + lift $ closeFileHandle fileId rcvFiles + forM_ filePath_ removeFsFile + withStore' $ \db -> do + deleteGroupRosterFile db userId groupId + deleteGroupRosterTransfers db groupId + +-- Supersede/cancel one source relay's in-flight roster transfer: remove its on-disk file + cached +-- handle first (the cascade only does rows), then the files + transfer rows. +cleanupRosterTransfer :: GroupInfo -> GroupMemberId -> CM () +cleanupRosterTransfer gInfo fromMemberId = + withStore' (\db -> getRosterTransferId db gInfo fromMemberId) >>= mapM_ cleanupRosterTransferById + +cleanupRosterTransferById :: Int64 -> CM () +cleanupRosterTransferById transferId = do + file_ <- withStore' $ \db -> getRosterTransferFile db transferId + forM_ file_ $ \(fileId, filePath_) -> do + lift $ closeFileHandle fileId rcvFiles + forM_ filePath_ removeFsFile + withStore' $ \db -> do + deleteRosterTransferFile db transferId + deleteRosterTransfer db transferId + +-- MUST evict the cached AppendMode handle before deleting chunks, else re-driven bytes append +-- after the stale prefix and corrupt the blob. +resetRosterPartialChunks :: RcvFileTransfer -> CM () +resetRosterPartialChunks ft@RcvFileTransfer {fileId, fileStatus} = do + lift $ closeFileHandle fileId rcvFiles + forM_ (rcvFilePath fileStatus) removeFsFile + withStore' $ \db -> deleteRcvFileChunks db ft + where + rcvFilePath = \case + RFSAccepted p -> Just p + RFSConnected p -> Just p + _ -> Nothing + +removeFsFile :: FilePath -> CM () +removeFsFile fp = do + p <- lift $ toFSFilePath fp + removeFile p `catchAllErrors` \_ -> pure () + deleteMembersConnections :: User -> [GroupMember] -> CM () deleteMembersConnections user members = deleteMembersConnections' user members False @@ -1911,6 +2031,33 @@ sendDirectContactMessages' user ct events = do forM_ pqEnc_ $ \pqEnc' -> void $ createContactPQSndItem user ct conn pqEnc' pure sndMsgs' +-- present the user's own badge on an outgoing profile: a fresh, single-use proof from the stored credential. +-- the send's incognito profile (when set) suppresses it - an incognito identity must never carry the badge. +-- a long-expired badge is not presented at all (receivers would hide it anyway). +presentUserBadge :: User -> Maybe i -> Profile -> CM Profile +presentUserBadge User {profile = LocalProfile {localBadge}} incognitoProfile p = case (incognitoProfile, localBadge) of + (Nothing, Just (OwnBadge cred@(BadgeCredential keyIdx _ _ _) st)) | st == BSActive || st == BSExpired -> do + keys <- asks $ badgePublicKeys . config + case M.lookup keyIdx keys of + Nothing -> p <$ logError "presentUserBadge: badge key index not in config" + Just key -> do + nonce <- drgRandomBytes 16 + liftIO (badgeProof key cred (PHTest nonce)) >>= \case + Right proof -> pure p {badge = Just proof} + Left e -> p <$ logError ("presentUserBadge: proof generation failed: " <> T.pack e) + _ -> pure p + +-- receiving side of contact/invitation link data: verify the badge proof from the link profile +-- and set the crypto-free display badge for the UI (the raw proof stays in profile for APIPrepareContact) +linkDataBadge :: ContactShortLinkData -> CM ContactShortLinkData +linkDataBadge cld@ContactShortLinkData {profile = Profile {badge}} = case badge of + Nothing -> pure cld + Just b@(BadgeProof _ _ _ info) -> do + keys <- asks $ badgePublicKeys . config + verified <- liftIO $ verifyBadge keys b + now <- liftIO getCurrentTime + pure (cld :: ContactShortLinkData) {localBadge = Just $ ShownBadge info (mkBadgeStatus now verified info)} + sendDirectContactMessage :: MsgEncodingI e => User -> Contact -> ChatMsgEvent e -> CM (SndMessage, Int64) sendDirectContactMessage user ct chatMsgEvent = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct @@ -2024,6 +2171,26 @@ encodeConnInfoPQ pqSup v chatMsgEvent = do _ -> pure connInfo ECMLarge -> throwChatError $ CEException "large info" +-- conn-info wrapped as a signed element, so the receiver can verify the signature over the body +encodeSignedConnInfo :: MsgEncodingI e => MsgSigning -> ChatMsgEvent e -> CM ByteString +encodeSignedConnInfo signing chatMsgEvent = do + vr <- chatVersionRange + let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent} + case encodeChatMessage maxEncodedInfoLength info of + ECMEncoded body -> pure $ encodeBatchElement (Just $ signChatMsgBody signing body) body + ECMLarge -> throwChatError $ CEException "large signed info" + +-- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries +-- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay +encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString +encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend = + case groupKeys of + Just GroupKeys {publicGroupId, memberPrivKey} -> + let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId) + signing = MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey + in encodeSignedConnInfo signing xMemberEvt + Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" + deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} @@ -2097,6 +2264,68 @@ sendGroupMessage' user gInfo members chatMsgEvent = ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" +-- TODO [relays] improvement: publish roster_version in link data so the owner can recover the latest version +-- TODO after restoring from a stale backup (relays accept only strictly-greater versions) +-- Persist the next roster version before sending the events that carry it (so a recipient never advances +-- past a version the owner hasn't recorded). The matching blob is broadcast separately, by broadcastRoster, +-- after the change is applied to the owner's members - so the served roster excludes demoted/removed members. +reserveRosterVersion :: GroupInfo -> CM VersionRoster +reserveRosterVersion gInfo = do + let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo) + withStore' $ \db -> setGroupRosterVersion db gInfo rosterVer + pure rosterVer + +broadcastRoster :: User -> GroupInfo -> VersionRoster -> CM () +broadcastRoster user gInfo rosterVer = do + cxt <- chatStoreCxt + (relays, rosterMems) <- withStore' $ \db -> + (,) <$> getGroupRelayMembers db cxt user gInfo <*> getGroupRosterMembers db cxt user gInfo + forM_ (L.nonEmpty relays) $ \relays' -> + sendRoster user gInfo (L.toList relays') rosterVer (buildGroupRoster rosterMems) + +-- Send the current roster (no version bump) to a newly added relay so it can serve joiners. +sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM () +sendGroupRosterToRelay user gInfo relayMember = + forM_ (rosterVersion gInfo) $ \rosterVer -> do + cxt <- chatStoreCxt + rosterMems <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo + sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster rosterMems) + +-- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's. +sendRoster :: User -> GroupInfo -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM () +sendRoster user gInfo members rosterVer roster = do + let blob = encodeRosterBlob roster + fileInv = InlineFileInvitation {fileSize = fromIntegral (B.length blob), fileDigest = FD.FileDigest $ LC.sha512Hash $ LB.fromStrict blob} + SndMessage {sharedMsgId} <- sendGroupMessage' user gInfo members (XGrpRoster GroupRoster {version = rosterVer, fileInv}) + sendInlineBlobChunks user gInfo members sharedMsgId blob + +-- Send a binary blob as BFileChunks under a shared_msg_id to the given members (chunked by fileChunkSize). +sendInlineBlobChunks :: User -> GroupInfo -> [GroupMember] -> SharedMsgId -> ByteString -> CM () +sendInlineBlobChunks user gInfo members sharedMsgId blob = do + chSize <- fromIntegral <$> asks (fileChunkSize . config) + go chSize 1 blob + where + go chSize chunkNo bytes = do + let (chunk, rest) = B.splitAt chSize bytes + void $ sendGroupMessage' user gInfo members (BFileChunk sharedMsgId (FileChunk chunkNo chunk)) + unless (B.null rest) $ go chSize (chunkNo + 1) rest + +-- Relay advertises its current web preview capability to channel owners. +-- Idempotent: sends only when the configured web domain differs from what was last sent, and only to +-- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap). +sendRelayCapIfNeeded :: User -> GroupInfo -> CM () +sendRelayCapIfNeeded user gInfo = do + ChatConfig {webPreviewConfig} <- asks config + let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) + when (currentWebDomain /= sentWebDomain) $ do + cxt <- chatStoreCxt + owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo + let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners + unless (null capableOwners) $ do + void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain + sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupMessages user gInfo scope asGroup members events = do -- TODO [knocking] send current profile to pending member after approval? @@ -2117,9 +2346,10 @@ sendGroupMessages user gInfo scope asGroup members events = do _ -> False sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - profileUpdateEvent = XInfo $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p - void $ sendGroupMessage' user gInfo members' profileUpdateEvent + 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 currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -2316,10 +2546,14 @@ saveDirectRcvMSG conn@Connection {connId} agentMsgMeta chatMsg@ChatMessage {chat msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing pure (conn', msg) -saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage) +saveGroupRcvMsg :: forall e. MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage) saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta verifiedMsg = do let ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = verifiedChatMsg verifiedMsg - (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- updateMemberChatVRange authorMember conn chatVRange + -- binary messages (file chunks) carry only the initial-version sentinel, not the sender's range; + -- applying it would downgrade the member's negotiated version and suppress version-gated delivery + (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- case encoding @e of + SBinary -> pure (authorMember, conn) + SJson -> updateMemberChatVRange authorMember conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta brokerTs = metaBrokerTs agentMsgMeta newMsg = NewRcvMessage {chatMsgEvent, verifiedMsg, brokerTs} @@ -2457,11 +2691,11 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing -mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = +mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d +mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgSigned currentTs = let ts@(_, ft_) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ - in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember Nothing currentTs + in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs = @@ -2623,7 +2857,7 @@ createFeatureEnabledItems_ :: User -> Contact -> CM [AChatItem] createFeatureEnabledItems_ user ct@Contact {mergedPreferences} = forM allChatFeatures $ \(ACF f) -> do let state = featureState $ getContactUserPreference f mergedPreferences - createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing + createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing Nothing createFeatureItems :: MsgDirectionI d => @@ -2653,15 +2887,15 @@ createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do unless (null errs) $ toView' $ CEvtChatErrors errs toView' $ CEvtNewChatItems user acis where - contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) + contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) contactChangedFeatures (Contact {mergedPreferences = cups}, ct'@Contact {mergedPreferences = cups'}) = do let contents = mapMaybe (\(ACF f) -> featureCIContent_ f) allChatFeatures (chatDir ct', False, contents) where - featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId) + featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus) featureCIContent_ f - | state /= state' = Just (fContent ciFeature state', Nothing) - | prefState /= prefState' = Just (fContent ciOffer prefState', Nothing) + | state /= state' = Just (fContent ciFeature state', Nothing, Nothing) + | prefState /= prefState' = Just (fContent ciOffer prefState', Nothing, Nothing) | otherwise = Nothing where fContent :: FeatureContent a d -> (a, Maybe Int) -> CIContent d @@ -2694,16 +2928,16 @@ createGroupFeatureItems_ user cd showGroupAsSender ciContent GroupInfo {fullGrou forM allGroupFeatures $ \(AGF f) -> do let p = getGroupPreference f fullGroupPreferences (_, param, role) = groupFeatureState p - createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing + createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing Nothing createInternalChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> CM () createInternalChatItem user cd content itemTs_ = do - ci <- createChatItem user cd False content Nothing itemTs_ + ci <- createChatItem user cd False content Nothing Nothing itemTs_ toView $ CEvtNewChatItems user [ci] -createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe UTCTime -> CM AChatItem -createChatItem user cd showGroupAsSender content sharedMsgId itemTs_ = - lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId)])]) >>= \case +createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe MsgSigStatus -> Maybe UTCTime -> CM AChatItem +createChatItem user cd showGroupAsSender content sharedMsgId msgSigned itemTs_ = + lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId, msgSigned)])]) >>= \case [Right ci] -> pure ci [Left e] -> throwError e rs -> throwChatError $ CEInternalError $ "createInternalChatItem: expected 1 result, got " <> show (length rs) @@ -2715,7 +2949,7 @@ createChatItems :: (ChatTypeI c, MsgDirectionI d) => User -> Maybe UTCTime -> - [(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)])] -> + [(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)])] -> CM' [Either ChatError AChatItem] createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime @@ -2724,24 +2958,24 @@ createChatItems user itemTs_ dirsCIContents = do void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents where - updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO () + updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> IO () updateChat db cxt createdAt (cd, _, contents) - | any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats + | any (\(content, _, _) -> ciRequiresAttention content) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats | otherwise = pure () where memberChatStats :: Maybe (Int, MemberAttention, Int) memberChatStats = case cd of CDGroupRcv _g (Just scope) m -> do - let unread = length $ filter (ciRequiresAttention . fst) contents + let unread = length $ filter (\(content, _, _) -> ciRequiresAttention content) contents in Just (unread, memberAttentionChange unread itemTs_ (Just m) scope, 0) _ -> Nothing - createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> [IO AChatItem] + createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> [IO AChatItem] createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents where - createACI (content, sharedMsgId) = do + createACI (content, sharedMsgId, msgSigned) = do let hasLink_ = ciContentHasLink content Nothing - ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ itemTs createdAt - let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt + ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ msgSigned itemTs createdAt + let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing msgSigned createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci -- rcvMem_ Nothing means message from channel - treated same as message from moderator, @@ -2854,7 +3088,8 @@ simplexTeamContactProfile = contactLink = Just $ CLFull adminContactReq, simplexName = Nothing, peerType = Nothing, - preferences = Nothing + preferences = Nothing, + badge = Nothing } simplexStatusContactProfile :: Profile @@ -2867,7 +3102,8 @@ simplexStatusContactProfile = contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), simplexName = Nothing, peerType = Just CPTBot, - preferences = Nothing + preferences = Nothing, + badge = Nothing } timeItToView :: String -> CM' a -> CM' a diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 28ca07bddc..59c796b9b2 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -24,6 +24,7 @@ import Control.Monad.IO.Unlift import Control.Monad.Reader import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr', foldrM) import Data.Functor (($>)) @@ -40,13 +41,16 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime) +import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery +import Simplex.Chat.Files (getChatTempDirectory) import Simplex.Chat.Library.Internal +import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent @@ -76,7 +80,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForUserNetwork, waitForWork, waitWhileSuspended, withWorkItems, withWork_) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Worker (..)) +import Simplex.Messaging.Agent.Env.SQLite (Worker (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) @@ -86,8 +90,10 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR +import qualified Simplex.Messaging.Crypto.Lazy as LC import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..), ServiceSub (..), ServiceSubError (..), ServiceSubResult (..)) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) @@ -105,6 +111,13 @@ import UnliftIO.STM smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 +-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) <> signedBody under the given key. +-- signatures is NonEmpty so the verification can't be vacuously true. +verifyGroupSig :: C.PublicKeyEd25519 -> B64UrlByteString -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool +verifyGroupSig key publicGroupId memberId signatures signedBody = + let prefix = smpEncode CBGroup <> smpEncode (publicGroupId, memberId) + in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures + processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessage _ _ (DEL_RCVQS delQs) = toView $ CEvtAgentRcvQueuesDeleted $ L.map rcvQ delQs @@ -451,9 +464,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- [incognito] send saved profile (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + profileToSend <- + presentUserBadge user incognitoProfile $ case gInfo_ of + Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do @@ -567,7 +581,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId - let p = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True + p <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True allowAgentConnectionAsync user conn'' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' XGrpLinkInv glInv -> do @@ -575,10 +589,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo, host) <- withStore $ \db -> do liftIO $ deleteContactCardKeepConn db connId ct createGroupInvitedViaLink db cxt user conn'' glInv - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" @@ -740,8 +754,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 @@ -808,7 +825,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn' confId $ XInfo profileToSend toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" @@ -822,8 +839,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XGrpMemInfo memId _memProfile | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + 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 allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile @@ -870,6 +887,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = else pure gInfo pure (m {memberStatus = GSMemConnected}, gInfo') toView $ CEvtUserJoinedGroup user gInfo' m' + when (isRelay membership) $ do + cc <- ask + atomically $ channelProfileUpdated cc groupId groupProfile (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' -- Create e2ee, feature and group description chat items only on first connected relay ifM @@ -894,8 +914,21 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = GCInviteeMember | isRelay m -> do withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected - gLink <- withStore $ \db -> getGroupLink db user gInfo - setGroupLinkDataAsync user gInfo gLink + if m `supportsVersion` groupRosterVersion + then do + -- send the relay a roster (materializing version 0 for old channels with NULL roster_version); + -- the relay stays RSInvited (unpublishable) until it acks, so no joiner can impersonate a privileged member + gInfo' <- case rosterVersion gInfo of + Just _ -> pure gInfo + Nothing -> do + withStore' $ \db -> setGroupRosterVersion db gInfo (VersionRoster 0) + pure gInfo {rosterVersion = Just (VersionRoster 0)} + sendGroupRosterToRelay user gInfo' m + else do + -- a relay below groupRosterVersion can't ack a roster; publish it on connect as before + -- the handshake (getPublishableGroupRelays and the LINK handler include/activate it by version) + gLink <- withStore $ \db -> getGroupLink db user gInfo + setGroupLinkDataAsync user gInfo gLink | otherwise -> do (gInfo', mStatus) <- if not (memberPending m) @@ -931,7 +964,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected @@ -1017,7 +1050,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure newDeliveryTasks processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' verifiedMsg = do - (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg + cc <- ask + (m'', conn', msg@RcvMessage {msgId, sharedMsgId_, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg let ctx js = DeliveryTaskContext js False checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) checkSendAsGroup asGroup_ a @@ -1056,25 +1090,37 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv - XGrpMemRole memId memRole -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole memberKey rosterVer msg brokerTs XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId - XGrpMemDel memId withMessages -> case encoding @e of - SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages verifiedMsg msg brokerTs False + XGrpMemDel memId withMessages rosterVer -> case encoding @e of + SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages rosterVer verifiedMsg msg brokerTs False SBinary -> pure Nothing XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg + XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs + XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr -- TODO [knocking] why don't we forward these messages? XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe - BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta + BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' m'' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) - forM deliveryTaskContext_ $ \taskContext -> + forM deliveryTaskContext_ $ \taskContext -> do + let contentChanged :: CM () + contentChanged = atomically $ channelContentChanged cc groupId + case event of + XMsgNew {} -> contentChanged + XMsgUpdate {} -> contentChanged + XMsgDel {} -> contentChanged + XMsgReact {} -> contentChanged + XGrpInfo p' -> atomically $ channelProfileUpdated cc groupId p' + XGrpDel {} -> atomically $ channelRemoved cc groupId + _ -> pure () pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} checkSendRcpt :: [AParsedMsg] -> CM Bool checkSendRcpt aMsgs = do @@ -1125,7 +1171,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy) - when continued $ sendPendingGroupMessages user gInfo m conn + when continued $ do + when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + sendPendingGroupMessages user gInfo m conn SWITCH qd phase cStats -> do toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m @@ -1177,9 +1225,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = CFGetRelayDataJoin -> do -- Update relay member with key, memberId and profile from link relayLinkData_ <- liftIO $ decodeLinkUserData cData - case (relayLinkData_, linkEntityId) of - (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withStore $ \db -> updateRelayMemberData db user m (MemberId entityId) (MemberKey relayKey) p + relayMemberId <- case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do + withStore $ \db -> updateRelayMemberData db cxt user m (MemberId entityId) (MemberKey relayKey) p + pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" case cReq of CRContactUri crData@ConnReqUriData {crClientData} -> do @@ -1192,13 +1241,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} -- Update connection with data derived from cReq, now available after getConnShortLinkAsync withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup - let GroupMember {memberId = membershipMemId} = membership - incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo - profileToSend = userProfileInGroup user gInfo incognitoProfile - memberPubKey <- case groupKeys gInfo of - Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" - dm <- encodeConnInfo $ XMember profileToSend membershipMemId (MemberKey memberPubKey) + let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile + dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend subMode <- chatReadVar subscriptionMode void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode CFGetRelayDataAccept -> do @@ -1208,7 +1253,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = relayProfile <- liftIO (decodeLinkUserData cData) >>= \case Just RelayShortLinkData {relayProfile = p} -> pure p Nothing -> throwChatError $ CEException "relay link: no relay link data" - (confId, m', relay) <- withStore $ \db -> do + (confId, m', relay) <- withStore $ \db -> do confId <- getRelayConfId db m liftIO $ updateGroupMemberStatus db userId m GSMemAccepted (m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile @@ -1221,7 +1266,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> throwChatError $ CECommandError "unexpected cmdFunction" QCONT -> do continued <- continueSending connEntity conn - when continued $ sendPendingGroupMessages user gInfo m conn + when continued $ do + when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + sendPendingGroupMessages user gInfo m conn MWARN msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err) processConnMWARN connEntity conn err @@ -1294,13 +1341,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = r n'' = Just (ci, CIRcvDecryptionError mde n'') mdeUpdatedCI _ _ = Nothing - receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () - receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case - FileChunkCancel -> - unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft - ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId - toView $ CEvtRcvFileSndCancelled user ci ft + receiveFileChunk :: Maybe GroupInfo -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () + receiveFileChunk gInfo_ ft@RcvFileTransfer {fileId, fileType, chunkSize} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case + FileChunkCancel -> case fileType of + -- cancel only this source's transfer; other relays' in-flight transfers are independent + FTRoster -> do + t_ <- withStore' $ \db -> getRosterTransfer db fileId + forM_ t_ $ \RcvRosterTransfer {rosterTransferId} -> cleanupRosterTransferById rosterTransferId + FTNormal -> + unless (rcvFileCompleteOrCancelled ft) $ do + cancelRcvFileTransfer user ft + ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId + toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -1311,30 +1363,33 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = RcvChunkOk -> if B.length chunk /= fromInteger chunkSize then badRcvFileChunk ft "incorrect chunk size" - else withAckMessage' "file msg" agentConnId meta $ appendFileChunk ft chunkNo chunk False + else appendFileChunk ft chunkNo chunk False RcvChunkFinal -> if B.length chunk > fromInteger chunkSize then badRcvFileChunk ft "incorrect chunk size" else do appendFileChunk ft chunkNo chunk True - ci <- withStore $ \db -> do - liftIO $ do - updateRcvFileStatus db fileId FSComplete - updateCIFileStatus db user fileId CIFSRcvComplete - deleteRcvFileChunks db ft - getChatItemByFileId db cxt user fileId - toView $ CEvtRcvFileComplete user ci - mapM_ (deleteAgentConnectionAsync . aConnId) conn_ - RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () + case fileType of + FTRoster -> forM_ gInfo_ $ \gInfo -> rosterCompletion gInfo ft + FTNormal -> do + ci <- withStore $ \db -> do + liftIO $ do + updateRcvFileStatus db fileId FSComplete + updateCIFileStatus db user fileId CIFSRcvComplete + deleteRcvFileChunks db ft + getChatItemByFileId db cxt user fileId + toView $ CEvtRcvFileComplete user ci + mapM_ (deleteAgentConnectionAsync . aConnId) conn_ + RcvChunkDuplicate -> pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of REQ invId pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo case chatMsgEvent of XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport - XMember p joiningMemberId joiningMemberKey -> memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey + XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge @@ -1346,13 +1401,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = CFSetShortLink -> case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do - (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do + (gInfo, gLink, relays, relaysChanged, newlyActiveLinks, newlyActiveGMIds) <- withStore $ \db -> do gInfo <- getGroupInfo db cxt user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo - (relays', changed, newlyActive) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays + (relays', changed, newlyActiveLinks, newlyActiveGMIds) <- liftIO $ foldrM (updateRelay db) ([], False, [], []) relays liftIO $ setGroupInProgressDone db gInfo - pure (gInfo, gLink, relays', changed, newlyActive) + pure (gInfo, gLink, relays', changed, newlyActiveLinks, newlyActiveGMIds) toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged let GroupSummary {publicMemberCount} = groupSummary gInfo -- Owner is counted in publicMemberCount; > 1 means at least one subscriber. @@ -1370,14 +1425,16 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (null recipients) $ void $ sendGroupMessages user gInfo Nothing False recipients events where - updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact]) - updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActive) = + updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) -> IO ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) + updateRelay db relay@GroupRelay {groupMemberId, relayLink, relayStatus} (acc, changed, newlyActiveLinks, newlyActiveGMIds) = case relayLink of Just rLink - | rLink `elem` relayLinks && relayStatus == RSAccepted -> do + -- version is gated upstream at publish (getPublishableGroupRelays): an RSAccepted relay + -- whose link is in the published data is necessarily pre-roster, so activate it too + | rLink `elem` relayLinks && (relayStatus == RSAcknowledgedRoster || relayStatus == RSAccepted) -> do relay' <- updateRelayStatus db relay RSActive - pure (relay' : acc, True, rLink : newlyActive) - | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActive) + pure (relay' : acc, True, rLink : newlyActiveLinks, groupMemberId : newlyActiveGMIds) + | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds) | relayStatus == RSActive -> do -- Relay link absent from link data — deactivate. -- RSAccepted relays are not deactivated: their own link data update @@ -1386,8 +1443,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO the SMP server, but this owner won't receive a LINK callback for it -- TODO (LINK only fires in response to own setConnShortLink calls). relay' <- updateRelayStatus db relay RSInactive - pure (relay' : acc, True, newlyActive) - _ -> pure (relay : acc, changed, newlyActive) + pure (relay' : acc, True, newlyActiveLinks, newlyActiveGMIds) + _ -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds) _ -> throwChatError $ CECommandError "LINK event expected for a group link only" _ -> throwChatError $ CECommandError "unexpected cmdFunction" MERR _ err -> do @@ -1428,12 +1485,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- they will be updated after connection is accepted. upsertDirectRequestItem cd (requestMsg_, prevSharedMsgId_) Nothing -> do - void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let e2eContent = CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ Just $ CR.pqSupportToEnc $ reqPQSup - void $ createChatItem user cd False e2eContent Nothing Nothing + void $ createChatItem user cd False e2eContent Nothing Nothing Nothing void $ createFeatureEnabledItems_ user ct forM_ (autoReply addressSettings) $ \mc -> forM_ welcomeSharedMsgId $ \sharedMsgId -> - createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing + createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing mapM (createRequestItem cd) requestMsg_ case autoAccept of Nothing -> do @@ -1458,13 +1515,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- they will be updated after connection is accepted. upsertBusinessRequestItem cd (requestMsg_, prevSharedMsgId_) Nothing -> do - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) -- TODO [short links] possibly, we can just keep them created where they are created on the business side due to auto-accept -- let e2eContent = CIRcvGroupE2EEInfo $ E2EInfo $ Just False -- no PQ encryption in groups - -- void $ createChatItem user cd False e2eContent Nothing Nothing + -- void $ createChatItem user cd False e2eContent Nothing Nothing Nothing -- void $ createFeatureEnabledItems_ user ct forM_ (autoReply addressSettings) $ \arMC -> forM_ welcomeSharedMsgId $ \sharedMsgId -> - createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing + createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing Nothing mapM (createRequestItem cd) requestMsg_ toView $ CEvtAcceptingBusinessRequest user gInfo where @@ -1528,7 +1585,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = upsertBusinessRequestItem (CDChannelRcv _ _) = const $ pure Nothing createRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> (SharedMsgId, MsgContent) -> CM AChatItem createRequestItem cd (sharedMsgId, mc) = do - aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing + aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing Nothing toView $ CEvtNewChatItems user [aci] pure aci upsertRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> ((SharedMsgId, MsgContent) -> CM (Maybe AChatItem)) -> (SharedMsgId -> CM ()) -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem) @@ -1543,9 +1600,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) @@ -1553,7 +1613,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = messageError "processContactConnMessage: chat version range incompatible for accepting group join request" | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing Nothing (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1574,34 +1634,55 @@ 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 () - memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey = do + memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> Profile -> MemberId -> MemberKey -> Maybe MemberId -> CM () + memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey@(MemberKey joiningKey) viaRelay = do (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey) + existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId) + case existing_ of + Just rosterMem + -- a privileged memberId's key is owner-authoritative (the roster); the joiner must prove + -- possession of that exact key, otherwise this is an attempt to impersonate it + | isRosterRole (memberRole' rosterMem) -> + if verifyKey gInfo rosterMem + then acceptJoin gInfo (Just rosterMem) (memberRole' rosterMem) + else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" + _ -> acceptJoin gInfo Nothing gLinkMemRole + Nothing -> + messageError "memberJoinRequestViaRelay: no group link info for relay link" + where + -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join + verifyKey gInfo rosterMem = case (signedMsg_, groupKeys gInfo) of + (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just GroupKeys {publicGroupId}) -> + memberPubKey rosterMem == Just joiningKey + && verifyGroupSig joiningKey publicGroupId joiningMemberId signatures signedBody + && viaRelay == Just (memberId' (membership gInfo)) + _ -> False + acceptJoin gInfo existingMem_ acceptRole = do + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' - Nothing -> - messageError "memberJoinRequestViaRelay: no group link info for relay link" muteEventInChannel :: GroupInfo -> GroupMember -> Bool muteEventInChannel gInfo@GroupInfo {membership} m = @@ -1879,7 +1960,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processFDMessage fileId aci fileDescr = do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do - (rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs}) <- withStore $ \db -> do + (rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs, fileInvitation = FileInvitation {fileSize}}) <- withStore $ \db -> do rfd <- appendRcvFD db userId fileId fileDescr -- reading second time in the same transaction as appending description -- to prevent race condition with accept @@ -1887,15 +1968,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure (rfd, ft') when fileDescrComplete $ toView $ CEvtRcvFileDescrReady user aci ft' rfd case (fileStatus, xftpRcvFile) of - (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd userApprovedRelays cryptoArgs + (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd fileSize userApprovedRelays cryptoArgs _ -> pure () processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> CM (Maybe (RcvFileTransfer, CIFile 'MDRcv)) - processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv' -> do + processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv -> do ChatConfig {fileChunkSize} <- asks config - let fInv@FileInvitation {fileName, fileSize} = mkValidFileInvitation fInv' - inline <- receiveInlineMode fInv (Just mc) fileChunkSize - ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv inline fileChunkSize + fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + inline <- receiveInlineMode fInv' (Just mc) fileChunkSize + ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv' inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP (filePath, fileStatus, ft') <- case inline of Just IFMSent -> do @@ -1912,6 +1993,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = mkValidFileInvitation :: FileInvitation -> FileInvitation mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = FP.makeValid $ FP.takeFileName fileName} + validateFileInvitation :: FileInvitation -> CM FileInvitation + validateFileInvitation fInv@FileInvitation {fileName, fileSize} + | fileSize > 0 = pure $ mkValidFileInvitation fInv + | otherwise = throwChatError $ CEFileSize fileName + messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> CM () messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -2128,7 +2214,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (maybe False memberBlocked m') $ autoAcceptFile file_ processFileInv gInfo' m' = let fileMember_ = if sentAsGroup then Nothing else m' - in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ + in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ FTNormal sharedMsgId_ newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed live = do let mentions' = if maybe False memberBlocked m' then M.empty else mentions (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed live mentions' @@ -2317,11 +2403,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> CM () - processFileInvitation' ct fInv' msg@RcvMessage {sharedMsgId_} msgMeta = do + processFileInvitation' ct fInv msg@RcvMessage {sharedMsgId_} msgMeta = do ChatConfig {fileChunkSize} <- asks config - let fInv@FileInvitation {fileName, fileSize} = mkValidFileInvitation fInv' - inline <- receiveInlineMode fInv Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize + fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + inline <- receiveInlineMode fInv' Nothing fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv' inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" @@ -2332,10 +2418,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO remove once XFile is discontinued processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> UTCTime -> CM () - processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} brokerTs = do + processGroupFileInvitation' gInfo m fInv msg@RcvMessage {sharedMsgId_} brokerTs = do ChatConfig {fileChunkSize} <- asks config - inline <- receiveInlineMode fInv Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) fInv inline fileChunkSize + fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + inline <- receiveInlineMode fInv' Nothing fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" @@ -2431,10 +2518,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ft <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId >>= getRcvFileTransfer db user receiveInlineChunk ft chunk meta - bFileChunkGroup :: GroupInfo -> SharedMsgId -> FileChunk -> MsgMeta -> CM () - bFileChunkGroup GroupInfo {groupId} sharedMsgId chunk meta = do - ft <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId >>= getRcvFileTransfer db user - receiveInlineChunk ft chunk meta + -- A group BFileChunk is a normal inline file chunk or a roster blob chunk, both located by + -- (group_id, shared_msg_id). A chunk matching no in-flight transfer (an orphaned re-served roster + -- chunk, or a missing normal file) is ignored; the outer withAckMessage acks it. + bFileChunkGroup :: GroupInfo -> GroupMember -> SharedMsgId -> FileChunk -> MsgMeta -> CM () + bFileChunkGroup gInfo@GroupInfo {groupId} fromMember sharedMsgId chunk meta = do + fileId_ <- withStore' $ \db -> getGroupRcvFileId db userId groupId (groupMemberId' fromMember) sharedMsgId + forM_ fileId_ $ \fileId -> do + ft <- withStore $ \db -> getRcvFileTransfer db user fileId + case fileType ft of + FTRoster -> receiveRosterChunk gInfo ft meta chunk + FTNormal -> receiveInlineChunk ft chunk meta receiveInlineChunk :: RcvFileTransfer -> FileChunk -> MsgMeta -> CM () receiveInlineChunk RcvFileTransfer {fileId, fileStatus = RFSNew} FileChunk {chunkNo} _ @@ -2444,7 +2538,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = case chunk of FileChunk {chunkNo} -> when (chunkNo == 1) $ startReceivingFile user fileId _ -> pure () - receiveFileChunk ft Nothing meta chunk + receiveFileChunk Nothing ft Nothing meta chunk + + -- A roster re-serve re-sends the blob from chunk 1; discard any partial first, else chunk 1 over a + -- partial is out-of-order (RcvChunkError) and appending after the stale prefix corrupts the blob. + receiveRosterChunk :: GroupInfo -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM () + receiveRosterChunk gInfo ft meta chunk = do + case chunk of + FileChunk {chunkNo} | chunkNo == 1 -> do + last_ <- withStore' $ \db -> getRcvFileLastChunkNo db ft + when (isJust last_) $ resetRosterPartialChunks ft + _ -> pure () + receiveFileChunk (Just gInfo) ft Nothing meta chunk xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext) xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do @@ -2502,7 +2607,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership if sameGroupLinkId groupLinkId groupLinkId' then do @@ -2559,14 +2664,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processContactProfileUpdate :: Contact -> Profile -> Bool -> CM Contact processContactProfileUpdate c@Contact {profile = lp} p' createItems - | p /= p' = do + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify lp = do c' <- withStore $ \db -> if userTTL == rcvTTL - then updateContactProfile db user c p' + then updateContactProfile db cxt user c p' else do c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' - updateContactProfile db user c' p' - when (directOrUsed c' && createItems) $ do + updateContactProfile db cxt user c' p' + when (contentChanged && directOrUsed c' && createItems) $ do createProfileUpdatedItem c' lift $ createRcvFeatureItems user c c' toView $ CEvtContactUpdated user c c' @@ -2574,6 +2680,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure c where + contentChanged = not (sameProfileContent p p') p = fromLocalProfile lp Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs @@ -2676,22 +2783,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' msgTs_ - | redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do - updateBusinessChatProfile gInfo + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify p = do + when contentChanged $ updateBusinessChatProfile gInfo case memberContactId of Nothing -> do - m' <- withStore $ \db -> updateMemberProfile db user m p' + m' <- withStore $ \db -> updateMemberProfile db cxt user m p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db cxt user mContactId if canUpdateProfile mCt then do - (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' + (m', ct') <- withStore $ \db -> updateContactMemberProfile db cxt user m mCt p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' toView $ CEvtContactUpdated user mCt ct' pure m' @@ -2705,7 +2813,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure m where - allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo + contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p')) + 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' @@ -2967,40 +3076,63 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> pure (conn', Nothing) xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do - if useRelays' gInfo && isRelay m - then when (memRole > GRMember) $ throwChatError $ CEException "x.grp.mem.new: relay cannot introduce role above member in channel" - else checkHostRole m memRole + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do + let fromRelay = useRelays' gInfo && isRelay m + unless fromRelay $ checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing - else do + else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case - Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - (updatedMember, gInfo') <- withStore $ \db -> do - updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus - gInfo' <- - if memberPending updatedMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo - pure (updatedMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' - toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember - memberAnnouncedToView updatedMember gInfo'' - pure $ deliveryJobScope updatedMember + Right unknownMember@GroupMember {memberStatus = GSMemUnknown} + -- roster-established privileged member: the relay may update the profile only, + -- never the role or key (those are owner-authoritative via the roster, and + -- XGrpMemNew is unsigned) + | fromRelay && isRosterRole (memberRole' unknownMember) -> do + -- a member's key is immutable per memberId and identical across relays; mismatch + -- is unambiguous relay misbehavior (role can legitimately differ across relays + -- under multi-relay skew, so we deliberately don't warn on role) + let assertedKey = (\(MemberKey k) -> k) <$> assertedKey_ + -- TODO [relays] member: surface relay-key-mismatch as a dedicated event / chat item / relay state + when (assertedKey /= memberPubKey unknownMember) $ + messageWarning $ "x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" <> safeDecodeUtf8 (strEncode memId) + updatedMember <- withStore $ \db -> updateRosterMemberAnnounced db cxt user m unknownMember memInfo initialStatus + -- roster members can't be pending, so no members-require-attention update + gInfo' <- updatePublicGroupData user gInfo + toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo' + pure $ deliveryJobScope updatedMember + -- asserted privileged but NOT roster-established: relay conjuring a moderator + | fromRelay && isRosterRole memRole -> + messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing + | otherwise -> do + (updatedMember, gInfo') <- withStore $ \db -> do + updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus + gInfo' <- + if memberPending updatedMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (updatedMember, gInfo') + gInfo'' <- updatePublicGroupData user gInfo' + toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo'' + pure $ deliveryJobScope updatedMember Right _ | useRelays' gInfo -> logInfo "x.grp.mem.new: member already created via another relay" $> Nothing | otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing - Left _ -> do - (newMember, gInfo') <- withStore $ \db -> do - newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus - gInfo' <- - if memberPending newMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo - pure (newMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' - memberAnnouncedToView newMember gInfo'' - pure $ deliveryJobScope newMember + Left _ + -- a privileged member absent from the roster is a relay conjuring a moderator + | fromRelay && isRosterRole memRole -> messageError "x.grp.mem.new: privileged member not established by roster" $> Nothing + | otherwise -> do + (newMember, gInfo') <- withStore $ \db -> do + newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus + gInfo' <- + if memberPending newMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (newMember, gInfo') + gInfo'' <- updatePublicGroupData user gInfo' + memberAnnouncedToView newMember gInfo'' + pure $ deliveryJobScope newMember where initialStatus = case msgScope_ of Just (MSMember _) -> GSMemPendingReview @@ -3039,12 +3171,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.intro ignored: member already exists" Left _ | useRelays' gInfo -> do - -- owner key must only come from link data, not from relay intro + -- role + key are owner-authoritative (roster); an intro establishes neither - a privileged + -- claim is created at the channel default with no key until the owner-signed roster confirms it + defaultRole <- unknownMemberRole gInfo let memInfo' = case memInfo of MemberInfo mId mRole v p _ - | mRole == GROwner -> MemberInfo mId mRole v p Nothing + | mRole >= GRMember -> MemberInfo mId defaultRole v p Nothing _ -> memInfo - void $ withStore $ \db -> createIntroReMember db user gInfo memInfo' memRestrictions + void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' memRestrictions | otherwise -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) case memChatVRange of @@ -3056,7 +3190,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createConn subMode let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> do - reMember <- createIntroReMember db user gInfo memInfo memRestrictions + reMember <- createIntroReMember db cxt user gInfo memInfo memRestrictions createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" @@ -3091,7 +3225,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. `catchError` \case - SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db cxt user gInfo m memInfo GCPostMember GSMemAnnounced e -> throwError e -- TODO [knocking] separate pending statuses from GroupMemberStatus? -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? @@ -3101,8 +3235,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure toMember subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership - 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 groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode @@ -3112,28 +3246,241 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = chatV = vr cxt `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode - xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg@RcvMessage {msgSigned} brokerTs + -- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted + -- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one + -- batch), then advance it in the same transaction; a strictly lower version is a replay and is ignored. + -- Only an owner sender may advance it: a non-owner signed event is rejected by the action that follows, + -- but must not bump roster_version first, or every later owner roster at a lower version is dropped. + applyAtRosterVersion :: GroupInfo -> GroupMember -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope) + applyAtRosterVersion gInfo sender rosterVer_ action + | not (useRelays' gInfo) = action + | otherwise = case rosterVer_ of + Nothing -> action + Just _ | memberRole' sender /= GROwner -> action + Just v -> do + accept <- withStore' $ \db -> do + cur <- getGroupRosterVersion db gInfo + let fresh = maybe True (v >=) cur + when fresh $ setGroupRosterVersion db gInfo v + pure fresh + if accept + then action + else messageWarning "x.grp.mem: roster version not newer than current, ignoring" $> Nothing + + xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs | membershipMemId == memId = - let gInfo' = gInfo {membership = membership {memberRole = memRole}} - in changeMemberRole gInfo' membership $ RGEUserRole memRole - | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case - Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole - Left _ -> messageError "x.grp.mem.role with unknown member ID" $> Nothing + applyAtRosterVersion gInfo m rosterVer_ $ + let gInfo' = gInfo {membership = membership {memberRole = memRole}} + in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) $ RGEUserRole memRole + | otherwise = applyAtRosterVersion gInfo m rosterVer_ $ do + defaultRole <- unknownMemberRole gInfo + -- an owner-signed event with a key TOFU-creates an unknown member only for a roster role; else a plain lookup + let allowCreate = useRelays' gInfo && senderRole == GROwner && isRosterRole memRole && isJust memberKey_ + withStore' (\db -> runExceptT $ getCreateUnknownGMByMemberId db cxt user gInfo memId (nameFromMemberId memId) defaultRole allowCreate) >>= \case + Right (Just (member, created)) + -- just created (keyless, and allowCreate ensured the event carries its key): pin key + role + | created, Just (MemberKey pubKey) <- memberKey_ -> + let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole + in changeMemberRole gInfo member created (\db -> void $ applyMemberKeyRole db member pubKey memRole) gEvent + -- known member: apply the role (its key is established via roster/intro; the event's key is ignored) + | otherwise -> + let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole + in changeMemberRole gInfo member created (\db -> updateGroupMemberRole db user member memRole) gEvent + -- in relay groups the roster may deliver role update for previously-unknown privileged members + _ | useRelays' gInfo -> pure Nothing + | otherwise -> messageError "x.grp.mem.role with unknown member ID" $> Nothing where GroupMember {memberId = membershipMemId} = membership - changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent - | senderRole < GRAdmin || senderRole < fromRole = + -- applyMember writes the change (role, or role + pinned key for a freshly TOFU-created member); + -- the delivery scope (relay forwarding) is computed on the pre-change role + changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent + | senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing + | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner = + messageError "x.grp.mem.role: only the owner can change member, moderator and admin roles in relay groups" $> Nothing + -- a forwarded role event the roster already applied is a no-op; suppress it. + -- a just-created member is keyless here, so fall through to pin its owner-attested key. + | useRelays' gInfo && not created && fromRole == memRole = pure $ memberEventDeliveryScope member | otherwise = do - withStore' $ \db -> updateGroupMemberRole db user member memRole + withStore' applyMember (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned} pure $ memberEventDeliveryScope member + -- The header only starts the transfer; the roster is applied and the version bumped only at + -- blob completion, so a withheld or corrupted blob leaves the last good roster intact. + -- fromMember is the relay that delivered THIS roster copy (the owner on a relay receiving directly, + -- a relay on a member receiving a forward); author is the owner who signed it. + xGrpRoster :: GroupInfo -> GroupMember -> GroupMember -> GroupRoster -> VerifiedMsg e -> Maybe SharedMsgId -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpRoster gInfo fromMember author GroupRoster {version = newVer, fileInv = InlineFileInvitation {fileSize, fileDigest}} verifiedMsg sharedMsgId_ brokerTs + -- only an owner may sign a roster; otherwise a relay could route it as a member whose key it controls + | memberRole' author /= GROwner = messageError "x.grp.roster: not signed by an owner" $> Nothing + | fileSize > maxGroupRosterBytes = messageError "x.grp.roster: roster blob size exceeds limit" $> Nothing + | otherwise = case verifiedMsg of + -- unreachable: XGrpRoster is in requiresSignature, so withVerifiedMsg rejected unsigned + VMUnsigned _ -> pure Nothing + VMSigned _ sm _ -> case sharedMsgId_ of + Nothing -> Nothing <$ messageWarning "x.grp.roster: missing shared message id" + Just sharedMsgId -> do + -- per-source pending version (THIS relay's own in-flight transfer), not a single group slot + pendingVer_ <- withStore' $ \db -> getRosterTransferVersion db gInfo (groupMemberId' fromMember) + -- accept a version not below BOTH applied and this source's pending (>=, Nothing below 0): a preceding + -- signed event may have already advanced rosterVersion to this blob's version; a lower one is a downgrade. + if newVer `notBelowRoster` rosterVersion gInfo && newVer `notBelowRoster` pendingVer_ + then startRosterTransfer sm sharedMsgId + else pure Nothing + where + startRosterTransfer sm sharedMsgId = do + -- supersede THIS source's own in-flight transfer (older version or a restart); other relays' transfers are independent + cleanupRosterTransfer gInfo (groupMemberId' fromMember) + let relayHdr = if isUserGrpFwdRelay gInfo then Just sm else Nothing + chSize <- asks $ fileChunkSize . config + let rosterFInv = FileInvitation {fileName = "roster", fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing} + -- transfer record + its scratch file in one transaction (file owned by the transfer, keyed per source) + rft@RcvFileTransfer {fileId} <- withStore $ \db -> do + transferId <- liftIO $ createRosterTransfer db gInfo (groupMemberId' fromMember) newVer fileDigest (groupMemberId' author) brokerTs relayHdr + createRosterRcvFile db userId gInfo fromMember transferId sharedMsgId rosterFInv (Just IFMSent) (fromIntegral chSize) + -- accept the chat-item-free file before chunk 1 (FIFO before it) so chunk 1 isn't rejected on RFSNew + -- transient scratch file (consumed into roster_blob, then deleted): temp folder, not the user's files folder / Downloads + tmpDir <- lift getChatTempDirectory + rosterTs <- liftIO getCurrentTime + let GroupInfo {groupId = gId} = gInfo + rosterFile = "roster_" <> show gId <> "_" <> show (groupMemberId' fromMember) <> "_" <> formatTime defaultTimeLocale "%Y%m%d_%H%M%S" rosterTs + filePath <- getRcvFilePath fileId (Just tmpDir) rosterFile False + withStore' $ \db -> startRcvInlineFT db user rft filePath (Just IFMSent) + pure Nothing + + -- Roster version comparison treating Nothing (un-materialized) as below 0. Non-strict (>=) so a relay + -- accepts the owner's blob at the version a preceding signed event already advanced rosterVersion to. + notBelowRoster :: VersionRoster -> Maybe VersionRoster -> Bool + notBelowRoster v = maybe True (v >=) + + -- Blob arrived: verify the owner-attested digest over the plaintext and guard against + -- downgrade before applying; on a relay, ack the owner and re-serve to members. + rosterCompletion :: GroupInfo -> RcvFileTransfer -> CM () + rosterCompletion gInfo RcvFileTransfer {fileId, fileStatus} = + withStore' (\db -> getRosterTransfer db fileId) >>= \case + -- defensive: the file always has its transfer (created together, deleted together) + Nothing -> lift (closeFileHandle fileId rcvFiles) >> forM_ (rosterFilePath fileStatus) removeFsFile + Just RcvRosterTransfer {rosterTransferId = transferId, rosterTransferVersion = pendingVer, rosterTransferDigest = pendingDigest, rosterTransferOwnerGMId = ownerGMId, rosterTransferBrokerTs = rosterBrokerTs, rosterTransferHeader = header_} -> do + owner_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberById db cxt user ownerGMId) + blob <- readAssembledRoster + let isRelay = isUserGrpFwdRelay gInfo + ackErr err = do + cleanupRosterTransferById transferId + when isRelay $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err) + if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest + then ackErr "relay could not verify the roster blob" + else case parseAll rosterBlobP blob of + Left _ -> ackErr "relay could not parse the roster blob" + Right entries -> case owner_ of + Nothing -> cleanupRosterTransferById transferId + Just author -> do + defaultRole <- unknownMemberRole gInfo + -- gate against the persisted roster_version inside the apply transaction: a roster from another + -- relay (or a preceding signed event) may already have advanced it past this one; a stale + -- completion (e.g. relay1 sent v5 then v6, relay2's v5 completes after v6) is rejected. + results_ <- withStore $ \db -> do + cur <- liftIO $ getGroupRosterVersion db gInfo + if maybe False (pendingVer <) cur + then pure Nothing + else do + res <- processRosterEntries db gInfo defaultRole (validateGroupRoster entries) + liftIO $ setGroupLiveRoster db gInfo pendingVer ownerGMId rosterBrokerTs header_ blob + pure (Just res) + cleanupRosterTransferById transferId + forM_ results_ $ \results -> do + emitRosterResults gInfo author rosterBrokerTs results + -- ack while setting up (own status accepted/acknowledged); a serving (active) relay must not ack broadcasts. + when (isRelay && (relayOwnStatus gInfo == Just RSAccepted || relayOwnStatus gInfo == Just RSAcknowledgedRoster)) $ do + sendRosterAck gInfo author pendingVer Nothing + withStore' $ \db -> void $ updateRelayOwnStatusFromTo db gInfo RSAccepted RSAcknowledgedRoster + where + rosterFilePath = \case + RFSAccepted p -> Just p + RFSConnected p -> Just p + RFSComplete p -> Just p + _ -> Nothing + readAssembledRoster = case rosterFilePath fileStatus of + Just fp -> readAt fp + Nothing -> throwChatError $ CEInternalError "roster file not in progress" + readAt fp = lift (toFSFilePath fp) >>= liftIO . B.readFile + + -- TOFU-apply an owner-signed (key, role) to a resolved member: pin the key if absent; for a keyed + -- member keep the trusted key (Left = reject a different one), else update the role. Right + -- (Just (member-at-new-role, fromRole)) when the role changed, Right Nothing when already current. + applyMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO (Either MemberId (Maybe (GroupMember, GroupMemberRole))) + applyMemberKeyRole db m pubKey role = case memberPubKey m of + Just k + | k /= pubKey -> pure (Left (memberId' m)) + | memberRole' m == role -> pure (Right Nothing) + | otherwise -> updateGroupMemberRole db user m role $> Right (Just (m {memberRole = role}, memberRole' m)) + Nothing -> setGroupMemberKeyRole db m pubKey role $> Right (Just (m {memberRole = role}, memberRole' m)) + + -- TOFU apply: pin each member's key on first use, then update roles. + processRosterEntries :: DB.Connection -> GroupInfo -> GroupMemberRole -> [RosterMember] -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole, Bool)]) + processRosterEntries db gInfo defaultRole entries = do + let rosterIds = map (\RosterMember {memberId} -> memberId) entries + (cs, as) <- foldrM applyRosterEntry ([], []) entries + currentPriv <- liftIO $ getGroupRosterMembers db cxt user gInfo + reverted <- liftIO $ fmap catMaybes $ forM currentPriv $ \m -> + if memberId' m `notElem` rosterIds + then updateGroupMemberRole db user m defaultRole $> Just ((m :: GroupMember) {memberRole = defaultRole}, memberRole' m, False) + else pure Nothing + pure (cs, as <> reverted) + where + -- entry-level failure (StoreError or IO exception) is muted; the entry is dropped + applyRosterEntry RosterMember {memberId, key = MemberKey pubKey, role} (cs, as) = + ( getCreateUnknownGMByMemberId db cxt user gInfo memberId (nameFromMemberId memberId) defaultRole True >>= \case + Nothing -> pure (cs, as) + Just (m, created) -> liftIO (applyMemberKeyRole db m pubKey role) >>= \case + Left mid -> pure (mid : cs, as) + Right Nothing -> pure (cs, as) + Right (Just (rm, fromR)) -> pure (cs, (rm, fromR, created) : as) + ) + `catchAllErrors` \_ -> pure (cs, as) + + emitRosterResults :: GroupInfo -> GroupMember -> UTCTime -> ([MemberId], [(GroupMember, GroupMemberRole, Bool)]) -> CM () + emitRosterResults gInfo author rosterBrokerTs (conflicts, applied) = do + forM_ conflicts $ \mid' -> + messageWarning $ "x.grp.roster: member key conflict, keeping trusted key, memberId=" <> safeDecodeUtf8 (strEncode mid') + forM_ applied $ \(member, fromRole, created) -> + unless created $ createItems member fromRole + where + createItems member fromRole = do + let toRole = memberRole' member + gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) toRole + (gInfo', author', scopeInfo) <- mkGroupChatScope gInfo author + ci <- createChatItem user (CDGroupRcv gInfo' scopeInfo author') False (CIRcvGroupEvent gEvent) Nothing (Just MSSVerified) (Just rosterBrokerTs) + toView $ CEvtNewChatItems user [ci] + toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified} + + sendRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () + sendRosterAck gInfo owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err) + + xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () + xGrpRosterAck gInfo m ackVer err = do + relay_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupRelayByGMId db (groupMemberId' m)) + case relay_ of + Just relay@GroupRelay {relayStatus = RSAccepted} -> case err of + Nothing + | rosterVersion gInfo == Just ackVer -> do + (relay', gLink) <- withStore $ \db -> do + relay' <- liftIO $ updateRelayStatus db relay RSAcknowledgedRoster + gLink <- getGroupLink db user gInfo + pure (relay', gLink) + setGroupLinkDataAsync user gInfo gLink + toView $ CEvtGroupRelayUpdated user gInfo m relay' + | otherwise -> messageWarning "x.grp.roster.ack: stale version, awaiting ack for the current roster" + Just e -> do + relay' <- withStore' $ \db -> updateRelayStatusFromTo db relay RSAccepted RSRejected + toView $ CEvtGroupRelayUpdated user gInfo m relay' + messageError $ "x.grp.roster.ack: relay could not save roster, marked rejected: " <> e + _ -> pure () + checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = when (memberRole < GRAdmin || memberRole < memRole) $ throwChatError (CEGroupContactRole localDisplayName) @@ -3178,11 +3525,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) + xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId - then checkRole membership $ do + then applyAtRosterVersion gInfo m rosterVer_ $ checkRole membership $ do deleteGroupLinkIfExists user gInfo -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False @@ -3194,7 +3541,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = deleteMemberItem msg gInfo RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} - else + else applyAtRosterVersion gInfo m rosterVer_ $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> do messageError "x.grp.mem.del with unknown member ID" @@ -3309,6 +3656,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (useRelays' g'') $ void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' + -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) + when (isRelay (membership g)) $ sendRelayCapIfNeeded user g pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope) @@ -3404,7 +3753,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createItems mCt m' joinConn subMode = do -- [incognito] send membership incognito profile - let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user Nothing True connReq dm subMode @@ -3443,7 +3792,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM () processForwardedMsg verifiedMsg author_ = do rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ verifiedMsg brokerTs - forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of + forM_ rcvMsg_ $ \rcvMsg@RcvMessage {sharedMsgId_, chatMsgEvent = ACME _ event} -> case event of XMsgNew mc -> void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True where @@ -3458,13 +3807,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> void $ xGrpRelayNew gInfo author rl XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs - XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs + XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole memberKey rosterVer rcvMsg msgTs XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs - XGrpMemDel memId withMessages -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages verifiedMsg rcvMsg msgTs True + XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg + XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo m author gr verifiedMsg sharedMsgId_ msgTs _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) where withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () @@ -3484,12 +3834,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just sm@SignedMsg {chatBinding, signatures, signedBody} | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> case chatBinding of - CBGroup -> - let prefix = smpEncode chatBinding <> bindingData - bindingData = case groupKeys gInfo of - Just GroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId) - Nothing -> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups - in signed MSSVerified <$ guard (all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) + CBGroup + | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> + signed MSSVerified <$ guard (verifyGroupSig pubKey publicGroupId memberId signatures signedBody) + | otherwise -> + let prefix = smpEncode chatBinding <> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups + in signed MSSVerified <$ guard (all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) _ -> signed MSSSignedNoKey <$ guard signatureOptional | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) where @@ -3641,13 +3991,13 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks + let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" - lift . void $ getDeliveryJobWorker True deliveryKey + when (isJust body_) . lift . void $ getDeliveryJobWorker True deliveryKey -- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion DJRelayRemoved | workerScope /= DWSGroup -> @@ -3751,10 +4101,15 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do bucketSize <- asks $ deliveryBucketSize . config senders <- withStore' $ \db -> fmap catMaybes . forM senderGMIds $ \sId -> - fmap eitherToMaybe . runExceptT $ do + fmap (join . eitherToMaybe) . runExceptT $ do sender <- getNonRemovedMemberById db cxt user sId - vec <- getMemberRelationsVector db sender - pure (sender, vec) + -- owners are already known to every member (group link + owner-intro in introduceInChannel), + -- so we never disseminate their profile (redundant, and races with joins re-announcing the owner) + if memberRole' sender == GROwner + then pure Nothing + else do + vec <- getMemberRelationsVector db sender + pure $ Just (sender, vec) let missingSenders = length senderGMIds - length senders when (missingSenders > 0) $ logInfo $ "delivery job " <> tshow jobId <> ": " <> tshow missingSenders <> " senders missing; skipping their profile prepend" @@ -3764,13 +4119,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do if null senders then pure (body, [], [], []) else do - -- Skip role > GRMember (mirrors xGrpMemNew gate). - -- TODO [relays] public groups: revisit if mods/admins are introduced via this sidecar. - let (encoderErrs, validLabeled) = - partitionEithers - [ (\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s - | (s, _) <- senders, memberRole' s <= GRMember - ] + -- all members' profiles disseminate; privileged key/role come from the roster, not here + let (encoderErrs, validLabeled) = partitionEithers [(\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s | (s, _) <- senders] (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled (overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2] diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 9507375527..e8cd381941 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -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 diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index ed65bd4af7..81861aad74 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -24,7 +24,6 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B import Data.Char (ord) import Data.Function (on) -import Data.Foldable (foldr') import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L @@ -79,15 +78,15 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) let encoded = encodeBatch mode bodies in Right (MsgBatch encoded msgs) : batches --- | Batches delivery tasks into (batch, accepted, large). +-- | Batches delivery tasks into (batch if any task was accepted, accepted, large). -- Always uses binary batch format for relay groups. -batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (Maybe ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList where addToBatch :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> MessageDeliveryTask -> ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) addToBatch (msgBodies, accepted, large, len, n) task - -- too large: skip, record in large - | msgLen > maxLen = (msgBodies, accepted, task : large, len, n) + -- element can't fit even a singleton batch (4-byte binary-batch framing) + | msgLen + 4 > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) @@ -98,10 +97,11 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} verifiedMsg msgLen = B.length msgBody len' = len + msgLen - toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) + toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (Maybe ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) toResult (msgBodies, accepted, large, _, _) = let encoded = encodeBinaryBatch (reverse msgBodies) - in (encoded, reverse accepted, reverse large) + body = if null accepted then Nothing else Just encoded + in (body, reverse accepted, reverse large) -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 018457c7e7..85074e93f4 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -38,6 +38,7 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -138,6 +139,10 @@ foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt +foreign export ccall "chat_badge_keygen" cChatBadgeKeygen :: IO CJSONString + +foreign export ccall "chat_badge_issue" cChatBadgeIssue :: CString -> IO CJSONString + foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString @@ -256,6 +261,7 @@ mobileChatOpts dbOptions = tbqSize = 4096, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Just "", diff --git a/src/Simplex/Chat/Mobile/Badges.hs b/src/Simplex/Chat/Mobile/Badges.hs new file mode 100644 index 0000000000..91e90e16c3 --- /dev/null +++ b/src/Simplex/Chat/Mobile/Badges.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} + +module Simplex.Chat.Mobile.Badges + ( cChatBadgeKeygen, + cChatBadgeIssue, + BadgeResult (..), + BadgeIssueReq (..), + IssuerKeyPair (..), + ) +where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString as B +import Data.Text (Text) +import qualified Data.Text as T +import Foreign.C (CString) +import Simplex.Chat.Badges +import Simplex.Chat.Mobile.Shared (CJSONString, newCStringFromLazyBS) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) +import Simplex.Messaging.Parsers (defaultJSON) + +-- FFI envelope for a generated issuer keypair (the BBS keypair tuple serialized with named fields) +data IssuerKeyPair = IssuerKeyPair + { publicKey :: BBSPublicKey, + secretKey :: BBSSecretKey + } + +data BadgeIssueReq = BadgeIssueReq + { badgeKeyIdx :: Int, + secretKey :: BBSSecretKey, + request :: BadgeRequest + } + +data BadgeResult r + = BadgeResult {result :: r} + | BadgeError {error :: Text} + +$(JQ.deriveJSON defaultJSON ''IssuerKeyPair) + +$(JQ.deriveJSON defaultJSON ''BadgeIssueReq) + +$(pure []) + +instance ToJSON r => ToJSON (BadgeResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +instance FromJSON r => FromJSON (BadgeResult r) where + parseJSON = $(JQ.mkParseJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +cChatBadgeKeygen :: IO CJSONString +cChatBadgeKeygen = + bbsKeyGen >>= \case + Right (pk, sk) -> encodeResult $ BadgeResult (IssuerKeyPair pk sk) + Left e -> encodeResult @IssuerKeyPair $ BadgeError (T.pack e) + +cChatBadgeIssue :: CString -> IO CJSONString +cChatBadgeIssue cReq = do + bs <- B.packCString cReq + encodeResult @BadgeCredential =<< case J.eitherDecodeStrict' bs of + Left e -> pure $ BadgeError (T.pack e) + Right BadgeIssueReq {badgeKeyIdx, secretKey, request} -> + either (BadgeError . T.pack) BadgeResult <$> issueBadge badgeKeyIdx secretKey (VerifiedBadgeRequest request) + +encodeResult :: ToJSON r => BadgeResult r -> IO CJSONString +encodeResult = newCStringFromLazyBS . J.encode diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 08a765077f..a936f58848 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -28,7 +28,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Numeric.Natural (Natural) import Options.Applicative -import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), updateStr, versionNumber, versionString) +import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), WebPreviewConfig (..), updateStr, versionNumber, versionString) import Simplex.FileTransfer.Description (mb) import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) import Simplex.Messaging.Encoding.String @@ -66,6 +66,7 @@ data CoreChatOpts = CoreChatOpts tbqSize :: Natural, deviceName :: Maybe Text, chatRelay :: Bool, + webPreviewConfig :: Maybe WebPreviewConfig, highlyAvailable :: Bool, yesToUpMigrations :: Bool, migrationBackupPath :: Maybe FilePath, @@ -240,6 +241,46 @@ coreChatOptsP appDir defaultDbName = do ( long "relay" <> help "Run as a chat relay client" ) + webPreviewConfig <- do + webDomain_ <- + optional $ + strOption + ( long "relay-web-domain" + <> metavar "DOMAIN" + <> help "Domain for channel web previews (relay only)" + ) + webJsonDir_ <- + optional $ + strOption + ( long "relay-web-dir" + <> metavar "DIR" + <> help "Directory for channel web preview JSON files (relay only)" + ) + webCorsFile <- + optional $ + strOption + ( long "relay-web-cors-file" + <> metavar "FILE" + <> help "Path to generated Caddy CORS config file (relay only)" + ) + webUpdateInterval <- + option auto + ( long "relay-web-interval" + <> metavar "SECONDS" + <> help "Interval between web preview regeneration in seconds (relay only)" + <> value 300 + ) + webPreviewItemCount <- + option auto + ( long "relay-web-item-count" + <> metavar "COUNT" + <> help "Number of recent messages in channel web preview (relay only)" + <> value 50 + ) + pure $ case (webDomain_, webJsonDir_) of + (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" highlyAvailable <- switch ( long "ha" @@ -283,6 +324,7 @@ coreChatOptsP appDir defaultDbName = do tbqSize, deviceName, chatRelay, + webPreviewConfig, highlyAvailable, yesToUpMigrations, migrationBackupPath, diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index 3d4f650d42..722c7c5f62 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -10,7 +10,7 @@ generateRandomProfile :: IO Profile generateRandomProfile = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Nothing, preferences = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, simplexName = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index b692dba04d..223fe492a9 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -48,12 +48,14 @@ import Data.Time.Clock (UTCTime) import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Data.Type.Equality import Data.Typeable (Typeable) -import Data.Word (Word32) +import Data.Word (Word16, Word32) +import Simplex.Chat.Badges (LocalBadge) import Simplex.Chat.Call import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import qualified Simplex.FileTransfer.Description as FD import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize) @@ -82,12 +84,14 @@ import Simplex.Messaging.Version hiding (version) -- 15 - support specifying message scopes for group messages (2025-03-12) -- 16 - support short link data (2025-06-10) -- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +-- 18 - relay web capabilities (2026-05-31) +-- 19 - group roster (2026-06-18) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 17 +currentChatVersion = VersionChat 19 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -154,6 +158,15 @@ shortLinkDataVersion = VersionChat 16 memberSupportVoiceVersion :: VersionChat memberSupportVoiceVersion = VersionChat 17 +-- relay sends web preview capabilities to owner +relayWebCapVersion :: VersionChat +relayWebCapVersion = VersionChat 18 + +-- owner-signed roster (promoted members/moderators/admins) and the relay roster-ack handshake; +-- a relay below this version is published without the handshake (it can't ack a roster) +groupRosterVersion :: VersionChat +groupRosterVersion = VersionChat 19 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -367,6 +380,36 @@ data GrpMsgForward = GrpMsgForward } deriving (Eq, Show) +-- | Owner-signed roster header for the privileged (moderator/admin/member) set; owners +-- are not included, their keys come from the link. The member list itself is not +-- here: it is sent as a binary blob over the inline file transfer, and this header +-- carries only its inline-file invitation (size + owner-attested digest). +data GroupRoster = GroupRoster + { version :: VersionRoster, + fileInv :: InlineFileInvitation + } + deriving (Eq, Show) + +-- | Lean always-inline file invitation for the roster blob, carried in the signed +-- header. The digest authenticates the unsigned blob; integrity is entirely the digest. +data InlineFileInvitation = InlineFileInvitation + { fileSize :: Integer, + fileDigest :: FD.FileDigest + } + deriving (Eq, Show) + +data RosterMember = RosterMember + { memberId :: MemberId, + key :: MemberKey, -- trust-on-first-use pinned per memberId + role :: GroupMemberRole, + privileges :: Word16 -- reserved: serialized as 0, parsed and ignored in v1 + } + deriving (Eq, Show) + +-- RosterMember is binary-only: it rides in the roster blob, never in a JSON message. +instance Encoding RosterMember where + smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges) + smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP instance Encoding FwdSender where smpEncode = \case @@ -433,6 +476,11 @@ data MsgSigning = MsgSigning encodeChatBinding :: ChatBinding -> ByteString -> ByteString encodeChatBinding cb bindingData = smpEncode cb <> bindingData +signChatMsgBody :: MsgSigning -> ByteString -> SignedMsg +signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody = + let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody) + in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig L.:| [], signedBody = msgBody} + data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -446,7 +494,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json - XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json + XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json @@ -465,16 +513,18 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json - XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json + XGrpMemRole :: MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> ChatMsgEvent 'Json XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json XGrpMemCon :: MemberId -> ChatMsgEvent 'Json XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented - XGrpMemDel :: MemberId -> Bool -> ChatMsgEvent 'Json + XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> ChatMsgEvent 'Json XGrpLeave :: ChatMsgEvent 'Json XGrpDel :: ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json + XGrpRoster :: GroupRoster -> ChatMsgEvent 'Json + XGrpRosterAck :: VersionRoster -> Maybe Text -> ChatMsgEvent 'Json XGrpMsgForward :: GrpMsgForward -> ChatMessage 'Json -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json @@ -518,6 +568,7 @@ isForwardedGroupMsg ev = case ev of XGrpDel -> True XGrpInfo _ -> True XGrpPrefs _ -> True + XGrpRoster _ -> True _ -> False data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} @@ -786,6 +837,8 @@ data MsgMention = MsgMention {memberId :: MemberId} newtype MsgMentions = MsgMentions (Map MemberName MsgMention) deriving (Eq, Show) +$(JQ.deriveJSON defaultJSON ''InlineFileInvitation) + $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MCL") ''MsgChatLink) $(JQ.deriveJSON defaultJSON ''LinkOwnerSig) @@ -886,6 +939,28 @@ maxCompressedMsgLength = 13380 maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 +-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the +-- promotion cap over the promoted (member/moderator/admin) set. +maxGroupRosterSize :: Int +maxGroupRosterSize = 256 + +-- Receive-side byte bound: reject an owner-signed header whose claimed fileSize exceeds what +-- maxGroupRosterSize entries can occupy (128 B/entry is a generous worst case), before a file is created. +-- 128 B/entry ~ memberId + X.509 Ed25519 key (44 B) + role + privileges + 1-byte length prefixes (~2x the ~65 B typical). +maxGroupRosterBytes :: Integer +maxGroupRosterBytes = fromIntegral maxGroupRosterSize * 128 + +-- The byte sequence the owner-signed digest is computed over and verified against +-- before parsing. Word16 count (smpEncodeList's 1-byte count is too small for the future cap). +encodeRosterBlob :: [RosterMember] -> ByteString +encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms) + +rosterBlobP :: A.Parser [RosterMember] +rosterBlobP = do + n <- fromIntegral <$> smpP @Word16 + when (n > maxGroupRosterSize) $ fail "roster: too many entries" + A.count n smpP + -- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead) -- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008 maxEncodedInfoLength :: Int @@ -931,7 +1006,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 @@ -1022,6 +1097,8 @@ data CMEventTag (e :: MsgEncoding) where XGrpInfo_ :: CMEventTag 'Json XGrpPrefs_ :: CMEventTag 'Json XGrpDirectInv_ :: CMEventTag 'Json + XGrpRoster_ :: CMEventTag 'Json + XGrpRosterAck_ :: CMEventTag 'Json XGrpMsgForward_ :: CMEventTag 'Json XInfoProbe_ :: CMEventTag 'Json XInfoProbeCheck_ :: CMEventTag 'Json @@ -1082,6 +1159,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpInfo_ -> "x.grp.info" XGrpPrefs_ -> "x.grp.prefs" XGrpDirectInv_ -> "x.grp.direct.inv" + XGrpRoster_ -> "x.grp.roster" + XGrpRosterAck_ -> "x.grp.roster.ack" XGrpMsgForward_ -> "x.grp.msg.forward" XInfoProbe_ -> "x.info.probe" XInfoProbeCheck_ -> "x.info.probe.check" @@ -1143,6 +1222,8 @@ instance StrEncoding ACMEventTag where "x.grp.info" -> XGrpInfo_ "x.grp.prefs" -> XGrpPrefs_ "x.grp.direct.inv" -> XGrpDirectInv_ + "x.grp.roster" -> XGrpRoster_ + "x.grp.roster.ack" -> XGrpRosterAck_ "x.grp.msg.forward" -> XGrpMsgForward_ "x.info.probe" -> XInfoProbe_ "x.info.probe.check" -> XInfoProbeCheck_ @@ -1190,7 +1271,7 @@ toCMEventTag msg = case msg of XGrpMemInv _ _ -> XGrpMemInv_ XGrpMemFwd _ _ -> XGrpMemFwd_ XGrpMemInfo _ _ -> XGrpMemInfo_ - XGrpMemRole _ _ -> XGrpMemRole_ + XGrpMemRole {} -> XGrpMemRole_ XGrpMemRestrict _ _ -> XGrpMemRestrict_ XGrpMemCon _ -> XGrpMemCon_ XGrpMemConAll _ -> XGrpMemConAll_ @@ -1200,6 +1281,8 @@ toCMEventTag msg = case msg of XGrpInfo _ -> XGrpInfo_ XGrpPrefs _ -> XGrpPrefs_ XGrpDirectInv {} -> XGrpDirectInv_ + XGrpRoster _ -> XGrpRoster_ + XGrpRosterAck {} -> XGrpRosterAck_ XGrpMsgForward {} -> XGrpMsgForward_ XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ @@ -1258,6 +1341,7 @@ requiresSignature = \case XGrpMemRestrict_ -> True XGrpLeave_ -> True XGrpRelayNew_ -> True + XGrpRoster_ -> True XInfo_ -> True _ -> False @@ -1326,7 +1410,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do reqContent <- opt "content" let requestMsg = (,) <$> reqMsgId <*> reqContent pure XContact {profile, contactReqId, welcomeMsgId, requestMsg} - XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" + XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay" XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" @@ -1348,16 +1432,18 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" XGrpMemFwd_ -> XGrpMemFwd <$> p "memberInfo" <*> p "memberIntro" XGrpMemInfo_ -> XGrpMemInfo <$> p "memberId" <*> p "profile" - XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role" + XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role" <*> opt "memberKey" <*> opt "rosterVersion" XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions" XGrpMemCon_ -> XGrpMemCon <$> p "memberId" XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId" - XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") + XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion" XGrpLeave_ -> pure XGrpLeave XGrpDel_ -> pure XGrpDel XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" + XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "fileInv") + XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error" XGrpMsgForward_ -> do fwdSender <- opt "memberId" >>= \case Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName" @@ -1399,7 +1485,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile] - XMember {profile, newMemberId, newMemberKey} -> o ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] + XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] @@ -1420,16 +1506,18 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] XGrpMemFwd memInfo memIntro -> o ["memberInfo" .= memInfo, "memberIntro" .= memIntro] XGrpMemInfo memId profile -> o ["memberId" .= memId, "profile" .= profile] - XGrpMemRole memId role -> o ["memberId" .= memId, "role" .= role] + XGrpMemRole memId role memberKey rosterVersion -> o $ ("memberKey" .=? memberKey) $ ("rosterVersion" .=? rosterVersion) ["memberId" .= memId, "role" .= role] XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions] XGrpMemCon memId -> o ["memberId" .= memId] XGrpMemConAll memId -> o ["memberId" .= memId] - XGrpMemDel memId messages -> o $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] + XGrpMemDel memId messages rosterVersion -> o $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] XGrpLeave -> JM.empty XGrpDel -> JM.empty XGrpInfo p -> o ["groupProfile" .= p] XGrpPrefs p -> o ["groupPreferences" .= p] XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] + XGrpRoster GroupRoster {version, fileInv} -> o ["version" .= version, "fileInv" .= fileInv] + XGrpRosterAck version err -> o $ ("error" .=? err) ["version" .= version] XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs] where encodeFwdSender = \case @@ -1481,7 +1569,10 @@ instance FromField (ChatMessage 'Json) where data ContactShortLinkData = ContactShortLinkData { profile :: Profile, message :: Maybe MsgContent, - business :: Bool + business :: Bool, + -- set by the receiving client for the UI: the link profile's badge, verified and crypto-free. + -- never part of the published link data (the link carries the proof inside profile). + localBadge :: Maybe LocalBadge } deriving (Show) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 5b2ce20059..73050131d4 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -29,7 +29,8 @@ import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (fromMaybe) -import Data.Time.Clock (getCurrentTime) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Badges (rowToBadge) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -104,8 +105,9 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do (userId, agentConnId, ConnDeleted) getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact getContactRec_ contactId c = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getDirectChatTags db contactId - firstRow (toContact' contactId c chatTags) (SEInternalError "referenced contact not found") $ + firstRow (toContact' currentTs contactId c chatTags) (SEInternalError "referenced contact not found") $ DB.query db [sql| @@ -113,16 +115,18 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, c.simplex_name, p.simplex_name, c.simplex_name_verified_at + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + c.simplex_name, p.simplex_name, c.simplex_name_verified_at FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 |] (userId, contactId, CSActive) - toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, ctSimplexNameRaw, cpSimplexNameRaw, simplexNameVerifiedAt)) = + toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact + toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. (ctSimplexNameRaw, cpSimplexNameRaw, simplexNameVerifiedAt)) = let simplexName = decodeSimplexName ctSimplexNameRaw - profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName cpSimplexNameRaw, peerType, preferences, localAlias} + profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName cpSimplexNameRaw, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn @@ -131,9 +135,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData, simplexName, simplexNameVerifiedAt} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do + currentTs <- liftIO getCurrentTime gm <- ExceptT $ - firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + firstRow (toGroupAndMember currentTs c) (SEInternalError "referenced group member not found") $ -- Mirrors Store/Shared.hs groupInfoQueryFields — keep column lists in sync. DB.query db @@ -147,19 +152,21 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, g.simplex_name, gp.simplex_name, g.simplex_name_verified_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.simplex_name, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.simplex_name, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.simplex_name, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -173,10 +180,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do |] (groupMemberId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) liftIO $ bitraverse (addGroupChatTags db) pure gm - toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) - toGroupAndMember c (groupInfoRow :. memberRow) = - let groupInfo = toGroupInfo cxt userContactId [] groupInfoRow - member = toGroupMember userContactId memberRow + toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + toGroupAndMember currentTs c (groupInfoRow :. memberRow) = + let groupInfo = toGroupInfo currentTs cxt userContactId [] groupInfoRow + member = toGroupMember currentTs userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact getUserContact_ userContactLinkId = ExceptT $ do diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index b4faef8491..4c2a7312c2 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -24,6 +24,7 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) +import Simplex.Chat.Badges (badgeToRow, verifyBadge_) import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -72,7 +73,7 @@ createOrUpdateContactRequest isSimplexTeam invId cReqChatVRange@(VersionRange minV maxV) - profile@Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + profile@Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} xContactId_ welcomeMsgId_ requestMsg_ @@ -103,8 +104,9 @@ createOrUpdateContactRequest where getAcceptedContact :: XContactId -> IO (Maybe Contact) getAcceptedContact xContactId = do + currentTs <- getCurrentTime ct_ <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -113,7 +115,8 @@ createOrUpdateContactRequest ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -127,26 +130,29 @@ createOrUpdateContactRequest mapM (addDirectChatTags db) ct_ getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) getAcceptedBusinessChat xContactId = do + currentTs <- getCurrentTime g_ <- - maybeFirstRow (toGroupInfo cxt userContactId []) $ + maybeFirstRow (toGroupInfo currentTs cxt userContactId []) $ DB.query db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") (xContactId, userId, userContactId) mapM (addGroupChatTags db) g_ getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) - getContactRequestByXContactId xContactId = - maybeFirstRow toContactRequest $ + getContactRequestByXContactId xContactId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.simplex_name, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.simplex_name FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -157,12 +163,13 @@ createOrUpdateContactRequest createContactRequest :: ExceptT StoreError IO RequestStage createContactRequest = do currentTs <- liftIO $ getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId) :. ("" :: LocalAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db liftIO $ DB.execute @@ -214,7 +221,7 @@ createOrUpdateContactRequest ucr <- getContactRequest db user contactRequestId pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage - updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = LocalProfile {displayName = oldDisplayName}} = do currentTs <- liftIO getCurrentTime liftIO $ updateProfile currentTs updateRequest currentTs @@ -222,7 +229,8 @@ createOrUpdateContactRequest re_ <- getRequestEntity ucr' pure $ RSCurrentRequest (Just ucr) ucr' re_ where - updateProfile currentTs = + updateProfile currentTs = do + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge DB.execute db [sql| @@ -232,7 +240,16 @@ createOrUpdateContactRequest short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -240,7 +257,7 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) + ((displayName, fullName, shortDescr, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) updateRequest currentTs = if displayName == oldDisplayName then diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 5e2e45f278..4600c13004 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -367,7 +367,8 @@ getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cur :. (cursorGMId, count) ) #if defined(dbPostgres) - map (toContactMember cxt user) <$> + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_member_id IN ? ORDER BY m.group_member_id ASC") diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 449429b040..8b6485b612 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -109,6 +109,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality +import Simplex.Chat.Badges (badgeToRow) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -312,8 +313,9 @@ getConnReqContactXContactId db cxt user@User {userId} cReqHash1 cReqHash2 = getContactByConnReqHash :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do + currentTs <- getCurrentTime ct <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -322,7 +324,9 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -405,7 +409,7 @@ createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId simplex currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs simplexName + contactId <- createContact_ db cxt user p ctUserPreferences prepared "" currentTs simplexName getContact db cxt user contactId updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -450,7 +454,7 @@ createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profil createDirectContact db cxt user Connection {connId, localAlias} p simplexName = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs simplexName + contactId <- createContact_ db cxt user p ctUserPreferences Nothing localAlias currentTs simplexName liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db cxt user contactId @@ -566,31 +570,34 @@ deleteUnusedProfile_ db userId profileId = -- Also clears contacts.simplex_name_verified_at when the peer's simplex_name -- claim changes (any value transition, including Nothing<->Just): the prior -- verification was tied to the prior claim and must be re-issued by the user. -updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact -updateContactProfile db user@User {userId} c p' - | displayName == newName = do - liftIO $ clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName - liftIO $ updateContactProfile_ db userId profileId p' - liftIO clearVerifiedAtIfClaimChanged - pure $ c' {profile, mergedPreferences} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName - updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - clearVerifiedAtIfClaimChanged - pure $ Right c' {localDisplayName = ldn, profile, mergedPreferences} +updateContactProfile :: DB.Connection -> StoreCxt -> User -> Contact -> Profile -> ExceptT StoreError IO Contact +updateContactProfile db cxt user@User {userId} c p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) lp p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactProfile' currentTs badgeVerified profile where - Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias, simplexName = prevClaim}, userPreferences} = c + Contact {contactId, localDisplayName, profile = lp@LocalProfile {profileId, displayName, localAlias, simplexName = prevClaim}, userPreferences} = c Profile {displayName = newName, simplexName = profileSimplexName, preferences} = p' - profile = toLocalProfile profileId p' localAlias mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c claimChanged = prevClaim /= profileSimplexName c' = if claimChanged then (c :: Contact) {simplexNameVerifiedAt = Nothing} else c clearVerifiedAtIfClaimChanged = when claimChanged $ DB.execute db "UPDATE contacts SET simplex_name_verified_at = NULL WHERE user_id = ? AND contact_id = ?" (userId, contactId) + updateContactProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + liftIO $ updateContactProfile_' db userId profileId p' badgeVerified currentTs + liftIO clearVerifiedAtIfClaimChanged + pure c' {profile, mergedPreferences} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + updateContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + clearVerifiedAtIfClaimChanged + pure $ Right c' {localDisplayName = ldn, profile, mergedPreferences} -- | Records that the user successfully RSLV-verified the peer's simplex_name -- claim against the contact's stored connection link. Cleared back to NULL by @@ -727,55 +734,61 @@ setQuotaErrCounter db User {userId} Connection {connId} counter = do updatedAt <- getCurrentTime DB.execute db "UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ?" (counter, updatedAt, userId, connId) -updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateContactProfile_ db userId profileId profile = do +updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateContactProfile_' db userId profileId profile currentTs + updateContactProfile_' db userId profileId profile badgeVerified currentTs -updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType} updatedAt = do +updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, simplex_name = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + simplex_name = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType, updatedAt) :. (userId, profileId)) + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. Only simplexName :. (userId, profileId)) -- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs) -updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfileReset_ db userId profileId profile = do +updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfileReset_' db userId profileId profile currentTs + updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, simplexName} updatedAt = do +updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, simplexName, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, simplex_name = ?, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + simplex_name = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, simplexName, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. Only simplexName :. (userId, profileId)) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) -updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfile_ db userId profileId profile = do +updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfile_' db userId profileId profile currentTs + updateMemberContactProfile_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, simplexName} updatedAt = do +updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, simplexName, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, simplex_name = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + simplex_name = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, simplexName, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. Only simplexName :. (userId, profileId)) updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do @@ -823,18 +836,21 @@ getUserContactLinkIdByCReq db contactRequestId = DB.query db "SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ?" (Only contactRequestId) getContactRequest :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserContactRequest -getContactRequest db User {userId} contactRequestId = - ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ +getContactRequest db User {userId} contactRequestId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactRequest currentTs) (SEContactRequestNotFound contactRequestId) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getContactRequest' :: DB.Connection -> User -> Int64 -> IO (Maybe UserContactRequest) -getContactRequest' db User {userId} contactRequestId = - maybeFirstRow toContactRequest $ +getContactRequest' db User {userId} contactRequestId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getBusinessContactRequest :: DB.Connection -> User -> GroupId -> IO (Maybe UserContactRequest) -getBusinessContactRequest db _user groupId = - maybeFirstRow toContactRequest $ +getBusinessContactRequest db _user groupId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.business_group_id = ?") (Only groupId) contactRequestQuery :: Query @@ -843,10 +859,12 @@ contactRequestQuery = SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.simplex_name, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + p.simplex_name FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) |] @@ -882,7 +900,7 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) +createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> LocalProfile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) createContactFromRequest db user@User {userId, profile = LocalProfile {preferences}} uclId_ agentConnId connChatVersion cReqChatVRange localDisplayName profileId profile xContactId incognitoProfile subMode pqSup contactUsed = do currentTs <- getCurrentTime let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences @@ -898,7 +916,7 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc Contact { contactId, localDisplayName, - profile = toLocalProfile profileId profile "", + profile, activeConn = Just conn, contactUsed, contactStatus = CSActive, @@ -956,8 +974,9 @@ getContact db cxt user contactId = getContact_ db cxt user contactId False getContact_ :: DB.Connection -> StoreCxt -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact getContact_ db cxt user@User {userId} contactId deleted = do + currentTs <- liftIO getCurrentTime chatTags <- liftIO $ getDirectChatTags db contactId - ExceptT . firstRow (toContact cxt user chatTags) (SEContactNotFound contactId) $ + ExceptT . firstRow (toContact currentTs cxt user chatTags) (SEContactNotFound contactId) $ DB.query db [sql| @@ -966,7 +985,9 @@ getContact_ db cxt user@User {userId} contactId deleted = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, + ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -980,8 +1001,9 @@ getContact_ db cxt user@User {userId} contactId deleted = do (userId, contactId, BI deleted) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User -getUserByContactRequestId db contactRequestId = - ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ +getUserByContactRequestId db contactRequestId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) getContactConnections :: DB.Connection -> StoreCxt -> UserId -> Contact -> IO [Connection] diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 5289a3b304..dee72731a8 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -31,12 +31,19 @@ module Simplex.Chat.Store.Files getSharedMsgIdByFileId, getFileIdBySharedMsgId, getGroupFileIdBySharedMsgId, + getGroupRcvFileId, + getGroupRosterFileInfo, + deleteGroupRosterFile, + getRosterTransferFile, + deleteRosterTransferFile, + getRcvFileLastChunkNo, getDirectFileIdBySharedMsgId, getChatRefByFileId, lookupChatRefByFileId, updateSndFileStatus, createRcvFileTransfer, createRcvGroupFileTransfer, + createRosterRcvFile, createRcvStandaloneFileTransfer, appendRcvFD, getRcvFileDescrByRcvFileId, @@ -79,6 +86,7 @@ import Data.Functor ((<&>)) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) +import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality @@ -320,6 +328,64 @@ getGroupFileIdBySharedMsgId db userId groupId sharedMsgId = |] (userId, groupId, sharedMsgId) +-- Resolve the in-flight received group inline file for a chunk: read its file_type by shared_msg_id +-- (LIMIT 1 is safe -- all files sharing a shared_msg_id share a type), then look up by type: a roster +-- file is scoped to its source relay (every relay re-serves the owner's same shared_msg_id, so the source +-- disambiguates), a normal file is by shared_msg_id. Nothing => no in-flight transfer (orphaned chunk). +getGroupRcvFileId :: DB.Connection -> UserId -> Int64 -> GroupMemberId -> SharedMsgId -> IO (Maybe Int64) +getGroupRcvFileId db userId groupId fromMemberId sharedMsgId = do + fileType_ <- getFileType + case fileType_ of + Just FTRoster -> + maybeFirstRow fromOnly $ + DB.query db (rcvFileIdQ <> " AND r.group_member_id = ?") (userId, groupId, sharedMsgId, FTRoster, fromMemberId) + Just FTNormal -> + maybeFirstRow fromOnly $ + DB.query db rcvFileIdQ (userId, groupId, sharedMsgId, FTNormal) + Nothing -> pure Nothing + where + getFileType = + maybeFirstRow fromOnly $ + DB.query db "SELECT file_type FROM files WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? LIMIT 1" (userId, groupId, sharedMsgId) + rcvFileIdQ = + [sql| + SELECT f.file_id FROM files f + JOIN rcv_files r ON r.file_id = f.file_id + WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ? AND f.file_type = ? + |] + +-- The roster scratch file for a transfer (for fs/handle cleanup before deleting the transfer). +-- A transfer owns exactly one file (created together in one transaction), so this is single-valued. +getRosterTransferFile :: DB.Connection -> Int64 -> IO (Maybe (Int64, Maybe FilePath)) +getRosterTransferFile db transferId = + maybeFirstRow id $ DB.query db "SELECT file_id, file_path FROM files WHERE roster_transfer_id = ?" (Only transferId) + +-- Deletes a transfer's file row; rcv_files and rcv_file_chunks cascade on the FK. +deleteRosterTransferFile :: DB.Connection -> Int64 -> IO () +deleteRosterTransferFile db transferId = + DB.execute db "DELETE FROM files WHERE roster_transfer_id = ?" (Only transferId) + +-- For roster-file cleanup keyed on the group (not a chat item): every matching file_id and its on-disk +-- path, so the caller evicts the handle and removes the file for each — delete-all like deleteGroupRosterFile. +getGroupRosterFileInfo :: DB.Connection -> UserId -> Int64 -> IO [(Int64, Maybe FilePath)] +getGroupRosterFileInfo db userId groupId = + DB.query + db + "SELECT file_id, file_path FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?" + (userId, groupId, FTRoster) + +-- Deletes the roster files row; rcv_files and rcv_file_chunks cascade on the FK. +deleteGroupRosterFile :: DB.Connection -> UserId -> Int64 -> IO () +deleteGroupRosterFile db userId groupId = + DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?" (userId, groupId, FTRoster) + +-- The highest stored chunk number, or Nothing if no partial chunks exist (used to decide +-- whether an arriving chunk 1 is a re-driven transfer that must reset). +getRcvFileLastChunkNo :: DB.Connection -> RcvFileTransfer -> IO (Maybe Integer) +getRcvFileLastChunkNo db RcvFileTransfer {fileId} = + maybeFirstRow fromOnly $ + DB.query db "SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1" (Only fileId) + getDirectFileIdBySharedMsgId :: DB.Connection -> User -> Contact -> SharedMsgId -> ExceptT StoreError IO Int64 getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId = ExceptT . firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ @@ -378,10 +444,10 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} -createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer -createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do +createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ @@ -393,15 +459,34 @@ createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gNam fileId <- liftIO $ do DB.execute db - "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, currentTs, currentTs) + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) insertedRowId db liftIO $ DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} + +-- Roster scratch file owned by a per-source transfer: group_member_id is the delivering relay (so chunk +-- streams from different relays are distinct files), roster_transfer_id links to the metadata record. +createRosterRcvFile :: DB.Connection -> UserId -> GroupInfo -> GroupMember -> Int64 -> SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRosterRcvFile db userId GroupInfo {groupId} src@GroupMember {localDisplayName = senderName} transferId sharedMsgId f@FileInvitation {fileName, fileSize, fileConnReq, fileInline} rcvFileInline chunkSize = do + currentTs <- liftIO getCurrentTime + let grpMemberId_ = groupMemberId' src + fileId <- liftIO $ do + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, roster_transfer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, FPSMP, FTRoster) :. (sharedMsgId, transferId, currentTs, currentTs)) + insertedRowId db + liftIO $ + DB.execute + db + "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, currentTs, currentTs) + pure RcvFileTransfer {fileId, xftpRcvFile = Nothing, fileInvitation = f, fileStatus = RFSNew, fileType = FTRoster, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = Just grpMemberId_, cryptoArgs = Nothing} createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64 createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do @@ -422,7 +507,7 @@ createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize createRcvFD_ :: DB.Connection -> UserId -> UTCTime -> FileDescr -> ExceptT StoreError IO RcvFileDescr createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do - when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart + when (fileDescrPartNo /= 0 || not (rcvFileDescrWithinLimits fileDescrPartNo fileDescrText)) $ throwError SERcvFileInvalidDescrPart fileDescrId <- liftIO $ do DB.execute db @@ -450,8 +535,8 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD fileDescrPartNo = rfdPNo, fileDescrComplete = rfdComplete } -> do - when (fileDescrPartNo /= rfdPNo + 1 || rfdComplete) $ throwError SERcvFileInvalidDescrPart let fileDescrText' = rfdText <> fileDescrText + when (fileDescrPartNo /= rfdPNo + 1 || rfdComplete || not (rcvFileDescrWithinLimits fileDescrPartNo fileDescrText')) $ throwError SERcvFileInvalidDescrPart liftIO $ DB.execute db @@ -463,6 +548,23 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD (fileDescrText', fileDescrPartNo, BI fileDescrComplete, fileDescrId) pure RcvFileDescr {fileDescrId, fileDescrText = fileDescrText', fileDescrPartNo, fileDescrComplete} +-- Upper bounds sized above the largest legitimate received description; derived from simplexmq's +-- chunk tiers and redundancy, so a change there must revisit them. +-- ~1280 chunks max = maxFileSizeHard (5gb) / largest chunk tier (4mb). +-- ~150 chars per chunk in the description YAML = replicaId 24 + Ed25519 key 64 + SHA-256 digest 44 + chunkNo/colons. +-- Total ~0.18 MB at 1 replica/chunk (~0.42 MB at 3x), under the 1mb text and 1024 part caps. +maxRcvFileDescrParts :: Int +maxRcvFileDescrParts = 1024 + +maxRcvFileDescrTextLength :: Int +maxRcvFileDescrTextLength = 1024 * 1024 + +rcvFileDescrWithinLimits :: Int -> Text -> Bool +rcvFileDescrWithinLimits partNo descrText = + partNo >= 0 + && partNo <= maxRcvFileDescrParts + && T.length descrText <= maxRcvFileDescrTextLength + getRcvFileDescrByRcvFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr getRcvFileDescrByRcvFileId db fileId = do liftIO (getRcvFileDescrByRcvFileId_ db fileId) >>= \case @@ -530,7 +632,7 @@ getRcvFileTransfer_ db userId fileId = do SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -544,9 +646,9 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. Only (Maybe ContactName) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe ContactName, FileType) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. Only groupName_) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType)) = case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> @@ -564,7 +666,7 @@ getRcvFileTransfer_ db userId fileId = do let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_ - in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} + in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} filePath = case filePath_ of Nothing -> throwError $ SERcvFileInvalid fileId Just fp -> pure fp @@ -660,7 +762,15 @@ createRcvFileChunk db RcvFileTransfer {fileId, fileInvitation = FileInvitation { currentTs <- getCurrentTime DB.execute db - "INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)" + [sql| + INSERT INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT (file_id, chunk_number) DO UPDATE SET + chunk_agent_msg_id = excluded.chunk_agent_msg_id, + chunk_stored = 0, + created_at = excluded.created_at, + updated_at = excluded.updated_at + |] (fileId, chunkNo, msgId, currentTs, currentTs) pure status where diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index da00d578eb..9085057963 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -71,6 +71,10 @@ module Simplex.Chat.Store.Groups getGroupMembersByIndexes, getSupportScopeMembersByIndexes, getGroupModerators, + getGroupRosterMembers, + getGroupAdminsMods, + getGroupOnlyMembers, + getGroupOwners, getGroupRelayMembers, getGroupMembersForExpiration, getRemovedMembersToCleanup, @@ -87,7 +91,19 @@ module Simplex.Chat.Store.Groups getGroupRelayById, getGroupRelayByGMId, getGroupRelays, - getConnectedGroupRelays, + getPublishableGroupRelays, + setGroupRosterVersion, + getGroupRosterVersion, + getGroupRoster, + RcvRosterTransfer (..), + createRosterTransfer, + getRosterTransferVersion, + getRosterTransferId, + getRosterTransfer, + setGroupLiveRoster, + deleteRosterTransfer, + deleteGroupRosterTransfers, + setGroupMemberKeyRole, createRelayForOwner, getCreateRelayForMember, createRelayConnection, @@ -102,9 +118,12 @@ module Simplex.Chat.Store.Groups createRelayRequestGroup, updateRelayOwnStatusFromTo, updateRelayOwnStatus_, + getRelaySentWebDomain, + updateRelaySentWebDomain, isRelayGroupRejected, allowRelayGroup, getRelayServedGroups, + getRelayPublishableGroups, getRelayInactiveGroups, createNewContactMemberAsync, createJoiningMember, @@ -173,6 +192,7 @@ module Simplex.Chat.Store.Groups createLinkOwnerMember, updatePreparedChannelMember, updateUnknownMemberAnnounced, + updateRosterMemberAnnounced, updateUserMemberProfileSentAt, setGroupCustomData, setGroupUIThemes, @@ -203,6 +223,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.Badges (BadgeRow, badgeToRow, verifyBadge_) import Simplex.Chat.Messages import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) @@ -215,6 +236,8 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), SimplexNameInfo, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) +import qualified Simplex.FileTransfer.Description as FD +import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import Simplex.Messaging.Agent.Store.Entity (DBEntityId) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -232,12 +255,12 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences, Maybe Text) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow :. Only (Maybe Text)) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences, profileSimplexNameRaw) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences, profileSimplexNameRaw) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) -toMaybeGroupMember _ _ = Nothing +toMaybeGroupMember :: UTCTime -> Int64 -> MaybeGroupMemberRow -> Maybe GroupMember +toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow :. Only profileSimplexNameRaw) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow :. Only profileSimplexNameRaw) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) +toMaybeGroupMember _ _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink createGroupLink db gVar user@User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole subMode = do @@ -358,6 +381,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) Nothing -> (Nothing, Nothing, Nothing) fullGroupPreferences = mergeGroupPreferences groupPreferences + rosterVersion0 = if useRelays then Just (VersionRoster 0) else Nothing currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do @@ -388,11 +412,11 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays INSERT INTO groups (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, - root_priv_key, root_pub_key, member_priv_key, public_member_count) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + root_priv_key, root_pub_key, member_priv_key, public_member_count, roster_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) - :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_) + :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_, rosterVersion0) ) insertedRowId db let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys @@ -419,6 +443,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays chatItemTTL = Nothing, uiThemes = Nothing, groupSummary = GroupSummary {currentMembers = 1, publicMemberCount = publicMemberCount_}, + rosterVersion = rosterVersion0, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, @@ -498,6 +523,7 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti chatItemTTL = Nothing, uiThemes = Nothing, groupSummary = GroupSummary {currentMembers = 2, publicMemberCount = Nothing}, + rosterVersion = Nothing, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, @@ -645,7 +671,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b randHostId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_host_" <> randHostId hostProfile = profileFromName $ nameFromBS randHostId - (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user hostProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -800,7 +826,7 @@ updatePreparedUserAndHostMembers' |] (memberId, memberRole, membershipStatus, currentTs, groupMemberId' membership) updateHostMember currentTs = do - _ <- updateMemberProfile db user hostMember fromMemberProfile + _ <- updateMemberProfile db cxt user hostMember fromMemberProfile let MemberIdRole memberId memberRole = fromMember gmId = groupMemberId' hostMember liftIO $ @@ -850,7 +876,7 @@ createGroupViaLink' (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where insertHost_ currentTs groupId = do - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -1016,7 +1042,8 @@ getInProgressGroups db cxt user@User {userId} createdAtCutoff = do getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1076,16 +1103,18 @@ getGroupIdBySimplexName db User {userId} ni = DB.query db "SELECT group_id FROM groups WHERE user_id = ? AND simplex_name = ?" (userId, ni) getGroupMember :: DB.Connection -> StoreCxt -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db cxt user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember db cxt user@User {userId} groupId groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, groupMemberId, userId) getHostMember :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db cxt user groupId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupHostMemberNotFound groupId) $ +getHostMember db cxt user groupId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") @@ -1125,40 +1154,45 @@ toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) in CIMention {memberId, memberRef} getGroupMemberById :: DB.Connection -> StoreCxt -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db cxt user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById db cxt user@User {userId} groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) getNonRemovedMemberById :: DB.Connection -> StoreCxt -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getNonRemovedMemberById db cxt user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getNonRemovedMemberById db cxt user@User {userId} groupMemberId = do + ts <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember ts cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ? AND m.member_status NOT IN (?,?,?,?)") (groupMemberId, userId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) getGroupMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember -getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") (groupId, indexInGroup) getSupportScopeMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember -getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) getGroupMemberByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") @@ -1191,8 +1225,9 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = (userId, groupId, memberId) getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = - map (toContactMember cxt user) +getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") @@ -1201,8 +1236,9 @@ getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = getGroupMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> [Int64] -> IO [GroupMember] getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") @@ -1214,8 +1250,9 @@ getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do getSupportScopeMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") @@ -1226,15 +1263,58 @@ getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do getGroupModerators :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) +-- The full roster set - members, moderators and admins - excluding owners (link-anchored) and +-- left/removed members. For the privileged subset only use getGroupAdminsMods; for plain members +-- only use getGroupOnlyMembers. +getGroupRosterMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupRosterMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") + (userId, groupId, userContactId, GRMember, GRModerator, GRAdmin) + +-- Moderators and admins only (excluding owners and plain members) - the set introduced to a +-- joiner; plain members are learned from the roster blob, not via introductions. +getGroupAdminsMods :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupAdminsMods db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?)") + (userId, groupId, userContactId, GRModerator, GRAdmin) + +getGroupOnlyMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupOnlyMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") + (userId, groupId, userContactId, GRMember) + +getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") + (userId, groupId, userContactId, GROwner) + getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") @@ -1242,7 +1322,8 @@ getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId getGroupMembersForExpiration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db ( groupMemberQuery @@ -1258,8 +1339,9 @@ getGroupMembersForExpiration db cxt user@User {userId, userContactId} GroupInfo (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) getRemovedMembersToCleanup :: DB.Connection -> StoreCxt -> User -> UTCTime -> IO [GroupMember] -getRemovedMembersToCleanup db cxt user@User {userId} cutoffTs = - map (toContactMember cxt user) +getRemovedMembersToCleanup db cxt user@User {userId} cutoffTs = do + ts <- getCurrentTime + map (toContactMember ts cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.removed_at < ?") @@ -1378,21 +1460,30 @@ getGroupRelays db GroupInfo {groupId} = (groupRelayQuery <> " WHERE gr.group_id = ?") (Only groupId) -getConnectedGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay] -getConnectedGroupRelays db GroupInfo {groupId} = - map toGroupRelay - <$> DB.query - db - ( groupRelayQuery - <> " " - <> [sql| - JOIN group_members m ON m.group_member_id = gr.group_member_id - WHERE gr.group_id = ? - AND m.member_status = ? - AND gr.relay_status IN (?,?) - |] - ) - (groupId, GSMemConnected, RSAccepted, RSActive) +-- Relays whose link is published to subscribers: acked relays (RSAcknowledgedRoster/RSActive) plus +-- pre-roster relays at RSAccepted (below groupRosterVersion, they can't ack a roster), gated by the +-- relay's negotiated version read from its member connection. +getPublishableGroupRelays :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupRelay] +getPublishableGroupRelays db cxt user gInfo@GroupInfo {groupId} = do + relays <- + map toGroupRelay + <$> DB.query + db + ( groupRelayQuery + <> " " + <> [sql| + JOIN group_members m ON m.group_member_id = gr.group_member_id + WHERE gr.group_id = ? + AND m.member_status = ? + AND gr.relay_status IN (?,?,?) + |] + ) + (groupId, GSMemConnected, RSAccepted, RSAcknowledgedRoster, RSActive) + members <- getGroupRelayMembers db cxt user gInfo + pure [gr | gr@GroupRelay {groupMemberId} <- relays, m <- members, groupMemberId' m == groupMemberId, publishable gr m] + where + publishable GroupRelay {relayStatus} m = + relayStatus /= RSAccepted || not (m `supportsVersion` groupRosterVersion) groupRelayQuery :: Query groupRelayQuery = @@ -1410,11 +1501,154 @@ toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, f relayCap = RelayCapabilities {webDomain} in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap} +setGroupRosterVersion :: DB.Connection -> GroupInfo -> VersionRoster -> IO () +setGroupRosterVersion db GroupInfo {groupId} v = do + currentTs <- getCurrentTime + DB.execute db "UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?" (v, currentTs, groupId) + +-- Persisted roster version (the gate baseline; the in-memory gInfo copy is batch-constant and stale on reorder). +getGroupRosterVersion :: DB.Connection -> GroupInfo -> IO (Maybe VersionRoster) +getGroupRosterVersion db GroupInfo {groupId} = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT roster_version FROM groups WHERE group_id = ?" (Only groupId) + +-- The live roster header a relay re-serves to joiners, with the completed blob served alongside it +-- (both are written together at completion, so the blob is present whenever the header is). +getGroupRoster :: DB.Connection -> GroupInfo -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg, Maybe ByteString)) +getGroupRoster db GroupInfo {groupId} = + (>>= toRoster) + <$> maybeFirstRow + id + ( DB.query + db + "SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body, roster_blob FROM groups WHERE group_id = ?" + (Only groupId) + ) + where + toRoster (Just ownerGMId, Just brokerTs, Just cb, Just (Binary sigsBs), Just (Binary body), blob_) = + (\sigs -> (ownerGMId, brokerTs, SignedMsg cb sigs body, (\(Binary b) -> b) <$> blob_)) <$> eitherToMaybe (smpDecode sigsBs) + toRoster _ = Nothing + +-- A per-source in-flight roster transfer, keyed (group_id, from_member_id): replaces the single +-- roster_pending_* slot, so two relays serving one member can't share a chunk stream. The signed-header +-- columns are relay-only (NULL on members), promoted to the live roster_msg_* on groups at completion. +createRosterTransfer :: DB.Connection -> GroupInfo -> GroupMemberId -> VersionRoster -> FD.FileDigest -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> IO Int64 +createRosterTransfer db GroupInfo {groupId} fromMemberId v digest ownerGMId brokerTs sm_ = do + -- one in-flight transfer per (group, source): drop any prior row from this source so the INSERT can't hit + -- the UNIQUE constraint even if the caller's fs/handle cleanup was skipped (the scratch file would then leak + -- until group delete, but the transfer never gets stuck). Normally cleanupRosterTransfer ran first. + DB.execute db "DELETE FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId) + DB.execute + db + [sql| + INSERT INTO rcv_roster_transfers + (group_id, from_member_id, roster_version, roster_digest, sending_owner_gm_id, broker_ts, + roster_msg_chat_binding, roster_msg_signatures, roster_msg_body) + VALUES (?,?,?,?,?,?,?,?,?) + |] + ( (groupId, fromMemberId, v, Binary (FD.unFileDigest digest), ownerGMId, brokerTs) + :. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_) + ) + insertedRowId db + +getRosterTransferVersion :: DB.Connection -> GroupInfo -> GroupMemberId -> IO (Maybe VersionRoster) +getRosterTransferVersion db GroupInfo {groupId} fromMemberId = + maybeFirstRow fromOnly $ + DB.query db "SELECT roster_version FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId) + +getRosterTransferId :: DB.Connection -> GroupInfo -> GroupMemberId -> IO (Maybe Int64) +getRosterTransferId db GroupInfo {groupId} fromMemberId = + maybeFirstRow fromOnly $ + DB.query db "SELECT roster_transfer_id FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId) + +-- An in-flight received roster transfer (a rcv_roster_transfers row joined to its scratch file), read at +-- completion. The header is the relay's re-serve SignedMsg -- present only on a serving relay (NULL on a +-- member, whose live roster_msg_* stay NULL so it never re-serves). +data RcvRosterTransfer = RcvRosterTransfer + { rosterTransferId :: Int64, + rosterTransferVersion :: VersionRoster, + rosterTransferDigest :: FD.FileDigest, + rosterTransferOwnerGMId :: GroupMemberId, + rosterTransferBrokerTs :: UTCTime, + rosterTransferHeader :: Maybe SignedMsg + } + deriving (Show) + +-- The in-flight transfer for a received roster file (joined via files.roster_transfer_id), with its +-- relay-only signed header. Read at completion to apply, promote into the live roster, and ack. +getRosterTransfer :: DB.Connection -> Int64 -> IO (Maybe RcvRosterTransfer) +getRosterTransfer db fileId = + (>>= toTransfer) + <$> maybeFirstRow + id + ( DB.query + db + [sql| + SELECT t.roster_transfer_id, t.roster_version, t.roster_digest, t.sending_owner_gm_id, t.broker_ts, + t.roster_msg_chat_binding, t.roster_msg_signatures, t.roster_msg_body + FROM rcv_roster_transfers t + JOIN files f ON f.roster_transfer_id = t.roster_transfer_id + WHERE f.file_id = ? + |] + (Only fileId) + ) + where + toTransfer (tId, v, Binary d, ownerGMId, brokerTs, cb_, sigs_, body_) = + Just + RcvRosterTransfer + { rosterTransferId = tId, + rosterTransferVersion = v, + rosterTransferDigest = FD.FileDigest d, + rosterTransferOwnerGMId = ownerGMId, + rosterTransferBrokerTs = brokerTs, + rosterTransferHeader = sm_ + } + where + sm_ = case (cb_, sigs_, body_) of + (Just cb, Just (Binary sigsBs), Just (Binary body)) -> + (\sigs -> SignedMsg cb sigs body) <$> eitherToMaybe (smpDecode sigsBs) + _ -> Nothing + +-- Write the single live roster on groups from a completed transfer's values (header NULL on a member, +-- so its live roster_msg_* stay NULL and it never re-serves; only relays re-serve). +setGroupLiveRoster :: DB.Connection -> GroupInfo -> VersionRoster -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> ByteString -> IO () +setGroupLiveRoster db GroupInfo {groupId} v ownerGMId brokerTs sm_ blob = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE groups SET + roster_version = ?, roster_blob = ?, + roster_sending_owner_gm_id = ?, roster_broker_ts = ?, + roster_msg_chat_binding = ?, roster_msg_signatures = ?, roster_msg_body = ?, + updated_at = ? + WHERE group_id = ? + |] + ( (v, Binary blob, ownerGMId, brokerTs) + :. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_, currentTs, groupId) + ) + +-- Delete one in-flight transfer row (its files/rcv_files/rcv_file_chunks are removed separately, with +-- the on-disk file). Caller removes the fs file + cached handle first. +deleteRosterTransfer :: DB.Connection -> Int64 -> IO () +deleteRosterTransfer db transferId = + DB.execute db "DELETE FROM rcv_roster_transfers WHERE roster_transfer_id = ?" (Only transferId) + +-- All in-flight transfers for a group (group delete). +deleteGroupRosterTransfers :: DB.Connection -> Int64 -> IO () +deleteGroupRosterTransfers db groupId = + DB.execute db "DELETE FROM rcv_roster_transfers WHERE group_id = ?" (Only groupId) + +setGroupMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO () +setGroupMemberKeyRole db GroupMember {groupMemberId} pubKey role = do + currentTs <- getCurrentTime + DB.execute db "UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, role, currentTs, groupMemberId) + createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName displayName - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, memProfileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs groupMemberId <- createWithRandomId' db gVar $ \memId -> runExceptT $ do indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ @@ -1433,11 +1667,12 @@ createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {gro getGroupMemberById db cxt user groupMemberId getCreateRelayForMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember -getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = - liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure +getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = do + currentTs <- liftIO getCurrentTime + liftIO (getGroupMemberByRelayLink currentTs) >>= maybe createRelayMember pure where - getGroupMemberByRelayLink = - maybeFirstRow (toContactMember cxt user) $ + getGroupMemberByRelayLink currentTs = + maybeFirstRow (toContactMember currentTs cxt user) $ DB.query db #if defined(dbPostgres) @@ -1452,7 +1687,7 @@ getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo randRelayId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_relay_" <> randRelayId relayProfile = profileFromName $ nameFromBS randRelayId - (localDisplayName, profileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId groupMemberId <- liftIO $ do DB.execute @@ -1525,7 +1760,7 @@ setRelayLinkAccepted db cxt user m (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (relayKey, currentTs, gmId) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile (,) <$> getGroupMemberById db cxt user gmId <*> getGroupRelayByGMId db gmId setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () @@ -1572,8 +1807,8 @@ getRelayConfId db m = |] (Only (groupMemberId' m)) -updateRelayMemberData :: DB.Connection -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () -updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do +updateRelayMemberData :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () +updateRelayMemberData db cxt user m memberId (MemberKey relayKey) profile = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1584,7 +1819,7 @@ updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (memberId, relayKey, currentTs, groupMemberId' m) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile setGroupInProgressDone :: DB.Connection -> GroupInfo -> IO () setGroupInProgressDone db GroupInfo {groupId} = do @@ -1638,7 +1873,7 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb insertOwner_ currentTs groupId = do let MemberIdRole {memberId, memberRole} = fromMember VersionRange minV maxV = reqChatVRange - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -1668,6 +1903,14 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId) +getRelaySentWebDomain :: DB.Connection -> GroupInfo -> IO (Maybe Text) +getRelaySentWebDomain db GroupInfo {groupId} = + join <$> maybeFirstRow fromOnly (DB.query db "SELECT relay_sent_web_domain FROM groups WHERE group_id = ?" (Only groupId)) + +updateRelaySentWebDomain :: DB.Connection -> GroupInfo -> Maybe Text -> IO () +updateRelaySentWebDomain db GroupInfo {groupId} webDomain_ = + DB.execute db "UPDATE groups SET relay_sent_web_domain = ? WHERE group_id = ?" (webDomain_, groupId) + -- Flip every RSRejected row sharing the targeted group's relay_request_group_link -- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId. allowRelayGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupInfo @@ -1705,18 +1948,38 @@ isRelayGroupRejected db User {userId} groupLink = getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] getRelayServedGroups db cxt User {userId, userContactId} = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery - <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?)" + <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?, ?)" ) - (userId, userContactId, RSAccepted, RSActive) + (userId, userContactId, RSAccepted, RSAcknowledgedRoster, RSActive) + +getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] +getRelayPublishableGroups db User {userId, userContactId} = + map toRow <$> + DB.query + db + [sql| + SELECT g.group_id, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ? + WHERE g.user_id = ? AND g.relay_own_status IN (?, ?) + AND gp.public_group_id IS NOT NULL + |] + (userContactId, userId, RSAccepted, RSActive) + where + toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow) getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do - cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + let cutoffTs = addUTCTime (- ttl) currentTs + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1751,14 +2014,15 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember db + cxt gVar User {userId, userContactId} GroupInfo {groupId, membership} cReqChatVRange - Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} cReqXContactId_ cReqMemberId_ welcomeMsgId_ @@ -1766,12 +2030,13 @@ createJoiningMember memberStatus memberKey_ = do currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT . withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db case cReqMemberId_ of Just memberId -> do @@ -2119,10 +2384,10 @@ increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, memb pure g {membersRequireAttention = membersRequireAttention + 1} -- | add new member with profile -createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do +createNewGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user profile currentTs let newMember = NewGroupMember { memInfo, @@ -2135,19 +2400,20 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs -createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) -createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, preferences} createdAt = +createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Maybe Bool) +createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) :. badgeToRow badge badgeVerified) profileId <- insertedRowId db - pure $ Right (ldn, profileId) + pure $ Right (ldn, profileId, badgeVerified) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> ExceptT StoreError IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} @@ -2163,6 +2429,7 @@ createNewMember_ memContactId = memberContactId, memProfileId = memberContactProfileId } + badgeVerified createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing @@ -2200,7 +2467,7 @@ createNewMember_ invitedBy, invitedByGroupMemberId = memInvitedByGroupMemberId, localDisplayName, - memberProfile = toLocalProfile memberContactProfileId memberProfile "", + memberProfile = toLocalProfile memberContactProfileId memberProfile "" createdAt badgeVerified, memberContactId, memberContactProfileId, activeConn, @@ -2314,18 +2581,19 @@ getMemberRelationsVector db GroupMember {groupMemberId} = "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" (Only groupMemberId) -createIntroReMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember createIntroReMember db + cxt user gInfo memInfo@(MemberInfo _ _ _ memberProfile _) memRestrictions_ = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile currentTs let memRestriction = restriction <$> memRestrictions_ newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMemberConn @@ -3113,50 +3381,54 @@ setMemberContactStartedConnection db Contact {contactId} = do -- | Updates the member profile, also clearing the simplex_name on any other -- contact_profiles row in the same user that already holds the same -- (user_id, simplex_name) — newer-claim-wins, required by the partial UNIQUE index. -updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db user@User {userId} m p' - | displayName == newName = liftIO $ do - currentTs <- getCurrentTime - clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName - updateMemberContactProfileReset_' db userId profileId p' currentTs - pure m {memberProfile = profile} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName - updateMemberContactProfileReset_' db userId profileId p' currentTs - DB.execute - db - "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" - (ldn, currentTs, userId, groupMemberId) - safeDeleteLDN db user localDisplayName - pure $ Right m {localDisplayName = ldn, memberProfile = profile} +updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db cxt user@User {userId} m p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateMemberProfile' currentTs badgeVerified memberProfile where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName, simplexName = profileSimplexName} = p' - profile = toLocalProfile profileId p' localAlias + updateMemberProfile' currentTs badgeVerified memberProfile + | displayName == newName = do + liftIO $ clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + liftIO $ updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + pure m {memberProfile} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + safeDeleteLDN db user localDisplayName + pure $ Right m {localDisplayName = ldn, memberProfile} -- | Updates the member's contact profile, also clearing the simplex_name on any -- other contact_profiles row in the same user that already holds the same -- (user_id, simplex_name) — newer-claim-wins, required by the partial UNIQUE index. -updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' - | displayName == newName = liftIO $ do - currentTs <- getCurrentTime - clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName - updateMemberContactProfile_' db userId profileId p' currentTs - pure (m {memberProfile = profile}, ct {profile} :: Contact) - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName - updateMemberContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) +updateContactMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) +updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactMemberProfile' currentTs badgeVerified profile where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName, simplexName = profileSimplexName} = p' - profile = toLocalProfile profileId p' localAlias + updateContactMemberProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + liftIO $ updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + pure (m {memberProfile = profile}, ct {profile} :: Contact) + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = @@ -3175,7 +3447,7 @@ createNewUnknownGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3200,7 +3472,7 @@ createLinkOwnerMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName $ nameFromMemberId memberId - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3221,33 +3493,32 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI where VersionRange minV maxV = vr cxt --- member_pub_key is not updated here — introduced members are owners --- whose keys are loaded from link data (trusted out-of-band). --- Updating from an in-band message would allow a compromised relay to substitute keys. +-- Intro refreshes only profile / status / peer version. Role and key stay owner-authoritative +-- (the owner-signed roster for members/moderators/admins, link data for owners), so taking either from +-- an in-band relayed intro would let a compromised relay substitute them. updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do - _ <- updateMemberProfile db user member profile +updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} = do + _ <- updateMemberProfile db cxt user member profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute db [sql| UPDATE group_members - SET member_role = ?, - member_status = ?, + SET member_status = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? |] - (memberRole, GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId) + (GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId) getGroupMemberById db cxt user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do - _ <- updateMemberProfile db user unknownMember profile + _ <- updateMemberProfile db cxt user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -3272,6 +3543,30 @@ updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMem VersionRange minV maxV = maybe memberChatVRange fromChatVRange v memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey +-- Like updateUnknownMemberAnnounced but preserves member_role and member_pub_key +-- (roster-established for moderators/admins; the dissemination carries only the profile). +updateRosterMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +updateRosterMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} status = do + _ <- updateMemberProfile db cxt user unknownMember profile + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET member_category = ?, + member_status = ?, + invited_by_group_member_id = ?, + peer_chat_min_version = ?, + peer_chat_max_version = ?, + updated_at = ? + WHERE user_id = ? AND group_member_id = ? + |] + ((GCPostMember, status, groupMemberId' invitingMember) :. (minV, maxV, currentTs, userId, groupMemberId)) + getGroupMemberById db cxt user groupMemberId + where + VersionRange minV maxV = maybe memberChatVRange fromChatVRange v + updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs = DB.execute diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index ba20f1164c..531474842a 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -137,6 +137,7 @@ module Simplex.Chat.Store.Messages getGroupSndStatuses, getGroupSndStatusCounts, getGroupHistoryItems, + getGroupWebPreviewItems, ) where @@ -237,10 +238,7 @@ createNewSndMessage db gVar connOrGroupId chatMsgEvent msgSigning_ encodeMessage case encodeMessage (SharedMsgId sharedMsgId) of ECMLarge -> pure $ Left SELargeMsg ECMEncoded msgBody -> do - let signedMsg_ = signBody <$> msgSigning_ - signBody MsgSigning {bindingTag, bindingData, keyRef, privKey} = - let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody) - in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig :| [], signedBody = msgBody} + let signedMsg_ = (`signChatMsgBody` msgBody) <$> msgSigning_ createdAt <- getCurrentTime DB.execute db @@ -583,9 +581,9 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgS CDChannelRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> (Just $ Just userMemberId == memberId, memberId) -createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs = - createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing Nothing +createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> Maybe MsgSigStatus -> UTCTime -> UTCTime -> IO ChatItemId +createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink msgSigned itemTs = + createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgSigned where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) @@ -662,7 +660,8 @@ insertChatItemMessage_ :: DB.Connection -> ChatItemId -> MessageId -> UTCTime -> insertChatItemMessage_ db ciId msgId ts = DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, ts, ts) getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> QuotedMsg -> IO (CIQuote c) -getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = +getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = do + currentTs <- getCurrentTime case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> @@ -670,13 +669,13 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing CDChannelRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c @@ -705,8 +704,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" (userId, groupId, msgId, MDRcv, groupMemberId) - getGroupChatItemQuote_ :: Int64 -> MemberId -> IO (CIQuote 'CTGroup) - getGroupChatItemQuote_ groupId mId = do + getGroupChatItemQuote_ :: UTCTime -> Int64 -> MemberId -> IO (CIQuote 'CTGroup) + getGroupChatItemQuote_ currentTs groupId mId = do ciQuoteGroup <$> DB.query db @@ -715,7 +714,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.simplex_name, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -731,7 +731,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe where ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing - ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow + ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember currentTs userContactId memberRow getChatPreviews :: DB.Connection -> StoreCxt -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] getChatPreviews db cxt user withPCC pagination query = do @@ -1121,22 +1121,25 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} getContactRequestChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] -getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = False} -> pure [] - CLQFilters {favorite = False, unread = True} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = True} -> map toPreview <$> getPreviews "" - CLQSearch {search} -> map toPreview <$> getPreviews search +getContactRequestChatPreviews_ db User {userId} pagination clq = do + currentTs <- getCurrentTime + case clq of + CLQFilters {favorite = False, unread = False} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = False} -> pure [] + CLQFilters {favorite = False, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQSearch {search} -> map (toPreview currentTs) <$> getPreviews search where query = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.simplex_name FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -1158,9 +1161,9 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of PTLast count -> DB.query db (query <> " ORDER BY cr.updated_at DESC LIMIT ?") (params search :. Only count) PTAfter ts count -> DB.query db (query <> " AND cr.updated_at > ? ORDER BY cr.updated_at ASC LIMIT ?") (params search :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ?") (params search :. (ts, count)) - toPreview :: ContactRequestRow -> AChatPreviewData - toPreview cReqRow = - let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow + toPreview :: UTCTime -> ContactRequestRow -> AChatPreviewData + toPreview now cReqRow = + let cReq@UserContactRequest {updatedAt} = toContactRequest now cReqRow aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] emptyChatStats in ACPD SCTContactRequest $ ContactRequestPD updatedAt aChat @@ -2368,9 +2371,9 @@ toGroupChatItem ) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where - member_ = toMaybeGroupMember userContactId memberRow_ - quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - deletedByGroupMember_ = toMaybeGroupMember userContactId deletedByGroupMemberRow_ + member_ = toMaybeGroupMember currentTs userContactId memberRow_ + quotedMember_ = toMaybeGroupMember currentTs userContactId quotedMemberRow_ + deletedByGroupMember_ = toMaybeGroupMember currentTs userContactId deletedByGroupMemberRow_ invalid = ACIContent msgDir $ CIInvalidJSON itemContentText chatItem itemContent = case (itemContent, itemStatus, member_, fileStatus_) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> @@ -3066,7 +3069,8 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.simplex_name, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -3074,13 +3078,15 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- quoted GroupMember rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, - rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rp.simplex_name, + rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rp.simplex_name, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, - dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbp.simplex_name, + dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbp.simplex_name, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i @@ -3708,3 +3714,21 @@ getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do LIMIT ? |] (groupMemberId' m, userId, groupId, count) + +getGroupWebPreviewItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] +getGroupWebPreviewItems db user@User {userId} g@GroupInfo {groupId} count = do + ciIds <- + map fromOnly + <$> DB.query + db + [sql| + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + |] + (userId, groupId, count) + reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 2681fa44bf..db5d46f051 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -32,9 +32,12 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access +import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain +import Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster import Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name import Simplex.Chat.Store.Postgres.Migrations.M20260604_simplex_name_profiles import Simplex.Chat.Store.Postgres.Migrations.M20260606_simplex_name_verified @@ -71,9 +74,12 @@ schemaMigrations = ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), + ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster), ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name), ("20260604_simplex_name_profiles", m20260604_simplex_name_profiles, Just down_m20260604_simplex_name_profiles), ("20260606_simplex_name_verified", m20260606_simplex_name_verified, Just down_m20260606_simplex_name_verified), diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..ffc3122e3f --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260516_supporter_badges :: Text +m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TIMESTAMPTZ; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified SMALLINT; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx BIGINT; +|] + +down_m20260516_supporter_badges :: Text +down_m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..1b8efbcead --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260601_relay_sent_web_domain :: Text +m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Text +down_m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs new file mode 100644 index 0000000000..892b2c70da --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs @@ -0,0 +1,64 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260602_group_roster :: Text +m20260602_group_roster = + [r| +ALTER TABLE groups ADD COLUMN roster_version BIGINT; +ALTER TABLE groups ADD COLUMN roster_msg_body BYTEA; +ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT; +ALTER TABLE groups ADD COLUMN roster_msg_signatures BYTEA; +ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id BIGINT; +ALTER TABLE groups ADD COLUMN roster_broker_ts TIMESTAMPTZ; +ALTER TABLE groups ADD COLUMN roster_blob BYTEA; + +CREATE TABLE rcv_roster_transfers( + roster_transfer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + from_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + roster_version BIGINT NOT NULL, + roster_digest BYTEA NOT NULL, + sending_owner_gm_id BIGINT NOT NULL, + broker_ts TIMESTAMPTZ NOT NULL, + roster_msg_body BYTEA, + roster_msg_chat_binding TEXT, + roster_msg_signatures BYTEA, + created_at TEXT NOT NULL DEFAULT (now()), + updated_at TEXT NOT NULL DEFAULT (now()) +); +CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON rcv_roster_transfers(group_id, from_member_id); +CREATE INDEX idx_rcv_roster_transfers_from_member_id ON rcv_roster_transfers(from_member_id); + +ALTER TABLE files ADD COLUMN shared_msg_id BYTEA; +ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal'; +ALTER TABLE files ADD COLUMN roster_transfer_id BIGINT; +CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id); +CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); +|] + +down_m20260602_group_roster :: Text +down_m20260602_group_roster = + [r| +DROP INDEX idx_files_roster_transfer_id; +DROP INDEX idx_files_group_id_shared_msg_id; +ALTER TABLE files DROP COLUMN roster_transfer_id; +ALTER TABLE files DROP COLUMN file_type; +ALTER TABLE files DROP COLUMN shared_msg_id; + +DROP INDEX idx_rcv_roster_transfers_from_member_id; +DROP INDEX idx_rcv_roster_transfers_group_id_from_member_id; +DROP TABLE rcv_roster_transfers; + +ALTER TABLE groups DROP COLUMN roster_blob; +ALTER TABLE groups DROP COLUMN roster_broker_ts; +ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id; +ALTER TABLE groups DROP COLUMN roster_msg_signatures; +ALTER TABLE groups DROP COLUMN roster_msg_chat_binding; +ALTER TABLE groups DROP COLUMN roster_msg_body; +ALTER TABLE groups DROP COLUMN roster_version; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index fdabe855a8..6f4d0c8721 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -533,6 +533,15 @@ CREATE TABLE test_chat_schema.contact_profiles ( contact_link bytea, short_descr text, chat_peer_type text, + badge_proof bytea, + badge_pres_header bytea, + badge_expiry timestamp with time zone, + badge_type text, + badge_verified smallint, + badge_extra text, + badge_master_key bytea, + badge_signature bytea, + badge_key_idx bigint, simplex_name text ); @@ -615,7 +624,8 @@ CREATE TABLE test_chat_schema.contacts ( grp_direct_inv_from_group_member_id bigint, grp_direct_inv_from_member_conn_id bigint, grp_direct_inv_started_connection smallint DEFAULT 0 NOT NULL, - simplex_name text + simplex_name text, + simplex_name_verified_at timestamp with time zone ); @@ -746,7 +756,10 @@ CREATE TABLE test_chat_schema.files ( file_crypto_key bytea, file_crypto_nonce bytea, note_folder_id bigint, - redirect_file_id bigint + redirect_file_id bigint, + shared_msg_id bytea, + file_type text DEFAULT 'normal'::text NOT NULL, + roster_transfer_id bigint ); @@ -972,9 +985,18 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone, - simplex_name text + relay_sent_web_domain text, + roster_version bigint, + roster_msg_body bytea, + roster_msg_chat_binding text, + roster_msg_signatures bytea, + roster_sending_owner_gm_id bigint, + roster_broker_ts timestamp with time zone, + roster_blob bytea, + simplex_name text, + simplex_name_verified_at timestamp with time zone ); @@ -1201,6 +1223,34 @@ CREATE TABLE test_chat_schema.rcv_files ( +CREATE TABLE test_chat_schema.rcv_roster_transfers ( + roster_transfer_id bigint NOT NULL, + group_id bigint NOT NULL, + from_member_id bigint NOT NULL, + roster_version bigint NOT NULL, + roster_digest bytea NOT NULL, + sending_owner_gm_id bigint NOT NULL, + broker_ts timestamp with time zone NOT NULL, + roster_msg_body bytea, + roster_msg_chat_binding text, + roster_msg_signatures bytea, + created_at text DEFAULT now() NOT NULL, + updated_at text DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.rcv_roster_transfers ALTER COLUMN roster_transfer_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.rcv_roster_transfers_roster_transfer_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.received_probes ( received_probe_id bigint NOT NULL, contact_id bigint, @@ -1735,6 +1785,11 @@ ALTER TABLE ONLY test_chat_schema.rcv_files +ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers + ADD CONSTRAINT rcv_roster_transfers_pkey PRIMARY KEY (roster_transfer_id); + + + ALTER TABLE ONLY test_chat_schema.received_probes ADD CONSTRAINT received_probes_pkey PRIMARY KEY (received_probe_id); @@ -2276,10 +2331,18 @@ CREATE INDEX idx_files_group_id ON test_chat_schema.files USING btree (group_id) +CREATE INDEX idx_files_group_id_shared_msg_id ON test_chat_schema.files USING btree (group_id, shared_msg_id); + + + CREATE INDEX idx_files_redirect_file_id ON test_chat_schema.files USING btree (redirect_file_id); +CREATE INDEX idx_files_roster_transfer_id ON test_chat_schema.files USING btree (roster_transfer_id); + + + CREATE INDEX idx_files_user_id ON test_chat_schema.files USING btree (user_id); @@ -2460,6 +2523,14 @@ CREATE INDEX idx_rcv_files_group_member_id ON test_chat_schema.rcv_files USING b +CREATE INDEX idx_rcv_roster_transfers_from_member_id ON test_chat_schema.rcv_roster_transfers USING btree (from_member_id); + + + +CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON test_chat_schema.rcv_roster_transfers USING btree (group_id, from_member_id); + + + CREATE INDEX idx_received_probes_contact_id ON test_chat_schema.received_probes USING btree (contact_id); @@ -3145,6 +3216,16 @@ ALTER TABLE ONLY test_chat_schema.rcv_files +ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers + ADD CONSTRAINT rcv_roster_transfers_from_member_id_fkey FOREIGN KEY (from_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers + ADD CONSTRAINT rcv_roster_transfers_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.received_probes ADD CONSTRAINT received_probes_contact_id_fkey FOREIGN KEY (contact_id) REFERENCES test_chat_schema.contacts(contact_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 464e80c4cb..6e4ddd3452 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -43,6 +43,7 @@ module Simplex.Chat.Store.Profiles updateUserGroupReceipts, updateUserAutoAcceptMemberContacts, updateUserProfile, + setUserBadge, setUserProfileContactLink, getUserContactProfiles, createUserContactLink, @@ -97,6 +98,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Simplex.Chat.Badges (LocalBadge, localBadgeToRow) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Operators @@ -159,7 +161,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing, Nothing) + pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. Only Nothing -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -193,8 +195,9 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} getUsers :: DB.Connection -> IO [User] -getUsers db = - map toUser <$> DB.query_ db userQuery +getUsers db = do + now <- getCurrentTime + map (toUser now) <$> DB.query_ db userQuery setActiveUser :: DB.Connection -> User -> IO User setActiveUser db user@User {userId} = do @@ -211,13 +214,15 @@ getNextActiveOrder db = do else pure $ order + 1 getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User -getUser db userId = - ExceptT . firstRow toUser (SEUserNotFound userId) $ +getUser db userId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFound userId) $ DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) getRelayUser :: DB.Connection -> ExceptT StoreError IO User -getRelayUser db = - ExceptT . firstRow toUser SERelayUserNotFound $ +getRelayUser db = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) SERelayUserNotFound $ DB.query_ db (userQuery <> " WHERE u.is_user_chat_relay = 1") getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 @@ -226,38 +231,45 @@ getUserIdByName db uName = DB.query db "SELECT user_id FROM users WHERE local_display_name = ?" (Only uName) getUserByAConnId :: DB.Connection -> AgentConnId -> IO (Maybe User) -getUserByAConnId db agentConnId = - maybeFirstRow toUser $ +getUserByAConnId db agentConnId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId) getUserByASndFileId :: DB.Connection -> AgentSndFileId -> IO (Maybe User) -getUserByASndFileId db aSndFileId = - maybeFirstRow toUser $ +getUserByASndFileId db aSndFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.agent_snd_file_id = ?") (Only aSndFileId) getUserByARcvFileId :: DB.Connection -> AgentRcvFileId -> IO (Maybe User) -getUserByARcvFileId db aRcvFileId = - maybeFirstRow toUser $ +getUserByARcvFileId db aRcvFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id JOIN rcv_files r ON r.file_id = f.file_id WHERE r.agent_rcv_file_id = ?") (Only aRcvFileId) getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User -getUserByContactId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByContactId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ? AND ct.deleted = 0") (Only contactId) getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User -getUserByGroupId db groupId = - ExceptT . firstRow toUser (SEUserNotFoundByGroupId groupId) $ +getUserByGroupId db groupId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByGroupId groupId) $ DB.query db (userQuery <> " JOIN groups g ON g.user_id = u.user_id WHERE g.group_id = ?") (Only groupId) getUserByNoteFolderId :: DB.Connection -> NoteFolderId -> ExceptT StoreError IO User -getUserByNoteFolderId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByNoteFolderId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN note_folders nf ON nf.user_id = u.user_id WHERE nf.note_folder_id = ?") (Only contactId) getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User -getUserByFileId db fileId = - ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ +getUserByFileId db fileId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByFileId fileId) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo] @@ -317,10 +329,10 @@ updateUserAutoAcceptMemberContacts db User {userId} autoAccept = updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do - updateContactProfile_ db userId profileId pNoSimplexName currentTs <- getCurrentTime + updateUserProfileFields_' db userId profileId p' currentTs userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs - pure user {profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime @@ -330,9 +342,9 @@ updateUserProfile db user p' db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) - updateContactProfile_' db userId profileId pNoSimplexName currentTs + updateUserProfileFields_' db userId profileId p' currentTs updateContactLDN_ db user userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs | userMemberProfileChanged = do @@ -340,17 +352,42 @@ updateUserProfile db user p' pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localAlias}, userMemberProfileUpdatedAt} = user + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p' - -- contact_profiles.simplex_name is reserved for peer claims received via XInfo. - -- The user's own broadcastable simplex_name lives on contacts.simplex_name - -- (loaded by toUser into User.profile.simplexName via uct.simplex_name); - -- writing it here would (a) collide with peer claims on the partial UNIQUE - -- index, and (b) make a subsequent peer claim displace the user's own row. - pNoSimplexName = (p' :: Profile) {simplexName = Nothing} - profile = toLocalProfile profileId p' localAlias + -- contact_profiles.simplex_name is reserved for peer claims received via XInfo; + -- updateUserProfileFields_' deliberately does not write it. The user's own + -- broadcastable simplex_name lives on contacts.simplex_name (loaded by toUser + -- into User.profile.simplexName via uct.simplex_name). fullPreferences = fullPreferences' preferences +-- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge) +updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () +updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt = + DB.execute + db + [sql| + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId)) + +-- store the user's own badge credential; touches only the badge columns. +-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message. +setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User +setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do + ts <- getCurrentTime + DB.execute + db + [sql| + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (localBadgeToRow localBadge :. (ts, userId, profileId)) + DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) + pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts} + setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do ts <- getCurrentTime @@ -380,7 +417,7 @@ getUserContactProfiles db User {userId} = (Only userId) where toContactProfile :: (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Text, Maybe Preferences) -> Profile - toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, simplexNameRaw, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, preferences} + toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, simplexNameRaw, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, preferences, badge = Nothing} createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> ExceptT StoreError IO () createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f4b60dd9e8..1c1343fdd5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -155,9 +155,12 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access +import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain +import Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster import Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name import Simplex.Chat.Store.SQLite.Migrations.M20260604_simplex_name_profiles import Simplex.Chat.Store.SQLite.Migrations.M20260606_simplex_name_verified @@ -317,9 +320,12 @@ schemaMigrations = ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), + ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster), ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name), ("20260604_simplex_name_profiles", m20260604_simplex_name_profiles, Just down_m20260604_simplex_name_profiles), ("20260606_simplex_name_verified", m20260606_simplex_name_verified, Just down_m20260606_simplex_name_verified), diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..d263d63a2b --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260516_supporter_badges :: Query +m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified INTEGER; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx INTEGER; +|] + +down_m20260516_supporter_badges :: Query +down_m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..922a563356 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260601_relay_sent_web_domain :: Query +m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Query +down_m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs new file mode 100644 index 0000000000..d68fea3a56 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs @@ -0,0 +1,63 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260602_group_roster :: Query +m20260602_group_roster = + [sql| +ALTER TABLE groups ADD COLUMN roster_version INTEGER; +ALTER TABLE groups ADD COLUMN roster_msg_body BLOB; +ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT; +ALTER TABLE groups ADD COLUMN roster_msg_signatures BLOB; +ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id INTEGER; +ALTER TABLE groups ADD COLUMN roster_broker_ts TEXT; +ALTER TABLE groups ADD COLUMN roster_blob BLOB; + +CREATE TABLE rcv_roster_transfers( + roster_transfer_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + from_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + roster_version INTEGER NOT NULL, + roster_digest BLOB NOT NULL, + sending_owner_gm_id INTEGER NOT NULL, + broker_ts TEXT NOT NULL, + roster_msg_body BLOB, + roster_msg_chat_binding TEXT, + roster_msg_signatures BLOB, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; +CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON rcv_roster_transfers(group_id, from_member_id); +CREATE INDEX idx_rcv_roster_transfers_from_member_id ON rcv_roster_transfers(from_member_id); + +ALTER TABLE files ADD COLUMN shared_msg_id BLOB; +ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal'; +ALTER TABLE files ADD COLUMN roster_transfer_id INTEGER; +CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id); +CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); +|] + +down_m20260602_group_roster :: Query +down_m20260602_group_roster = + [sql| +DROP INDEX idx_files_roster_transfer_id; +DROP INDEX idx_files_group_id_shared_msg_id; +ALTER TABLE files DROP COLUMN roster_transfer_id; +ALTER TABLE files DROP COLUMN file_type; +ALTER TABLE files DROP COLUMN shared_msg_id; + +DROP INDEX idx_rcv_roster_transfers_from_member_id; +DROP INDEX idx_rcv_roster_transfers_group_id_from_member_id; +DROP TABLE rcv_roster_transfers; + +ALTER TABLE groups DROP COLUMN roster_blob; +ALTER TABLE groups DROP COLUMN roster_broker_ts; +ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id; +ALTER TABLE groups DROP COLUMN roster_msg_signatures; +ALTER TABLE groups DROP COLUMN roster_msg_chat_binding; +ALTER TABLE groups DROP COLUMN roster_msg_body; +ALTER TABLE groups DROP COLUMN roster_version; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 1743763fb3..9dee5e10b7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -548,15 +548,6 @@ Plan: SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) -Query: - UPDATE rcv_messages - SET receive_attempts = receive_attempts + 1 - WHERE conn_id = ? AND internal_id = ? - RETURNING receive_attempts - -Plan: -SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) - Query: DELETE FROM conn_confirmations WHERE conn_id = ? diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 47275983bf..fb6166f8c9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -30,6 +30,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -82,6 +83,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -122,11 +124,12 @@ Query: ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -147,19 +150,20 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, - g.simplex_name, gp.simplex_name, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.simplex_name, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -284,6 +288,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -318,6 +323,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -352,6 +358,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -390,10 +397,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.simplex_name, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -458,7 +466,16 @@ Query: short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -525,6 +542,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -558,6 +576,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -592,6 +611,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -626,6 +646,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -665,7 +686,8 @@ Query: c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, c.simplex_name, p.simplex_name + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 @@ -913,7 +935,7 @@ Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, - conn_chat_version, peer_chat_min_version, peer_chat_max_version, simplex_name + conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? AND conn_status != ? @@ -923,7 +945,7 @@ SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, - conn_chat_version, peer_chat_min_version, peer_chat_max_version, simplex_name + conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE (user_id = ? AND via_contact_uri_hash = ?) OR (user_id = ? AND via_contact_uri_hash = ?) @@ -973,7 +995,7 @@ Query: SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, - gp.simplex_name, gp.preferences, gp.member_admission + gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? @@ -1015,7 +1037,8 @@ Query: -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -1068,6 +1091,17 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) +Query: + SELECT t.roster_transfer_id, t.roster_version, t.roster_digest, t.sending_owner_gm_id, t.broker_ts, + t.roster_msg_chat_binding, t.roster_msg_signatures, t.roster_msg_body + FROM rcv_roster_transfers t + JOIN files f ON f.roster_transfer_id = t.roster_transfer_id + WHERE f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH t USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET user_id = ?, updated_at = ? @@ -1111,6 +1145,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 = ? @@ -1155,6 +1202,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1190,6 +1238,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1230,8 +1279,8 @@ Query: INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, - business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count, simplex_name) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1239,8 +1288,8 @@ Query: INSERT INTO groups (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, - root_priv_key, root_pub_key, member_priv_key, public_member_count) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + root_priv_key, root_pub_key, member_priv_key, public_member_count, roster_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1297,7 +1346,8 @@ Query: -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -1305,13 +1355,15 @@ Query: -- quoted GroupMember rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, - rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rp.simplex_name, + rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, - dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbp.simplex_name, + dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i @@ -1363,11 +1415,12 @@ Query: ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id @@ -1417,7 +1470,7 @@ Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? @@ -1600,6 +1653,18 @@ Plan: SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) + Query: SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i @@ -1626,7 +1691,7 @@ Query: SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -1746,7 +1811,6 @@ Query: SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, - simplex_name = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id @@ -1823,6 +1887,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1857,6 +1922,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1904,6 +1970,17 @@ Query: Plan: +Query: + INSERT INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT (file_id, chunk_number) DO UPDATE SET + chunk_agent_msg_id = excluded.chunk_agent_msg_id, + chunk_stored = 0, + created_at = excluded.created_at, + updated_at = excluded.updated_at + +Plan: + Query: INSERT INTO remote_hosts (host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) @@ -1918,11 +1995,12 @@ Query: ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id @@ -2000,10 +2078,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2029,10 +2108,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2058,10 +2138,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -3391,7 +3472,7 @@ Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? @@ -3404,7 +3485,7 @@ Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL @@ -3570,7 +3651,7 @@ Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, - conn_chat_version, peer_chat_min_version, peer_chat_max_version, simplex_name + conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? @@ -3591,7 +3672,8 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, cp.simplex_name -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? @@ -3634,7 +3716,7 @@ SEARCH f USING PRIMARY KEY (file_id=?) SEARCH d USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT display_name, full_name, short_descr, image, contact_link, chat_peer_type, simplex_name, preferences + SELECT display_name, full_name, short_descr, image, contact_link, chat_peer_type, preferences FROM contact_profiles WHERE user_id = ? @@ -3661,6 +3743,15 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=?) SEARCH f USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT f.file_id FROM files f + JOIN rcv_files r ON r.file_id = f.file_id + WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ? AND f.file_type = ? + AND r.group_member_id = ? +Plan: +SEARCH f USING INDEX idx_files_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) +SEARCH r USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=? AND rowid=?) + Query: SELECT file_id, contact_id, group_id, note_folder_id FROM files @@ -3677,6 +3768,20 @@ Query: Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT g.group_id, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ? + WHERE g.user_id = ? AND g.relay_own_status IN (?, ?) + AND gp.public_group_id IS NOT NULL + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT group_member_id FROM group_members @@ -3998,6 +4103,19 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_category = ?, + member_status = ?, + invited_by_group_member_id = ?, + peer_chat_min_version = ?, + peer_chat_max_version = ?, + updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_id = ?, member_pub_key = ?, updated_at = ? @@ -4016,8 +4134,7 @@ SCAN group_members Query: UPDATE group_members - SET member_role = ?, - member_status = ?, + SET member_status = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? @@ -4641,8 +4758,8 @@ Query: INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, user_contact_link_id, created_at, updated_at, - conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption, simplex_name - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -4722,6 +4839,14 @@ Query: Plan: +Query: + INSERT INTO rcv_roster_transfers + (group_id, from_member_id, roster_version, roster_digest, sending_owner_gm_id, broker_ts, + roster_msg_chat_binding, roster_msg_signatures, roster_msg_body) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO remote_controllers (ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key) @@ -4958,6 +5083,14 @@ Query: Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET contact_link = ?, updated_at = ? @@ -4968,7 +5101,7 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, simplex_name = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -4976,7 +5109,8 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, simplex_name = ?, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -4984,7 +5118,17 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, simplex_name = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5179,6 +5323,17 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups SET + roster_version = ?, roster_blob = ?, + roster_sending_owner_gm_id = ?, roster_broker_ts = ?, + roster_msg_chat_binding = ?, roster_msg_signatures = ?, roster_msg_body = ?, + updated_at = ? + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE msg_deliveries SET delivery_status = ?, updated_at = ? @@ -5196,6 +5351,14 @@ Query: Plan: SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE rcv_file_chunks + SET chunk_stored = 1, updated_at = ? + WHERE file_id = ? AND chunk_number = ? + +Plan: +SEARCH rcv_file_chunks USING PRIMARY KEY (file_id=? AND chunk_number=?) + Query: UPDATE rcv_files SET to_receive = 1, user_approved_relays = ?, updated_at = ? @@ -5311,13 +5474,13 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, - g.simplex_name, gp.simplex_name, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.simplex_name, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5349,13 +5512,13 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, - g.simplex_name, gp.simplex_name, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.simplex_name, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5380,13 +5543,13 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, - g.simplex_name, gp.simplex_name, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.simplex_name, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5405,10 +5568,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.simplex_name, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.business_group_id = ? @@ -5420,10 +5584,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.simplex_name, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? AND cr.contact_request_id = ? @@ -5434,13 +5599,14 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5461,13 +5627,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5481,13 +5648,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5500,13 +5668,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5519,13 +5688,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5538,13 +5708,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5557,13 +5728,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5576,13 +5748,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5595,13 +5768,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5614,13 +5788,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5633,13 +5808,54 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5652,13 +5868,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5671,13 +5888,14 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, c.simplex_name + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id @@ -5757,11 +5975,11 @@ Query: FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id - JOIN group_members m ON m.group_member_id = gr.group_member_id - WHERE gr.group_id = ? - AND m.member_status = ? - AND gr.relay_status IN (?,?) - + JOIN group_members m ON m.group_member_id = gr.group_member_id + WHERE gr.group_id = ? + AND m.member_status = ? + AND gr.relay_status IN (?,?,?) + Plan: SEARCH gr USING INDEX idx_group_relays_group_id (group_id=?) SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) @@ -5871,7 +6089,8 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5883,7 +6102,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5896,7 +6116,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5909,7 +6130,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5923,7 +6145,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5936,7 +6159,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5949,7 +6173,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5962,7 +6187,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5975,7 +6201,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5987,7 +6214,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6366,6 +6594,14 @@ SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND loca SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: DELETE FROM files WHERE roster_transfer_id = ? +Plan: +SEARCH files USING COVERING INDEX idx_files_roster_transfer_id (roster_transfer_id=?) +SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) +SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) + Query: DELETE FROM files WHERE user_id = ? AND contact_id = ? Plan: SEARCH files USING INDEX idx_files_contact_id (contact_id=?) @@ -6374,9 +6610,18 @@ SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) +Query: DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ? +Plan: +SEARCH files USING INDEX idx_files_group_id (group_id=?) +SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) +SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) + Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -6406,6 +6651,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -6435,6 +6681,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM groups WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_id (group_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_group_id (group_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_group_id (group_id=?) @@ -6507,6 +6754,18 @@ Query: DELETE FROM rcv_file_chunks WHERE file_id = ? Plan: SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_file_id (file_id=?) +Query: DELETE FROM rcv_roster_transfers WHERE group_id = ? +Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=?) + +Query: DELETE FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ? +Plan: +SEARCH rcv_roster_transfers USING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=? AND from_member_id=?) + +Query: DELETE FROM rcv_roster_transfers WHERE roster_transfer_id = ? +Plan: +SEARCH rcv_roster_transfers USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM received_probes WHERE created_at <= ? Plan: SEARCH received_probes USING COVERING INDEX idx_received_probes_created_at (created_at User -> Profile -> ExceptT StoreError IO () -createContact db user profile = do +createContact :: DB.Connection -> StoreCxt -> User -> Profile -> ExceptT StoreError IO () +createContact db cxt user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db user profile emptyChatPrefs Nothing "" currentTs Nothing + void $ createContact_ db cxt user profile emptyChatPrefs Nothing "" currentTs Nothing -- | Clears simplex_name on any other contact_profiles row that holds the same -- (user_id, simplex_name) so a subsequent UPDATE/INSERT setting that value @@ -467,17 +468,18 @@ clearConflictingContactProfileSimplexName_ db userId (Just profileId) (Just simp -- peer-claimed Profile.simplexName that collides with an existing row (the -- partial UNIQUE index on contact_profiles.(user_id, simplex_name)) displaces -- the prior holder's name — newer-claim-wins. -createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> Maybe SimplexNameInfo -> ExceptT StoreError IO ContactId -createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, simplexName = profileSimplexName, peerType, preferences} ctUserPreferences prepared localAlias currentTs simplexName = +createContact_ :: DB.Connection -> StoreCxt -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> Maybe SimplexNameInfo -> ExceptT StoreError IO ContactId +createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, simplexName = profileSimplexName, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs simplexName = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do -- Clear any existing peer claim on the same simplex_name before INSERT -- so the partial UNIQUE index doesn't reject the new row. Pass Nothing -- as the excluded profileId — there's no self-row yet. clearConflictingContactProfileSimplexName_ db userId Nothing profileSimplexName + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, simplex_name, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, profileSimplexName, currentTs, currentTs)) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, simplex_name) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified :. Only profileSimplexName) profileId <- insertedRowId db DB.execute db @@ -544,16 +546,16 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64, Maybe Text, Maybe Text, Maybe UTCTime) +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow :. (Maybe Text, Maybe Text, Maybe UTCTime) type ContactRow = Only ContactId :. ContactRow' -- ct.simplex_name -> Contact.simplexName (user's locally-known label) -- cp.simplex_name -> LocalProfile.simplexName (peer's broadcast claim) -toContact :: StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, ctSimplexNameRaw, cpSimplexNameRaw, simplexNameVerifiedAt)) :. connRow) = +toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact +toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. (ctSimplexNameRaw, cpSimplexNameRaw, simplexNameVerifiedAt)) :. connRow) = let simplexName = decodeSimplexName ctSimplexNameRaw - profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName cpSimplexNameRaw, peerType, preferences, localAlias} + profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName cpSimplexNameRaw, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn @@ -579,22 +581,24 @@ toGroupDirectInvitation (Just groupDirectInvLink, fromGroupId_, fromGroupMemberI Just $ GroupDirectInvitation {groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, groupDirectInvStartedConnection} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile -getProfileById db userId profileId = - ExceptT . firstRow rowToLocalProfile (SEProfileNotFound profileId) $ +getProfileById db userId profileId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (rowToLocalProfile currentTs) (SEProfileNotFound profileId) $ DB.query db [sql| - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, cp.simplex_name -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, -- , ct.user_preferences + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.simplex_name FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? |] (userId, profileId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Text) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow :. Only (Maybe Text) -toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, simplexNameRaw) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer)) = do - let profile = Profile {displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, preferences} +toContactRequest :: UTCTime -> ContactRequestRow -> UserContactRequest +toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow :. Only simplexNameRaw) = do + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias} cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in UserContactRequest {contactRequestId, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, createdAt, updatedAt} @@ -602,17 +606,18 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, uct.simplex_name + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, uct.simplex_name FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides, Maybe Text) -> User -toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes, simplexNameRaw)) = +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. Only (Maybe Text) -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. Only simplexNameRaw) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where - profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, preferences = userPreferences, localAlias = ""} + profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ @@ -728,17 +733,17 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. (Maybe Text, Maybe Text, Maybe UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. (Maybe Text, Maybe Text, Maybe UTCTime) :. GroupMemberRow type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolInt) type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences, Maybe Text) +type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow :. Only (Maybe Text) -toGroupInfo :: StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. (gSimplexNameRaw, gpSimplexNameRaw, simplexNameVerifiedAt) :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr cxt} +toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. (gSimplexNameRaw, gpSimplexNameRaw, simplexNameVerifiedAt) :. userMemberRow) = + let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) @@ -752,7 +757,7 @@ toGroupInfo cxt userContactId chatTags ((groupId, localDisplayName, displayName, businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} - in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, simplexName, simplexNameVerifiedAt} + in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, simplexName, simplexNameVerifiedAt} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -786,9 +791,9 @@ toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) toGroupKeys _ _ = Nothing -toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - let memberProfile = rowToLocalProfile profileRow +toGroupMember :: UTCTime -> Int64 -> GroupMemberRow -> GroupMember +toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + let memberProfile = rowToLocalProfile now profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById @@ -812,7 +817,8 @@ groupMemberQuery = [sql| SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.simplex_name, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.simplex_name, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -824,13 +830,13 @@ groupMemberQuery = LEFT JOIN connections c ON c.group_member_id = m.group_member_id |] -toContactMember :: StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember cxt User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} +toContactMember :: UTCTime -> StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember now cxt User {userContactId} (memberRow :. connRow) = + (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} -rowToLocalProfile :: ProfileRow -> LocalProfile -rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences, simplexNameRaw) = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, localAlias, preferences} +rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile +rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow :. Only simplexNameRaw) = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName simplexNameRaw, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId} @@ -852,13 +858,14 @@ groupInfoQueryFields = g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, g.simplex_name, gp.simplex_name, g.simplex_name_verified_at, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.simplex_name, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.simplex_name, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link |] @@ -947,8 +954,9 @@ addGroupChatTags db g@GroupInfo {groupId} = do getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo cxt userContactId chatTags) (SEGroupNotFound groupId) $ + firstRow (toGroupInfo currentTs cxt userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index da4af5e11c..7888ea23a1 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -40,6 +40,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Functor (($>)) import Data.Int (Int64) +import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -47,6 +48,8 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Data.Word (Word16) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), localBadgeInfo, localBadgeStatus, mkBadgeStatus, verifyBadge) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -375,7 +378,7 @@ data UserContactRequest = UserContactRequest cReqChatVRange :: VersionRangeChat, localDisplayName :: ContactName, profileId :: Int64, - profile :: Profile, + profile :: LocalProfile, createdAt :: UTCTime, updatedAt :: UTCTime, xContactId :: Maybe XContactId, @@ -493,6 +496,7 @@ data GroupInfo = GroupInfo uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData, groupSummary :: GroupSummary, + rosterVersion :: Maybe VersionRoster, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact, groupKeys :: Maybe GroupKeys, @@ -647,6 +651,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) @@ -696,9 +706,10 @@ data Profile = Profile shortDescr :: Maybe Text, -- short description limited to 160 characters image :: Maybe ImageData, contactLink :: Maybe ConnLinkContact, - simplexName :: Maybe SimplexNameInfo, preferences :: Maybe Preferences, - peerType :: Maybe ChatPeerType + peerType :: Maybe ChatPeerType, + badge :: Maybe BadgeProof, + simplexName :: Maybe SimplexNameInfo -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id -- - incognito @@ -731,7 +742,7 @@ instance TextEncoding ChatPeerType where profileFromName :: ContactName -> Profile profileFromName displayName = - Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, preferences = Nothing, peerType = Nothing} + Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing, simplexName = Nothing} -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool @@ -740,6 +751,15 @@ profilesMatch LocalProfile {displayName = n2, fullName = fn2, image = i2} = n1 == n2 && fn1 == fn2 && i1 == i2 +-- equal for profile-update detection: badge proofs are re-generated for every presentation, +-- so compare badges by disclosed info (not proof bytes) - a re-presentation of the same badge is a no-op +sameProfileContent :: Profile -> Profile -> Bool +sameProfileContent p@Profile {badge = b} p'@Profile {badge = b'} = + p {badge = Nothing} == p' {badge = Nothing} && (proofInfo <$> b) == (proofInfo <$> b') + where + proofInfo :: BadgeProof -> BadgeInfo + proofInfo (BadgeProof _ _ _ info) = info + data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile fromIncognitoProfile :: IncognitoProfile -> Profile @@ -769,23 +789,48 @@ data LocalProfile = LocalProfile shortDescr :: Maybe Text, image :: Maybe ImageData, contactLink :: Maybe ConnLinkContact, - simplexName :: Maybe SimplexNameInfo, preferences :: Maybe Preferences, peerType :: Maybe ChatPeerType, - localAlias :: LocalAlias + localBadge :: Maybe LocalBadge, + localAlias :: LocalAlias, + simplexName :: Maybe SimplexNameInfo } deriving (Eq, Show) localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile {profileId} = profileId -toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile -toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType} localAlias = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType, localAlias} +toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> LocalProfile +toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge, simplexName} localAlias now verified = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias, simplexName} + where + localBadge = (\b@(BadgeProof _ _ _ info) -> PeerBadge b (mkBadgeStatus now verified info)) <$> badge fromLocalProfile :: LocalProfile -> Profile -fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType} = - Profile {displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType} +fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, simplexName} = + Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge, simplexName} + where + -- any stored peer proof rides the wire (receivers verify independently); the own credential is presented fresh, and a display-only badge never sends + wireBadge :: LocalBadge -> Maybe BadgeProof + wireBadge = \case + PeerBadge b _ -> Just b + OwnBadge _ _ -> Nothing + ShownBadge _ _ -> Nothing + +profileBadgeVerified :: Map Int BBSPublicKey -> LocalProfile -> Profile -> IO (Maybe Bool) +profileBadgeVerified keys LocalProfile {localBadge} Profile {badge = newBadge} = + case (localBadge, newBadge) of + (_, Nothing) -> pure (Just False) + -- an unchanged badge that verified before stays verified; failed or unknown-key badges + -- are re-verified, so an unknown key heals once an app update adds it + (Just lb, Just (BadgeProof _ _ _ newInfo)) + | localBadgeInfo lb == newInfo && localBadgeStatus lb `notElem` [BSFailed, BSUnknownKey] -> pure (Just True) + (_, Just newB) -> verifyBadge keys newB + +-- a failed or unknown-key badge is re-verified on the next profile update even when its disclosed content +-- is unchanged, so it heals once an app update adds the issuer key +badgeNeedsReverify :: LocalProfile -> Bool +badgeNeedsReverify LocalProfile {localBadge} = maybe False ((`elem` [BSFailed, BSUnknownKey]) . localBadgeStatus) localBadge data GroupType = GTChannel @@ -856,8 +901,13 @@ instance FromJSON ImageData where parseJSON = fmap ImageData . J.parseJSON instance ToJSON ImageData where - toJSON (ImageData t) = J.toJSON t - toEncoding (ImageData t) = J.toEncoding t + toJSON (ImageData t) = J.toJSON $ safeImageData t + toEncoding (ImageData t) = J.toEncoding $ safeImageData t + +safeImageData :: Text -> Text +safeImageData t + | "data:" `T.isPrefixOf` t = t + | otherwise = "" instance ToField ImageData where toField (ImageData t) = toField t @@ -985,6 +1035,11 @@ newtype MemberKey = MemberKey C.PublicKeyEd25519 deriving (Eq, Show) deriving newtype (StrEncoding) +-- Binary encoding for the roster blob; delegates to the Ed25519 key. +instance Encoding MemberKey where + smpEncode (MemberKey k) = smpEncode k + smpP = MemberKey <$> smpP + instance FromJSON MemberKey where parseJSON = strParseJSON "MemberKey" @@ -1506,11 +1561,38 @@ instance ToJSON InlineFileMode where toJSON = J.String . textEncode toEncoding = JE.text . textEncode +-- Discriminates ordinary chat files from the roster blob file, so the receive +-- completion / cancel paths branch on the type rather than on chat_item_id (note +-- folders and redirects also lack a chat item). +data FileType = FTNormal | FTRoster + deriving (Eq, Show) + +instance TextEncoding FileType where + textEncode = \case + FTNormal -> "normal" + FTRoster -> "roster" + textDecode = \case + "normal" -> Just FTNormal + "roster" -> Just FTRoster + _ -> Nothing + +instance FromField FileType where fromField = fromTextField_ textDecode + +instance ToField FileType where toField = toField . textEncode + +instance FromJSON FileType where + parseJSON = textParseJSON "FileType" + +instance ToJSON FileType where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + data RcvFileTransfer = RcvFileTransfer { fileId :: FileTransferId, xftpRcvFile :: Maybe XFTPRcvFile, fileInvitation :: FileInvitation, fileStatus :: RcvFileStatus, + fileType :: FileType, rcvFileInline :: Maybe InlineFileMode, senderDisplayName :: ContactName, chunkSize :: Integer, @@ -2051,11 +2133,17 @@ type VersionRangeChat = VersionRange ChatVersion -- | Store-wide context passed to store functions in place of the bare `vr` -- parameter. Built from config by mkStoreCxt; more fields are added here over time. -newtype StoreCxt = StoreCxt {vr :: VersionRangeChat} +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey} pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v +-- A monotonic per-change counter, not a negotiated protocol version: Int64 rather than the Word16 of +-- Version, so a long-lived high-churn channel cannot wrap and be permanently rejected by relays (v >= cur). +newtype VersionRoster = VersionRoster Int64 + deriving (Eq, Ord, Show) + deriving newtype (FromJSON, ToJSON, FromField, ToField) + -- this newtype exists to have a concise JSON encoding of version ranges in chat protocol messages in the form of "1-2" or just "1" newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRangeChat} deriving (Eq, Show) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index c71f7ce37a..296268b58f 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -11,6 +11,7 @@ import qualified Data.ByteString.Char8 as B import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON) import Simplex.Messaging.Util ((<$?>)) @@ -57,6 +58,12 @@ instance ToJSON GroupMemberRole where toJSON = textToJSON toEncoding = textToEncoding +-- Binary encoding for the roster blob; delegates to the canonical TextEncoding +-- (same member/moderator/admin form JSON and the DB use). GRUnknown round-trips. +instance Encoding GroupMemberRole where + smpEncode = smpEncode . textEncode + smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP + data GroupAcceptance = GAAccepted | GAPendingApproval | GAPendingReview deriving (Eq, Show) instance StrEncoding GroupAcceptance where @@ -82,6 +89,7 @@ data RelayStatus = RSNew -- only for owner | RSInvited | RSAccepted + | RSAcknowledgedRoster | RSActive | RSInactive | RSRejected @@ -92,6 +100,7 @@ relayStatusText = \case RSNew -> "new" RSInvited -> "invited" RSAccepted -> "accepted" + RSAcknowledgedRoster -> "acknowledged_roster" RSActive -> "active" RSInactive -> "inactive" RSRejected -> "rejected" @@ -101,6 +110,7 @@ instance TextEncoding RelayStatus where RSNew -> "new" RSInvited -> "invited" RSAccepted -> "accepted" + RSAcknowledgedRoster -> "acknowledged_roster" RSActive -> "active" RSInactive -> "inactive" RSRejected -> "rejected" @@ -108,6 +118,7 @@ instance TextEncoding RelayStatus where "new" -> Just RSNew "invited" -> Just RSInvited "accepted" -> Just RSAccepted + "acknowledged_roster" -> Just RSAcknowledgedRoster "active" -> Just RSActive "inactive" -> Just RSInactive "rejected" -> Just RSRejected diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 0a2505c150..2cf6d853ca 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -43,6 +43,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Help import Simplex.Chat.Library.Commands (maxImageSize) import Simplex.Chat.Markdown +import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -111,7 +112,7 @@ chatErrorToView isCmd ChatConfig {logLevel, testView} = viewChatError isCmd logL chatResponseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveItems ts tz outputRH = \case - CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes + CRActiveUser User {profile = p@LocalProfile {localBadge}, uiThemes} -> viewUserProfile localBadge (fromLocalProfile p) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] @@ -194,7 +195,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci - CRUserProfile u p -> ttyUser u $ viewUserProfile p + CRUserProfile u@User {profile = LocalProfile {localBadge}} p -> ttyUser u $ viewUserProfile localBadge p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix hu outputRH u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info @@ -453,7 +454,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtRcvFileProgressXFTP {} -> [] CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CEvtGroupMemberUpdated {} -> [] - CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c profile + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c (fromLocalProfile profile) CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci CEvtRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft @@ -620,8 +621,8 @@ viewUsersList us = in if null ss then ["no users"] else ss where ldn (UserInfo User {localDisplayName = n} _) = T.toLower n - userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType}, activeUser, showNtfs, viewPwdHash, clientService} count) - | activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName shortDescr <> infoStr <> bot + userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType, localBadge}, activeUser, showNtfs, viewPwdHash, clientService} count) + | activeUser || isNothing viewPwdHash = Just $ ttyFullNameBadge n fullName shortDescr localBadge <> infoStr <> bot | otherwise = Nothing where infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")" @@ -1214,8 +1215,8 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} = ] showRelay :: GroupRelay -> StyledString -showRelay GroupRelay {groupRelayId, relayStatus} = - " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) +showRelay GroupRelay {groupRelayId, relayStatus, relayCap = RelayCapabilities {webDomain}} = + " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) <> maybe "" (\d -> ", web: " <> plain d) webDomain viewGroupRelays :: GroupInfo -> [GroupRelay] -> [StyledString] viewGroupRelays g relays = @@ -1517,9 +1518,9 @@ viewContactAndMemberAssociated ct g m ct' = "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] -viewUserProfile :: Profile -> [StyledString] -viewUserProfile Profile {displayName, fullName, shortDescr, peerType, preferences} = - [ "user profile: " <> ttyFullName displayName fullName shortDescr <> bot, +viewUserProfile :: Maybe LocalBadge -> Profile -> [StyledString] +viewUserProfile localBadge Profile {displayName, fullName, shortDescr, peerType, preferences} = + [ "user profile: " <> ttyFullNameBadge displayName fullName shortDescr localBadge <> bot, "use " <> highlight' "/p []" <> " to change it" ] ++ viewCommands @@ -1772,9 +1773,22 @@ smpProxyModeStr :: SMPProxyMode -> SMPProxyFallback -> String smpProxyModeStr SPMNever _ = "private message routing disabled." smpProxyModeStr mode fallback = T.unpack $ safeDecodeUtf8 $ "private message routing mode: " <> strEncode mode <> ", fallback: " <> strEncode fallback +viewContactBadge :: Maybe LocalBadge -> [StyledString] +viewContactBadge = maybe [] $ \lb -> + let BadgeInfo {badgeType, badgeExpiry} = localBadgeInfo lb + st = case localBadgeStatus lb of + BSActive -> "active" + BSExpired -> "expired" + BSExpiredOld -> "expired (old)" + BSFailed -> "verification failed" + BSUnknownKey -> "unknown key" + expiry = maybe "no expiry" (("expires " <>) . T.pack . formatTime defaultTimeLocale "%Y-%m-%d") badgeExpiry + in [plain (textEncode badgeType <> " badge - " <> st), plain expiry] + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn, uiThemes, customData, simplexName} stats incognitoProfile = +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge}, activeConn, uiThemes, customData, simplexName} stats incognitoProfile = ["contact ID: " <> sShow contactId] + <> viewContactBadge localBadge <> maybe [] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> plain (shareLinkStr simplexName (strEncode (simplexChatContact' l)))]) contactLink <> maybe @@ -1807,10 +1821,11 @@ viewCustomData :: Maybe CustomData -> [StyledString] viewCustomData = maybe [] (\(CustomData v) -> ["custom data: " <> viewJSON (J.Object v)]) viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink}, activeConn} stats = +viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink, localBadge}, activeConn} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] + <> viewContactBadge localBadge <> maybe ["member not connected"] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> ["alias: " <> plain localAlias | localAlias /= ""] @@ -1975,10 +1990,10 @@ countactUserPrefText cup = case cup of viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> Maybe MsgSigStatus -> [StyledString] viewGroupUpdated - GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma}} - g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}} + GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma, publicGroup = pg}} + g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma', publicGroup = pg'}} m signed = do - let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated + let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated <> publicGroupAccessUpdated if null update then [] else memberUpdated <> update @@ -2003,6 +2018,18 @@ viewGroupUpdated memberAdmissionUpdated | ma == ma' = [] | otherwise = ["changed member admission rules"] + publicGroupAccessUpdated + | access == access' = [] + | otherwise = ["updated public group access:" <> viewAccess access'] + where + access = pg >>= publicGroupAccess + access' = pg' >>= publicGroupAccess + viewAccess Nothing = " removed" + viewAccess (Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}) = + maybe "" (\u -> " web=" <> plain u) groupWebPage + <> maybe "" (\d -> " domain=" <> plain d) groupDomain + <> (if domainWebPage then " domain_page=on" else "") + <> (if allowEmbedding then " embed=on" else "") viewGroupProfile :: GroupInfo -> [StyledString] viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, description, image, groupPreferences = gps}} = @@ -2808,9 +2835,47 @@ ttyContact = styled (colored Green) . viewName ttyContact' :: Contact -> StyledString ttyContact' Contact {localDisplayName = c} = ttyContact c +-- Supporter badge: a colored star marks an active badge (only the star is colored). +-- supporter cyan, legend blue, investor yellow, unknown cyan; business has no star. +badgeStarColor :: BadgeType -> Maybe Color +badgeStarColor = \case + BTSupporter -> Just Cyan + BTLegend -> Just Blue + BTInvestor -> Just Yellow + BTUnknown _ -> Just Cyan + +-- (star color, type word) for an active, colorable badge +activeBadge :: Maybe LocalBadge -> Maybe (Color, Text) +activeBadge lb_ = do + lb <- lb_ + case localBadgeStatus lb of + BSActive -> let BadgeInfo {badgeType} = localBadgeInfo lb in (\col -> (col, textEncode badgeType)) <$> badgeStarColor badgeType + _ -> Nothing + +badgeStar :: Color -> StyledString +badgeStar col = styled (colored col) ("*" :: Text) + +-- " *" (space + colored star) for sender prefixes, "" if no active badge +badgeStarSep :: Maybe LocalBadge -> StyledString +badgeStarSep lb_ = maybe "" (\(c, _) -> " " <> badgeStar c) (activeBadge lb_) + +-- name + badge for full-name contexts: "alice (Alice, * supporter)" / "alice (* supporter)" / "alice (Alice)" / "alice" +ttyFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +ttyFullNameBadge c fullName shortDescr lb_ = ttyContact c <> optFullNameBadge c fullName shortDescr lb_ + +optFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +optFullNameBadge c fullName shortDescr lb_ = case activeBadge lb_ of + Nothing -> optFullName c fullName shortDescr + Just (color, typeWord) -> " (" <> nameInner <> badgeStar color <> plain (" " <> typeWord) <> ")" + where + nameInner = maybe "" (\t -> plain (t <> ", ")) innerName + innerName + | T.null fullName || c == fullName = shortDescr + | otherwise = Just fullName + ttyFullContact :: Contact -> StyledString -ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr}} = - ttyFullName localDisplayName fullName shortDescr +ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr, localBadge}} = + ttyFullNameBadge localDisplayName fullName shortDescr localBadge ttyMember :: GroupMember -> StyledString ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName @@ -2839,7 +2904,8 @@ ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (vie ttyQuotedMember Nothing = ">" ttyFromContact :: Contact -> StyledString -ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ") +ttyFromContact ct@Contact {localDisplayName = c, profile = LocalProfile {localBadge}} = + ctIncognito ct <> ttyFrom (viewName c) <> badgeStarSep localBadge <> ttyFrom "> " ttyFromContactEdited :: Contact -> StyledString ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> [edited] ") diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs new file mode 100644 index 0000000000..fc3e4b2a26 --- /dev/null +++ b/src/Simplex/Chat/Web.hs @@ -0,0 +1,435 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Simplex.Chat.Web + ( WebChannelPreview (..), + WebMessage (..), + WebMemberProfile (..), + WebFileInfo (..), + webPreviewWorker, + writeCorsConfig, + removeStaleFiles, + channelContentChanged, + channelProfileUpdated, + channelRemoved, + extractOrigin, + ) +where + +import Control.Concurrent.STM (check, flushTQueue) +import Control.Exception (SomeException, catch) +import Control.Logger.Simple +import Control.Monad +import Control.Monad.Except (runExceptT) +import Data.Either (rights) +import Data.Int (Int64) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Text.Encoding (encodeUtf8) +import qualified Data.Map.Strict as M +import qualified Data.Set as S +import Data.Maybe (isJust, mapMaybe, maybeToList) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Messages + ( CChatItem (..), + CIDirection (..), + CIFile (..), + CIMeta (..), + CIQDirection (..), + CIQuote (..), + CIReactionCount, + ChatItem (..), + ChatType (..), + ) +import Simplex.Chat.Messages.CIContent (ciMsgContent) +import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport) +import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups, updatePublicMemberCount) +import Simplex.Chat.Store.Messages (getGroupWebPreviewItems) +import Simplex.Chat.Store.Shared (getGroupInfo) +import Simplex.Chat.Types + ( B64UrlByteString, + GroupInfo (..), + GroupMember (..), + GroupProfile (..), + GroupSummary (..), + ImageData, + LocalProfile (..), + MemberId, + PublicGroupAccess (..), + PublicGroupProfile (..), + User (..), + ) +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Util (catchOwn, eitherToMaybe, safeDecodeUtf8, tshow) +import Simplex.Messaging.Parsers (defaultJSON) +import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) +import System.FilePath (dropExtension, takeExtension, ()) +import qualified URI.ByteString as U +import UnliftIO.STM + +data WebFileInfo = WebFileInfo + { fileName :: String, + fileSize :: Integer + } + deriving (Show) + +data WebMemberProfile = WebMemberProfile + { memberId :: MemberId, + displayName :: Text, + image :: Maybe ImageData + } + deriving (Show) + +data WebMessage = WebMessage + { sender :: Maybe MemberId, + ts :: UTCTime, + content :: MsgContent, + formattedText :: Maybe MarkdownList, + file :: Maybe WebFileInfo, + quote :: Maybe QuotedMsg, + reactions :: [CIReactionCount], + forward :: Maybe Bool, + edited :: Bool + } + deriving (Show) + +data WebChannelPreview = WebChannelPreview + { channel :: GroupProfile, + shortDescription :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + members :: [WebMemberProfile], + subscribers :: Maybe Int64, + messages :: [WebMessage], + updatedAt :: UTCTime + } + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''WebFileInfo) + +$(JQ.deriveJSON defaultJSON ''WebMemberProfile) + +$(JQ.deriveJSON defaultJSON ''WebMessage) + +$(JQ.deriveJSON defaultJSON ''WebChannelPreview) + +webPreviewWorker :: WebPreviewConfig -> ChatController -> [User] -> IO () +webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterval} cc users = + forM_ (webPreviewState cc) $ \wps -> do + createDirectoryIfMissing True webJsonDir + initPublishableGroups wps + cleanStaleFiles wps + regenerateCors wps + seedRoutinePending wps + forever $ workerLoop wps `catchOwn` \e -> logError ("web preview worker error: " <> tshow e) + where + cxt = mkStoreCxt (config cc) + + workerLoop wps@WebPreviewState {priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} = do + drainRemovals + drainPriority + handleCors + renderRoutine + noRoutine <- atomically $ S.null <$> readTVar routinePending + when noRoutine waitRefresh + where + drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case + Nothing -> pure () + Just f -> do + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + drainRemovals + + -- flush the whole queue and render each group once: a burst of changes in one + -- channel enqueues its id many times, but only needs a single render + drainPriority = do + gIds <- atomically $ flushTQueue priorityRender + forM_ (S.fromList gIds) $ renderOneGroup wps + + handleCors = do + needed <- atomically $ swapTVar corsNeeded False + when needed $ regenerateCors wps + + -- render a single routine item; the main loop calls this once per iteration + renderRoutine = do + mGId <- atomically $ do + pending <- readTVar routinePending + case S.minView pending of + Nothing -> pure Nothing + Just (gId, rest) -> writeTVar routinePending rest >> pure (Just gId) + forM_ mGId $ renderOneGroup wps + + -- routine list drained: wait for the refresh timer or a change signal; only the timer + -- seeds the next full sweep, a change just returns to let the main loop service it + waitRefresh = do + delay <- registerDelay (webUpdateInterval * 1000000) + timerFired <- atomically $ + (True <$ (readTVar delay >>= check)) `orElse` (False <$ takeTMVar wakeSignal) + when timerFired $ seedRoutinePending wps + + initPublishableGroups WebPreviewState {publishableGroupIds} = do + rows <- withTransaction (chatStore cc) $ \db -> + concat <$> mapM (getRelayPublishableGroups db) users + let gIds = M.fromList [(gId, toPublishableGroup pgId access) | (gId, pgId, access) <- rows] + atomically $ writeTVar publishableGroupIds gIds + + cleanStaleFiles WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let activeFiles = S.fromList $ map pgFileName $ M.elems ids + removeStaleFiles webJsonDir activeFiles + + regenerateCors WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let entries = mapMaybe pgCorsEntry $ M.elems ids + forM_ webCorsFile $ writeCorsConfig entries + + seedRoutinePending WebPreviewState {publishableGroupIds, routinePending} = + atomically $ M.keysSet <$> readTVar publishableGroupIds >>= writeTVar routinePending + + renderOneGroup WebPreviewState {publishableGroupIds} gId = do + publishable <- atomically $ M.member gId <$> readTVar publishableGroupIds + when publishable $ + renderOrRemoveStale `catch` \(e :: SomeException) -> + logError $ "web preview: error rendering group " <> T.pack (show gId) <> ": " <> T.pack (show e) + where + renderOrRemoveStale = do + r <- withTransaction (chatStore cc) $ \db -> + findUser $ \u -> fmap (\g -> (u, g)) <$> runExceptT (getGroupInfo db cxt u gId) + case r of + Just (u, gInfo) | hasPublicGroup gInfo -> + void $ renderGroupPreview cfg cc u gInfo + _ -> do + fName <- atomically $ do + pg <- M.lookup gId <$> readTVar publishableGroupIds + modifyTVar' publishableGroupIds (M.delete gId) + pure $ pgFileName <$> pg + forM_ fName $ \f -> + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + logInfo $ "web preview: group " <> T.pack (show gId) <> " no longer publishable" + + findUser f = go users + where + go [] = pure Nothing + go (u : us) = f u >>= \case + Right a -> pure (Just a) + Left _ -> go us + +renderGroupPreview :: WebPreviewConfig -> ChatController -> User -> GroupInfo -> IO (Maybe (Text, CorsOrigin)) +renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gInfo@GroupInfo {groupProfile = gp@GroupProfile {shortDescr = sd, description = wd, publicGroup}, groupSummary = GroupSummary {publicMemberCount}} = + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let fName = publicGroupIdFileName publicGroupId <> ".json" + -- backfill the subscriber count for channels created before it was tracked + subscribers <- case publicMemberCount of + Just _ -> pure publicMemberCount + Nothing -> do + g_ <- withTransaction (chatStore cc) (\db -> runExceptT $ updatePublicMemberCount db cxt user gInfo) + pure $ eitherToMaybe g_ >>= \GroupInfo {groupSummary = GroupSummary {publicMemberCount = pmc}} -> pmc + (items, owners) <- withTransaction (chatStore cc) $ \db -> do + is <- getGroupWebPreviewItems db user gInfo webPreviewItemCount + os <- getGroupOwners db cxt user gInfo + pure (is, os) + ts <- getCurrentTime + let rendered = mapMaybe toRenderedItem $ rights items + msgs = map fst rendered + senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + preview = WebChannelPreview + { channel = gp, + shortDescription = toFormattedText =<< sd, + welcomeMessage = toFormattedText =<< wd, + members = senders, + subscribers, + messages = msgs, + updatedAt = ts + } + let destPath = webJsonDir fName + tmpPath = destPath <> ".tmp" + LB.writeFile tmpPath (J.encode preview) + renameFile tmpPath destPath + pure $ corsEntry publicGroupId <$> publicGroupAccess + Nothing -> pure Nothing + where + cxt = mkStoreCxt (config cc) + +channelContentChanged :: ChatController -> Int64 -> STM () +channelContentChanged cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + when (M.member gId ids) $ do + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + void $ tryPutTMVar wakeSignal () + +channelProfileUpdated :: ChatController -> Int64 -> GroupProfile -> STM () +channelProfileUpdated cc gId GroupProfile {publicGroup} = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} -> + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let pg = PublishableGroup + { pgFileName = publicGroupIdFileName publicGroupId <> ".json", + pgCorsEntry = corsEntry publicGroupId <$> publicGroupAccess + } + modifyTVar' publishableGroupIds (M.insert gId pg) + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + Nothing -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +channelRemoved :: ChatController -> Int64 -> STM () +channelRemoved cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, filesToRemove, corsNeeded, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) + | isJust itemTimed = Nothing + | otherwise = case ciMsgContent content of + Just mc | not (isReport mc) -> + let (sender, senderProfile) = case chatDir of + CIGroupRcv m@GroupMember {memberId} -> (Just memberId, [memberToProfile m]) + _ -> (Nothing, []) + quotedProfile = case quotedItem of + Just CIQuote {chatDir = CIQGroupRcv (Just m)} -> [memberToProfile m] + _ -> [] + in Just + ( WebMessage + { sender, + ts = itemTs, + content = mc, + formattedText, + file = webFileInfo <$> file, + quote = quotedItem >>= ciQuoteToQuotedMsg, + reactions, + forward = if isJust itemForwarded then Just True else Nothing, + edited = itemEdited + }, + senderProfile <> quotedProfile + ) + _ -> Nothing + +ciQuoteToQuotedMsg :: CIQuote c -> Maybe QuotedMsg +ciQuoteToQuotedMsg CIQuote {chatDir = qDir, sharedMsgId, sentAt, content = qContent} = + Just QuotedMsg + { msgRef = MsgRef + { msgId = sharedMsgId, + sentAt, + sent = case qDir of + CIQDirectSnd -> True + CIQGroupSnd -> True + _ -> False, + memberId = case qDir of + CIQGroupRcv (Just GroupMember {memberId}) -> Just memberId + _ -> Nothing + }, + content = qContent + } + +webFileInfo :: CIFile d -> WebFileInfo +webFileInfo CIFile {fileName, fileSize} = WebFileInfo {fileName, fileSize} + +collectSenders :: [WebMemberProfile] -> [WebMemberProfile] +collectSenders = M.elems . M.fromList . map (\p@WebMemberProfile {memberId} -> (memberId, p)) + +memberToProfile :: GroupMember -> WebMemberProfile +memberToProfile GroupMember {memberId, memberProfile = LocalProfile {displayName, image}} = + WebMemberProfile {memberId, displayName, image} + +toPublishableGroup :: B64UrlByteString -> Maybe PublicGroupAccess -> PublishableGroup +toPublishableGroup pgId access = + PublishableGroup + { pgFileName = publicGroupIdFileName pgId <> ".json", + pgCorsEntry = corsEntry pgId <$> access + } + +corsEntry :: B64UrlByteString -> PublicGroupAccess -> (Text, CorsOrigin) +corsEntry publicGroupId PublicGroupAccess {groupWebPage, allowEmbedding} = + let fName = T.pack $ publicGroupIdFileName publicGroupId <> ".json" + origin + | allowEmbedding = CorsAny + | otherwise = CorsOrigins $ mapMaybe extractOrigin $ maybeToList groupWebPage + in (fName, origin) + +extractOrigin :: Text -> Maybe Text +extractOrigin url = + case U.parseURI U.laxURIParserOptions (encodeUtf8 url) of + Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority = Just _} + | sch == "https" || sch == "http" -> + let originUri = uri {U.uriPath = "", U.uriQuery = U.Query [], U.uriFragment = Nothing} + origin = safeDecodeUtf8 $ U.serializeURIRef' originUri + in if T.all safeOriginChar origin then Just origin else Nothing + _ -> Nothing + where + -- percent-encoded bytes in the host (e.g. %22, %0a) are decoded by serializeURIRef', + -- so reject any origin with characters that could break out of the Caddy CORS config or header + safeOriginChar c = + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c `elem` (".-:/[]" :: [Char]) + +channelPath :: Text +channelPath = "/channel/" + +writeCorsConfig :: [(Text, CorsOrigin)] -> FilePath -> IO () +writeCorsConfig entries path = + TIO.writeFile path $ T.unlines $ + ["map {path} {cors_origin} {"] + <> map corsLine entries + <> [ " default \"\"", + "}", + "header " <> channelPath <> "*.json Access-Control-Allow-Origin {cors_origin}", + "header " <> channelPath <> "*.json Access-Control-Allow-Methods \"GET, OPTIONS\"" + ] + where + corsLine (fName, origin) = case origin of + CorsAny -> " " <> channelPath <> fName <> " \"*\"" + CorsOrigins origins -> case origins of + [] -> " # " <> fName <> " (no origin configured)" + (o : _) -> " " <> channelPath <> fName <> " \"" <> o <> "\"" + +removeStaleFiles :: FilePath -> S.Set FilePath -> IO () +removeStaleFiles dir activeFiles = do + let -- matches ".json" and leftover ".json.tmp" from an interrupted write + isPreviewFile f = + let f' = if takeExtension f == ".tmp" then dropExtension f else f + base = dropExtension f' + in takeExtension f' == ".json" && not (null base) && all isBase64Url base + isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' + allFiles <- S.filter isPreviewFile . S.fromList <$> listDirectory dir + mapM_ (\f -> removeFile (dir f)) $ S.difference allFiles activeFiles + +toFormattedText :: Text -> Maybe MarkdownList +toFormattedText t = case parseMaybeMarkdownList t of + Just fts | any hasFormat fts -> Just fts + _ -> Nothing + where + hasFormat (FormattedText fmt _) = isJust fmt + +publicGroupIdFileName :: B64UrlByteString -> String +publicGroupIdFileName = B.unpack . strEncode + +hasPublicGroup :: GroupInfo -> Bool +hasPublicGroup GroupInfo {groupProfile = GroupProfile {publicGroup}} = isJust publicGroup + diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs new file mode 100644 index 0000000000..90e3e9ae7a --- /dev/null +++ b/tests/BadgeTests.hs @@ -0,0 +1,142 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DisambiguateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeTests (badgeTests) where + +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import qualified Data.Aeson as J +import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges +import Simplex.Messaging.Crypto.BBS +import Test.Hspec + +badgeTests :: Spec +badgeTests = do + it "full workflow: request, issue, verify credential, generate and verify proof" testFullWorkflow + it "should reject badge with tampered type" testTamperedType + it "should reject badge with tampered expiry" testTamperedExpiry + it "should reject badge with wrong server key" testWrongKey + it "should report a key index missing from configured keys" testUnknownKeyIdx + it "should compute badge status correctly" testExpiryCheck + it "should treat lifetime badges as always active" testLifetimeBadge + it "should accept unknown badge types" testUnknownBadgeType + it "credential serializes to a paste-able token and back" testCredentialSerialization + +proofOf :: BadgeProof -> BBSProof +proofOf (BadgeProof _ _ p _) = p + +proofInfo :: BadgeProof -> BadgeInfo +proofInfo (BadgeProof _ _ _ i) = i + +testKeyIdx :: Int +testKeyIdx = 1 + +keysFor :: BBSPublicKey -> Map Int BBSPublicKey +keysFor = M.singleton testKeyIdx + +testFullWorkflow :: IO () +testFullWorkflow = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let req = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just futureTime, badgeExtra = ""}} + Just vreq <- verifyPayment (BPRedeemCode "TEST") req + Right cred <- issueBadge testKeyIdx sk vreq + let BadgeCredential idx mk' _ _ = cred + idx `shouldBe` testKeyIdx + mk' `shouldBe` mk + verifyCredential pk cred >>= (`shouldBe` True) + Right badge <- generateBadgeProof pk cred (BBSPresHeader "nonce-1") + -- the proof inherits the credential's key index, so receivers find the right key + let BadgeProof {badgeKeyIdx} = badge + badgeKeyIdx `shouldBe` testKeyIdx + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + Right badge2 <- generateBadgeProof pk cred (BBSPresHeader "nonce-2") + verifyBadge (keysFor pk) badge2 >>= (`shouldBe` Just True) + proofOf badge `shouldNotBe` proofOf badge2 + +testTamperedType :: IO () +testTamperedType = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeType = BTLegend}) >>= (`shouldBe` Just False) + +testTamperedExpiry :: IO () +testTamperedExpiry = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeExpiry = Just pastTime}) >>= (`shouldBe` Just False) + +testWrongKey :: IO () +testWrongKey = do + (_, badge) <- issueBadgeProof BTSupporter (Just futureTime) + Right (pk2, _) <- bbsKeyGen + verifyBadge (keysFor pk2) badge >>= (`shouldBe` Just False) + +testUnknownKeyIdx :: IO () +testUnknownKeyIdx = do + (pk, badge) <- issueBadgeProof BTSupporter (Just futureTime) + -- a key index not in the configured keys cannot be verified at all (Nothing) + verifyBadge (M.singleton (testKeyIdx + 1) pk) badge >>= (`shouldBe` Nothing) + +testExpiryCheck :: IO () +testExpiryCheck = do + now <- getCurrentTime + let info expiry = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""} + futureInfo = info (Just futureTime) + mkBadgeStatus now (Just True) futureInfo `shouldBe` BSActive + mkBadgeStatus now (Just True) (info (Just (addUTCTime (-nominalDay) now))) `shouldBe` BSExpired + mkBadgeStatus now (Just True) (info (Just pastTime)) `shouldBe` BSExpiredOld + mkBadgeStatus now (Just False) futureInfo `shouldBe` BSFailed + mkBadgeStatus now Nothing futureInfo `shouldBe` BSUnknownKey + +testLifetimeBadge :: IO () +testLifetimeBadge = do + now <- getCurrentTime + (pk, badge) <- issueBadgeProof BTInvestor Nothing + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + mkBadgeStatus now (Just True) (proofInfo badge) `shouldBe` BSActive + +testUnknownBadgeType :: IO () +testUnknownBadgeType = do + (pk, badge) <- issueBadgeProof (BTUnknown "future_type") (Just futureTime) + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + +testCredentialSerialization :: IO () +testCredentialSerialization = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let mkCred expiry = do + Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}}) + pure cred + dated <- mkCred (Just futureTime) + lifetime <- mkCred Nothing + J.eitherDecode (J.encode dated) `shouldBe` Right dated + J.eitherDecode (J.encode lifetime) `shouldBe` Right lifetime + -- a decoded credential still verifies against the issuing key + case J.eitherDecode (J.encode dated) of + Right cred -> verifyCredential pk cred >>= (`shouldBe` True) + Left e -> expectationFailure e + +-- Helpers + +futureTime :: UTCTime +futureTime = posixSecondsToUTCTime 4102444800 -- 2099-12-31 + +pastTime :: UTCTime +pastTime = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +issueBadgeProof :: BadgeType -> Maybe UTCTime -> IO (BBSPublicKey, BadgeProof) +issueBadgeProof bt expiry = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let vreq = VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = bt, badgeExpiry = expiry, badgeExtra = ""}} + Right cred <- issueBadge testKeyIdx sk vreq + Right badge <- generateBadgeProof pk cred (BBSPresHeader "test-nonce") + pure (pk, badge) diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 803069ac56..be58e1ad6c 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -33,7 +33,7 @@ withBroadcastBot opts test = bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile -broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Just CPTBot, preferences = Nothing} +broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, simplexName = Nothing} mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts mkBotOpts ps publishers = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 1e1d41b222..a277d077ea 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -27,8 +27,10 @@ import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (memberSupportVoiceVersion) import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Version import System.FilePath (()) import Test.Hspec hiding (it) @@ -96,7 +98,7 @@ directoryServiceTests = do it "should update subscriber count periodically" testLinkCheckUpdatesCount directoryProfile :: Profile -directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Just CPTBot, preferences = Nothing} +directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, simplexName = Nothing} mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = @@ -1492,7 +1494,7 @@ testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgVPrev "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps testCfg {chatVRange = (chatVRange testCfg) {maxVersion = prevVersion memberSupportVoiceVersion}} "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" bob #> "@'SimpleX Directory' /role 1" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 2a25c987a0..8687c696a7 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -24,11 +24,12 @@ import Control.Monad.Reader import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (isNothing) +import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), WebPreviewConfig (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options @@ -153,6 +154,7 @@ testCoreOpts = tbqSize = 16, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Nothing, @@ -162,6 +164,9 @@ testCoreOpts = relayTestOpts :: ChatOpts relayTestOpts = testOpts {coreOptions = testCoreOpts {chatRelay = True}} +relayWebTestOpts :: Text -> FilePath -> Maybe FilePath -> ChatOpts +relayWebTestOpts webDomain webDir webCorsFile = testOpts {coreOptions = testCoreOpts {chatRelay = True, webPreviewConfig = Just WebPreviewConfig {webDomain, webJsonDir = webDir, webCorsFile, webUpdateInterval = 300, webPreviewItemCount = 50}}} + #if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {maintenance, dbOptions = (dbOptions testCoreOpts) {dbKey}}} @@ -212,7 +217,7 @@ testCfg = shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16, - channelSubscriberRole = GRMember, -- starting role is GRMember to test members sending messages + channelSubscriberRole = GRObserver, confirmMigrations = MCYesUp } @@ -582,7 +587,6 @@ smpServerCfg = allowSMPProxy = True, serverClientConcurrency = 16, namesConfig = Nothing, - namesResolverCall_ = Nothing, information = Nothing, startOptions = StartOptions {maintenance = False, compactLog = False, logLevel = LogError, skipWarnings = False, confirmMigrations = MCYesUp} } diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 4b09347dcf..5d1abcfe21 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -1,11 +1,14 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} 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,10 +17,17 @@ 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.Chat.Controller (CorsOrigin (..)) +import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig) +import Simplex.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) +import qualified Data.Set as S +import System.Directory (createDirectoryIfMissing, doesFileExist, listDirectory) +import System.FilePath (takeExtension, ()) import Test.Hspec hiding (it) chatRelayTests :: SpecWith TestParams @@ -28,10 +38,57 @@ chatRelayTests = do it "re-add soft-deleted relay by same name" testReAddRelaySameName it "test chat relay" testChatRelayTest it "relay profile updated in address" testRelayProfileUpdateInAddress + describe "relay capabilities" $ do + it "relay sends webDomain in capabilities" testRelayWebCapabilities + describe "web preview" $ do + it "render messages and members" testWebPreviewRender + it "incremental render adds new messages" testWebPreviewIncremental + it "edited and deleted messages" testWebPreviewEditedDeleted + it "reactions in rendered messages" testWebPreviewReactions + it "non-public group produces no file" testWebPreviewNonPublic + it "multiple channels produce multiple files" testWebPreviewMultipleChannels + it "channel deletion removes preview file" testWebPreviewChannelDeleted + it "removeStaleFiles preserves non-base64url files" testWebPreviewStaleCleanup + it "generate CORS config" testWebPreviewCors + it "extractOrigin strips path from URL" testExtractOrigin describe "share channel card" $ 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 = @@ -325,6 +382,238 @@ testShareChannelChannel ps = getTermLine2 :: TestCC -> IO (String, String) getTermLine2 c = (,) <$> getTermLine c <*> getTermLine c +testRelayWebCapabilities :: HasCallStack => TestParams -> IO () +testRelayWebCapabilities ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" (tmpPath ps "web_cap") Nothing) "bob" bobProfile $ \relay -> do + rName <- userName relay + relay ##> "/ad" + (relaySLink, _cLink) <- getContactLinks relay True + alice ##> ("/relays name=" <> rName <> " " <> relaySLink) + alice <## "ok" + alice ##> "/public group relays=1 #news" + alice <## "group #news is created" + alice <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + alice <## "#news: group link relays updated, current relays:" + alice <### [EndsWith ": active, web: relay.example.com"] + alice <## "group link:" + _ <- getTermLine alice + pure (), + relay <## "#news: you joined the group as relay" + ] + +-- Helper: set up relay with web config + channel +withWebChannel :: TestParams -> String -> (TestCC -> TestCC -> FilePath -> IO ()) -> IO () +withWebChannel ps gName test = do + let webDir = tmpPath ps "web_" <> gName + corsFile = tmpPath ps "cors_" <> gName <> ".conf" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir (Just corsFile)) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb gName alice relay + test alice relay webDir + +createChannelWithRelayWeb :: HasCallStack => String -> TestCC -> TestCC -> IO () +createChannelWithRelayWeb gName owner relay = do + owner ##> ("/public group relays=1 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner <### [EndsWith ": active, web: relay.example.com"] + owner <## "group link:" + _ <- getTermLine owner + pure (), + relay <## ("#" <> gName <> ": you joined the group as relay") + ] + +-- Poll for a JSON preview file written by the worker that satisfies predicate, with timeout +waitPreviewWith :: HasCallStack => FilePath -> (WebChannelPreview -> Bool) -> IO WebChannelPreview +waitPreviewWith webDir check = go 50 + where + go :: Int -> IO WebChannelPreview + go 0 = error "waitPreview: timed out waiting for matching JSON file" + go n = do + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + case files of + [f] -> do + jsonBytes <- LB.readFile (webDir f) + case J.eitherDecode jsonBytes of + Right p | check p -> pure p + _ -> threadDelay 100000 >> go (n - 1) + _ -> threadDelay 100000 >> go (n - 1) + +waitPreview :: HasCallStack => FilePath -> IO WebChannelPreview +waitPreview webDir = waitPreviewWith webDir (const True) + +testWebPreviewRender :: HasCallStack => TestParams -> IO () +testWebPreviewRender ps = + withWebChannel ps "news" $ \alice relay webDir -> do + alice #> "#news hello from the channel" + relay <# "#news> hello from the channel" + alice #> "#news second message" + relay <# "#news> second message" + wPreview <- waitPreviewWith webDir (\p -> length (messages p) >= 2) + let GroupProfile {displayName = chName} = channel wPreview + chName `shouldBe` "news" + length (messages wPreview) `shouldBe` 2 + content (messages wPreview !! 0) `shouldBe` MCText "hello from the channel" + content (messages wPreview !! 1) `shouldBe` MCText "second message" + length (members wPreview) `shouldSatisfy` (>= 1) + all (\m -> ts m > read "2020-01-01 00:00:00 UTC") (messages wPreview) `shouldBe` True + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + +testWebPreviewIncremental :: HasCallStack => TestParams -> IO () +testWebPreviewIncremental ps = + withWebChannel ps "inc" $ \alice relay webDir -> do + alice #> "#inc first" + relay <# "#inc> first" + p1 <- waitPreviewWith webDir (\p -> length (messages p) >= 1) + length (messages p1) `shouldBe` 1 + content (messages p1 !! 0) `shouldBe` MCText "first" + alice #> "#inc second" + relay <# "#inc> second" + alice #> "#inc third" + relay <# "#inc> third" + p2 <- waitPreviewWith webDir (\p -> length (messages p) >= 3) + length (messages p2) `shouldBe` 3 + content (messages p2 !! 0) `shouldBe` MCText "first" + content (messages p2 !! 1) `shouldBe` MCText "second" + content (messages p2 !! 2) `shouldBe` MCText "third" + +testWebPreviewEditedDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewEditedDeleted ps = + withWebChannel ps "ed" $ \alice relay webDir -> do + alice #> "#ed msg one" + relay <# "#ed> msg one" + alice #> "#ed msg two" + relay <# "#ed> msg two" + msgId2 <- lastItemId alice + alice #> "#ed msg three" + relay <# "#ed> msg three" + msgId3 <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId2 <> " text msg two edited") + alice <# "#ed [edited] msg two edited" + relay <# "#ed> [edited] msg two edited" + alice #$> ("/_delete item #1 " <> msgId3 <> " broadcast", id, "message marked deleted") + relay <# "#ed> [marked deleted] msg three" + p <- waitPreviewWith webDir (\p -> length (messages p) == 2 && any edited (messages p)) + length (messages p) `shouldBe` 2 + content (messages p !! 0) `shouldBe` MCText "msg one" + content (messages p !! 1) `shouldBe` MCText "msg two edited" + edited (messages p !! 0) `shouldBe` False + edited (messages p !! 1) `shouldBe` True + +testWebPreviewReactions :: HasCallStack => TestParams -> IO () +testWebPreviewReactions ps = + withWebChannel ps "react" $ \alice relay webDir -> do + alice #> "#react hello" + relay <# "#react> hello" + alice ##> "+1 #react hello" + alice <## "added 👍" + relay <# "#react alice> > hello" + relay <## " + 👍" + p <- waitPreviewWith webDir (\p -> not (null (messages p)) && not (null (reactions (head (messages p))))) + length (messages p) `shouldBe` 1 + length (reactions (messages p !! 0)) `shouldSatisfy` (>= 1) + +testWebPreviewNonPublic :: HasCallStack => TestParams -> IO () +testWebPreviewNonPublic ps = do + let webDir = tmpPath ps "web_nonpub" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + alice ##> "/g private" + alice <## "group #private is created" + alice <## "to add members use /a private or /create link #private" + alice #> "#private hello" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 0 + +testWebPreviewMultipleChannels :: HasCallStack => TestParams -> IO () +testWebPreviewMultipleChannels ps = do + let webDir = tmpPath ps "web_multi" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb "ch1" alice relay + createChannelWithRelayWeb "ch2" alice relay + alice #> "#ch1 msg in ch1" + relay <# "#ch1> msg in ch1" + alice #> "#ch2 msg in ch2" + relay <# "#ch2> msg in ch2" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 2 + +testWebPreviewChannelDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewChannelDeleted ps = + withWebChannel ps "del" $ \alice relay webDir -> do + alice #> "#del hello" + relay <# "#del> hello" + _ <- waitPreviewWith webDir (\p -> not (null (messages p))) + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + let previewFile = webDir head jsonFiles + alice ##> "/d #del" + alice <## "#del: you deleted the group (signed)" + relay <## "#del: alice deleted the group (signed)" + relay <## "use /d #del to delete the local copy of the group" + waitFileDeleted previewFile 50 + +testWebPreviewStaleCleanup :: HasCallStack => TestParams -> IO () +testWebPreviewStaleCleanup ps = do + let webDir = tmpPath ps "web_stale_unit" + activeFile = "abc123.json" + staleFile = "AAAA_stale.json" + safeFile = "my.config.json" + createDirectoryIfMissing True webDir + writeFile (webDir activeFile) "{}" + writeFile (webDir staleFile) "{}" + writeFile (webDir safeFile) "{}" + removeStaleFiles webDir (S.singleton activeFile) + doesFileExist (webDir staleFile) `shouldReturn` False + doesFileExist (webDir safeFile) `shouldReturn` True + doesFileExist (webDir activeFile) `shouldReturn` True + +waitFileDeleted :: HasCallStack => FilePath -> Int -> IO () +waitFileDeleted _ 0 = error "waitFileDeleted: timed out" +waitFileDeleted path n = + doesFileExist path >>= \case + False -> pure () + True -> threadDelay 100000 >> waitFileDeleted path (n - 1) + +testWebPreviewCors :: HasCallStack => TestParams -> IO () +testWebPreviewCors ps = do + let corsFile = tmpPath ps "simplex-cors.conf" + entries = + [ ("abc123.json", CorsAny), + ("def456.json", CorsOrigins ["https://owner-site.com"]), + ("ghi789.json", CorsOrigins []) + ] + writeCorsConfig entries corsFile + corsContent <- readFile corsFile + corsContent `shouldContain` "/channel/abc123.json \"*\"" + corsContent `shouldContain` "/channel/def456.json \"https://owner-site.com\"" + corsContent `shouldContain` "# ghi789.json (no origin configured)" + corsContent `shouldContain` "Access-Control-Allow-Origin" + corsContent `shouldContain` "Access-Control-Allow-Methods" + +testExtractOrigin :: HasCallStack => TestParams -> IO () +testExtractOrigin _ps = do + extractOrigin "https://owner.example.com/channel.html" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com/path/to/page?q=1#frag" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com:8443/page" `shouldBe` Just "https://owner.example.com:8443" + extractOrigin "https://owner.example.com" `shouldBe` Just "https://owner.example.com" + extractOrigin "http://localhost:3000/preview" `shouldBe` Just "http://localhost:3000" + extractOrigin "ftp://example.com/file" `shouldBe` Nothing + extractOrigin "not-a-url" `shouldBe` Nothing + -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 740e757ed8..7acfae1a95 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -122,6 +122,7 @@ chatDirectTests = do it "create user with same servers" testCreateUserSameServers it "delete user" testDeleteUser it "delete user with chat tags" testDeleteUserChatTags + it "rejects raw chat TTL updates for another user's chat" testRejectCrossUserChatTTL it "users have different chat item TTL configuration, chat items expire" testUsersDifferentCIExpirationTTL it "chat items expire after restart for all users according to per user configuration" testUsersRestartCIExpiration it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser @@ -2096,6 +2097,25 @@ testDeleteUserChatTags = alice ##> "/users" alice <## "alisa (active)" +testRejectCrossUserChatTTL :: HasCallStack => TestParams -> IO () +testRejectCrossUserChatTTL = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice #$> ("/_ttl 1 @2 2", id, "ok") + alice #$> ("/ttl @bob", id, "old messages are set to be deleted after: 2 second(s)") + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + alice ##> "/_ttl 2 @2 9" + alice <##. "chat db error:" + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl @bob", id, "old messages are set to be deleted after: 2 second(s)") + testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () testUsersDifferentCIExpirationTTL ps = do withNewTestChat ps "bob" bobProfile $ \bob -> do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 200af95320..a32aa2d07c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -20,13 +20,13 @@ import Control.Monad (forM_, void, when) import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Maybe (fromMaybe, isJust, listToMaybe, maybeToList) +import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Time (UTCTime) import Data.Int (Int64) -import Data.List (intercalate, isInfixOf) +import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) @@ -83,6 +83,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 @@ -110,6 +111,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 @@ -286,6 +288,18 @@ chatGroupTests = do it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRejectUnrelatedChannel it "concurrent fresh invitations both rejected" testRelayRejectRaceConcurrentInvitations + describe "promoted members roster" $ do + it "moderator action verifies via owner-signed roster" testChannelModeratorActionViaRoster + it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster + it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster + it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember + it "should add relay to channel with roster (relay caches roster before joinable)" testChannelAddRelayWithRoster + it "roster blob spanning multiple chunks reassembles" testChannelRosterMultipartReassembly + it "corrupted roster blob is rejected on digest mismatch" testChannelRosterDigestMismatchRejected + it "promoted member enters the roster and can post" testChannelPromotedMemberCanPost + it "observer cannot post until promoted" testChannelObserverCannotPost + it "promoted member re-connecting via a new relay is accepted via the roster-pinned key" testChannelPromotedMemberRejoinViaRelay + it "2 relays: multi-chunk roster reassembles per source (no stream interleaving)" testChannelRosterMultiRelayMultipart describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -1617,6 +1631,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 @@ -2977,6 +3022,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 $ @@ -8624,6 +8688,20 @@ createChannel1Relay gName owner relay cath dan eve = do forM_ [cath, dan, eve] $ \member -> memberJoinChannel gName [relay] [owner] shortLink fullLink member +-- Promote a fresh channel subscriber (observer default) to member so it can post; the roster bump +-- re-serves to the other (still-unknown) subscribers, who see the change rendered by member id hash. +promoteChannelMember :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> [TestCC] -> IO () +promoteChannelMember gName owner relay member others = do + mName <- userName member + oName <- userName owner + owner ##> ("/mr #" <> gName <> " " <> mName <> " member") + owner <## ("#" <> gName <> ": you changed the role of " <> mName <> " to member (signed)") + concurrentlyN_ $ + [ relay <## ("#" <> gName <> ": " <> oName <> " changed the role of " <> mName <> " from observer to member (signed)"), + member <## ("#" <> gName <> ": " <> oName <> " changed your role from observer to member (signed)") + ] + <> [o <### [EndsWith "from observer to member (signed)"] | o <- others] + setupRelay :: TestCC -> TestCC -> IO String setupRelay owner relay = do rName <- userName relay @@ -8658,7 +8736,7 @@ prepareChannel' relayId gName owner relay = do ] owner ##> ("/show link #" <> gName) - getGroupLinks owner gName GRMember False + getGroupLinks owner gName GRObserver False createChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () createChannel2Relays gName owner relay1 relay2 dan eve frank = do @@ -8689,7 +8767,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do owner <## ("#" <> gName <> ": group link relays updated, current relays:") owner <### [ EndsWith ": active", - EndsWith ": accepted" + Predicate (\l -> ": invited" `isSuffixOf` l || ": accepted" `isSuffixOf` l || ": acknowledged_roster" `isSuffixOf` l) ] owner <## "group link:" void $ getTermLine owner -- consume group link line @@ -8706,7 +8784,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do ] owner ##> ("/show link #" <> gName) - getGroupLinks owner gName GRMember False + getGroupLinks owner gName GRObserver False memberJoinChannel :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO () memberJoinChannel gName = memberJoinChannel' gName 1 0 0 0 @@ -8835,6 +8913,9 @@ testChannelsSenderDeduplicateOwn ps = do withNewTestChat ps "eve" eveProfile $ \eve -> do withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath and dan while the relay is online, so their buffered posts replay as members + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] -- chat relay bob is offline alice #> "#team 1" @@ -8860,14 +8941,16 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6 [>>]" ] cath - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to dan", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + "#team: bob introduced cath (Catherine) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8875,7 +8958,9 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team cath> 5 [>>]" ] eve - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + EndsWith "updated to dan", + "#team: bob introduced cath (Catherine) in the channel", "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", @@ -8896,11 +8981,13 @@ testChannelLateJoinerReceivesProfile ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] - -- first forward: dan learns cath via prepended XGrpMemNew. + -- first forward: dan resolves cath (roster-known by id hash) on the prepended XGrpMemNew. cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -8936,12 +9023,23 @@ testChannel2RelaysDeduplicateProfile ps = memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve + -- promote dan (observer default) so it can post; eve learns dan via the roster (id hash) + alice ##> "/mr #team dan member" + alice <## "#team: you changed the role of dan to member (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of dan from observer to member (signed)", + cath <## "#team: alice changed the role of dan from observer to member (signed)", + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"] + ] + -- first forward: both relays prepend XGrpMemNew(dan) for eve; -- second hits xGrpMemNew's "already created via another relay" branch. dan #> "#team hi" bob <# "#team dan> hi" cath <# "#team dan> hi" alice <# "#team dan> hi [>>]" + eve <### [EndsWith "updated to dan"] eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> hi [>>]" @@ -8983,6 +9081,7 @@ testChannelLargeProfileFits ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] -- ~14000 chars: profile fits in a singleton batch AND packs -- inline with the forwarded body (exercises the in-body path). @@ -8993,6 +9092,7 @@ testChannelLargeProfileFits ps = cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -9006,6 +9106,8 @@ testChannelMultipleLargeProfiles ps = withNewTestChat ps "dan" danProfile $ \dan -> do withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] -- ~14500 chars each: one rides inline with the body, -- the other spills into a standalone overflow batch. @@ -9027,15 +9129,19 @@ testChannelMultipleLargeProfiles ps = WithTime "#team dan> from dan [>>]" ] cath - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to dan", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team dan> from dan [>>]" ] dan - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + "#team: bob introduced cath (Catherine) in the channel", WithTime "#team cath> from cath [>>]" ] eve - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to cath", + EndsWith "updated to dan", + "#team: bob introduced dan (Daniel) in the channel", "#team: bob introduced cath (Catherine) in the channel", WithTime "#team cath> from cath [>>]", WithTime "#team dan> from dan [>>]" @@ -9057,10 +9163,12 @@ testChannelProfileUpdateNoRePrepend ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -9086,22 +9194,28 @@ testChannelMultiSendersIndependent ps = withNewTestChat ps "dan" danProfile $ \dan -> do withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] - -- cath posts: dan and eve learn cath via prepended XGrpMemNew + -- cath posts: dan and eve resolve cath on the prepended XGrpMemNew cath #> "#team from cath" bob <# "#team cath> from cath" alice <# "#team cath> from cath [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> from cath [>>]" + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> from cath [>>]" - -- dan posts: cath and eve learn dan independently of cath's vector + -- dan posts: cath and eve resolve dan independently of cath's vector dan #> "#team from dan" bob <# "#team dan> from dan" alice <# "#team dan> from dan [>>]" + cath <### [EndsWith "updated to dan"] cath <## "#team: bob introduced dan (Daniel) in the channel" cath <# "#team dan> from dan [>>]" + eve <### [EndsWith "updated to dan"] eve <## "#team: bob introduced dan (Daniel) in the channel" eve <# "#team dan> from dan [>>]" @@ -9122,6 +9236,17 @@ testChannels2RelaysDeliver ps = withNewTestChat ps "frank" frankProfile $ \frank -> do createChannel2Relays "team" alice bob cath dan eve frank + -- promote dan (observer default) so it can send; eve/frank learn dan via the roster + alice ##> "/mr #team dan member" + alice <## "#team: you changed the role of dan to member (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of dan from observer to member (signed)", + cath <## "#team: alice changed the role of dan from observer to member (signed)", + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"], + frank <### [EndsWith "from observer to member (signed)"] + ] + alice #> "#team hi" [bob, cath] *<# "#team> hi" [dan, eve, frank] *<# "#team> hi [>>]" @@ -9134,18 +9259,15 @@ testChannels2RelaysDeliver ps = cath <## " + 👍" alice <# "#team dan> > hi" alice <## " + 👍" + eve .<##. ("#team: unknown member ", " updated to dan") eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> > hi" eve <## " + 👍" + frank .<##. ("#team: unknown member ", " updated to dan") frank .<## " introduced dan (Daniel) in the channel" frank <# "#team dan> > hi" frank <## " + 👍" - -- remove below if default role is changed to observer - dan #> "#team hey" - [bob, cath] *<# "#team dan> hey" - [alice, eve, frank] *<# "#team dan> hey [>>]" - testChannels2RelaysIncognito :: HasCallStack => TestParams -> IO () testChannels2RelaysIncognito ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -9159,6 +9281,17 @@ testChannels2RelaysIncognito ps = forM_ [eve, frank] $ \member -> memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink member + -- promote dan (observer default) so it can send; eve/frank learn dan via the roster + alice ##> ("/mr #team " <> danIncognito <> " member") + alice <## ("#team: you changed the role of " <> danIncognito <> " to member (signed)") + concurrentlyN_ + [ bob <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"), + cath <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"), + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"], + frank <### [EndsWith "from observer to member (signed)"] + ] + alice #> "#team hi" [bob, cath] *<# "#team> hi" dan ?<# "#team> hi [>>]" @@ -9172,18 +9305,15 @@ testChannels2RelaysIncognito ps = cath <## " + 👍" alice <# ("#team " <> danIncognito <> "> > hi") alice <## " + 👍" + eve .<##. ("#team: unknown member ", (" updated to " <> danIncognito)) eve .<## (" introduced " <> danIncognito <> " in the channel") eve <# ("#team " <> danIncognito <> "> > hi") eve <## " + 👍" + frank .<##. ("#team: unknown member ", (" updated to " <> danIncognito)) frank .<## (" introduced " <> danIncognito <> " in the channel") frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" - -- remove below if default role is changed to observer - dan ?#> "#team hey" - [bob, cath] *<# ("#team " <> danIncognito <> "> hey") - [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]") - testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO () testChannelUpdateProfileSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9241,7 +9371,7 @@ testChannelLinkAfterProfileUpdate ps = -- late subscriber joins via the same channel link after profile update threadDelay 100000 alice ##> "/show link #my_team" - (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan @@ -9278,7 +9408,7 @@ testChannelLinkAfterWelcomeUpdate ps = -- re-fetch updated link, late subscriber joins threadDelay 100000 alice ##> "/show link #team" - (shortLink', fullLink') <- getGroupLinks alice "team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "team" [bob] [alice] shortLink' fullLink' dan @@ -9315,7 +9445,7 @@ testChannelOwnerKeyAfterLinkUpdate ps = -- Late subscriber joins via the same channel link after profile update. alice ##> "/show link #my_team" - (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan @@ -9382,15 +9512,20 @@ testChannelChangeRoleSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath cath #> "#team hello from cath" bob <# "#team cath> hello from cath" concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9411,21 +9546,21 @@ testChannelChangeRoleSigned ps = dan #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) - -- change role of silent member (other members don't know about member) + -- change role of silent member; cath/eve don't know dan via xGrpMemRole, but the + -- subsequent roster apply emits the chat item with dan TOFU-created at the new role threadDelay 1000000 alice ##> "/mr #team dan admin" alice <## "#team: you changed the role of dan to admin (signed)" - bob <## "#team: alice changed the role of dan from member to admin (signed)" concurrentlyN_ - [ dan <## "#team: alice changed your role from member to admin (signed)", - cath <## "error: x.grp.mem.role with unknown member ID", - eve <## "error: x.grp.mem.role with unknown member ID" + [ bob <## "#team: alice changed the role of dan from observer to admin (signed)", + dan <## "#team: alice changed your role from observer to admin (signed)", + cath .<##. ("#team: alice changed the role of ", " from observer to admin (signed)"), + eve .<##. ("#team: alice changed the role of ", " from observer to admin (signed)") ] + -- cath/eve render dan by id hash (unknown to them, roster-TOFU); arrival verified above alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of dan to admin (signed)")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin (signed)")]) - cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) -- now new chat item dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) - eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) -- now new chat item testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO () testChannelBlockMemberSigned ps = @@ -9436,6 +9571,9 @@ testChannelBlockMemberSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath threadDelay 1000000 cath #> "#team hello from cath" @@ -9443,9 +9581,11 @@ testChannelBlockMemberSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9491,6 +9631,224 @@ testChannelBlockMemberSigned ps = r2 `shouldStartWith` "blocked" r2 `shouldEndWith` "(signed)" +checkMemberRow :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () +checkMemberRow cc name expectedRole = do + roles <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] + map (\(Only r) -> r) roles `shouldBe` maybeToList expectedRole + +testChannelModeratorActionViaRoster :: HasCallStack => TestParams -> IO () +testChannelModeratorActionViaRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + forM_ [cath, dan, eve] $ \member -> + memberJoinChannel "team" [bob] [alice] shortLink fullLink member + + -- promote dan (observer default) so it can post; cath and eve then discover dan + threadDelay 1000000 + promoteChannelMember "team" alice bob dan [cath, eve] + dan #> "#team hello from dan" + bob <# "#team dan> hello from dan" + concurrentlyN_ + [ alice <# "#team dan> hello from dan [>>]", + do + cath <### [EndsWith "updated to dan"] + cath <## "#team: bob introduced dan (Daniel) in the channel" + cath <# "#team dan> hello from dan [>>]", + do + eve <### [EndsWith "updated to dan"] + eve <## "#team: bob introduced dan (Daniel) in the channel" + eve <# "#team dan> hello from dan [>>]" + ] + + -- cath promoted observer -> moderator; dan/eve learn cath via the roster re-serve + -- (no name yet -> rendered by member id hash) + threadDelay 1000000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + eve <### [EndsWith "to moderator (signed)"] + ] + + -- cath (moderator) blocks dan; profile prepend carries cath's full profile to dan/eve + threadDelay 1000000 + cath ##> "/block for all #team dan" + cath <## "#team: you blocked dan (signed)" + bob <## "#team: cath blocked dan (signed)" + alice <## "#team: cath blocked dan (signed)" + eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <## "#team: cath blocked dan (signed)" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + + -- frank joins after the roster update; cached roster gives him cath as moderator. + -- both alice (owner) and cath (mod) receive XGrpMemNew(frank) via introduceInChannel. + -- the roster apply also emits the role-change chat item on frank's side (owner + -- profile may not be loaded yet, so the actor renders by memberId hash) + threadDelay 1000000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank + -- the late joiner learns the roster from the served snapshot (verified below); under the + -- no-broadcast model the apply finds no role change to surface, so no item here + threadDelay 1000000 -- the served roster arrives async + checkMemberRole frank "cath" "moderator" + where + checkMemberRole :: HasCallStack => TestCC -> T.Text -> T.Text -> IO () + checkMemberRole cc name expectedRole = do + roles <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] + map (\(Only r) -> r) roles `shouldBe` [expectedRole] + +testChannelRemovedModeratorRefreshesRoster :: HasCallStack => TestParams -> IO () +testChannelRemovedModeratorRefreshesRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + forM_ [cath, dan, eve] $ \member -> + memberJoinChannel "team" [bob] [alice] shortLink fullLink member + -- cath promoted observer -> moderator; dan/eve learn cath via the roster (id hash) + threadDelay 1000000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + eve <### [EndsWith "to moderator (signed)"] + ] + threadDelay 1000000 + alice ##> "/rm #team cath" + alice <## "#team: you removed cath from the group (signed)" + bob <## "#team: alice removed cath from the group (signed)" + cath <## "#team: alice removed you from the group (signed)" + cath <## "use /d #team to delete the group" + dan <### [EndsWith "from the group (signed)"] + eve <### [EndsWith "from the group (signed)"] + + -- frank joins after the removal; cached roster has dropped cath + threadDelay 1000000 + memberJoinChannel "team" [bob] [alice] shortLink fullLink frank + threadDelay 100000 + checkMemberRow frank "cath" Nothing + +testChannelRoleTransitionsUpdateRoster :: HasCallStack => TestParams -> IO () +testChannelRoleTransitionsUpdateRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + -- observer -> moderator + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + -- dan joins; cached roster has cath as moderator (learned from the served snapshot, + -- no separate role-change item under the no-broadcast model) + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan + threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state + checkMemberRow dan "cath" (Just "moderator") + -- moderator -> admin: dan now knows cath, role event lands cleanly + threadDelay 100000 + alice ##> "/mr #team cath admin" + alice <## "#team: you changed the role of cath to admin (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from moderator to admin (signed)", + cath <## "#team: alice changed your role from moderator to admin (signed)", + dan <## "#team: alice changed the role of cath from moderator to admin (signed)" + ] + -- eve joins; cached roster has cath as admin (learned from the served snapshot) + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink eve + threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state + checkMemberRow eve "cath" (Just "admin") + -- admin -> observer (crossing out of roster, since member is now in-roster): roster drops cath + threadDelay 100000 + alice ##> "/mr #team cath observer" + alice <## "#team: you changed the role of cath to observer (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from admin to observer (signed)", + cath <## "#team: alice changed your role from admin to observer (signed)", + dan <## "#team: alice changed the role of cath from admin to observer (signed)", + eve <## "#team: alice changed the role of cath from admin to observer (signed)" + ] + -- frank joins; cath isn't in the roster, so frank has no record of her + threadDelay 100000 + memberJoinChannel "team" [bob] [alice] shortLink fullLink frank + threadDelay 100000 + checkMemberRow frank "cath" Nothing + +testChannelRelayCannotDowngradeRosterMember :: HasCallStack => TestParams -> IO () +testChannelRelayCannotDowngradeRosterMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChatOpts ps (testOpts {coreOptions = testCoreOpts {logLevel = CLLWarning}}) "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink frank + -- promote cath; roster TOFU-creates cath on frank as moderator with the real key + threadDelay 1000000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + frank <### [EndsWith "to moderator (signed)"] + ] + threadDelay 100000 + realKey <- getMemberPubKey bob "cath" + -- malicious relay: corrupt bob's local record of cath so its XGrpMemNew dissemination + -- carries a downgraded role + no key + withCCTransaction bob $ \db -> + DB.execute + db + "UPDATE group_members SET member_role = ?, member_pub_key = NULL WHERE local_display_name = ?" + ("member" :: T.Text, "cath" :: T.Text) + -- cath posts; bob prepends XGrpMemNew(cath, member, NULL) to the delivery (frank not yet introduced) + threadDelay 100000 + cath #> "#team hello from cath" + bob <# "#team cath> hello from cath" + concurrentlyN_ + [ alice <# "#team cath> hello from cath [>>]", + do + frank <##. "warning: x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" + frank <### [EndsWith "updated to cath"] + frank <## "#team: bob introduced cath (Catherine) in the channel" + frank <# "#team cath> hello from cath [>>]" + ] + threadDelay 100000 + checkMemberRow frank "cath" (Just "moderator") + frankKey <- getMemberPubKey frank "cath" + frankKey `shouldBe` realKey + where + getMemberPubKey :: TestCC -> T.Text -> IO (Maybe ByteString) + getMemberPubKey cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_pub_key FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only (Maybe ByteString)] + case rows of + [Only k] -> pure k + _ -> fail $ "expected one row for " <> T.unpack name + testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO () testChannelRemoveMemberSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9500,15 +9858,20 @@ testChannelRemoveMemberSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote eve to member (observer default) so it can post + promoteChannelMember "team" alice bob eve [cath, dan] + -- other members discover eve eve #> "#team hello from eve" bob <# "#team eve> hello from eve" concurrentlyN_ [ alice <# "#team eve> hello from eve [>>]", do + dan <### [EndsWith "updated to eve"] dan <## "#team: bob introduced eve (Eve) in the channel" dan <# "#team eve> hello from eve [>>]", do + cath <### [EndsWith "updated to eve"] cath <## "#team: bob introduced eve (Eve) in the channel" cath <# "#team eve> hello from eve [>>]" ] @@ -9681,6 +10044,9 @@ testChannelSubscriberLeave ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath threadDelay 1000000 cath #> "#team hello from cath" @@ -9688,9 +10054,11 @@ testChannelSubscriberLeave ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9916,6 +10284,9 @@ testChannelSubscriberProfileUpdate ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote dan to member early (observer default) so its role-change item precedes the messages + promoteChannelMember "team" alice bob dan [cath, eve] + -- enable support and create support chat for cath (but not dan) threadDelay 1000000 alice ##> "/set support #team on" @@ -9935,6 +10306,9 @@ testChannelSubscriberProfileUpdate ps = (dan "#team hello from cath" @@ -9942,9 +10316,11 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9974,9 +10350,8 @@ testChannelSubscriberProfileUpdate ps = cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from cath"), (1, "hello from kate")]) -- verify profiles are updated correctly forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dan", "eve"] - cath `hasContactProfiles` ["alice", "bob", "kate"] dan `hasContactProfiles` ["alice", "bob", "kate", "dan"] - eve `hasContactProfiles` ["alice", "bob", "kate", "eve"] + -- cath/eve also know dan by id hash now (roster-learned before dan posts); not asserted -- previously silent subscriber updates profile -- dan has no support chat -> no profile update item created @@ -9988,9 +10363,11 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team dave> hello from dave [>>]", do + eve <### [EndsWith "updated to dave"] eve <## "#team: bob introduced dave in the channel" eve <# "#team dave> hello from dave [>>]", do + cath <### [EndsWith "updated to dave"] cath <## "#team: bob introduced dave in the channel" cath <# "#team dave> hello from dave [>>]" ] @@ -10076,6 +10453,250 @@ testChannelAddRelay ps = [bob, cath] *<# "#team> hello" [dan, eve] *<# "#team> hello [>>]" +testChannelAddRelayWithRoster :: HasCallStack => TestParams -> IO () +testChannelAddRelayWithRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "eve" eveProfile $ \_eve -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + + -- promote cath observer -> moderator: the roster is created (bob caches it) + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + threadDelay 100000 + + -- add dan as a 2nd relay; with a roster present it must cache the roster and ack + -- (XGrpRosterAck) before alice publishes it as joinable + dan ##> "/ad" + (danSLink, _cLink) <- getContactLinks dan True + alice ##> ("/relays name=dan " <> danSLink) + alice <## "ok" + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice + <### [ " - relay id 1: active", + " - relay id 2: active" + ] + alice <## "group link:" + void $ getTermLine alice, + dan <## "#team: you joined the group as relay" + ] + + -- cath (an existing member) connects to the new relay and is attached to her roster + -- record, kept as moderator (the relay learned cath from the cached roster snapshot, so + -- it surfaces no role-change item for her) + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay dan)..." + cath <## "#team: you joined the group (connected to relay dan)", + dan + <### [ EndsWith "accepting request to join group #team...", + EndsWith "is connected" + ] + ] + + threadDelay 100000 + -- the new relay holds the roster (cath is moderator) and learns her name when she connects + checkMemberRow dan "cath" (Just "moderator") + +testChannelRosterMultipartReassembly :: HasCallStack => TestParams -> IO () +testChannelRosterMultipartReassembly ps = + withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfgOpts ps cfg testOpts "cath" cathProfile $ \cath -> + withNewTestChatCfgOpts ps cfg testOpts "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan + -- dan reassembles the multi-chunk roster from the served snapshot (arrives async) + threadDelay 1000000 + checkMemberRow dan "cath" (Just "moderator") + where + cfg = testCfg {fileChunkSize = 30} + +testChannelRosterDigestMismatchRejected :: HasCallStack => TestParams -> IO () +testChannelRosterDigestMismatchRejected ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + threadDelay 100000 + -- corrupt the relay's stored blob (same length, different content) so its digest no + -- longer matches the signed header (DB-agnostic: read it, overwrite with zeroed bytes) + withCCTransaction bob $ \db -> do + rows <- DB.query_ db "SELECT roster_blob FROM groups WHERE roster_blob IS NOT NULL" :: IO [Only (Binary ByteString)] + forM_ rows $ \(Only (Binary blob)) -> + DB.execute db "UPDATE groups SET roster_blob = ? WHERE roster_blob IS NOT NULL" (Only (Binary (B.replicate (B.length blob) '\NUL'))) + -- frank joins; bob re-serves the valid header with the corrupted blob, frank rejects it + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank + threadDelay 1000000 + -- the rejected roster never elevates cath: the intro caps her to the channel default, so she + -- stays observer (not moderator), and the version must not advance to the corrupted roster's version 1 + checkMemberRow frank "cath" (Just "observer") + checkRosterNotApplied frank + where + -- the version is the second guarantee (the role is asserted above): frank holds exactly the team + -- group with no roster applied, so roster_version is NULL - it never advanced to the corrupted version 1 + checkRosterNotApplied :: HasCallStack => TestCC -> IO () + checkRosterNotApplied cc = do + vs <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT roster_version FROM groups" :: IO [Only (Maybe Int64)] + map (\(Only v) -> v) vs `shouldBe` [Nothing] + +testChannelPromotedMemberCanPost :: HasCallStack => TestParams -> IO () +testChannelPromotedMemberCanPost ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + -- promote cath to member: cath enters the owner-signed roster (dan learns cath by id hash) + promoteChannelMember "team" alice bob cath [dan] + -- the promoted member can now post; dan resolves cath on the first forward + cath #> "#team hi from cath" + bob <# "#team cath> hi from cath" + alice <# "#team cath> hi from cath [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi from cath [>>]" + checkMemberRow dan "cath" (Just "member") + +testChannelObserverCannotPost :: HasCallStack => TestParams -> IO () +testChannelObserverCannotPost ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + -- cath is an observer (default): its own post is rejected locally and never reaches the relay + cath ##> "#team observer attempt" + cath <## "#team: you don't have permission to send messages" + -- promote cath to member; the post is now accepted and delivered, dan resolves cath + promoteChannelMember "team" alice bob cath [dan] + cath #> "#team member post" + bob <# "#team cath> member post" + alice <# "#team cath> member post [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> member post [>>]" + +testChannelPromotedMemberRejoinViaRelay :: HasCallStack => TestParams -> IO () +testChannelPromotedMemberRejoinViaRelay ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + -- promote cath to member: cath enters the owner-signed roster with her pinned key + threadDelay 100000 + promoteChannelMember "team" alice bob cath [] + threadDelay 100000 + -- add dan as a 2nd relay; it caches the roster (incl. member cath) before joinable + dan ##> "/ad" + (danSLink, _cLink) <- getContactLinks dan True + alice ##> ("/relays name=dan " <> danSLink) + alice <## "ok" + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice + <### [ " - relay id 1: active", + " - relay id 2: active" + ] + alice <## "group link:" + void $ getTermLine alice, + dan <## "#team: you joined the group as relay" + ] + -- cath (a promoted member) connects to the new relay; the widened join gate + -- (verifyKey over the roster-pinned key) accepts her and keeps her as member + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay dan)..." + cath <## "#team: you joined the group (connected to relay dan)", + dan + <### [ EndsWith "accepting request to join group #team...", + EndsWith "is connected" + ] + ] + threadDelay 100000 + checkMemberRow dan "cath" (Just "member") + +testChannelRosterMultiRelayMultipart :: HasCallStack => TestParams -> IO () +testChannelRosterMultiRelayMultipart ps = + withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfgOpts ps cfg relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChatCfgOpts ps cfg testOpts "dan" danProfile $ \dan -> + withNewTestChatCfgOpts ps cfg testOpts "eve" eveProfile $ \eve -> + withNewTestChatCfgOpts ps cfg testOpts "frank" frankProfile $ \frank -> do + createChannel2Relays "team" alice bob cath dan eve frank + + -- promote eve to moderator: the owner-signed roster broadcasts through BOTH relays to dan and + -- frank (each connected to both). At fileChunkSize=30 the blob spans multiple chunks, so each + -- member receives two interleaved multi-chunk streams (one per relay) for the same roster. + threadDelay 1000000 + alice ##> "/mr #team eve moderator" + alice <## "#team: you changed the role of eve to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of eve from observer to moderator (signed)", + cath <## "#team: alice changed the role of eve from observer to moderator (signed)", + eve <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + frank <### [EndsWith "to moderator (signed)"] + ] + threadDelay 1000000 -- let both relays' interleaved multipart streams settle + + -- per-source transfers keep the streams independent, so each member reassembles the blob and pins + -- eve as the single moderator WITH her owner-attested key (role + key both come from the blob) + checkOneModeratorWithKey dan + checkOneModeratorWithKey frank + where + cfg = testCfg {fileChunkSize = 30} + checkOneModeratorWithKey cc = do + rows <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT member_pub_key FROM group_members WHERE member_role = 'moderator'" :: IO [Only (Maybe ByteString)] + map (\(Only k) -> isJust k) rows `shouldBe` [True] + testChannelRemoveRelay :: HasCallStack => TestParams -> IO () testChannelRemoveRelay ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -10656,42 +11277,48 @@ testChannelMessageFile ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - + -- the roster arrives as a file before this one; Postgres assigns it a new id and does not + -- reuse it on delete (SQLite does), so the received message file is id 2 here, 1 on SQLite. +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- all members receive the file concurrently src <- B.readFile "./tests/fixtures/test.jpg" concurrentlyN_ - [ receiveFile bob "bob" src, - receiveFile cath "cath" src, - receiveFile dan "dan" src, - receiveFile eve "eve" src + [ receiveFile bob "bob" rcvFileId src, + receiveFile cath "cath" rcvFileId src, + receiveFile dan "dan" rcvFileId src, + receiveFile eve "eve" rcvFileId src ] where - receiveFile cc name src = do + receiveFile cc name fileId src = do let path = "./tests/tmp/test_" <> name <> ".jpg" - cc ##> ("/fr 1 " <> path) + cc ##> ("/fr " <> show fileId <> " " <> path) cc - <### [ ConsoleString ("saving file 1 from #team to " <> path), - "started receiving file 1 (test.jpg) from #team" + <### [ ConsoleString ("saving file " <> show fileId <> " from #team to " <> path), + ConsoleString ("started receiving file " <> show fileId <> " (test.jpg) from #team") ] - cc <## "completed receiving file 1 (test.jpg) from #team" + cc <## ("completed receiving file " <> show fileId <> " (test.jpg) from #team") B.readFile path >>= (`shouldBe` src) testChannelMessageFileCancel :: HasCallStack => TestParams -> IO () @@ -10702,33 +11329,37 @@ testChannelMessageFileCancel ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- owner cancels file alice ##> "/fc 1" alice <## "cancelled sending file 1 (test.jpg) to bob" - bob <## "team cancelled sending file 1 (test.jpg)" + bob <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)") concurrentlyN_ - [ cath <## "team cancelled sending file 1 (test.jpg)", - dan <## "team cancelled sending file 1 (test.jpg)", - eve <## "team cancelled sending file 1 (test.jpg)" + [ cath <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + dan <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + eve <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)") ] testChannelMessageQuote :: HasCallStack => TestParams -> IO () @@ -10745,6 +11376,9 @@ testChannelMessageQuote ps = bob <# "#team> hello from channel" [cath, dan, eve] *<# "#team> hello from channel [>>]" + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member quotes channel message cath `send` "> #team (hello from) replying to channel" cath <# "#team > hello from channel" @@ -10756,10 +11390,12 @@ testChannelMessageQuote ps = alice <# "#team cath> > hello from channel [>>]" alice <## " replying to channel [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hello from channel [>>]" dan <## " replying to channel [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hello from channel [>>]" eve <## " replying to channel [>>]" @@ -10873,43 +11509,47 @@ testChannelOwnerFileTransferAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- all members receive the file src <- B.readFile "./tests/fixtures/test.jpg" concurrentlyN_ - [ receiveFile bob "bob" src, - receiveFile cath "cath" src, - receiveFile dan "dan" src, - receiveFile eve "eve" src + [ receiveFile bob "bob" rcvFileId src, + receiveFile cath "cath" rcvFileId src, + receiveFile dan "dan" rcvFileId src, + receiveFile eve "eve" rcvFileId src ] where - receiveFile cc name src = do + receiveFile cc name fileId src = do let path = "./tests/tmp/test_" <> name <> ".jpg" - cc ##> ("/fr 1 " <> path) + cc ##> ("/fr " <> show fileId <> " " <> path) cc - <### [ ConsoleString ("saving file 1 from alice to " <> path), - "started receiving file 1 (test.jpg) from alice" + <### [ ConsoleString ("saving file " <> show fileId <> " from alice to " <> path), + ConsoleString ("started receiving file " <> show fileId <> " (test.jpg) from alice") ] - cc <## "completed receiving file 1 (test.jpg) from alice" + cc <## ("completed receiving file " <> show fileId <> " (test.jpg) from alice") B.readFile path >>= (`shouldBe` src) testChannelOwnerFileCancelAsMember :: HasCallStack => TestParams -> IO () @@ -10920,34 +11560,38 @@ testChannelOwnerFileCancelAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- owner cancels file alice ##> "/fc 1" alice <## "cancelled sending file 1 (test.jpg) to bob" - bob <## "alice cancelled sending file 1 (test.jpg)" + bob <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)") concurrentlyN_ - [ cath <## "alice cancelled sending file 1 (test.jpg)", - dan <## "alice cancelled sending file 1 (test.jpg)", - eve <## "alice cancelled sending file 1 (test.jpg)" + [ cath <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + dan <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + eve <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)") ] testChannelReactionAttribution :: HasCallStack => TestParams -> IO () @@ -11111,14 +11755,19 @@ testChannelMemberMessageUpdate ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member sends a message cath #> "#team hello" bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob introduced cath (Catherine) in the channel" + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob introduced cath (Catherine) in the channel" + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] @@ -11142,14 +11791,19 @@ testChannelMemberMessageDelete ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member sends a message cath #> "#team hello" bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob introduced cath (Catherine) in the channel" + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob introduced cath (Catherine) in the channel" + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index c2fd1baa74..bc062a3704 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -18,11 +19,18 @@ import Control.Monad.Except import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import Data.Time.Format (defaultTimeLocale, formatTime) +import qualified Data.Map.Strict as M +import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatHooks (..), defaultChatHooks, mkStoreCxt) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (currentChatVersion) import Simplex.Chat.Store.Shared (createContact) import Simplex.Chat.Types (ConnStatus (..), Profile (..), GroupRejectionReason (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite @@ -40,6 +48,13 @@ chatProfileTests = do it "update user profile and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage it "use multiword profile names" testMultiWordProfileNames + it "present supporter badge to contacts" testUserBadgeBroadcast + it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect + it "supporter badge sent to member joining via group link" testUserBadgeGroupLink + it "expired supporter badge shows as expired" testUserBadgeExpired + it "long-expired supporter badge is not presented" testUserBadgeExpiredOld + it "incognito connection does not carry supporter badge" testUserBadgeIncognito + it "supporter badge sent to contact connecting via address" testUserBadgeContactAddress describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "retry connecting via contact link" testRetryConnectingViaContactLink @@ -188,6 +203,210 @@ testUpdateProfile = bob <## "use @cat to send messages" ] +-- the test issuer key under index 1 in the test config +testBadgeKeys :: BBSPublicKey -> M.Map Int BBSPublicKey +testBadgeKeys = M.singleton 1 + +-- issue a supporter badge credential with the given expiry (test issuer) +issueTestBadge :: BBSSecretKey -> Maybe UTCTime -> IO BadgeCredential +issueTestBadge sk badgeExpiry = do + drg <- C.newRandom + mk <- generateMasterKey drg + let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry, badgeExtra = ""} + Just vreq <- verifyPayment (BPRedeemCode "TEST") BadgeRequest {masterKey = mk, badgeInfo = info} + Right cred <- issueBadge 1 sk vreq + pure cred + +-- the same single-line JSON `simplex-chat badge sign` prints, pasted into the app +addTestBadge :: HasCallStack => TestCC -> BadgeCredential -> IO () +addTestBadge cc cred = do + cc ##> ("/badge add " <> T.unpack (encodeJSON cred)) + cc <## "ok" + +testUserBadgeBroadcast :: HasCallStack => TestParams -> IO () +testUserBadgeBroadcast ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + connectUsers alice bob + addTestBadge alice =<< issueTestBadge sk Nothing + -- own badge is shown (add succeeded) + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + -- the badge XInfo is delivered in order before this message, so the contact has stored it + alice #> "@bob hi" + bob <# "alice *> hi" + +testUserBadgeOnConnect :: HasCallStack => TestParams -> IO () +testUserBadgeOnConnect ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- a contact connecting after the badge is attached receives it in the connection handshake + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeGroupLink :: HasCallStack => TestParams -> IO () +testUserBadgeGroupLink ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + -- the host's profile (x.grp.link.mem) is sent over the same connection as group messages, + -- so receiving a message guarantees the badge arrived + alice #> "#team hello" + bob <# "#team alice> hello" + -- no prior contact: the host's badge arrives via the group link handshake + bob ##> "/i #team alice" + bob <## "group ID: 1" + bob <##. "member ID: " + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "connection not verified, use /code command to see security code" + bob <## currentChatVRangeInfo + +testUserBadgeContactAddress :: HasCallStack => TestParams -> IO () +testUserBadgeContactAddress ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + alice ##> "/ad" + (shortLink, cLink) <- getContactLinks alice True + -- the address link data carries the badge proof; the connect plan returns it verified, without crypto + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldContain` "\"proof\":" + sLinkData `shouldContain` "\"localBadge\":{\"badge\":{\"badgeType\":\"supporter\"" + sLinkData `shouldContain` "\"status\":\"active\"" + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeExpired :: HasCallStack => TestParams -> IO () +testUserBadgeExpired ps = do + Right (pk, sk) <- bbsKeyGen + -- expired recently (within 31 days), so the badge is still presented and shown as expired + expiry <- addUTCTime (-2 * nominalDay) <$> getCurrentTime + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk expiry) ps + where + test sk expiry alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just expiry) + -- expired badge: no star + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - expired" + bob <## ("expires " <> formatTime defaultTimeLocale "%Y-%m-%d" expiry) + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeExpiredOld :: HasCallStack => TestParams -> IO () +testUserBadgeExpiredOld ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just pastDate) + -- a badge that expired over a month ago is not presented to contacts at all + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + pastDate = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +testUserBadgeIncognito :: HasCallStack => TestParams -> IO () +testUserBadgeIncognito ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- an incognito identity must not carry the badge + bob ##> "/connect" + inv <- getInvitation bob + alice ##> ("/connect incognito " <> inv) + alice <## "confirmation sent!" + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob to print out this incognito profile again" + ] + bob ##> ("/i " <> aliceIncognito) + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ @@ -282,7 +501,7 @@ testMultiWordProfileNames = aliceProfile' = baseProfile {displayName = "Alice Jones"} bobProfile' = baseProfile {displayName = "Bob James"} cathProfile' = baseProfile {displayName = "Cath Johnson"} - baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Nothing, preferences = defaultPrefs} + baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, simplexName = Nothing} testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = @@ -1190,13 +1409,13 @@ testPlanAddressContactViaAddress = Left _ -> error "error parsing contact link" Right cReq -> do let profile = aliceProfile {contactLink = Just cReq} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1211,7 +1430,7 @@ testPlanAddressContactViaAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api @@ -1252,13 +1471,13 @@ testPlanAddressContactViaShortAddress = Left _ -> error "error parsing contact link" Right shortLink -> do let profile = aliceProfile {contactLink = Just shortLink} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> sLink) @@ -1273,7 +1492,7 @@ testPlanAddressContactViaShortAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api @@ -2687,6 +2906,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 inv <> "\\ntest\"") diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 7f03c5c2c0..cd24995db6 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -88,7 +88,7 @@ serviceProfile :: Profile serviceProfile = mkProfile "service_user" "Service user" Nothing mkProfile :: T.Text -> T.Text -> Maybe ImageData -> Profile -mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, simplexName = Nothing, peerType = Nothing, preferences = defaultPrefs} +mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, simplexName = Nothing} it :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expectation)) it name test = diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index efa010ceb1..2a5328ff26 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -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 diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 05322a0834..00cbbd757b 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -12,14 +12,31 @@ import qualified Data.ByteString as B import Data.ByteString.Internal (c2w) import Data.Either (partitionEithers) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty (..)) import Data.String (IsString (..)) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) +import Simplex.Chat.Delivery + ( DeliveryJobScope (DJSGroup, jobSpec), + DeliveryJobSpec (DJDeliveryJob, includePending), + MessageDeliveryTask (MessageDeliveryTask, brokerTs, fwdSender, jobScope, senderGMId, taskId, verifiedMsg), + deliveryTaskId, + ) import Simplex.Chat.Messages.Batch import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Messages (SndMessage (..)) -import Simplex.Chat.Protocol (maxEncodedMsgLength) -import Simplex.Chat.Types (SharedMsgId (..)) +import Simplex.Chat.Protocol + ( ChatMessage (ChatMessage), + ChatMsgEvent (XMsgNew), + FwdSender (FwdChannel), + GrpMsgForward (GrpMsgForward), + MsgContent (MCText), + VerifiedMsg (VMUnsigned), + maxEncodedMsgLength, + mcSimple, + ) +import Simplex.Chat.Types (SharedMsgId (..), chatInitialVRange) import Simplex.Messaging.Encoding (Large (..), smpEncodeList) import Test.Hspec @@ -28,6 +45,8 @@ batchingTests = describe "message batching tests" $ do testBatchingCorrectness testBinaryBatchingCorrectness it "image x.msg.new and x.msg.file.descr should fit into single batch" testImageFitsSingleBatch + it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge + it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow instance IsString SndMessage where fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s', signedMsg_ = Nothing} @@ -131,6 +150,37 @@ testImageFitsSingleBatch = do runBatcherTest' BMJson maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] +testRelayBatchAllLarge :: IO () +testRelayBatchAllLarge = do + let task1 = deliveryTask 1 "one" + task2 = deliveryTask 2 "two" + (body_, accepted, large) = batchDeliveryTasks1 chatInitialVRange 1 (task1 :| [task2]) + body_ `shouldBe` Nothing + map deliveryTaskId accepted `shouldBe` [] + map deliveryTaskId large `shouldBe` [1, 2] + +deliveryTask :: Int64 -> T.Text -> MessageDeliveryTask +deliveryTask taskId text = + MessageDeliveryTask + { taskId, + jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, + senderGMId = 1, + fwdSender = FwdChannel, + brokerTs = systemToUTCTime $ MkSystemTime 0 0, + verifiedMsg = + VMUnsigned + (ChatMessage chatInitialVRange Nothing $ XMsgNew $ mcSimple $ MCText text) + } + +testRelayBatchSingletonOverflow :: IO () +testRelayBatchSingletonOverflow = do + let task = deliveryTask 1 "overflow" + elemLen = B.length $ encodeFwdElement (GrpMsgForward (fwdSender task) (brokerTs task)) (verifiedMsg task) + (body_, accepted, large) = batchDeliveryTasks1 chatInitialVRange (elemLen + 2) (task :| []) + body_ `shouldBe` Nothing + map deliveryTaskId accepted `shouldBe` [] + map deliveryTaskId large `shouldBe` [1] + runBatcherTest :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest mode maxLen msgs expectedErrors expectedBatches = it diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index c75bc37166..4e3ddbc0fa 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -33,8 +33,10 @@ import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures import Simplex.Chat +import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) +import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -82,6 +84,8 @@ mobileTests = do describe "Parsers" $ do it "should parse server address" testChatParseServer it "should parse and sanitize URI" testChatParseUri + describe "Badges" $ do + it "should generate key and issue badge via C API, verify credential" testBadgeKeygenIssueCApi noActiveUser :: LB.ByteString noActiveUser = @@ -310,6 +314,25 @@ testChatParseUri :: TestParams -> IO () testChatParseUri _ = do pure () +-- Generate a server keypair and issue a badge credential via the C FFI, +-- constructing the request from the typed records, then verify the issued +-- credential's BBS signature on the Haskell side. +testBadgeKeygenIssueCApi :: TestParams -> IO () +testBadgeKeygenIssueCApi _ = do + g <- C.newRandom + IssuerKeyPair {publicKey, secretKey} <- ffiResult =<< (peekCString =<< cChatBadgeKeygen) + mk <- generateMasterKey g + let req = BadgeIssueReq {badgeKeyIdx = 1, secretKey, request = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Nothing, badgeExtra = ""}}} + cred <- ffiResult =<< (peekCString =<< cChatBadgeIssue =<< newCString (LB.unpack (J.encode req))) + verifyCredential publicKey cred `shouldReturn` True + +-- Decode an FFI `BadgeResult` envelope, returning the result or failing on error. +ffiResult :: FromJSON r => String -> IO r +ffiResult s = case J.eitherDecode (LB.pack s) of + Right (BadgeResult r) -> pure r + Right (BadgeError e) -> error $ "badge FFI error: " <> show e + Left e -> error $ "badge FFI decode failed: " <> e <> " in " <> s + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index fc67580750..85a3fc3410 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -9,7 +9,7 @@ module ProtocolTests where import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) -import Simplex.Chat.Library.Internal (redactedMemberProfile, userProfileInGroup') +import Simplex.Chat.Library.Internal (decodeLinkUserData, encodeShortLinkData, redactedMemberProfile, userProfileInGroup') import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -26,6 +26,7 @@ protocolTests :: Spec protocolTests = do decodeChatMessageTest outgoingProfileSimplexNameTest + shortLinkDataTests srv :: SMPServer srv = SMPServer "smp.simplex.im" "5223" (C.KeyHash "\215m\248\251") @@ -107,7 +108,7 @@ testGroupPreferences :: Maybe GroupPreferences testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} testProfile :: Profile -testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, simplexName = Nothing, preferences = testChatPreferences} +testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, simplexName = Nothing} testGroupProfile :: GroupProfile testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, simplexName = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} @@ -118,6 +119,25 @@ testSimplexName = SimplexNameInfo NTContact (SimplexNameDomain TLDSimplex "alice testGroupSimplexName :: SimplexNameInfo testGroupSimplexName = SimplexNameInfo NTPublicGroup (SimplexNameDomain TLDSimplex "team" []) +shortLinkDataTests :: Spec +shortLinkDataTests = describe "Short link data encoding/decoding" $ do + it "decodes compressed short-link user data below the decompressed size limit" $ do + let value = replicate 11000 'a' + decodeLinkUserData (linkData value) `shouldReturn` Just value + it "rejects compressed short-link user data above the decompressed size limit" $ do + let value = replicate (maxDecompressedMsgLength + 1) 'a' + decodeLinkUserData (linkData value) `shouldReturn` (Nothing :: Maybe String) + where + linkData value = + ContactLinkData + supportedSMPAgentVRange + UserContactData + { direct = True, + owners = [], + relays = [], + userData = encodeShortLinkData (value :: String) + } + decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new simple text" $ @@ -142,7 +162,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-17\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-19\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -227,10 +247,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XInfo testProfile it "x.info with empty full name" $ "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Nothing, preferences = testChatPreferences} + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, simplexName = Nothing} it "x.info with simplexName" $ "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"simplexName\":{\"nameType\":\"contact\",\"nameDomain\":{\"nameTLD\":\"simplex\",\"domain\":\"alice\",\"subDomain\":[]}},\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Just testSimplexName, peerType = Nothing, preferences = testChatPreferences} + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, simplexName = Just testSimplexName} it "x.contact with xContactId" $ "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing Nothing @@ -259,13 +279,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -280,7 +300,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -293,7 +313,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemConAll (MemberId "\1\2\3\4") it "x.grp.mem.del" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" - #==# XGrpMemDel (MemberId "\1\2\3\4") False + #==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing it "x.grp.leave" $ "{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave @@ -340,10 +360,11 @@ testUser sn = shortDescr = Nothing, image = Nothing, contactLink = Nothing, - simplexName = sn, preferences = Nothing, peerType = Nothing, - localAlias = "" + localBadge = Nothing, + localAlias = "", + simplexName = sn }, fullPreferences = fullPreferences' Nothing, activeUser = True, @@ -368,13 +389,13 @@ outgoingProfileSimplexNameTest = describe "outgoing Profile carries User.profile let Profile {simplexName = sn} = userProfileDirect (testUser Nothing) Nothing Nothing True sn `shouldBe` Nothing it "userProfileDirect with incognito profile suppresses simplexName" $ do - let incognito = Profile {displayName = "anon", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Nothing, peerType = Nothing, preferences = Nothing} + let incognito = Profile {displayName = "anon", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, simplexName = Nothing} Profile {simplexName = sn} = userProfileDirect (testUser (Just testSimplexName)) (Just incognito) Nothing True sn `shouldBe` Nothing it "userProfileInGroup' passes simplexName through" $ do let Profile {simplexName = sn} = userProfileInGroup' (testUser (Just testSimplexName)) True Nothing sn `shouldBe` Just testSimplexName it "redactedMemberProfile preserves simplexName" $ do - let p0 = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Nothing, contactLink = Nothing, simplexName = Just testSimplexName, peerType = Nothing, preferences = Nothing} + let p0 = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, simplexName = Just testSimplexName} Profile {simplexName = sn} = redactedMemberProfile True p0 sn `shouldBe` Just testSimplexName diff --git a/tests/Test.hs b/tests/Test.hs index 1fca7cddd5..4690983d8c 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -11,6 +11,7 @@ import ChatTests.DBUtils import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System +import BadgeTests import JSONTests import MarkdownTests import MemberRelationsTests @@ -61,6 +62,7 @@ main = do #endif around tmpBracket $ describe "WebRTC encryption" webRTCTests #endif + describe "Supporter badges" badgeTests describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests describe "Member relations" memberRelationsTests diff --git a/website/.eleventy.js b/website/.eleventy.js index f0310c5665..122e5bb673 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -310,7 +310,7 @@ module.exports = function (ty) { ty.addPassthroughCopy("src/img") ty.addPassthroughCopy("src/video") ty.addPassthroughCopy("src/css") - ty.addPassthroughCopy("src/js") + ty.addPassthroughCopy("src/js/**/*.js") ty.addPassthroughCopy("src/lottie_file") ty.addPassthroughCopy("src/contact/*.js") ty.addPassthroughCopy("src/call") @@ -326,6 +326,7 @@ module.exports = function (ty) { ty.addPassthroughCopy("src/CNAME") ty.addPassthroughCopy("src/.well-known") ty.addPassthroughCopy("src/file-assets") + ty.addPassthroughCopy("src/credits") ty.addCollection('blogs', function (collection) { return collection.getFilteredByGlob('src/blog/*.md').reverse() diff --git a/website/channel_sample.html b/website/channel_sample.html new file mode 100644 index 0000000000..169db55599 --- /dev/null +++ b/website/channel_sample.html @@ -0,0 +1,28 @@ + + + + + + SimpleX Channel Preview + + + + +
+ + + diff --git a/website/src/_data/docs_sidebar.json b/website/src/_data/docs_sidebar.json index f9b4d15b54..e640b7df71 100644 --- a/website/src/_data/docs_sidebar.json +++ b/website/src/_data/docs_sidebar.json @@ -6,6 +6,7 @@ "README.md", "send-messages.md", "secret-groups.md", + "channel-webpage.md", "chat-profiles.md", "managing-data.md", "audio-video-calls.md", diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 34ee893dd3..cec2aa0a01 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -148,7 +148,7 @@ - {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) %} + {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) and ('news' not in page.url) %}